From cc3ded3d2757da334df910511ee56e3f01c531cf Mon Sep 17 00:00:00 2001
From: Stefan Bienert <stefan.bienert@unibas.ch>
Date: Fri, 3 Mar 2023 17:00:08 +0100
Subject: [PATCH] Test example files

---
 pyproject.toml           |   2 +-
 validation/test-suite.py | 335 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 336 insertions(+), 1 deletion(-)
 create mode 100644 validation/test-suite.py

diff --git a/pyproject.toml b/pyproject.toml
index 33018da..4805e0a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
 [tool.black]
-line-length=80
+line-length=79
 
 [tool.pylint.REPORTS]
 reports='no'
diff --git a/validation/test-suite.py b/validation/test-suite.py
new file mode 100644
index 0000000..9f46a72
--- /dev/null
+++ b/validation/test-suite.py
@@ -0,0 +1,335 @@
+# Its a script, allow nicely formatted name
+# pylint: disable=invalid-name
+# pylint: enable=invalid-name
+"""Test the validation tool - this is *NOT* a set of unit tests for the
+validation tool but functional tests. The test suite makes sure, that the
+validation tool is working as intended, scanning ModelCIF files/ mmCIF files.
+"""
+from argparse import ArgumentParser
+import json
+import os
+import re
+import subprocess
+import sys
+
+import requests
+
+# Some global variables
+TST_FLS_DIR = "test_files"
+DCKR = "docker"  # `docker` command
+DCKR_IMG_RPO = "mmcif-dict-suite"  # Docker image "name"
+# collection of docker commads used
+DCKR_CMDS = {
+    "build": [DCKR, "build"],
+    "images": [DCKR, "images", "--format", "json"],
+    "inspect": [DCKR, "inspect", "--format", "json"],
+    "run": [DCKR, "run", "--rm"],
+}
+
+
+def _parse_args():
+    """Deal with command line arguments."""
+    parser = ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "-v",
+        "--verbose",
+        default=False,
+        action="store_true",
+        help="Print more output while running.",
+    )
+    args = parser.parse_args()
+
+    return args
+
+
+def _check_docker_installed():
+    """Make sure the `docker` command can be executed."""
+    # ToDo: check all Docker commands used in this script here (Add more over
+    #       time).
+    # just check `docker` as command on its own
+    args = [DCKR]
+    try:
+        subprocess.run(
+            args,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.STDOUT,
+            check=True,
+        )
+    except FileNotFoundError as exc:
+        if exc.filename == DCKR:
+            _print_abort(
+                "Looks like Docker is not installed, running command "
+                f"`{' '.join(args)}` failed."
+            )
+        raise
+    except subprocess.CalledProcessError as exc:
+        _print_abort(
+            "Looks like Docker does not work properly, test call "
+            f"(`{' '.join(exc.cmd)}`) failed with exit code {exc.returncode} "
+            f'and output:\n"""\n{exc.output.decode()}"""'
+        )
+
+    # check various docker commands used in this script
+    miss_arg_re = re.compile(r"requires (?:exactly|at least) 1 argument\.$")
+    for args in DCKR_CMDS.values():
+        try:
+            subprocess.run(
+                args,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.STDOUT,
+                check=True,
+            )
+        except subprocess.CalledProcessError as exc:
+            pass_ok = False
+            for line in exc.output.decode().splitlines():
+                if miss_arg_re.search(line):
+                    # This seems to be a default message of a working command
+                    # which lacks some arguments.
+                    pass_ok = True
+                    break
+
+            if not pass_ok:
+                _print_abort(
+                    "Looks like Docker does not work as expected, test call "
+                    f"(`{' '.join(exc.cmd)}`) failed with exit code "
+                    f'{exc.returncode} and output:\n"""\n'
+                    f'{exc.output.decode()}"""'
+                )
+
+
+def _get_modelcif_dic_version():
+    """Get the latest versionstring of the ModelCIF dictionary from the
+    official GitHub repo."""
+    rspns = requests.get(
+        "https://api.github.com/repos/ihmwg/ModelCIF/contents/archive",
+        headers={"accept": "application/vnd.github+json"},
+        timeout=180,
+    )
+    dic_re = re.compile(r"mmcif_ma-v(\d+)\.(\d+)\.(\d+).dic")
+    ltst = (0, 0, 0)
+    for arc_itm in rspns.json():
+        dic_mt = dic_re.match(arc_itm["name"])
+        if dic_mt:
+            mjr = int(dic_mt.group(1))
+            mnr = int(dic_mt.group(2))
+            htfx = int(dic_mt.group(3))
+            if mjr > ltst[0] or mnr > ltst[1] or htfx > ltst[2]:
+                ltst = (mjr, mnr, htfx)
+                continue
+
+    return f"v{'.'.join([str(x) for x in ltst])}"
+
+
+def _find_docker_image(repo_name, image_tag):
+    """Check that the Docker image to run validations is available. If its
+    there, return the name, None otherwise."""
+    dckr_p = subprocess.run(
+        DCKR_CMDS["images"],
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        check=True,
+    )
+    for j_line in dckr_p.stdout.decode().splitlines():
+        img = json.loads(j_line)
+        if img["Repository"] == repo_name and img["Tag"] == image_tag:
+            return f"{repo_name}:{image_tag}"
+
+    return None
+
+
+def _build_docker_image(repo_name, image_tag):
+    """Build the validation image."""
+    uid = os.getuid()
+    image = f"{repo_name}:{image_tag}"
+    args = DCKR_CMDS["build"]
+    args.extend(
+        [
+            "--build-arg",
+            f"MMCIF_USER_ID={uid}",
+            "-t",
+            image,
+            ".",
+        ]
+    )
+    subprocess.run(
+        args,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        check=True,
+        env={"DOCKER_BUILDKIT": "1"},
+    )
+
+    return image
+
+
+def _verify_docker_image(image_name, dic_version):
+    """Check certain version numbers inside the Docker image."""
+    lbls2chk = {
+        "org.modelarchive.base-image": "python:3.9-alpine3.17",
+        "org.modelarchive.cpp-dict-pack.version": "v2.500",
+        "org.modelarchive.dict_release": dic_version[1:],
+    }
+
+    args = DCKR_CMDS["inspect"]
+    args.append(image_name)
+    dckr_p = subprocess.run(
+        args,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        check=True,
+        env={"DOCKER_BUILDKIT": "1"},
+    )
+    img_lbls = json.loads(dckr_p.stdout.decode())
+    assert len(img_lbls) == 1
+    img_lbls = img_lbls[0]["Config"]["Labels"]
+
+    for lbl, val in lbls2chk.items():
+        if lbl not in img_lbls:
+            _print_abort(f"Label '{lbl}' not found in image '{image_name}'.")
+        if img_lbls[lbl] != val:
+            _print_abort(
+                f"Label '{lbl}' ({img_lbls[lbl]}) in image '{image_name}' "
+                + f"does not equal the reference value '{val}'."
+            )
+
+
+def _test_file(cif_file, cif_dir, image, expected_results):
+    """Check that a certain mmCIF file validates as expected"""
+    args = DCKR_CMDS["run"]
+    args.extend(
+        [
+            "-v",
+            f"{os.path.abspath(cif_dir)}:/data",
+            image,
+            "validate-mmcif-file",
+            "-a",
+            "/data",
+            f"/data/{cif_file}",
+        ]
+    )
+    # run validation
+    dckr_p = subprocess.run(
+        args,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        check=False,
+    )
+
+    # check output
+    if dckr_p.returncode != expected_results["ret_val"]:
+        _print_abort(
+            f"Exit value for '{cif_file}' not right: {dckr_p.returncode}, "
+            + f"expected: {expected_results['ret_val']}"
+        )
+
+    vldtn_json = json.loads(dckr_p.stdout.decode())
+    for report_key in ["cifcheck-errors", "status", "diagnosis"]:
+        if vldtn_json[report_key] != expected_results[report_key]:
+            _print_abort(
+                f"Validation report on '{cif_file}', value of '{report_key}' "
+                + f"not as expected, got:\n{vldtn_json[report_key]}\n"
+                + f"expected:\n{expected_results[report_key]}"
+            )
+
+
+def _print_abort(*args, **kwargs):
+    """Print an abort message and exit."""
+    print(*args, file=sys.stderr, **kwargs)
+    print("Aborting.", file=sys.stderr)
+    sys.exit(1)
+
+
+# This is a dummy function for non-verbose runs of this script. Unused
+# arguments are allowed at this point.    # pylint: disable=unused-argument
+# pylint: disable=unused-argument
+def _print_verbose(*args, **kwargs):
+    """Do not print anything."""
+
+
+# pylint: enable=unused-argument
+
+
+def _do_step(func, msg, *args, **kwargs):
+    """Perform next step decorated with a verbose message."""
+    _print_verbose(msg, "...")
+    ret_val = func(*args, **kwargs)
+    if isinstance(ret_val, str):
+        _print_verbose(f"{ret_val} ", end="")
+    _print_verbose("... done", msg)
+    return ret_val
+
+
+def _main():
+    """Run as script."""
+    expctd_rslts = {
+        "working.cif": {
+            "ret_val": 0,
+            "cifcheck-errors": [],
+            "status": "completed",
+            "diagnosis": [],
+        }
+    }
+
+    opts = _parse_args()
+    if opts.verbose:
+        # For verbose printing, a functions redefined sow e do not need to
+        # carry an extra argument around, no special class or logger... simply
+        # 'print'. But in general don't use 'global'.
+        # Name of the variable is allowed so it looks more like an ordinary
+        # function.
+        # pylint: disable=global-statement,invalid-name
+        global _print_verbose
+        _print_verbose = print
+
+    # Make sure Docker is installed and necessary commands are available.
+    _do_step(_check_docker_installed, "checking Docker installation")
+    # Get expected image tag (latest ModelCIF dic version from GitHub)
+    dic_version = _do_step(
+        _get_modelcif_dic_version,
+        "fetching latest ModelCIF dictionary version",
+    )
+    # Make sure Docker image is present present
+    image = _do_step(
+        _find_docker_image,
+        f"searching for Docker image ({DCKR_IMG_RPO}:{dic_version})",
+        DCKR_IMG_RPO,
+        dic_version,
+    )
+    if image is None:
+        image = _do_step(
+            _build_docker_image,
+            f"building Docker image ({DCKR_IMG_RPO}:{dic_version})",
+            DCKR_IMG_RPO,
+            dic_version,
+        )
+    # Verify some version numbers inside the container
+    _do_step(
+        _verify_docker_image, "verifying Docker image", image, dic_version
+    )
+
+    # Run the actual tests of the validation script/ validate all files in
+    # test_files/.
+    test_files = os.listdir(TST_FLS_DIR)
+    for cif in test_files:
+        if not cif.endswith(".cif"):
+            continue
+        # check that file is has expected results
+        if cif not in expctd_rslts:
+            raise RuntimeError(
+                f"File '{cif}' does not have expected results to be tested."
+            )
+        _do_step(
+            _test_file,
+            f"checking on file '{cif}'",
+            cif,
+            TST_FLS_DIR,
+            image,
+            expctd_rslts[cif],
+        )
+
+
+if __name__ == "__main__":
+    _main()
+
+#  LocalWords:  pylint argparse ArgumentParser subprocess sys DCKR args exc
+#  LocalWords:  stdout stderr FileNotFoundError CalledProcessError returncode
-- 
GitLab