← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/lpcraft:config-loader into lpcraft:main

 

Colin Watson has proposed merging ~cjwatson/lpcraft:config-loader into lpcraft:main with ~cjwatson/lpcraft:version-command as a prerequisite.

Commit message:
Add a basic configuration file parser

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/lpcraft/+git/lpcraft/+merge/411533
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/lpcraft:config-loader into lpcraft:main.
diff --git a/lpcraft/config.py b/lpcraft/config.py
new file mode 100644
index 0000000..7464488
--- /dev/null
+++ b/lpcraft/config.py
@@ -0,0 +1,47 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+from pathlib import Path
+from typing import Dict, List, Optional
+
+import pydantic
+from pydantic import StrictStr
+
+from lpcraft.utils import load_yaml
+
+
+class ModelConfigDefaults(
+    pydantic.BaseModel,
+    extra=pydantic.Extra.forbid,
+    frozen=True,
+    alias_generator=lambda s: s.replace("_", "-"),
+):
+    """Define lpcraft's model defaults."""
+
+
+class Job(ModelConfigDefaults):
+    """A job definition."""
+
+    series: StrictStr
+    architectures: List[StrictStr]
+    run: Optional[StrictStr]
+
+    @pydantic.validator("architectures", pre=True)
+    def validate_architectures(cls, v):
+        if isinstance(v, str):
+            v = [v]
+        return v
+
+
+class Config(ModelConfigDefaults):
+    """A .launchpad.yaml configuration file."""
+
+    pipeline: List[StrictStr]
+    jobs: Dict[StrictStr, Job]
+
+
+def load(filename: str) -> Config:
+    """Load config from the indicated file name."""
+    path = Path(filename)
+    content = load_yaml(path)
+    return Config.parse_obj(content)
diff --git a/lpcraft/errors.py b/lpcraft/errors.py
index 264c7e2..9c7f850 100644
--- a/lpcraft/errors.py
+++ b/lpcraft/errors.py
@@ -5,6 +5,7 @@
 
 __all__ = [
     "CommandError",
+    "YAMLError",
 ]
 
 
@@ -13,3 +14,7 @@ class CommandError(Exception):
 
     def __init__(self, message):
         super().__init__(message)
+
+
+class YAMLError(CommandError):
+    """Error reading YAML file."""
diff --git a/lpcraft/tests/test_config.py b/lpcraft/tests/test_config.py
new file mode 100644
index 0000000..9065665
--- /dev/null
+++ b/lpcraft/tests/test_config.py
@@ -0,0 +1,74 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+from pathlib import Path
+from textwrap import dedent
+
+from fixtures import TempDir
+from testtools import TestCase
+from testtools.matchers import Equals, MatchesDict, MatchesStructure
+
+from lpcraft.config import load
+
+
+class TestConfig(TestCase):
+    def setUp(self):
+        super().setUp()
+        self.tempdir = Path(self.useFixture(TempDir()).path)
+
+    def create_config(self, text):
+        path = self.tempdir / ".launchpad.yaml"
+        path.write_text(text)
+        return path
+
+    def test_load(self):
+        path = self.create_config(
+            dedent(
+                """
+                pipeline:
+                    - test
+
+                jobs:
+                    test:
+                        series: focal
+                        architectures: [amd64, arm64]
+                        run: |
+                            tox
+                """
+            )
+        )
+        config = load(str(path))
+        self.assertThat(
+            config,
+            MatchesStructure(
+                pipeline=Equals(["test"]),
+                jobs=MatchesDict(
+                    {
+                        "test": MatchesStructure.byEquality(
+                            series="focal",
+                            architectures=["amd64", "arm64"],
+                            run="tox\n",
+                        )
+                    }
+                ),
+            ),
+        )
+
+    def test_load_single_architecture(self):
+        # A single architecture can be written as a string, and is
+        # automatically wrapped in a list.
+        path = self.create_config(
+            dedent(
+                """
+                pipeline:
+                    - test
+
+                jobs:
+                    test:
+                        series: focal
+                        architectures: amd64
+                """
+            )
+        )
+        config = load(str(path))
+        self.assertEqual(["amd64"], config.jobs["test"].architectures)
diff --git a/lpcraft/tests/test_utils.py b/lpcraft/tests/test_utils.py
index f4f13b3..dc62806 100644
--- a/lpcraft/tests/test_utils.py
+++ b/lpcraft/tests/test_utils.py
@@ -1,10 +1,57 @@
 # Copyright 2021 Canonical Ltd.  This software is licensed under the
 # GNU General Public License version 3 (see the file LICENSE).
 
-from fixtures import EnvironmentVariable, MockPatch
+import re
+from pathlib import Path
+
+from fixtures import EnvironmentVariable, MockPatch, TempDir
 from testtools import TestCase
 
-from lpcraft.utils import ask_user
+from lpcraft.errors import YAMLError
+from lpcraft.utils import ask_user, load_yaml
+
+
+class TestLoadYAML(TestCase):
+    def setUp(self):
+        super().setUp()
+        self.tempdir = Path(self.useFixture(TempDir()).path)
+
+    def test_success(self):
+        path = self.tempdir / "testfile.yaml"
+        path.write_text("foo: 123\n")
+        self.assertEqual({"foo": 123}, load_yaml(path))
+
+    def test_no_file(self):
+        path = self.tempdir / "testfile.yaml"
+        self.assertRaisesRegex(
+            YAMLError,
+            re.escape(f"Couldn't find config file {str(path)!r}"),
+            load_yaml,
+            path,
+        )
+
+    def test_directory(self):
+        path = self.tempdir / "testfile.yaml"
+        path.mkdir()
+        self.assertRaisesRegex(
+            YAMLError,
+            re.escape(f"Couldn't find config file {str(path)!r}"),
+            load_yaml,
+            path,
+        )
+
+    def test_corrupted_format(self):
+        path = self.tempdir / "testfile.yaml"
+        path.write_text("foo: [1, 2\n")
+        self.assertRaisesRegex(
+            YAMLError,
+            re.escape(
+                f"Failed to read/parse config file {str(path)!r}: "
+                "while parsing a flow sequence"
+            ),
+            load_yaml,
+            path,
+        )
 
 
 class TestAskUser(TestCase):
diff --git a/lpcraft/utils.py b/lpcraft/utils.py
index 4e027d3..71cc846 100644
--- a/lpcraft/utils.py
+++ b/lpcraft/utils.py
@@ -6,8 +6,23 @@ __all__ = [
 ]
 
 import sys
+from pathlib import Path
+
+import yaml
 
 from lpcraft.env import is_managed_mode
+from lpcraft.errors import YAMLError
+
+
+def load_yaml(path: Path):
+    """Return the content of a YAML file."""
+    if not path.is_file():
+        raise YAMLError(f"Couldn't find config file {str(path)!r}")
+    try:
+        with path.open("rb") as f:
+            return yaml.safe_load(f)
+    except (yaml.error.YAMLError, OSError) as e:
+        raise YAMLError(f"Failed to read/parse config file {str(path)!r}: {e}")
 
 
 def ask_user(prompt, default=False) -> bool:
diff --git a/requirements.in b/requirements.in
index 86c5a80..44d1335 100644
--- a/requirements.in
+++ b/requirements.in
@@ -1,2 +1,4 @@
 craft-providers
+pydantic
+PyYAML
 git+git://github.com/canonical/craft-cli.git@4af19f9c0da733321dc754be1180aea28f3feeb1
diff --git a/requirements.txt b/requirements.txt
index 4fb931e..1a5a064 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -18,10 +18,12 @@ idna==3.3
     # via requests
 pydantic==1.8.2
     # via
+    #   -r requirements.in
     #   craft-cli
     #   craft-providers
 pyyaml==6.0
     # via
+    #   -r requirements.in
     #   craft-cli
     #   craft-providers
 requests==2.26.0
diff --git a/setup.cfg b/setup.cfg
index b6b72b2..5a4d39b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -22,8 +22,10 @@ classifiers =
 [options]
 packages = find:
 install_requires =
+    PyYAML
     craft-cli
     craft-providers
+    pydantic
 python_requires = >=3.8
 
 [options.entry_points]