Repository: MatterMiners/cobald

Issue:
Drop dependency on include package
The ``include`` package is currently used to load Python configuration files. Since it is not compatible with Py3.9 it limits ``cobald`` as well.

The package is no longer maintained. We should just [use the builtin ``importlib`` instead](https://stackoverflow.com/questions/67631/how-to-import-a-module-given-the-full-path).

Discussion:
maxfischer2781: CI is failing due to outdated formatting. This is fixed in #86.
maxfischer2781: Changed/commented the code to make it a tad more maintainable, and added a test for what I assume are expected usage errors.

Patch:
diff --git a/setup.py b/setup.py
index 9abe2b8..80c85bb 100644
--- a/setup.py
+++ b/setup.py
@@ -55,7 +55,6 @@ if __name__ == "__main__":
         install_requires=[
             "pyyaml",
             "trio>=0.4.0",
-            "include",
             "entrypoints",
             "toposort",
             "typing_extensions",
diff --git a/src/cobald/daemon/config/python.py b/src/cobald/daemon/config/python.py
index bf4fca7..81cfcff 100644
--- a/src/cobald/daemon/config/python.py
+++ b/src/cobald/daemon/config/python.py
@@ -1,5 +1,34 @@
-import include
+import pathlib
+import importlib.util
+import sys
+import itertools
+
+
+_unique_module_id = itertools.count()
 
 
 def load_configuration(path):
-    return include.path(path)
+    """
+    Load a configuration from a module stored at ``path``
+
+    The ``path`` must end in a valid file extension for the appropriate module type,
+    such as ``.py`` or ``.pyc`` for a plaintext or bytecode python module.
+
+    :raises ValueError: if the extension does not mark a known module type
+    """
+    # largely based on "Importing a source file directly"
+    # https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
+    current_index = next(_unique_module_id)
+    module_name = f"<cobald config {current_index}>"
+    # the following replicates the regular import machinery:
+    #     1. create the module metadata (spec)
+    #     2. create a module object base on the metadata
+    #     3. execute the source to populate the module
+    spec = importlib.util.spec_from_file_location(module_name, path)
+    if spec is None:
+        extension = pathlib.Path(path).suffix
+        raise ValueError(f"Unrecognized file extension {extension} for config {path}")
+    module = importlib.util.module_from_spec(spec)
+    sys.modules[module_name] = module
+    spec.loader.exec_module(module)
+    return module


Test patch:
diff --git a/cobald_tests/daemon/config/test_python.py b/cobald_tests/daemon/config/test_python.py
new file mode 100644
index 0000000..ec54018
--- /dev/null
+++ b/cobald_tests/daemon/config/test_python.py
@@ -0,0 +1,38 @@
+import tempfile
+import pytest
+
+from cobald.daemon.config.python import load_configuration
+
+
+def module_content(identifier):
+    return f"""\
+identifier = {identifier}
+"""
+
+
+def test_load_pyconfig():
+    with tempfile.NamedTemporaryFile(mode="w+", suffix=".py") as test_file:
+        test_file.write(module_content(0))
+        test_file.flush()
+        module = load_configuration(test_file.name)
+        assert module.identifier == 0
+
+
+def test_load_pyconfig_many():
+    modules = []
+    for ident in range(5):
+        with tempfile.NamedTemporaryFile(mode="w+", suffix=".py") as test_file:
+            test_file.write(module_content(ident))
+            test_file.flush()
+            modules.append((ident, load_configuration(test_file.name)))
+    for ident, module in modules:
+        assert ident == module.identifier
+
+
+@pytest.mark.parametrize("extension", [".pyi", ".yml", ""])
+def test_load_pyconfig_invalid(extension):
+    with pytest.raises(ValueError):
+        with tempfile.NamedTemporaryFile(mode="w+", suffix=extension) as test_file:
+            test_file.write(module_content("'unused'"))
+            test_file.flush()
+            _ = load_configuration(test_file.name)