Repository: scikit-hep/pyhf

Issue:
Drop uproot3 dependency and use uproot4 for writing
# Description

Thanks to @jpivarski's awesome and heroic work [`uproot` `v4.1.0`](https://github.com/scikit-hep/uproot4/releases/tag/4.1.0) can now write ROOT files! :rocket: 

This means that once we get everything working we can drop the `uproot3` dependency for the `xmlio` extra

https://github.com/scikit-hep/pyhf/blob/ce2ffabaaeadf2733d4d61284d750933f34cee28/setup.py#L11-L14

and in the tests

https://github.com/scikit-hep/pyhf/blob/ce2ffabaaeadf2733d4d61284d750933f34cee28/.github/workflows/dependencies-head.yml#L66-L89

and in the lower bound tests

https://github.com/scikit-hep/pyhf/blob/ce2ffabaaeadf2733d4d61284d750933f34cee28/lower-bound-requirements.txt#L8-L10

This shouldn't be too bad as there is only a few instances of `uproot3` being used at the moment

```console
$ git grep "uproot3" src/
src/pyhf/writexml.py:import uproot3 as uproot
src/pyhf/writexml.py:from uproot3_methods.classes import TH1
```


Discussion:
I've added this to the [`v0.6.3` board](https://github.com/scikit-hep/pyhf/projects/17) as it would be nice to get in, but if there is some thing that makes this not super easy to get in then I'll move it to [`v0.6.4`](https://github.com/scikit-hep/pyhf/projects/18) so that we can move forward with the release.
We might also need to check if this needs 

https://github.com/scikit-hep/pyhf/blob/ce2ffabaaeadf2733d4d61284d750933f34cee28/.github/workflows/lower-bound-requirements.yml#L30

needs to be

```console
python -m pip install --upgrade --requirement lower-bound-requirements.txt 
```

Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -8,10 +8,7 @@
     ],
     'torch': ['torch~=1.8'],
     'jax': ['jax~=0.2.8', 'jaxlib~=0.1.58,!=0.1.68'],  # c.f. Issue 1501
-    'xmlio': [
-        'uproot3>=3.14.1',
-        'uproot~=4.0',
-    ],  # uproot3 required until writing to ROOT supported in uproot4
+    'xmlio': ['uproot>=4.1.1'],
     'minuit': ['iminuit>=2.4'],
 }
 extras_require['backends'] = sorted(
diff --git a/src/pyhf/writexml.py b/src/pyhf/writexml.py
--- a/src/pyhf/writexml.py
+++ b/src/pyhf/writexml.py
@@ -6,9 +6,7 @@
 import xml.etree.ElementTree as ET
 import numpy as np
 
-# TODO: Move to uproot4 when ROOT file writing is supported
-import uproot3 as uproot
-from uproot3_methods.classes import TH1
+import uproot
 
 from pyhf.mixins import _ChannelSummaryMixin
 
@@ -47,12 +45,12 @@ def _make_hist_name(channel, sample, modifier='', prefix='hist', suffix=''):
     return f"{prefix}{middle}{suffix}"
 
 
-def _export_root_histogram(histname, data):
-    hist = TH1.from_numpy((np.asarray(data), np.arange(len(data) + 1)))
-    hist._fName = histname
-    if histname in _ROOT_DATA_FILE:
-        raise KeyError(f"Duplicate key {histname} being written.")
-    _ROOT_DATA_FILE[histname] = hist
+def _export_root_histogram(hist_name, data):
+    if hist_name in _ROOT_DATA_FILE:
+        raise KeyError(f"Duplicate key {hist_name} being written.")
+    _ROOT_DATA_FILE[hist_name] = uproot.to_writable(
+        (np.asarray(data), np.arange(len(data) + 1))
+    )
 
 
 # https://stackoverflow.com/a/4590052
@@ -236,7 +234,7 @@ def build_sample(spec, samplespec, channelname):
     attrs = {
         'Name': samplespec['name'],
         'HistoName': histname,
-        'InputFile': _ROOT_DATA_FILE._path,
+        'InputFile': _ROOT_DATA_FILE.file_path,
         'NormalizeByTheory': 'False',
     }
     sample = ET.Element('Sample', **attrs)
@@ -255,7 +253,7 @@ def build_sample(spec, samplespec, channelname):
 
 def build_data(obsspec, channelname):
     histname = _make_hist_name(channelname, 'data')
-    data = ET.Element('Data', HistoName=histname, InputFile=_ROOT_DATA_FILE._path)
+    data = ET.Element('Data', HistoName=histname, InputFile=_ROOT_DATA_FILE.file_path)
 
     observation = next((obs for obs in obsspec if obs['name'] == channelname), None)
     _export_root_histogram(histname, observation['data'])
@@ -264,7 +262,7 @@ def build_data(obsspec, channelname):
 
 def build_channel(spec, channelspec, obsspec):
     channel = ET.Element(
-        'Channel', Name=channelspec['name'], InputFile=_ROOT_DATA_FILE._path
+        'Channel', Name=channelspec['name'], InputFile=_ROOT_DATA_FILE.file_path
     )
     if obsspec:
         data = build_data(obsspec, channelspec['name'])


Test patch:
diff --git a/tests/test_export.py b/tests/test_export.py
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -1,9 +1,12 @@
-import pyhf
-import pyhf.writexml
-import pytest
 import json
-import xml.etree.ElementTree as ET
 import logging
+import xml.etree.ElementTree as ET
+
+import pytest
+import uproot
+
+import pyhf
+import pyhf.writexml
 
 
 def spec_staterror():
@@ -392,9 +395,26 @@ def test_export_data(mocker):
     assert pyhf.writexml._ROOT_DATA_FILE.__setitem__.called
 
 
+def test_export_root_histogram(mocker, tmp_path):
+    """
+    Test that pyhf.writexml._export_root_histogram writes out a histogram
+    in the manner that uproot is expecting and verifies this by reading
+    the serialized file
+    """
+    mocker.patch("pyhf.writexml._ROOT_DATA_FILE", {})
+    pyhf.writexml._export_root_histogram("hist", [0, 1, 2, 3, 4, 5, 6, 7, 8])
+
+    with uproot.recreate(tmp_path.joinpath("test_export_root_histogram.root")) as file:
+        file["hist"] = pyhf.writexml._ROOT_DATA_FILE["hist"]
+
+    with uproot.open(tmp_path.joinpath("test_export_root_histogram.root")) as file:
+        assert file["hist"].values().tolist() == [0, 1, 2, 3, 4, 5, 6, 7, 8]
+        assert file["hist"].axis().edges().tolist() == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
+        assert file["hist"].name == "hist"
+
+
 def test_export_duplicate_hist_name(mocker):
     mocker.patch('pyhf.writexml._ROOT_DATA_FILE', new={'duplicate_name': True})
-    mocker.patch.object(pyhf.writexml, 'TH1')
 
     with pytest.raises(KeyError):
         pyhf.writexml._export_root_histogram('duplicate_name', [0, 1, 2])