From 97664bcfcef3b4a459a7f700b0703b40aec9b842 Mon Sep 17 00:00:00 2001 From: Giordon Stark Date: Thu, 11 Aug 2022 00:46:17 -0400 Subject: [PATCH] feat: Handle absolute paths in XML config files (xml2json / readxml) (#1909) * Add support for XML configurations with absolute paths by pruning the paths out and renaming paths on-the-fly in readxml. * Add -v/--mounts command line arguments for xml2json with similar behavior to the Docker equivalent to support this feature. * Add tests and test XML files with absolute paths. --- src/pyhf/cli/rootio.py | 14 +- src/pyhf/readxml.py | 87 +++++++--- src/pyhf/utils.py | 19 +++ tests/test_import.py | 30 +++- tests/test_scripts.py | 28 +++ .../config/HistFactorySchema.dtd | 160 ++++++++++++++++++ .../config/example.xml | 8 + .../config/example_channel.xml | 16 ++ .../xmlimport_absolutePaths/data/example.root | Bin 0 -> 5101 bytes 9 files changed, 339 insertions(+), 23 deletions(-) create mode 100644 tests/test_scripts/xmlimport_absolutePaths/config/HistFactorySchema.dtd create mode 100644 tests/test_scripts/xmlimport_absolutePaths/config/example.xml create mode 100644 tests/test_scripts/xmlimport_absolutePaths/config/example_channel.xml create mode 100644 tests/test_scripts/xmlimport_absolutePaths/data/example.root diff --git a/src/pyhf/cli/rootio.py b/src/pyhf/cli/rootio.py index 1a88a93a52..06411773f8 100644 --- a/src/pyhf/cli/rootio.py +++ b/src/pyhf/cli/rootio.py @@ -6,6 +6,7 @@ import os from pathlib import Path import jsonpatch +from pyhf.utils import VolumeMountPath log = logging.getLogger(__name__) @@ -23,6 +24,14 @@ def cli(): type=click.Path(exists=True), default=Path.cwd(), ) +@click.option( + '-v', + '--mount', + help='Consists of two fields, separated by a colon character ( : ). The first field is the local path to where files are located, the second field is the path where the file or directory are saved in the XML configuration. This is similar in spirit to docker.', + type=VolumeMountPath(exists=True, resolve_path=True, path_type=Path), + default=None, + multiple=True, +) @click.option( '--output-file', help='The location of the output json file. If not specified, prints to screen.', @@ -30,7 +39,9 @@ def cli(): ) @click.option('--track-progress/--hide-progress', default=True) @click.option('--validation-as-error/--validation-as-warning', default=True) -def xml2json(entrypoint_xml, basedir, output_file, track_progress, validation_as_error): +def xml2json( + entrypoint_xml, basedir, mount, output_file, track_progress, validation_as_error +): """Entrypoint XML: The top-level XML file for the PDF definition.""" try: import uproot @@ -47,6 +58,7 @@ def xml2json(entrypoint_xml, basedir, output_file, track_progress, validation_as spec = readxml.parse( entrypoint_xml, basedir, + mounts=mount, track_progress=track_progress, validation_as_error=validation_as_error, ) diff --git a/src/pyhf/readxml.py b/src/pyhf/readxml.py index 23107dc8bd..5b10bd9cb5 100644 --- a/src/pyhf/readxml.py +++ b/src/pyhf/readxml.py @@ -1,18 +1,28 @@ -from pyhf import schema -from pyhf import compat -from pyhf import exceptions +from __future__ import annotations import logging - -from pathlib import Path +import os +from typing import TYPE_CHECKING, Callable, Iterable, Tuple, Union, IO import xml.etree.ElementTree as ET +from pathlib import Path + import numpy as np import tqdm import uproot +from pyhf import compat +from pyhf import exceptions +from pyhf import schema + log = logging.getLogger(__name__) +if TYPE_CHECKING: + PathOrStr = Union[str, os.PathLike[str]] +else: + PathOrStr = Union[str, "os.PathLike[str]"] + __FILECACHE__ = {} +MountPathType = Iterable[Tuple[Path, Path]] __all__ = [ "clear_filecache", @@ -31,6 +41,20 @@ def __dir__(): return __all__ +def resolver_factory(rootdir: Path, mounts: MountPathType) -> Callable[[str], Path]: + def resolver(filename: str) -> Path: + path = Path(filename) + for host_path, mount_path in mounts: + # NB: path.parents doesn't include the path itself, which might be + # a directory as well, so check that edge case + if mount_path == path or mount_path in path.parents: + path = host_path.joinpath(path.relative_to(mount_path)) + break + return rootdir.joinpath(path) + + return resolver + + def extract_error(hist): """ Determine the bin uncertainties for a histogram. @@ -50,14 +74,14 @@ def extract_error(hist): return np.sqrt(variance).tolist() -def import_root_histogram(rootdir, filename, path, name, filecache=None): +def import_root_histogram(resolver, filename, path, name, filecache=None): global __FILECACHE__ filecache = filecache or __FILECACHE__ # strip leading slashes as uproot doesn't use "/" for top-level path = path or '' path = path.strip('/') - fullpath = str(Path(rootdir).joinpath(filename)) + fullpath = str(resolver(filename)) if fullpath not in filecache: f = uproot.open(fullpath) keys = set(f.keys(cycle=False)) @@ -79,7 +103,7 @@ def import_root_histogram(rootdir, filename, path, name, filecache=None): def process_sample( - sample, rootdir, inputfile, histopath, channel_name, track_progress=False + sample, resolver, inputfile, histopath, channel_name, track_progress=False ): if 'InputFile' in sample.attrib: inputfile = sample.attrib.get('InputFile') @@ -87,7 +111,7 @@ def process_sample( histopath = sample.attrib.get('HistoPath') histoname = sample.attrib['HistoName'] - data, err = import_root_histogram(rootdir, inputfile, histopath, histoname) + data, err = import_root_histogram(resolver, inputfile, histopath, histoname) parameter_configs = [] modifiers = [] @@ -131,13 +155,13 @@ def process_sample( parameter_configs.append(parameter_config) elif modtag.tag == 'HistoSys': lo, _ = import_root_histogram( - rootdir, + resolver, modtag.attrib.get('HistoFileLow', inputfile), modtag.attrib.get('HistoPathLow', ''), modtag.attrib['HistoNameLow'], ) hi, _ = import_root_histogram( - rootdir, + resolver, modtag.attrib.get('HistoFileHigh', inputfile), modtag.attrib.get('HistoPathHigh', ''), modtag.attrib['HistoNameHigh'], @@ -154,7 +178,7 @@ def process_sample( staterr = err else: extstat, _ = import_root_histogram( - rootdir, + resolver, modtag.attrib.get('HistoFile', inputfile), modtag.attrib.get('HistoPath', ''), modtag.attrib['HistoName'], @@ -177,7 +201,7 @@ def process_sample( modtag.attrib['Name'], ) shapesys_data, _ = import_root_histogram( - rootdir, + resolver, modtag.attrib.get('InputFile', inputfile), modtag.attrib.get('HistoPath', ''), modtag.attrib['HistoName'], @@ -205,18 +229,18 @@ def process_sample( } -def process_data(sample, rootdir, inputfile, histopath): +def process_data(sample, resolver, inputfile, histopath): if 'InputFile' in sample.attrib: inputfile = sample.attrib.get('InputFile') if 'HistoPath' in sample.attrib: histopath = sample.attrib.get('HistoPath') histoname = sample.attrib['HistoName'] - data, _ = import_root_histogram(rootdir, inputfile, histopath, histoname) + data, _ = import_root_histogram(resolver, inputfile, histopath, histoname) return data -def process_channel(channelxml, rootdir, track_progress=False): +def process_channel(channelxml, resolver, track_progress=False): channel = channelxml.getroot() inputfile = channel.attrib.get('InputFile') @@ -230,7 +254,7 @@ def process_channel(channelxml, rootdir, track_progress=False): data = channel.findall('Data') if data: - parsed_data = process_data(data[0], rootdir, inputfile, histopath) + parsed_data = process_data(data[0], resolver, inputfile, histopath) else: raise RuntimeError(f"Channel {channel_name} is missing data. See issue #1911.") @@ -239,7 +263,7 @@ def process_channel(channelxml, rootdir, track_progress=False): for sample in samples: samples.set_description(f" - sample {sample.attrib.get('Name')}") result = process_sample( - sample, rootdir, inputfile, histopath, channel_name, track_progress + sample, resolver, inputfile, histopath, channel_name, track_progress ) channel_parameter_configs.extend(result.pop('parameter_configs')) results.append(result) @@ -343,7 +367,27 @@ def dedupe_parameters(parameters): return list({v['name']: v for v in parameters}.values()) -def parse(configfile, rootdir, track_progress=False, validation_as_error=True): +def parse( + configfile: PathOrStr | IO[bytes] | IO[str], + rootdir: PathOrStr, + mounts: MountPathType | None = None, + track_progress: bool = False, + validation_as_error: bool = True, +): + """ + Parse the ``configfile`` with respect to the ``rootdir``. + + Args: + configfile (:class:`pathlib.Path` or :obj:`str` or file object): The top-level XML config file to parse. + rootdir (:class:`pathlib.Path` or :obj:`str`): The path to the working directory for interpreting relative paths in the configuration. + mounts (:obj:`None` or :obj:`list` of 2-:obj:`tuple` of :class:`pathlib.Path` objects): The first field is the local path to where files are located, the second field is the path where the file or directory are saved in the XML configuration. This is similar in spirit to Docker volume mounts. Default is ``None``. + track_progress (:obj:`bool`): Show the progress bar. Default is to hide the progress bar. + validation_as_error (:obj:`bool`): Throw an exception (``True``) or print a warning (``False``) if the resulting HistFactory JSON does not adhere to the schema. Default is to throw an exception. + + Returns: + spec (:obj:`jsonable`): The newly built HistFactory JSON specification + """ + mounts = mounts or [] toplvl = ET.parse(configfile) inputs = tqdm.tqdm( [x.text for x in toplvl.findall('Input')], @@ -351,12 +395,15 @@ def parse(configfile, rootdir, track_progress=False, validation_as_error=True): disable=not (track_progress), ) + # create a resolver for finding files + resolver = resolver_factory(Path(rootdir), mounts) + channels = {} parameter_configs = [] for inp in inputs: inputs.set_description(f'Processing {inp}') channel, data, samples, channel_parameter_configs = process_channel( - ET.parse(Path(rootdir).joinpath(inp)), rootdir, track_progress + ET.parse(resolver(inp)), resolver, track_progress ) channels[channel] = {'data': data, 'samples': samples} parameter_configs.extend(channel_parameter_configs) diff --git a/src/pyhf/utils.py b/src/pyhf/utils.py index dd5a5ca14d..02a5507c58 100644 --- a/src/pyhf/utils.py +++ b/src/pyhf/utils.py @@ -2,6 +2,7 @@ import yaml import click import hashlib +from gettext import gettext import sys @@ -41,6 +42,24 @@ def convert(self, value, param, ctx): self.fail(f'{value:s} is not a valid equal-delimited string', param, ctx) +class VolumeMountPath(click.Path): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.name = f'{self.name}:{gettext("path")}' + + def convert(self, value, param, ctx): + try: + path_host, path_mount = value.split(':') + except ValueError: + # too many values to unpack / not enough values to unpack + self.fail(f"{value!r} is not a valid colon-separated option", param, ctx) + + return ( + super().convert(path_host, param, ctx), + self.coerce_path_result(path_mount), + ) + + def digest(obj, algorithm='sha256'): """ Get the digest for the provided object. Note: object must be JSON-serializable. diff --git a/tests/test_import.py b/tests/test_import.py index e32714e7bf..6513a57b05 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -111,7 +111,10 @@ def test_process_normfactor_configs(): def test_import_histogram(): data, uncert = pyhf.readxml.import_root_histogram( - "validation/xmlimport_input/data", "example.root", "", "data" + lambda x: Path("validation/xmlimport_input/data").joinpath(x), + "example.root", + "", + "data", ) assert data == [122.0, 112.0] assert uncert == [11.045360565185547, 10.58300495147705] @@ -120,7 +123,10 @@ def test_import_histogram(): def test_import_histogram_KeyError(): with pytest.raises(KeyError): pyhf.readxml.import_root_histogram( - "validation/xmlimport_input/data", "example.root", "", "invalid_key" + lambda x: Path("validation/xmlimport_input/data").joinpath(x), + "example.root", + "", + "invalid_key", ) @@ -498,3 +504,23 @@ def test_import_missingPOI(mocker, datadir): assert 'Measurement GaussExample is missing POI specification' in str( excinfo.value ) + + +def test_import_resolver(mocker): + rootdir = Path('/current/working/dir') + mounts = [(Path('/this/path/changed'), Path('/my/abs/path'))] + resolver = pyhf.readxml.resolver_factory(rootdir, mounts) + + assert resolver('relative/path') == Path('/current/working/dir/relative/path') + assert resolver('relative/path/') == Path('/current/working/dir/relative/path') + assert resolver('relative/path/to/file.txt') == Path( + '/current/working/dir/relative/path/to/file.txt' + ) + assert resolver('/absolute/path') == Path('/absolute/path') + assert resolver('/absolute/path/') == Path('/absolute/path') + assert resolver('/absolute/path/to/file.txt') == Path('/absolute/path/to/file.txt') + assert resolver('/my/abs/path') == Path('/this/path/changed') + assert resolver('/my/abs/path/') == Path('/this/path/changed') + assert resolver('/my/abs/path/to/file.txt') == Path( + '/this/path/changed/to/file.txt' + ) diff --git a/tests/test_scripts.py b/tests/test_scripts.py index 6dffc7ff61..e67eb5a012 100644 --- a/tests/test_scripts.py +++ b/tests/test_scripts.py @@ -163,6 +163,34 @@ def test_import_prepHistFactory_and_cls(tmpdir, script_runner): assert 'CLs_exp' in d +def test_import_usingMounts(datadir, tmpdir, script_runner): + data = datadir.joinpath("xmlimport_absolutePaths") + + temp = tmpdir.join("parsed_output.json") + command = f'pyhf xml2json --hide-progress -v {data}:/absolute/path/to -v {data}:/another/absolute/path/to --output-file {temp.strpath:s} {data.joinpath("config/example.xml")}' + + ret = script_runner.run(*shlex.split(command)) + assert ret.success + assert ret.stdout == '' + assert ret.stderr == '' + + parsed_xml = json.loads(temp.read()) + spec = {'channels': parsed_xml['channels']} + pyhf.schema.validate(spec, 'model.json') + + +def test_import_usingMounts_badDelimitedPaths(datadir, tmpdir, script_runner): + data = datadir.joinpath("xmlimport_absolutePaths") + + temp = tmpdir.join("parsed_output.json") + command = f'pyhf xml2json --hide-progress -v {data}::/absolute/path/to -v {data}/another/absolute/path/to --output-file {temp.strpath:s} {data.joinpath("config/example.xml")}' + + ret = script_runner.run(*shlex.split(command)) + assert not ret.success + assert ret.stdout == '' + assert 'is not a valid colon-separated option' in ret.stderr + + @pytest.mark.parametrize("backend", ["numpy", "tensorflow", "pytorch", "jax"]) def test_fit_backend_option(tmpdir, script_runner, backend): temp = tmpdir.join("parsed_output.json") diff --git a/tests/test_scripts/xmlimport_absolutePaths/config/HistFactorySchema.dtd b/tests/test_scripts/xmlimport_absolutePaths/config/HistFactorySchema.dtd new file mode 100644 index 0000000000..a1dbc10333 --- /dev/null +++ b/tests/test_scripts/xmlimport_absolutePaths/config/HistFactorySchema.dtd @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_scripts/xmlimport_absolutePaths/config/example.xml b/tests/test_scripts/xmlimport_absolutePaths/config/example.xml new file mode 100644 index 0000000000..96f476df31 --- /dev/null +++ b/tests/test_scripts/xmlimport_absolutePaths/config/example.xml @@ -0,0 +1,8 @@ + + + /absolute/path/to/config/example_channel.xml + + SigXsecOverSM + Lumi alpha_syst1 + + diff --git a/tests/test_scripts/xmlimport_absolutePaths/config/example_channel.xml b/tests/test_scripts/xmlimport_absolutePaths/config/example_channel.xml new file mode 100644 index 0000000000..a2325cd61f --- /dev/null +++ b/tests/test_scripts/xmlimport_absolutePaths/config/example_channel.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/test_scripts/xmlimport_absolutePaths/data/example.root b/tests/test_scripts/xmlimport_absolutePaths/data/example.root new file mode 100644 index 0000000000000000000000000000000000000000..6cecd57a84d09d91449b452201df9f42d4a2070a GIT binary patch literal 5101 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 ahu`gotE0o;c7MJ)qJGtL{kU{86Zj7r