diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index bf5ccd3615ec12af29cbb647ea867e696296128e..bc2df1b0c1fd1d7f22a175daa47af54fd2e98ffe 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -621,22 +621,33 @@ sanity:
   script:
     - cd ${CI_PROJECT_DIR}/Python  # change into the Python directory
     - pip install --user -e '.[test]'  # install the package + test deps
-    - make all 2&>1 | tee python-test.log  # this runs all of the Python tests
+    - python -m mypy corsika
+    - python -m isort --atomic corsika tests
+    - python -m black -t py37 corsika tests
+    - python -m flake8 corsika tests
+    - python -m pytest --cov=corsika tests
     - cd ${CI_PROJECT_DIR}  # reset the directory
   coverage: '/^TOTAL\s*\d+\s*\d+\s*(.*\%)/'
+<<<<<<< HEAD
   artifacts:
     when: always
     expire_in: 1 year
     paths:
       - ${CI_PROJECT_DIR}/Python/python-test.log
   allow_failure: true
+=======
+>>>>>>> 927acbbd... Move 'read_hist' into 'corsika' and new CI.
 
-# we now configure the jobs for the three
-# supported Python versions
+# we now configure the jobs for the three supported Python versions. We run
+# Python 3.7 for all commits but only run Python 3.6/3.8 before merging or if
+# the MR is an explict Python MR with the 'Python' tag.
 python-3.6:
   extends: .python
   image: python:3.6
-
+  rules:
+    - if: '$CI_MERGE_REQUEST_LABELS =~ /Python/'
+    - if: '$CI_MERGE_REQUEST_LABELS =~ /Code review finished/'
+      
 python-3.7:
   extends: .python
   image: python:3.7
@@ -644,3 +655,6 @@ python-3.7:
 python-3.8:
   extends: .python
   image: python:3.8
+  rules:
+    - if: '$CI_MERGE_REQUEST_LABELS =~ /Python/'
+    - if: '$CI_MERGE_REQUEST_LABELS =~ /Code review finished/'
diff --git a/Python/.gitignore b/Python/.gitignore
index d82fa7a96c90159c0dba3052f3a09284f027a1a7..7b9e44cc96089e3d033d74eb46965d968bd28d52 100644
--- a/Python/.gitignore
+++ b/Python/.gitignore
@@ -3,6 +3,10 @@ __pycache__/
 *.py[cod]
 *$py.class
 
+# ignore any generated output files
+*.npz
+*.dat
+
 # C extensions
 *.so
 
diff --git a/Python/corsika/io/hist.py b/Python/corsika/io/hist.py
index e12dcfd1bc828b1ea759f6051cb45694e1956e79..615db0faec0ec63cff8fe00fc5ebe1d8ce08f2ff 100644
--- a/Python/corsika/io/hist.py
+++ b/Python/corsika/io/hist.py
@@ -14,7 +14,7 @@ import numpy as np
 
 def read_hist(filename: str) -> bh.Histogram:
     """
-    Read a histogram produced with CORSIKA8's `save_hist()` function.
+    Read a histogram produced with CORSIKA8's `SaveBoostHistogram()` function.
 
     Parameters
     ----------
diff --git a/Python/tests/__init__.py b/Python/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c7d9b672b4c1bde7cc7725c619c0390a79e682c7
--- /dev/null
+++ b/Python/tests/__init__.py
@@ -0,0 +1,56 @@
+"""
+ Tests for `corsika`
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+
+import os
+import os.path as op
+
+__all__ = ["build_directory"]
+
+
+def find_build_directory() -> str:
+    """
+    Return the absolute path to the CORSIKA8 build directory.
+
+    Returns
+    -------
+    str
+        The absolute path to the build directory.
+
+    Raises
+    ------
+    RuntimeError
+        If the build directory cannot be found or it is empty.
+    """
+
+    # check if we are running on Gitlab
+    gitlab_build_dir = os.getenv("CI_BUILDS_DIR")
+
+    # if we are running on Gitlab
+    if gitlab_build_dir is not None:
+        build_dir = op.abspath(gitlab_build_dir)
+    else:  # otherwise, we are running locally
+        build_dir = op.abspath(
+            op.join(
+                op.dirname(__file__),
+                op.pardir,
+                op.pardir,
+                "build",
+            )
+        )
+
+    # check that the build directory contains 'CMakeCache.txt'
+    if not op.exists(op.join(build_dir, "CMakeCache.txt")):
+        raise RuntimeError("Python tests cannot find C8 build directory.")
+
+    return build_dir
+
+
+# find the build_directory once
+build_directory = find_build_directory()
diff --git a/Python/tests/io/__init__.py b/Python/tests/io/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d08be944644953fd08405a4502456e76c1203b9
--- /dev/null
+++ b/Python/tests/io/__init__.py
@@ -0,0 +1,9 @@
+"""
+ Tests for `corsika.io`
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
diff --git a/Python/tests/io/test_hist.py b/Python/tests/io/test_hist.py
new file mode 100644
index 0000000000000000000000000000000000000000..7cf5b38fb7798c81680cf14d108e3b31dd3dcaf1
--- /dev/null
+++ b/Python/tests/io/test_hist.py
@@ -0,0 +1,70 @@
+"""
+ Tests for `corsika.io.hist`
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+import os
+import os.path as op
+import subprocess
+
+import corsika
+
+from .. import build_directory
+
+# the directory containing 'testSaveBoostHistogram'
+bindir = op.join(build_directory, "Framework", "Utilities")
+
+
+def generate_hist() -> str:
+    """
+    Generate a test with `testSaveBoostHistogram`.
+
+    Returns
+    -------
+    str
+        The path to the generated histogram.
+    bool
+        If True, this file was regenerated.
+    """
+
+    # we construct the name of the bin
+    bin = op.join(bindir, "testSaveBoostHistogram")
+
+    # check if a histogram already exists
+    if op.exists(op.join(bin, "hist.npz")):
+        return op.join(bin, "hist.npz"), False
+    else:
+        # run the program - this generates "hist.npz" in the CWD
+        subprocess.call(bin)
+
+        return op.join(os.getcwd(), "hist.npz"), True
+
+
+def test_corsika_io() -> None:
+    """
+    Test I can corsika.io without a further import.
+    """
+    corsika.io.read_hist
+
+
+def test_corsika_read_hist() -> None:
+    """
+    Check that I can read in the test histograms with `read_hist`.
+    """
+
+    # generate a test histogram
+    filename, delete = generate_hist()
+
+    # and try and read in a histogram
+    h = corsika.io.read_hist(filename)
+
+    # and delete the generated histogram
+    if delete:
+        os.remove(filename)
+
+    # and check that it isn't empty
+    assert not h.empty()
diff --git a/Python/tests/test_hist.py b/Python/tests/test_hist.py
deleted file mode 100644
index 0ad70b734c0af38baf180987ed97e01de02b0740..0000000000000000000000000000000000000000
--- a/Python/tests/test_hist.py
+++ /dev/null
@@ -1,36 +0,0 @@
-"""
- Tests for `corsika.io.hist`
-
- (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
-
- This software is distributed under the terms of the GNU General Public
- Licence version 3 (GPL Version 3). See file LICENSE for a full version of
- the license.
-"""
-import pytest
-
-import corsika
-
-
-def test_corsika_io() -> None:
-    """
-    Test I can corsika.io without a further import.
-    """
-    corsika.io.read_hist
-
-
-def test_corsika_read_hist() -> None:
-    """
-    Check that I can read in the test histograms with `read_hist`.
-    """
-
-    # try and read in a continuous histogram
-
-    # try and read in a discrete histogram
-
-
-def test_corsika_read_hist_fail() -> None:
-    """
-    Check that an exception is thrown when reading
-    an incorrectly formatted histogram.
-    """