Repository: scikit-hep/pyhf

Issue:
Add mypy to pre-commit checks
...Use mypy in pre-commit and turn on "check untyped functions", and do just enough typing to get that up and running. (Again, not something to rush for a release).

_Originally posted by @henryiii in https://github.com/scikit-hep/pyhf/issues/1272#issuecomment-772064533_


Discussion:
Related to Issue #1284 and PR #948
@lukasheinrich @kratsg Let's move `mypy` work to `v0.7.0` to speed up the release of `v0.6.2`, unless you have strong objections.

Resolving patch:
diff --git a/src/pyhf/readxml.py b/src/pyhf/readxml.py
--- a/src/pyhf/readxml.py
+++ b/src/pyhf/readxml.py
@@ -1,8 +1,20 @@
 from __future__ import annotations
 
 import logging
-import os
-from typing import TYPE_CHECKING, Callable, Iterable, Tuple, Union, IO
+from typing import (
+    IO,
+    Callable,
+    Iterable,
+    List,
+    MutableMapping,
+    MutableSequence,
+    Sequence,
+    Set,
+    Tuple,
+    Union,
+    cast,
+)
+
 import xml.etree.ElementTree as ET
 from pathlib import Path
 
@@ -13,16 +25,32 @@
 from pyhf import compat
 from pyhf import exceptions
 from pyhf import schema
+from pyhf.typing import (
+    Channel,
+    HistoSys,
+    LumiSys,
+    Measurement,
+    Modifier,
+    NormFactor,
+    NormSys,
+    Observation,
+    Parameter,
+    ParameterBase,
+    PathOrStr,
+    Sample,
+    ShapeFactor,
+    ShapeSys,
+    StatError,
+    Workspace,
+)
 
 log = logging.getLogger(__name__)
 
-if TYPE_CHECKING:
-    PathOrStr = Union[str, os.PathLike[str]]
-else:
-    PathOrStr = Union[str, "os.PathLike[str]"]
-
-__FILECACHE__ = {}
+FileCacheType = MutableMapping[str, Tuple[Union[IO[str], IO[bytes]], Set[str]]]
 MountPathType = Iterable[Tuple[Path, Path]]
+ResolverType = Callable[[str], Path]
+
+__FILECACHE__: FileCacheType = {}
 
 __all__ = [
     "clear_filecache",
@@ -37,11 +65,11 @@
 ]
 
 
-def __dir__():
+def __dir__() -> list[str]:
     return __all__
 
 
-def resolver_factory(rootdir: Path, mounts: MountPathType) -> Callable[[str], Path]:
+def resolver_factory(rootdir: Path, mounts: MountPathType) -> ResolverType:
     def resolver(filename: str) -> Path:
         path = Path(filename)
         for host_path, mount_path in mounts:
@@ -55,7 +83,7 @@ def resolver(filename: str) -> Path:
     return resolver
 
 
-def extract_error(hist):
+def extract_error(hist: uproot.behaviors.TH1.TH1) -> list[float]:
     """
     Determine the bin uncertainties for a histogram.
 
@@ -71,10 +99,16 @@ def extract_error(hist):
     """
 
     variance = hist.variances() if hist.weighted else hist.to_numpy()[0]
-    return np.sqrt(variance).tolist()
+    return cast(List[float], np.sqrt(variance).tolist())
 
 
-def import_root_histogram(resolver, filename, path, name, filecache=None):
+def import_root_histogram(
+    resolver: ResolverType,
+    filename: str,
+    path: str,
+    name: str,
+    filecache: FileCacheType | None = None,
+) -> tuple[list[float], list[float]]:
     global __FILECACHE__
     filecache = filecache or __FILECACHE__
 
@@ -103,21 +137,25 @@ def import_root_histogram(resolver, filename, path, name, filecache=None):
 
 
 def process_sample(
-    sample, resolver, inputfile, histopath, channel_name, track_progress=False
-):
-    if 'InputFile' in sample.attrib:
-        inputfile = sample.attrib.get('InputFile')
-    if 'HistoPath' in sample.attrib:
-        histopath = sample.attrib.get('HistoPath')
+    sample: ET.Element,
+    resolver: ResolverType,
+    inputfile: str,
+    histopath: str,
+    channel_name: str,
+    track_progress: bool = False,
+) -> Sample:
+    inputfile = sample.attrib.get('InputFile', inputfile)
+    histopath = sample.attrib.get('HistoPath', histopath)
     histoname = sample.attrib['HistoName']
 
     data, err = import_root_histogram(resolver, inputfile, histopath, histoname)
 
-    parameter_configs = []
-    modifiers = []
+    parameter_configs: MutableSequence[Parameter] = []
+    modifiers: MutableSequence[Modifier] = []
     # first check if we need to add lumi modifier for this sample
     if sample.attrib.get("NormalizeByTheory", "False") == 'True':
-        modifiers.append({'name': 'lumi', 'type': 'lumi', 'data': None})
+        modifier_lumi: LumiSys = {'name': 'lumi', 'type': 'lumi', 'data': None}
+        modifiers.append(modifier_lumi)
 
     modtags = tqdm.tqdm(
         sample.iter(), unit='modifier', disable=not (track_progress), total=len(sample)
@@ -130,21 +168,23 @@ def process_sample(
         if modtag == sample:
             continue
         if modtag.tag == 'OverallSys':
-            modifiers.append(
-                {
-                    'name': modtag.attrib['Name'],
-                    'type': 'normsys',
-                    'data': {
-                        'lo': float(modtag.attrib['Low']),
-                        'hi': float(modtag.attrib['High']),
-                    },
-                }
-            )
+            modifier_normsys: NormSys = {
+                'name': modtag.attrib['Name'],
+                'type': 'normsys',
+                'data': {
+                    'lo': float(modtag.attrib['Low']),
+                    'hi': float(modtag.attrib['High']),
+                },
+            }
+            modifiers.append(modifier_normsys)
         elif modtag.tag == 'NormFactor':
-            modifiers.append(
-                {'name': modtag.attrib['Name'], 'type': 'normfactor', 'data': None}
-            )
-            parameter_config = {
+            modifier_normfactor: NormFactor = {
+                'name': modtag.attrib['Name'],
+                'type': 'normfactor',
+                'data': None,
+            }
+            modifiers.append(modifier_normfactor)
+            parameter_config: Parameter = {
                 'name': modtag.attrib['Name'],
                 'bounds': [[float(modtag.attrib['Low']), float(modtag.attrib['High'])]],
                 'inits': [float(modtag.attrib['Val'])],
@@ -166,13 +206,12 @@ def process_sample(
                 modtag.attrib.get('HistoPathHigh', ''),
                 modtag.attrib['HistoNameHigh'],
             )
-            modifiers.append(
-                {
-                    'name': modtag.attrib['Name'],
-                    'type': 'histosys',
-                    'data': {'lo_data': lo, 'hi_data': hi},
-                }
-            )
+            modifier_histosys: HistoSys = {
+                'name': modtag.attrib['Name'],
+                'type': 'histosys',
+                'data': {'lo_data': lo, 'hi_data': hi},
+            }
+            modifiers.append(modifier_histosys)
         elif modtag.tag == 'StatError' and modtag.attrib['Activate'] == 'True':
             if modtag.attrib.get('HistoName', '') == '':
                 staterr = err
@@ -186,13 +225,12 @@ def process_sample(
                 staterr = np.multiply(extstat, data).tolist()
             if not staterr:
                 raise RuntimeError('cannot determine stat error.')
-            modifiers.append(
-                {
-                    'name': f'staterror_{channel_name}',
-                    'type': 'staterror',
-                    'data': staterr,
-                }
-            )
+            modifier_staterror: StatError = {
+                'name': f'staterror_{channel_name}',
+                'type': 'staterror',
+                'data': staterr,
+            }
+            modifiers.append(modifier_staterror)
         elif modtag.tag == 'ShapeSys':
             # NB: ConstraintType is ignored
             if modtag.attrib.get('ConstraintType', 'Poisson') != 'Poisson':
@@ -207,17 +245,19 @@ def process_sample(
                 modtag.attrib['HistoName'],
             )
             # NB: we convert relative uncertainty to absolute uncertainty
-            modifiers.append(
-                {
-                    'name': modtag.attrib['Name'],
-                    'type': 'shapesys',
-                    'data': [a * b for a, b in zip(data, shapesys_data)],
-                }
-            )
+            modifier_shapesys: ShapeSys = {
+                'name': modtag.attrib['Name'],
+                'type': 'shapesys',
+                'data': [a * b for a, b in zip(data, shapesys_data)],
+            }
+            modifiers.append(modifier_shapesys)
         elif modtag.tag == 'ShapeFactor':
-            modifiers.append(
-                {'name': modtag.attrib['Name'], 'type': 'shapefactor', 'data': None}
-            )
+            modifier_shapefactor: ShapeFactor = {
+                'name': modtag.attrib['Name'],
+                'type': 'shapefactor',
+                'data': None,
+            }
+            modifiers.append(modifier_shapefactor)
         else:
             log.warning('not considering modifier tag %s', modtag)
 
@@ -229,22 +269,27 @@ def process_sample(
     }
 
 
-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')
+def process_data(
+    sample: ET.Element,
+    resolver: ResolverType,
+    inputfile: str,
+    histopath: str,
+) -> list[float]:
+    inputfile = sample.attrib.get('InputFile', inputfile)
+    histopath = sample.attrib.get('HistoPath', histopath)
     histoname = sample.attrib['HistoName']
 
     data, _ = import_root_histogram(resolver, inputfile, histopath, histoname)
     return data
 
 
-def process_channel(channelxml, resolver, track_progress=False):
+def process_channel(
+    channelxml: ET.ElementTree, resolver: ResolverType, track_progress: bool = False
+) -> tuple[str, list[float], list[Sample], list[Parameter]]:
     channel = channelxml.getroot()
 
-    inputfile = channel.attrib.get('InputFile')
-    histopath = channel.attrib.get('HistoPath')
+    inputfile = channel.attrib.get('InputFile', '')
+    histopath = channel.attrib.get('HistoPath', '')
 
     samples = tqdm.tqdm(
         channel.findall('Sample'), unit='sample', disable=not (track_progress)
@@ -259,7 +304,7 @@ def process_channel(channelxml, resolver, track_progress=False):
         raise RuntimeError(f"Channel {channel_name} is missing data. See issue #1911.")
 
     results = []
-    channel_parameter_configs = []
+    channel_parameter_configs: list[Parameter] = []
     for sample in samples:
         samples.set_description(f"  - sample {sample.attrib.get('Name')}")
         result = process_sample(
@@ -271,7 +316,10 @@ def process_channel(channelxml, resolver, track_progress=False):
     return channel_name, parsed_data, results, channel_parameter_configs
 
 
-def process_measurements(toplvl, other_parameter_configs=None):
+def process_measurements(
+    toplvl: ET.ElementTree,
+    other_parameter_configs: Sequence[Parameter] | None = None,
+) -> list[Measurement]:
     """
     For a given XML structure, provide a parsed dictionary adhering to defs.json/#definitions/measurement.
 
@@ -283,11 +331,11 @@ def process_measurements(toplvl, other_parameter_configs=None):
         :obj:`dict`: A measurement object.
 
     """
-    results = []
+    results: list[Measurement] = []
     other_parameter_configs = other_parameter_configs if other_parameter_configs else []
 
     for x in toplvl.findall('Measurement'):
-        parameter_configs_map = {k['name']: dict(**k) for k in other_parameter_configs}
+        parameter_configs_map: MutableMapping[str, Parameter] = {k['name']: dict(**k) for k in other_parameter_configs}  # type: ignore[misc]
         lumi = float(x.attrib['Lumi'])
         lumierr = lumi * float(x.attrib['LumiRelErr'])
 
@@ -299,7 +347,7 @@ def process_measurements(toplvl, other_parameter_configs=None):
                 f"Measurement {measurement_name} is missing POI specification"
             )
 
-        result = {
+        result: Measurement = {
             'name': measurement_name,
             'config': {
                 'poi': poi.text.strip() if poi.text else '',
@@ -317,7 +365,7 @@ def process_measurements(toplvl, other_parameter_configs=None):
 
         for param in x.findall('ParamSetting'):
             # determine what all parameters in the paramsetting have in common
-            overall_param_obj = {}
+            overall_param_obj: ParameterBase = {}
             if param.attrib.get('Const'):
                 overall_param_obj['fixed'] = param.attrib['Const'] == 'True'
             if param.attrib.get('Val'):
@@ -326,21 +374,21 @@ def process_measurements(toplvl, other_parameter_configs=None):
             # might be specifying multiple parameters in the same ParamSetting
             if param.text:
                 for param_name in param.text.strip().split(' '):
-                    param_interpretation = compat.interpret_rootname(param_name)
+                    param_interpretation = compat.interpret_rootname(param_name)  # type: ignore[no-untyped-call]
                     if not param_interpretation['is_scalar']:
                         raise ValueError(
                             f'pyhf does not support setting non-scalar parameters ("gammas")  constant, such as for {param_name}.'
                         )
                     if param_interpretation['name'] == 'lumi':
-                        result['config']['parameters'][0].update(overall_param_obj)
+                        result['config']['parameters'][0].update(overall_param_obj)  # type: ignore[typeddict-item]
                     else:
                         # pop from parameter_configs_map because we don't want to duplicate
-                        param_obj = parameter_configs_map.pop(
+                        param_obj: Parameter = parameter_configs_map.pop(
                             param_interpretation['name'],
                             {'name': param_interpretation['name']},
                         )
                         # ParamSetting will always take precedence
-                        param_obj.update(overall_param_obj)
+                        param_obj.update(overall_param_obj)  # type: ignore[typeddict-item]
                         # add it back in to the parameter_configs_map
                         parameter_configs_map[param_interpretation['name']] = param_obj
         result['config']['parameters'].extend(parameter_configs_map.values())
@@ -349,8 +397,8 @@ def process_measurements(toplvl, other_parameter_configs=None):
     return results
 
 
-def dedupe_parameters(parameters):
-    duplicates = {}
+def dedupe_parameters(parameters: Sequence[Parameter]) -> list[Parameter]:
+    duplicates: MutableMapping[str, MutableSequence[Parameter]] = {}
     for p in parameters:
         duplicates.setdefault(p['name'], []).append(p)
     for parname in duplicates:
@@ -373,14 +421,14 @@ def parse(
     mounts: MountPathType | None = None,
     track_progress: bool = False,
     validation_as_error: bool = True,
-):
+) -> Workspace:
     """
     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``.
+        mounts (:obj:`None` or :obj:`list` of 2-:obj:`tuple` of :class:`os.PathLike` 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.
 
@@ -398,30 +446,27 @@ def parse(
     # create a resolver for finding files
     resolver = resolver_factory(Path(rootdir), mounts)
 
-    channels = {}
+    channels: MutableSequence[Channel] = []
+    observations: MutableSequence[Observation] = []
     parameter_configs = []
     for inp in inputs:
         inputs.set_description(f'Processing {inp}')
         channel, data, samples, channel_parameter_configs = process_channel(
             ET.parse(resolver(inp)), resolver, track_progress
         )
-        channels[channel] = {'data': data, 'samples': samples}
+        channels.append({'name': channel, 'samples': samples})
+        observations.append({'name': channel, 'data': data})
         parameter_configs.extend(channel_parameter_configs)
 
     parameter_configs = dedupe_parameters(parameter_configs)
-    result = {
-        'measurements': process_measurements(
-            toplvl, other_parameter_configs=parameter_configs
-        ),
-        'channels': [
-            {'name': channel_name, 'samples': channel_spec['samples']}
-            for channel_name, channel_spec in channels.items()
-        ],
-        'observations': [
-            {'name': channel_name, 'data': channel_spec['data']}
-            for channel_name, channel_spec in channels.items()
-        ],
-        'version': schema.version,
+    measurements = process_measurements(
+        toplvl, other_parameter_configs=parameter_configs
+    )
+    result: Workspace = {
+        'measurements': measurements,
+        'channels': channels,
+        'observations': observations,
+        'version': schema.version,  # type: ignore[typeddict-item]
     }
     try:
         schema.validate(result, 'workspace.json')
@@ -433,6 +478,6 @@ def parse(
     return result
 
 
-def clear_filecache():
+def clear_filecache() -> None:
     global __FILECACHE__
     __FILECACHE__ = {}
diff --git a/src/pyhf/schema/validator.py b/src/pyhf/schema/validator.py
--- a/src/pyhf/schema/validator.py
+++ b/src/pyhf/schema/validator.py
@@ -2,10 +2,10 @@
 import pyhf.exceptions
 from pyhf.schema.loader import load_schema
 from pyhf.schema import variables
-from typing import Union
+from typing import Union, Mapping
 
 
-def validate(spec: dict, schema_name: str, version: Union[str, None] = None):
+def validate(spec: Mapping, schema_name: str, version: Union[str, None] = None):
     """
     Validate a provided specification against a schema.
 
diff --git a/src/pyhf/typing.py b/src/pyhf/typing.py
new file mode 100644
--- /dev/null
+++ b/src/pyhf/typing.py
@@ -0,0 +1,136 @@
+import os
+import sys
+from typing import TYPE_CHECKING, MutableSequence, Sequence, Union
+
+if sys.version_info >= (3, 8):
+    from typing import Literal, TypedDict
+else:
+    from typing_extensions import Literal, TypedDict
+
+__all__ = (
+    "PathOrStr",
+    "ParameterBase",
+    "Parameter",
+    "Measurement",
+    "ModifierBase",
+    "NormSys",
+    "NormFactor",
+    "HistoSys",
+    "StatError",
+    "ShapeSys",
+    "ShapeFactor",
+    "LumiSys",
+    "Modifier",
+    "Sample",
+    "Channel",
+    "Observation",
+    "Workspace",
+)
+
+if TYPE_CHECKING:
+    PathOrStr = Union[str, os.PathLike[str]]
+else:
+    PathOrStr = Union[str, "os.PathLike[str]"]
+
+
+class ParameterBase(TypedDict, total=False):
+    auxdata: Sequence[float]
+    bounds: Sequence[Sequence[float]]
+    inits: Sequence[float]
+    sigmas: Sequence[float]
+    fixed: bool
+
+
+class Parameter(ParameterBase):
+    name: str
+
+
+class Config(TypedDict):
+    poi: str
+    parameters: MutableSequence[Parameter]
+
+
+class Measurement(TypedDict):
+    name: str
+    config: Config
+
+
+class ModifierBase(TypedDict):
+    name: str
+
+
+class NormSysData(TypedDict):
+    lo: float
+    hi: float
+
+
+class NormSys(ModifierBase):
+    type: Literal['normsys']
+    data: NormSysData
+
+
+class NormFactor(ModifierBase):
+    type: Literal['normfactor']
+    data: None
+
+
+class HistoSysData(TypedDict):
+    lo_data: Sequence[float]
+    hi_data: Sequence[float]
+
+
+class HistoSys(ModifierBase):
+    type: Literal['histosys']
+    data: HistoSysData
+
+
+class StatError(ModifierBase):
+    type: Literal['staterror']
+    data: Sequence[float]
+
+
+class ShapeSys(ModifierBase):
+    type: Literal['shapesys']
+    data: Sequence[float]
+
+
+class ShapeFactor(ModifierBase):
+    type: Literal['shapefactor']
+    data: None
+
+
+class LumiSys(TypedDict):
+    name: Literal['lumi']
+    type: Literal['lumi']
+    data: None
+
+
+Modifier = Union[
+    NormSys, NormFactor, HistoSys, StatError, ShapeSys, ShapeFactor, LumiSys
+]
+
+
+class SampleBase(TypedDict, total=False):
+    parameter_configs: Sequence[Parameter]
+
+
+class Sample(SampleBase):
+    name: str
+    data: Sequence[float]
+    modifiers: Sequence[Modifier]
+
+
+class Channel(TypedDict):
+    name: str
+    samples: Sequence[Sample]
+
+
+class Observation(TypedDict):
+    name: str
+    data: Sequence[float]
+
+
+class Workspace(TypedDict):
+    measurements: Sequence[Measurement]
+    channels: Sequence[Channel]
+    observations: Sequence[Observation]


Test patch:
diff --git a/tests/test_import.py b/tests/test_import.py
--- a/tests/test_import.py
+++ b/tests/test_import.py
@@ -506,7 +506,7 @@ def test_import_missingPOI(mocker, datadir):
         )
 
 
-def test_import_resolver(mocker):
+def test_import_resolver():
     rootdir = Path('/current/working/dir')
     mounts = [(Path('/this/path/changed'), Path('/my/abs/path'))]
     resolver = pyhf.readxml.resolver_factory(rootdir, mounts)