Repository: scikit-hep/pyhf

Issue:
pyhf spec normalize
# Description

it might be nice to be able to normalize a pyhf spec

* sort samples alphabetically
* sort modifiers alphabetically

and similar things to ease e.g. comparisong with jsondiff and patch creation


assumptions

```
pyhf cls spec.json
pyhf spec normalize spec.json > normalized.json
pyhf cls normalized.json
```

should produce the same results

(based on discussions with @ntadej  @danikam @kratsg )
jq workspace sort
```
alias sortws="jq -S '.channels|=sort_by(.name)|.channels[].samples|=sort_by(.name)|.channels[].samples[].modifiers|=sort_by(.name,.type)|.observations|=sort_by(.name)'"
```


Discussion:
Issue #1033 might be something to keep in mind around the same time this get addressed.
This was ridiculously helpful. :+1: Clutch `jq` skills @kratsg. :wink: 

Resolving patch:
diff --git a/src/pyhf/cli/cli.py b/src/pyhf/cli/cli.py
--- a/src/pyhf/cli/cli.py
+++ b/src/pyhf/cli/cli.py
@@ -26,6 +26,7 @@ def pyhf():
 pyhf.add_command(spec.rename)
 pyhf.add_command(spec.combine)
 pyhf.add_command(spec.digest)
+pyhf.add_command(spec.sort)
 
 # pyhf.add_command(infer.cli)
 pyhf.add_command(infer.cls)
diff --git a/src/pyhf/cli/spec.py b/src/pyhf/cli/spec.py
--- a/src/pyhf/cli/spec.py
+++ b/src/pyhf/cli/spec.py
@@ -353,3 +353,44 @@ def digest(workspace, algorithm, output_json):
         )
 
     click.echo(output)
+
+
+@cli.command()
+@click.argument('workspace', default='-')
+@click.option(
+    '--output-file',
+    help='The location of the output json file. If not specified, prints to screen.',
+    default=None,
+)
+def sort(workspace, output_file):
+    """
+    Sort the workspace.
+
+    See :func:`pyhf.workspace.Workspace.sorted` for more information.
+
+    Example:
+
+    .. code-block:: shell
+
+        $ curl -sL https://raw.githubusercontent.com/scikit-hep/pyhf/master/docs/examples/json/2-bin_1-channel.json | pyhf sort | jq '.' | md5
+        8be5186ec249d2704e14dd29ef05ffb0
+
+    .. code-block:: shell
+
+        $ curl -sL https://raw.githubusercontent.com/scikit-hep/pyhf/master/docs/examples/json/2-bin_1-channel.json | jq -S '.channels|=sort_by(.name)|.channels[].samples|=sort_by(.name)|.channels[].samples[].modifiers|=sort_by(.name,.type)|.observations|=sort_by(.name)' | md5
+        8be5186ec249d2704e14dd29ef05ffb0
+
+
+    """
+    with click.open_file(workspace, 'r') as specstream:
+        spec = json.load(specstream)
+
+    workspace = Workspace(spec)
+    sorted_ws = Workspace.sorted(workspace)
+
+    if output_file is None:
+        click.echo(json.dumps(sorted_ws, indent=4, sort_keys=True))
+    else:
+        with open(output_file, 'w+') as out_file:
+            json.dump(sorted_ws, out_file, indent=4, sort_keys=True)
+        log.debug(f"Written to {output_file}")
diff --git a/src/pyhf/workspace.py b/src/pyhf/workspace.py
--- a/src/pyhf/workspace.py
+++ b/src/pyhf/workspace.py
@@ -676,3 +676,31 @@ def combine(cls, left, right, join='none'):
             'version': new_version,
         }
         return cls(newspec)
+
+    @classmethod
+    def sorted(cls, workspace):
+        """
+        Return a new workspace specification that is sorted.
+
+        Args:
+            workspace (~pyhf.workspace.Workspace): A workspace to sort
+
+        Returns:
+            ~pyhf.workspace.Workspace: A new sorted workspace object
+
+        """
+        newspec = copy.deepcopy(dict(workspace))
+
+        newspec['channels'].sort(key=lambda e: e['name'])
+        for channel in newspec['channels']:
+            channel['samples'].sort(key=lambda e: e['name'])
+            for sample in channel['samples']:
+                sample['modifiers'].sort(key=lambda e: (e['name'], e['type']))
+
+        newspec['measurements'].sort(key=lambda e: e['name'])
+        for measurement in newspec['measurements']:
+            measurement['config']['parameters'].sort(key=lambda e: e['name'])
+
+        newspec['observations'].sort(key=lambda e: e['name'])
+
+        return cls(newspec)


Test patch:
diff --git a/tests/test_scripts.py b/tests/test_scripts.py
--- a/tests/test_scripts.py
+++ b/tests/test_scripts.py
@@ -539,3 +539,30 @@ def test_patchset_apply(datadir, tmpdir, script_runner, output_file):
         "hi": 1.2,
         "lo": 0.8,
     }
+
+
+def test_sort(tmpdir, script_runner):
+    temp = tmpdir.join("parsed_output.json")
+    command = 'pyhf xml2json validation/xmlimport_input/config/example.xml --basedir validation/xmlimport_input/ --output-file {0:s} --hide-progress'.format(
+        temp.strpath
+    )
+    ret = script_runner.run(*shlex.split(command))
+
+    command = f'pyhf sort {temp.strpath}'
+
+    ret = script_runner.run(*shlex.split(command))
+    assert ret.success
+
+
+def test_sort_outfile(tmpdir, script_runner):
+    temp = tmpdir.join("parsed_output.json")
+    command = 'pyhf xml2json validation/xmlimport_input/config/example.xml --basedir validation/xmlimport_input/ --output-file {0:s} --hide-progress'.format(
+        temp.strpath
+    )
+    ret = script_runner.run(*shlex.split(command))
+
+    tempout = tmpdir.join("sort_output.json")
+    command = f'pyhf sort {temp.strpath} --output-file {tempout.strpath}'
+
+    ret = script_runner.run(*shlex.split(command))
+    assert ret.success
diff --git a/tests/test_workspace.py b/tests/test_workspace.py
--- a/tests/test_workspace.py
+++ b/tests/test_workspace.py
@@ -746,3 +746,18 @@ class FooWorkspace(pyhf.Workspace):
 
     combined = FooWorkspace.combine(ws, new_ws)
     assert isinstance(combined, FooWorkspace)
+
+
+def test_sorted(workspace_factory):
+    ws = workspace_factory()
+    # force the first sample in each channel to be last
+    for channel in ws['channels']:
+        channel['samples'][0]['name'] = 'zzzzlast'
+
+    new_ws = pyhf.Workspace.sorted(ws)
+    for channel in ws['channels']:
+        # check no sort
+        assert channel['samples'][0]['name'] == 'zzzzlast'
+    for channel in new_ws['channels']:
+        # check sort
+        assert channel['samples'][-1]['name'] == 'zzzzlast'