Repository: scikit-hep/pyhf

Issue:
DeserializationError too frequent when `HistoPath` is specified in HiFa XML
### Summary

When a `<Sample>` specifies the `HistoPath` and it's not empty, we will run into very frequent deserialization errors from `uproot` which is very slow to raise an exception (see scikit-hep/uproot4#504).

`pyhf` needs to fix this to be a bit smarter in how to check valid keys, and in particular, fix up its logic to not hit a/rely on `DeserializationError`.

### OS / Environment

```console
$ system_profiler -detailLevel mini SPSoftwareDataType | head -n 6
Software:

    System Software Overview:

      System Version: macOS 10.14.6 (18G9323)
      Kernel Version: Darwin 18.7.0
```


### Steps to Reproduce

See #1687 for the fundamental issue. It's reproducible using private workspaces for now, but can be confirmed reproducible.

```
$ time pyhf xml2json monotop_twmetComb0L1LBoosted_allCRs_normDStoDR_unblind_sigTheo_envelope__pmoder_sig_a250_DM10_H900_tb1_st0p7/NormalMeasurement.xml --basedir monotop_twmetComb0L1LBoosted_allCRs_normDStoDR_unblind_sigTheo_envelope__pmoder_sig_a250_DM10_H900_tb1_st0p7
Processing ./NormalMeasurement_CR_0LBoosted_ttbar_cuts.xml:   0%|                           | 0/1 [00:00<?, ?channel/smonotop_twmetComb0L1LBoosted_allCRs_normDStoDR_unblind_sigTheo_envelope__pmoder_sig_a250_DM10_H900_tb1_st0p7/results/monotop_twmetComb0L1LBoosted_allCRs_normDStoDR_unblind_sigTheo_envelope__pmoder_sig_a250_DM10_H900_tb1_st0p7/Exclusion_combined_NormalMeasurement_model.root not in filecache
path=CR_0LBoosted_ttbar_cuts_hists/data, name=hData_CR_0LBoosted_ttbar_obs_cuts
deserialization error, trying fullname=CR_0LBoosted_ttbar_cuts_hists/data/hData_CR_0LBoosted_ttbar_obs_cuts instead
                                                                                                                      monotop_twmetComb0L1LBoosted_allCRs_normDStoDR_unblind_sigTheo_envelope__pmoder_sig_a250_DM10_H900_tb1_st0p7/results/monotop_twmetComb0L1LBoosted_allCRs_normDStoDR_unblind_sigTheo_envelope__pmoder_sig_a250_DM10_H900_tb1_st0p7/Exclusion_combined_NormalMeasurement_model.root in filecache
path=CR_0LBoosted_ttbar_cuts_hists/Top0LBoosted, name=hTop0LBoostedNom_CR_0LBoosted_ttbar_obs_cuts
deserialization error, trying fullname=CR_0LBoosted_ttbar_cuts_hists/Top0LBoosted/hTop0LBoostedNom_CR_0LBoosted_ttbar_obs_cuts instead
                                                                                                                      monotop_twmetComb0L1LBoosted_allCRs_normDStoDR_unblind_sigTheo_envelope__pmoder_sig_a250_DM10_H900_tb1_st0p7/results/monotop_twmetComb0L1LBoosted_allCRs_normDStoDR_unblind_sigTheo_envelope__pmoder_sig_a250_DM10_H900_tb1_st0p7/Exclusion_combined_NormalMeasurement_model.root in filecache
path=CR_0LBoosted_ttbar_cuts_hists/Top0LBoosted, name=hTop0LBoostedEG_EffLow_CR_0LBoosted_ttbar_obs_cutsNorm
deserialization error, trying fullname=CR_0LBoosted_ttbar_cuts_hists/Top0LBoosted/hTop0LBoostedEG_EffLow_CR_0LBoosted_ttbar_obs_cutsNorm instead
```

### File Upload (optional)

_No response_

### Expected Results

`pyhf xml2json` should be fast.

### Actual Results

```console
`pyhf xml2json` is slow.
```


### pyhf Version

```console
This impacts all pyhf versions up to 0.6.4.
```


### Code of Conduct

- [X] I agree to follow the Code of Conduct


Discussion:

Resolving patch:
diff --git a/src/pyhf/readxml.py b/src/pyhf/readxml.py
--- a/src/pyhf/readxml.py
+++ b/src/pyhf/readxml.py
@@ -59,19 +59,21 @@ def import_root_histogram(rootdir, filename, path, name, filecache=None):
     fullpath = str(Path(rootdir).joinpath(filename))
     if fullpath not in filecache:
         f = uproot.open(fullpath)
-        filecache[fullpath] = f
+        keys = set(f.keys(cycle=False))
+        filecache[fullpath] = (f, keys)
     else:
-        f = filecache[fullpath]
-    try:
+        f, keys = filecache[fullpath]
+
+    fullname = "/".join([path, name])
+
+    if name in keys:
         hist = f[name]
-    except (KeyError, uproot.deserialization.DeserializationError):
-        fullname = "/".join([path, name])
-        try:
-            hist = f[fullname]
-        except KeyError:
-            raise KeyError(
-                f'Both {name} and {fullname} were tried and not found in {fullpath}'
-            )
+    elif fullname in keys:
+        hist = f[fullname]
+    else:
+        raise KeyError(
+            f'Both {name} and {fullname} were tried and not found in {fullpath}'
+        )
     return hist.to_numpy()[0].tolist(), extract_error(hist)
 
 


Test patch:
diff --git a/tests/test_import.py b/tests/test_import.py
--- a/tests/test_import.py
+++ b/tests/test_import.py
@@ -201,10 +201,22 @@ def test_import_measurement_gamma_bins(const):
         pyhf.readxml.process_measurements(toplvl)
 
 
-def test_import_prepHistFactory():
-    parsed_xml = pyhf.readxml.parse(
-        'validation/xmlimport_input/config/example.xml', 'validation/xmlimport_input/'
-    )
+@pytest.mark.parametrize(
+    "configfile,rootdir",
+    [
+        (
+            'validation/xmlimport_input/config/example.xml',
+            'validation/xmlimport_input/',
+        ),
+        (
+            'validation/xmlimport_input4/config/example.xml',
+            'validation/xmlimport_input4/',
+        ),
+    ],
+    ids=['xmlimport_input', 'xmlimport_input_histoPath'],
+)
+def test_import_prepHistFactory(configfile, rootdir):
+    parsed_xml = pyhf.readxml.parse(configfile, rootdir)
 
     # build the spec, strictly checks properties included
     spec = {