launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #28512
[Merge] ~jugmac00/lpcraft:add-modular-config into lpcraft:main
Jürgen Gmach has proposed merging ~jugmac00/lpcraft:add-modular-config into lpcraft:main.
Commit message:
Add modular plugin configuration
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~jugmac00/lpcraft/+git/lpcraft/+merge/423533
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~jugmac00/lpcraft:add-modular-config into lpcraft:main.
diff --git a/NEWS.rst b/NEWS.rst
index bcec6ce..3624a23 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -9,6 +9,7 @@ Version history
- Add ``lpcraft_execute_before_run`` and ``lpcraft_execute_after_run`` hooks.
+- Add support for pydantic configuration on plugin classes.
0.0.14 (2022-05-18)
===================
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 5e02c2b..ecaa727 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -82,6 +82,10 @@ Job definitions
removing the ``matrix`` key itself, and updating it with the contents of
each item in turn.
+.. note::
+
+ Plugins can define :ref:`plugin_configuration_keys`.
+
.. _output-properties:
Output properties
diff --git a/docs/plugins.rst b/docs/plugins.rst
index f150687..a8adda5 100644
--- a/docs/plugins.rst
+++ b/docs/plugins.rst
@@ -47,6 +47,42 @@ Available hooks
.. automodule:: lpcraft.plugin.hookspecs
:members:
+.. _plugin_configuration_keys:
+
+Additional configuration keys
+*****************************
+
+Plugins can have their own configuration keys.
+
+.. code-block:: python
+
+ @register(name="miniconda")
+ class MiniCondaPlugin(BasePlugin):
+ class Config(BaseConfig):
+ conda_packages: Optional[List[StrictStr]]
+ conda_python: Optional[StrictStr]
+
+The above code defines the ``MiniCondaPlugin`` with two additional configuration
+keys.
+
+These keys could be used in the ``.launchpad.yaml`` configuration file as
+following:
+
+.. code-block:: yaml
+
+ jobs:
+ myjob:
+ plugin: miniconda
+ conda-packages:
+ - mamba
+ - numpy=1.17
+ - scipy
+ - pip
+ conda-python: 3.8
+ run: |
+ pip install --upgrade pytest
+ python -m build .
+
Builtin plugins
---------------
diff --git a/lpcraft/config.py b/lpcraft/config.py
index 06f6e23..0a4120d 100644
--- a/lpcraft/config.py
+++ b/lpcraft/config.py
@@ -5,12 +5,14 @@ import re
from datetime import timedelta
from enum import Enum
from pathlib import Path
-from typing import Any, Dict, List, Optional, Union
+from typing import Any, Dict, List, Optional, Type, Union
import pydantic
from pydantic import StrictStr
from lpcraft.errors import ConfigurationError
+from lpcraft.plugins import PLUGINS
+from lpcraft.plugins.plugins import BaseConfig, BasePlugin
from lpcraft.utils import load_yaml
@@ -58,6 +60,22 @@ class Output(ModelConfigDefaults):
return v
+def _validate_plugin_config(
+ plugin: Type[BasePlugin],
+ values: Dict[StrictStr, Any],
+ job_fields: List[str],
+) -> Dict[StrictStr, Any]:
+ plugin_config = {}
+ for k in plugin.Config.schema()["properties"].keys():
+ # configuration key belongs to the plugin
+ if k in values and k not in job_fields:
+ # TODO: should some error be raised if a plugin tries consuming
+ # a job configuration key?
+ plugin_config[k] = values.pop(k)
+ values["plugin-config"] = plugin.Config.parse_obj(plugin_config)
+ return values
+
+
class Job(ModelConfigDefaults):
"""A job definition."""
@@ -75,6 +93,7 @@ class Job(ModelConfigDefaults):
snaps: Optional[List[StrictStr]]
packages: Optional[List[StrictStr]]
plugin: Optional[StrictStr]
+ plugin_config: Optional[BaseConfig]
@pydantic.validator("architectures", pre=True)
def validate_architectures(
@@ -84,6 +103,23 @@ class Job(ModelConfigDefaults):
v = [v]
return v
+ @pydantic.root_validator(pre=True)
+ def move_plugin_config_settings(
+ cls, values: Dict[StrictStr, Any]
+ ) -> Dict[StrictStr, Any]:
+ """Delegate plugin settings to the plugin."""
+ if "plugin" in values:
+ base_values = values.copy()
+ if values["plugin"] not in PLUGINS:
+ raise ConfigurationError("Unknown plugin")
+ plugin = PLUGINS[values["plugin"]]
+ return _validate_plugin_config(
+ plugin=plugin,
+ values=base_values,
+ job_fields=list(cls.__fields__.keys()),
+ )
+ return values
+
def _expand_job_values(
values: Dict[StrictStr, Any]
diff --git a/lpcraft/plugin/manager.py b/lpcraft/plugin/manager.py
index d78f306..f6dfdd3 100644
--- a/lpcraft/plugin/manager.py
+++ b/lpcraft/plugin/manager.py
@@ -1,7 +1,6 @@
import pluggy
from lpcraft.config import Job
-from lpcraft.errors import ConfigurationError
from lpcraft.plugin import NAME, hookspecs
from lpcraft.plugin.lib import InternalPlugins
from lpcraft.plugins import PLUGINS
@@ -16,8 +15,6 @@ def get_plugin_manager(job: Job) -> pluggy.PluginManager:
# register builtin plugins
if job.plugin:
- if job.plugin not in PLUGINS:
- raise ConfigurationError("Unknown plugin")
pm.register(PLUGINS[job.plugin](job))
return pm
diff --git a/lpcraft/plugin/tests/test_plugins.py b/lpcraft/plugin/tests/test_plugins.py
index cda49e4..6976849 100644
--- a/lpcraft/plugin/tests/test_plugins.py
+++ b/lpcraft/plugin/tests/test_plugins.py
@@ -5,13 +5,19 @@ import os
import subprocess
from pathlib import Path, PosixPath
from textwrap import dedent
+from typing import List, Optional, Union, cast
from unittest.mock import ANY, Mock, call, patch
from craft_providers.lxd import launch
from fixtures import TempDir
+from pydantic import StrictStr, validator
+import lpcraft.config
from lpcraft.commands.tests import CommandBaseTestCase
from lpcraft.errors import ConfigurationError
+from lpcraft.plugin.manager import get_plugin_manager
+from lpcraft.plugins import register
+from lpcraft.plugins.plugins import BaseConfig, BasePlugin
from lpcraft.providers.tests import makeLXDProvider
@@ -245,3 +251,76 @@ class TestPlugins(CommandBaseTestCase):
],
execute_run.call_args_list,
)
+
+ def test_plugin_config_raises_notimplementederror(self):
+ config = dedent(
+ """
+ pipeline:
+ - build
+
+ jobs:
+ build:
+ series: focal
+ architectures: amd64
+ plugin: pyproject-build
+ """
+ )
+ config_path = Path(".launchpad.yaml")
+ config_path.write_text(config)
+ config_obj = lpcraft.config.Config.load(config_path)
+ self.assertEqual(config_obj.jobs["build"][0].plugin, "pyproject-build")
+ pm = get_plugin_manager(config_obj.jobs["build"][0])
+ plugins = pm.get_plugins()
+ build_plugin = [
+ _
+ for _ in plugins
+ if _.__class__.__name__ == "PyProjectBuildPlugin"
+ ]
+ # build_plugin does not define its own configuration
+ self.assertRaises(
+ NotImplementedError, build_plugin[0].get_plugin_config
+ )
+
+ def test_plugin_config_sets_values(self):
+ config = dedent(
+ """
+ pipeline:
+ - build
+
+ jobs:
+ build:
+ series: focal
+ architectures: amd64
+ plugin: fake-plugin
+ python-version: 3.8
+ """
+ )
+ config_path = Path(".launchpad.yaml")
+ config_path.write_text(config)
+
+ @register(name="fake-plugin")
+ class FakePlugin(BasePlugin):
+ class Config(BaseConfig):
+ series: Optional[List[StrictStr]]
+ python_version: Optional[StrictStr]
+
+ @validator("python_version", pre=True)
+ def validate_python_version(
+ cls, v: Union[str, float, int]
+ ) -> str:
+ return str(v)
+
+ def get_plugin_config(self) -> "FakePlugin.Config":
+ return cast(FakePlugin.Config, self.config.plugin_config)
+
+ config_obj = lpcraft.config.Config.load(config_path)
+ job = config_obj.jobs["build"][0]
+ pm = get_plugin_manager(job)
+ plugins = pm.get_plugins()
+ fake_plugin = [
+ _ for _ in plugins if _.__class__.__name__ == "FakePlugin"
+ ]
+ self.assertEqual(job.plugin, "fake-plugin")
+ plugin_config = fake_plugin[0].get_plugin_config()
+ self.assertEqual(plugin_config.python_version, "3.8")
+ self.assertIsNone(getattr(plugin_config, "series", None))
diff --git a/lpcraft/plugins/plugins.py b/lpcraft/plugins/plugins.py
index 880816e..fc136ff 100644
--- a/lpcraft/plugins/plugins.py
+++ b/lpcraft/plugins/plugins.py
@@ -1,17 +1,47 @@
# Copyright 2021 Canonical Ltd. This software is licensed under the
# GNU General Public License version 3 (see the file LICENSE).
-from __future__ import annotations
+from __future__ import annotations # isort:skip
__all__ = ["ToxPlugin", "PyProjectBuildPlugin"]
-from lpcraft.config import Job
+from typing import TYPE_CHECKING
+
+import pydantic
+
from lpcraft.plugin import hookimpl
from lpcraft.plugins import register
+# XXX: techalchemy 2022-03-25: prevent circular import of Job class
+if TYPE_CHECKING:
+
+ from lpcraft.config import Job # pragma: no cover
+
+
+class BaseConfig(
+ pydantic.BaseModel,
+ extra=pydantic.Extra.forbid,
+ frozen=True,
+ alias_generator=lambda s: s.replace("_", "-"),
+ underscore_attrs_are_private=True,
+):
+ """Base config for plugin models."""
+
+
+class BasePlugin:
+ class Config(BaseConfig):
+ pass
+
+ def __init__(self, config: Job) -> None:
+ self.config = config
+
+ def get_plugin_config(self) -> BaseConfig:
+ """Return the properly typecast plugin configuration."""
+ raise NotImplementedError
+
@register(name="tox")
-class ToxPlugin:
+class ToxPlugin(BasePlugin):
"""Installs `tox` and executes the configured environments.
Usage:
@@ -19,9 +49,6 @@ class ToxPlugin:
within the job definition.
"""
- def __init__(self, config: Job) -> None:
- self.config = config
-
@hookimpl # type: ignore
def lpcraft_install_packages(self) -> list[str]:
return ["python3-pip"]
@@ -41,7 +68,7 @@ class ToxPlugin:
@register(name="pyproject-build")
-class PyProjectBuildPlugin:
+class PyProjectBuildPlugin(BasePlugin):
"""Installs `build` and builds a Python package according to PEP 517.
Usage:
@@ -49,9 +76,6 @@ class PyProjectBuildPlugin:
`pyproject-build` within the job definition.
"""
- def __init__(self, config: Job) -> None:
- self.config = config
-
@hookimpl # type: ignore
def lpcraft_install_packages(self) -> list[str]:
# Ubuntu 20.04 does not provide a packaged version of build,
References