← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~artivis/lpcraft:feature/yaml-include into lpcraft:main

 

jeremie has proposed merging ~artivis/lpcraft:feature/yaml-include into lpcraft:main with ~artivis/lpcraft:feature/yaml-alias-filter as a prerequisite.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~artivis/lpcraft/+git/lpcraft/+merge/435460

[Do Not Merge Yet]

Add support for yaml inclusion.

This allows for including other yaml files to a configuration file using the 'include' tag located at the root. Included file paths can be relative or absolute. Since yaml anchors are lost during the inclusion, this MP also introduce the 'extends' tag that essentially does the work of mapping.

Hereafter is an example of what that looks like,

# .included.launchpad.yaml
.test:
  series: bionic
  architectures: [amd64]

# .launchpad.yaml
pipeline:
  - test

include:
  - .included.launchpad.yaml
  
jobs:
  test-a:
      series: focal
      architectures: [amd64]
  test-b:
      extends: .test # maps '.test' nested entries
      packages: [file] # can be further extended with new entries

[Do Not Merge Yet]
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~artivis/lpcraft:feature/yaml-include into lpcraft:main.
diff --git a/lpcraft/config.py b/lpcraft/config.py
index 3564d9c..1409cde 100644
--- a/lpcraft/config.py
+++ b/lpcraft/config.py
@@ -373,7 +373,7 @@ class Config(ModelConfigDefaults):
         """Filter out extension field."""
         expanded_values = values.copy()
         for k, v in values.items():
-            if k.startswith("."):
+            if k.startswith(".") or k == "include":
                 expanded_values.pop(k)
         return expanded_values
 
diff --git a/lpcraft/tests/test_config.py b/lpcraft/tests/test_config.py
index 8695520..224f3c1 100644
--- a/lpcraft/tests/test_config.py
+++ b/lpcraft/tests/test_config.py
@@ -16,6 +16,7 @@ from testtools.matchers import (
     MatchesStructure,
 )
 
+<<<<<<< lpcraft/tests/test_config.py
 from lpcraft.config import (
     LAUNCHPAD_PPA_BASE_URL,
     Config,
@@ -25,6 +26,10 @@ from lpcraft.config import (
     get_ppa_url_parts,
 )
 from lpcraft.errors import CommandError
+=======
+from lpcraft.config import Config, OutputDistributeEnum, PackageRepository
+from lpcraft.errors import CommandError, ConfigurationError
+>>>>>>> lpcraft/tests/test_config.py
 
 
 class TestConfig(TestCase):
@@ -35,8 +40,8 @@ class TestConfig(TestCase):
         # So switch to the created project directory.
         os.chdir(self.tempdir)
 
-    def create_config(self, text):
-        path = self.tempdir / ".launchpad.yaml"
+    def create_config(self, text, filename=".launchpad.yaml"):
+        path = self.tempdir / filename
         path.write_text(text)
         return path
 
@@ -909,3 +914,205 @@ class TestConfig(TestCase):
                 ),
             ),
         )
+
+    def test_yaml_include(self):
+        # Include a config file given a relative path
+        self.create_config(
+            dedent(
+                """
+                .test:
+                    series: bionic
+                    architectures: [amd64]
+                """
+            ),
+            ".included.launchpad.yaml",
+        )
+
+        path = self.create_config(
+            dedent(
+                """
+                pipeline:
+                    - test
+
+                include:
+                  - .included.launchpad.yaml
+
+                jobs:
+                    test-a:
+                        series: focal
+                        architectures: [amd64]
+
+                    test-b:
+                        extends: .test
+                        packages: [file]
+                """
+            )
+        )
+
+        config = Config.load(path)
+
+        self.assertThat(
+            config,
+            MatchesStructure(
+                pipeline=Equals([["test"]]),
+                jobs=MatchesDict(
+                    {
+                        "test-a": MatchesListwise(
+                            [
+                                MatchesStructure.byEquality(
+                                    series="focal",
+                                    architectures=["amd64"],
+                                )
+                            ]
+                        ),
+                        "test-b": MatchesListwise(
+                            [
+                                MatchesStructure.byEquality(
+                                    series="bionic",
+                                    architectures=["amd64"],
+                                    packages=["file"],
+                                )
+                            ]
+                        ),
+                    }
+                ),
+            ),
+        )
+
+    def test_yaml_include_abs_path(self):
+        # Include a config file given an absolute path
+        tempdir = Path(self.useFixture(TempDir()).path)
+        included_config_path = tempdir / ".included.launchpad.yaml"
+        included_config_path.write_text(
+            dedent(
+                """
+                .test:
+                    series: bionic
+                    architectures: [amd64]
+                """
+            )
+        )
+
+        base_config_path = self.create_config(
+            dedent(
+                f"""
+                pipeline:
+                    - test
+
+                include:
+                  - '{included_config_path}'
+
+                jobs:
+                    test-a:
+                        series: focal
+                        architectures: [amd64]
+
+                    test-b:
+                        extends: .test
+                        packages: [file]
+                """
+            )
+        )
+
+        config = Config.load(base_config_path)
+
+        self.assertNotEqual(base_config_path.parent, included_config_path.parent)
+
+        self.assertThat(
+            config,
+            MatchesStructure(
+                pipeline=Equals([["test"]]),
+                jobs=MatchesDict(
+                    {
+                        "test-a": MatchesListwise(
+                            [
+                                MatchesStructure.byEquality(
+                                    series="focal",
+                                    architectures=["amd64"],
+                                )
+                            ]
+                        ),
+                        "test-b": MatchesListwise(
+                            [
+                                MatchesStructure.byEquality(
+                                    series="bionic",
+                                    architectures=["amd64"],
+                                    packages=["file"],
+                                )
+                            ]
+                        ),
+                    }
+                ),
+            ),
+        )
+
+    def test_yaml_bad_include(self):
+        # Include a config file that doesn't exists.
+        path = self.create_config(
+            dedent(
+                """
+                pipeline:
+                    - test
+
+                include:
+                  - bad_include.launchpad.yaml
+
+                jobs:
+                    test:
+                        series: focal
+                        architectures: [amd64]
+                """
+            )
+        )
+
+        self.assertRaisesRegex(
+            ConfigurationError,
+            "Couldn't find config file 'bad_include.launchpad.yaml'.",
+            Config.load,
+            path,
+        )
+
+    def test_yaml_include_not_mapping(self):
+        # Include a config file that is not defining a mapping
+        self.create_config(
+            dedent(
+                """
+                - foo
+                """
+            ),
+            ".launchpad.included.yaml",
+        )
+
+        path = self.create_config(
+            dedent(
+                """
+                include:
+                  - .launchpad.included.yaml
+                """
+            )
+        )
+
+        self.assertRaisesRegex(
+            ConfigurationError,
+            "Config file '.launchpad.included.yaml' does not define a mapping",
+            Config.load,
+            path,
+        )
+
+    def test_yaml_bad_extends(self):
+        # Extends a node that doesn't exists.
+        path = self.create_config(
+            dedent(
+                """
+                test:
+                    extends: .test
+                """
+            )
+        )
+
+        self.assertRaisesRegex(
+            ConfigurationError,
+            "Couldn't find extension '.test'.",
+            Config.load,
+            path,
+        )
diff --git a/lpcraft/tests/test_utils.py b/lpcraft/tests/test_utils.py
index 52b42e3..b4252bc 100644
--- a/lpcraft/tests/test_utils.py
+++ b/lpcraft/tests/test_utils.py
@@ -11,7 +11,7 @@ from systemfixtures import FakeProcesses
 from testtools import TestCase
 
 from lpcraft.errors import ConfigurationError
-from lpcraft.utils import ask_user, get_host_architecture, load_yaml
+from lpcraft.utils import ask_user, find, get_host_architecture, load_yaml
 
 
 class TestLoadYAML(TestCase):
@@ -66,6 +66,16 @@ class TestLoadYAML(TestCase):
             path,
         )
 
+    def test_find(self):
+        self.assertEqual(42, next(find({'a':42}, 'a')))
+        self.assertEqual(42, next(find({'b':41,'a':42}, 'a')))
+
+        self.assertEqual(42, next(find({'b':{'a':42}}, 'a')))
+
+        self.assertEqual(42, next(find([{'b':41,'a':42}], 'a')))
+        self.assertEqual(42, next(find([{'b':41},{'a':42}], 'a')))
+
+
 
 class TestGetHostArchitecture(TestCase):
     def setUp(self):
diff --git a/lpcraft/utils.py b/lpcraft/utils.py
index a369cbd..1aa346f 100644
--- a/lpcraft/utils.py
+++ b/lpcraft/utils.py
@@ -18,6 +18,71 @@ import yaml
 from lpcraft.errors import ConfigurationError
 
 
+def find(d: Any, key: Any) -> Any:
+    """Recursively search in dict/list/tuple."""
+    if isinstance(d, dict):
+        if key in d:
+            yield d[key]
+        for v in d.values():
+            for i in find(v, key):
+                yield i
+    if isinstance(d, (list, tuple)):
+        for v in d:
+            for i in find(v, key):
+                yield i
+
+
+def expand_yaml_extends(loaded: Any, root: Dict[Any, Any]) -> None:
+    """Expand 'extends' entries.
+
+    extends entries are searched for throughout the entire configuration.
+    When found, their associated value (pseudo-alias) is then
+    searched for throughout the entire configuration.
+    The value associated to the pseudo-alias is copied
+    at the level the initial 'extends' key was found at.
+    Finally the 'extends' entry is entirely removed.
+    """
+    tag = "extends"
+    if isinstance(loaded, dict):
+        for _, v in loaded.items():
+            if isinstance(v, (dict, list, tuple)):
+                expand_yaml_extends(v, root)
+        if tag in loaded.keys():
+            v = loaded.pop(tag)
+
+            try:
+                val = next(find(root, v))
+            except StopIteration:
+                raise ConfigurationError(
+                    f"Couldn't find extension {str(v)!r}."
+                )
+
+            loaded.update(val)
+    elif isinstance(loaded, (list, tuple)):
+        for v in loaded:
+            expand_yaml_extends(v, root)
+
+
+def load_yaml_includes(loaded: Dict[str, Any]) -> None:
+    """Load all configuration files listed as include."""
+    for include in loaded.get("include", []):
+        include_path = Path(include)
+        if not include_path.is_file():
+            raise ConfigurationError(
+                f"Couldn't find config file {str(include_path)!r}."
+            )
+        with include_path.open("rb") as f:
+
+            included = yaml.safe_load(f)
+
+            if not isinstance(included, dict):
+                raise ConfigurationError(
+                    f"Config file {str(include_path)!r} does not define a mapping"
+                )
+
+            loaded.update(included)
+
+
 def load_yaml(path: Path) -> Dict[Any, Any]:
     """Return the content of a YAML file."""
     if not path.is_file():
@@ -25,10 +90,16 @@ def load_yaml(path: Path) -> Dict[Any, Any]:
     try:
         with path.open("rb") as f:
             loaded = yaml.safe_load(f)
+
         if not isinstance(loaded, dict):
             raise ConfigurationError(
                 f"Config file {str(path)!r} does not define a mapping"
             )
+
+        load_yaml_includes(loaded)
+
+        expand_yaml_extends(loaded, loaded)
+
         return loaded
     except (yaml.error.YAMLError, OSError) as e:
         raise ConfigurationError(

Follow ups