launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27689
[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]