Merge ~artivis/lpci:feature/yaml-include into lpci:main

Proposed by jeremie
Status: Needs review
Proposed branch: ~artivis/lpci:feature/yaml-include
Merge into: lpci:main
Prerequisite: ~artivis/lpci:feature/yaml-alias-filter
Diff against target: 388 lines (+304/-4) (has conflicts)
4 files modified
lpcraft/config.py (+1/-1)
lpcraft/tests/test_config.py (+211/-2)
lpcraft/tests/test_utils.py (+21/-1)
lpcraft/utils.py (+71/-0)
Conflict in lpcraft/tests/test_config.py
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+435460@code.launchpad.net

Description of the change

[Do Not Merge Yet]

Note: This is based on top of another branch (https://git.launchpad.net/~artivis/lpcraft/tree/?h=feature/yaml-alias-filter) which may replace MP https://code.launchpad.net/~artivis/lpcraft/+git/lpcraft/+merge/435370

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]

To post a comment you must log in.
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

Similar to the notes on YAML aliases, let's discuss whether an external tool / script could be a viable solution.
https://chat.canonical.com/canonical/pl/r71cgk3ejtr1zmbfnfitbi187c

Unmerged commits

1bee04c... by jeremie

test yaml inclusion

Signed-off-by: artivis <email address hidden>

Succeeded
[SUCCEEDED] test:0 (build)
[SUCCEEDED] build:0 (build)
12 of 2 results
e1c0554... by jeremie

add support for yaml inclusion

Signed-off-by: artivis <email address hidden>

5e3aa4f... by jeremie

couple tests yaml anchor/alias

Signed-off-by: artivis <email address hidden>

Succeeded
[SUCCEEDED] test:0 (build)
[SUCCEEDED] build:0 (build)
12 of 2 results
31e792a... by jeremie

pydantic filter out extra fields with prefix

Signed-off-by: artivis <email address hidden>

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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):
373 """Filter out extension field."""373 """Filter out extension field."""
374 expanded_values = values.copy()374 expanded_values = values.copy()
375 for k, v in values.items():375 for k, v in values.items():
376 if k.startswith("."):376 if k.startswith(".") or k == "include":
377 expanded_values.pop(k)377 expanded_values.pop(k)
378 return expanded_values378 return expanded_values
379379
diff --git a/lpcraft/tests/test_config.py b/lpcraft/tests/test_config.py
index 8695520..6ca9de8 100644
--- a/lpcraft/tests/test_config.py
+++ b/lpcraft/tests/test_config.py
@@ -16,6 +16,7 @@ from testtools.matchers import (
16 MatchesStructure,16 MatchesStructure,
17)17)
1818
19<<<<<<< lpcraft/tests/test_config.py
19from lpcraft.config import (20from lpcraft.config import (
20 LAUNCHPAD_PPA_BASE_URL,21 LAUNCHPAD_PPA_BASE_URL,
21 Config,22 Config,
@@ -25,6 +26,10 @@ from lpcraft.config import (
25 get_ppa_url_parts,26 get_ppa_url_parts,
26)27)
27from lpcraft.errors import CommandError28from lpcraft.errors import CommandError
29=======
30from lpcraft.config import Config, OutputDistributeEnum, PackageRepository
31from lpcraft.errors import CommandError, ConfigurationError
32>>>>>>> lpcraft/tests/test_config.py
2833
2934
30class TestConfig(TestCase):35class TestConfig(TestCase):
@@ -35,8 +40,8 @@ class TestConfig(TestCase):
35 # So switch to the created project directory.40 # So switch to the created project directory.
36 os.chdir(self.tempdir)41 os.chdir(self.tempdir)
3742
38 def create_config(self, text):43 def create_config(self, text, filename=".launchpad.yaml"):
39 path = self.tempdir / ".launchpad.yaml"44 path = self.tempdir / filename
40 path.write_text(text)45 path.write_text(text)
41 return path46 return path
4247
@@ -909,3 +914,207 @@ class TestConfig(TestCase):
909 ),914 ),
910 ),915 ),
911 )916 )
917
918 def test_yaml_include(self):
919 # Include a config file given a relative path
920 self.create_config(
921 dedent(
922 """
923 .test:
924 series: bionic
925 architectures: [amd64]
926 """
927 ),
928 ".included.launchpad.yaml",
929 )
930
931 path = self.create_config(
932 dedent(
933 """
934 pipeline:
935 - test
936
937 include:
938 - .included.launchpad.yaml
939
940 jobs:
941 test-a:
942 series: focal
943 architectures: [amd64]
944
945 test-b:
946 extends: .test
947 packages: [file]
948 """
949 )
950 )
951
952 config = Config.load(path)
953
954 self.assertThat(
955 config,
956 MatchesStructure(
957 pipeline=Equals([["test"]]),
958 jobs=MatchesDict(
959 {
960 "test-a": MatchesListwise(
961 [
962 MatchesStructure.byEquality(
963 series="focal",
964 architectures=["amd64"],
965 )
966 ]
967 ),
968 "test-b": MatchesListwise(
969 [
970 MatchesStructure.byEquality(
971 series="bionic",
972 architectures=["amd64"],
973 packages=["file"],
974 )
975 ]
976 ),
977 }
978 ),
979 ),
980 )
981
982 def test_yaml_include_abs_path(self):
983 # Include a config file given an absolute path
984 tempdir = Path(self.useFixture(TempDir()).path)
985 included_config_path = tempdir / ".included.launchpad.yaml"
986 included_config_path.write_text(
987 dedent(
988 """
989 .test:
990 series: bionic
991 architectures: [amd64]
992 """
993 )
994 )
995
996 base_config_path = self.create_config(
997 dedent(
998 f"""
999 pipeline:
1000 - test
1001
1002 include:
1003 - '{included_config_path}'
1004
1005 jobs:
1006 test-a:
1007 series: focal
1008 architectures: [amd64]
1009
1010 test-b:
1011 extends: .test
1012 packages: [file]
1013 """
1014 )
1015 )
1016
1017 config = Config.load(base_config_path)
1018
1019 self.assertNotEqual(
1020 base_config_path.parent, included_config_path.parent
1021 )
1022
1023 self.assertThat(
1024 config,
1025 MatchesStructure(
1026 pipeline=Equals([["test"]]),
1027 jobs=MatchesDict(
1028 {
1029 "test-a": MatchesListwise(
1030 [
1031 MatchesStructure.byEquality(
1032 series="focal",
1033 architectures=["amd64"],
1034 )
1035 ]
1036 ),
1037 "test-b": MatchesListwise(
1038 [
1039 MatchesStructure.byEquality(
1040 series="bionic",
1041 architectures=["amd64"],
1042 packages=["file"],
1043 )
1044 ]
1045 ),
1046 }
1047 ),
1048 ),
1049 )
1050
1051 def test_yaml_bad_include(self):
1052 # Include a config file that doesn't exists.
1053 path = self.create_config(
1054 dedent(
1055 """
1056 pipeline:
1057 - test
1058
1059 include:
1060 - bad_include.launchpad.yaml
1061
1062 jobs:
1063 test:
1064 series: focal
1065 architectures: [amd64]
1066 """
1067 )
1068 )
1069
1070 self.assertRaisesRegex(
1071 ConfigurationError,
1072 "Couldn't find config file 'bad_include.launchpad.yaml'.",
1073 Config.load,
1074 path,
1075 )
1076
1077 def test_yaml_include_not_mapping(self):
1078 # Include a config file that is not defining a mapping
1079 self.create_config(
1080 dedent(
1081 """
1082 - foo
1083 """
1084 ),
1085 ".launchpad.included.yaml",
1086 )
1087
1088 path = self.create_config(
1089 dedent(
1090 """
1091 include:
1092 - .launchpad.included.yaml
1093 """
1094 )
1095 )
1096
1097 self.assertRaisesRegex(
1098 ConfigurationError,
1099 "Config file '.launchpad.included.yaml' does not define a mapping",
1100 Config.load,
1101 path,
1102 )
1103
1104 def test_yaml_bad_extends(self):
1105 # Extends a node that doesn't exists.
1106 path = self.create_config(
1107 dedent(
1108 """
1109 test:
1110 extends: .test
1111 """
1112 )
1113 )
1114
1115 self.assertRaisesRegex(
1116 ConfigurationError,
1117 "Couldn't find extension '.test'.",
1118 Config.load,
1119 path,
1120 )
diff --git a/lpcraft/tests/test_utils.py b/lpcraft/tests/test_utils.py
index 52b42e3..86cbda1 100644
--- a/lpcraft/tests/test_utils.py
+++ b/lpcraft/tests/test_utils.py
@@ -11,7 +11,7 @@ from systemfixtures import FakeProcesses
11from testtools import TestCase11from testtools import TestCase
1212
13from lpcraft.errors import ConfigurationError13from lpcraft.errors import ConfigurationError
14from lpcraft.utils import ask_user, get_host_architecture, load_yaml14from lpcraft.utils import ask_user, find, get_host_architecture, load_yaml
1515
1616
17class TestLoadYAML(TestCase):17class TestLoadYAML(TestCase):
@@ -66,6 +66,26 @@ class TestLoadYAML(TestCase):
66 path,66 path,
67 )67 )
6868
69 def test_find(self):
70 self.assertEqual(42, next(find({"a": 42}, "a")))
71 self.assertEqual(42, next(find({"b": 41, "a": 42}, "a")))
72
73 self.assertEqual(42, next(find({"b": {"a": 42}}, "a")))
74
75 self.assertEqual(42, next(find([{"b": 41, "a": 42}], "a")))
76 self.assertEqual(42, next(find([{"b": 41}, {"a": 42}], "a")))
77 self.assertEqual(42, next(find([("b", {"a": 42})], "a")))
78
79 def find_raises_map():
80 next(find({"a": 42}, "b"))
81
82 self.assertRaises(StopIteration, find_raises_map)
83
84 def find_raises_list():
85 next(find([1, 2, 3], "b"))
86
87 self.assertRaises(StopIteration, find_raises_list)
88
6989
70class TestGetHostArchitecture(TestCase):90class TestGetHostArchitecture(TestCase):
71 def setUp(self):91 def setUp(self):
diff --git a/lpcraft/utils.py b/lpcraft/utils.py
index a369cbd..9b5ef19 100644
--- a/lpcraft/utils.py
+++ b/lpcraft/utils.py
@@ -18,6 +18,71 @@ import yaml
18from lpcraft.errors import ConfigurationError18from lpcraft.errors import ConfigurationError
1919
2020
21def find(d: Any, key: Any) -> Any:
22 """Recursively search in dict/list/tuple."""
23 if isinstance(d, dict):
24 if key in d:
25 yield d[key]
26 for v in d.values():
27 for i in find(v, key):
28 yield i
29 if isinstance(d, (list, tuple)):
30 for v in d:
31 for i in find(v, key):
32 yield i
33
34
35def expand_yaml_extends(loaded: Any, root: Dict[Any, Any]) -> None:
36 """Expand 'extends' entries.
37
38 extends entries are searched for throughout the entire configuration.
39 When found, their associated value (pseudo-alias) is then
40 searched for throughout the entire configuration.
41 The value associated to the pseudo-alias is copied
42 at the level the initial 'extends' key was found at.
43 Finally the 'extends' entry is entirely removed.
44 """
45 tag = "extends"
46 if isinstance(loaded, dict):
47 for _, v in loaded.items():
48 if isinstance(v, (dict, list, tuple)):
49 expand_yaml_extends(v, root)
50 if tag in loaded.keys():
51 v = loaded.pop(tag)
52
53 try:
54 val = next(find(root, v))
55 except StopIteration:
56 raise ConfigurationError(
57 f"Couldn't find extension {str(v)!r}."
58 )
59
60 loaded.update(val)
61 elif isinstance(loaded, (list, tuple)):
62 for v in loaded:
63 expand_yaml_extends(v, root)
64
65
66def load_yaml_includes(loaded: Dict[str, Any]) -> None:
67 """Load all configuration files listed as include."""
68 for include in loaded.get("include", []):
69 include_path = Path(include)
70 if not include_path.is_file():
71 raise ConfigurationError(
72 f"Couldn't find config file {str(include_path)!r}."
73 )
74 with include_path.open("rb") as f:
75
76 included = yaml.safe_load(f)
77
78 if not isinstance(included, dict):
79 raise ConfigurationError(
80 f"Config file {str(include_path)!r} does not define a mapping" # noqa: E501
81 )
82
83 loaded.update(included)
84
85
21def load_yaml(path: Path) -> Dict[Any, Any]:86def load_yaml(path: Path) -> Dict[Any, Any]:
22 """Return the content of a YAML file."""87 """Return the content of a YAML file."""
23 if not path.is_file():88 if not path.is_file():
@@ -25,10 +90,16 @@ def load_yaml(path: Path) -> Dict[Any, Any]:
25 try:90 try:
26 with path.open("rb") as f:91 with path.open("rb") as f:
27 loaded = yaml.safe_load(f)92 loaded = yaml.safe_load(f)
93
28 if not isinstance(loaded, dict):94 if not isinstance(loaded, dict):
29 raise ConfigurationError(95 raise ConfigurationError(
30 f"Config file {str(path)!r} does not define a mapping"96 f"Config file {str(path)!r} does not define a mapping"
31 )97 )
98
99 load_yaml_includes(loaded)
100
101 expand_yaml_extends(loaded, loaded)
102
32 return loaded103 return loaded
33 except (yaml.error.YAMLError, OSError) as e:104 except (yaml.error.YAMLError, OSError) as e:
34 raise ConfigurationError(105 raise ConfigurationError(

Subscribers

People subscribed via source and target branches