launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27846
[Merge] ~jugmac00/lpcraft:use-pluggy-for-plugins into lpcraft:main
Jürgen Gmach has proposed merging ~jugmac00/lpcraft:use-pluggy-for-plugins into lpcraft:main.
Commit message:
Introduce pluggy to manage internal and external plugins
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~jugmac00/lpcraft/+git/lpcraft/+merge/413280
- pluggy has not been type annotated yet
- the documentation will be extended once all hooks are defined/created; e.g. tox uses a sphinx plugin to auto-document the hooks (this will be a new MP)
The internal hook `lpcraft_install_packages` needs access to the job configuration, the external plugins do not.
This "mismatch" could be solved by:
- creating two different hooks, e.g. `_lpcraft_install_packages` which takes a job configuration as argument, and `lpcraft_install_packages` which does not, and then combining the result; that is what `tox` does
- using a class as namespace, which I did, so the internal hook can access the job configuration via `self.config`
- just pass the job configuration to all hooks, and ignore the information where it is not needed; that is what pytest does a lot; I did not use this approach as the signature/interface of the hook would be more complex than needed
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~jugmac00/lpcraft:use-pluggy-for-plugins into lpcraft:main.
diff --git a/.mypy.ini b/.mypy.ini
index 3b71bdb..446a374 100644
--- a/.mypy.ini
+++ b/.mypy.ini
@@ -7,7 +7,7 @@ disallow_subclassing_any = false
disallow_untyped_calls = false
disallow_untyped_defs = false
-[mypy-fixtures.*,systemfixtures.*,testtools.*]
+[mypy-fixtures.*,systemfixtures.*,testtools.*,pluggy.*]
ignore_missing_imports = true
[mypy-craft_cli.*]
diff --git a/docs/index.rst b/docs/index.rst
index 8d171d8..c37c2fc 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -46,4 +46,5 @@ Example configuration
self
configuration
+ plugins
CONTRIBUTING
diff --git a/docs/plugins.rst b/docs/plugins.rst
new file mode 100644
index 0000000..2669cc3
--- /dev/null
+++ b/docs/plugins.rst
@@ -0,0 +1,37 @@
+Extending lpcraft
+=================
+
+lpcraft uses `pluggy <https://pluggy.readthedocs.io/>`_ to customize the
+default behaviour. For example the following code snippet would extend the
+packages which need to be installed into an environment by additional
+requirements:
+
+.. code-block:: python
+
+ from lpcraft.plugin import hookimpl
+
+
+ @hookimpl
+ def lpcraft_install_packages():
+ return ["tox"]
+
+You can define such hooks in a package alongside lpcraft.
+
+
+Discovery
+---------
+
+lpcraft uses `setuptools' <https://setuptools.pypa.io/en/latest/index.html>`_
+`entry_points <https://setuptools.pypa.io/en/latest/userguide/entry_point.html>`_
+for plugin discovery.
+
+You need to add a configuration similar to the following to your `setup.py`:
+
+.. code-block:: python
+
+ setup(
+ ...
+ install_requires="lpcraft",
+ entry_points={"lpcraft": ["entry_name = entry_point"]},
+ ...
+ )
diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
index 685ab70..07f9a1c 100644
--- a/lpcraft/commands/run.py
+++ b/lpcraft/commands/run.py
@@ -3,6 +3,7 @@
import fnmatch
import io
+import itertools
import json
import os
from argparse import Namespace
@@ -17,6 +18,7 @@ from dotenv import dotenv_values
from lpcraft import env
from lpcraft.config import Config, Output
from lpcraft.errors import CommandError
+from lpcraft.plugin.manager import get_plugin_manager
from lpcraft.providers import get_provider
from lpcraft.utils import get_host_architecture
@@ -201,8 +203,12 @@ def run(args: Namespace) -> int:
channel="stable",
classic=True,
)
- if job.packages:
- packages_cmd = ["apt", "install", "-y"] + job.packages
+ pm = get_plugin_manager(job)
+ packages = list(
+ itertools.chain(*pm.hook.lpcraft_install_packages())
+ )
+ if packages:
+ packages_cmd = ["apt", "install", "-y"] + packages
emit.progress("Installing system packages")
with emit.open_stream(f"Running {packages_cmd}") as stream:
proc = instance.execute_run(
diff --git a/lpcraft/plugin/__init__.py b/lpcraft/plugin/__init__.py
new file mode 100644
index 0000000..b2e74c1
--- /dev/null
+++ b/lpcraft/plugin/__init__.py
@@ -0,0 +1,3 @@
+import pluggy
+
+hookimpl = pluggy.HookimplMarker("lpcraft")
diff --git a/lpcraft/plugin/hookspecs.py b/lpcraft/plugin/hookspecs.py
new file mode 100644
index 0000000..7e9d4c8
--- /dev/null
+++ b/lpcraft/plugin/hookspecs.py
@@ -0,0 +1,10 @@
+from __future__ import annotations
+
+import pluggy
+
+hookspec = pluggy.HookspecMarker("lpcraft")
+
+
+@hookspec # type: ignore
+def lpcraft_install_packages() -> list[str]:
+ """system packages to be installed"""
diff --git a/lpcraft/plugin/lib.py b/lpcraft/plugin/lib.py
new file mode 100644
index 0000000..3bd1931
--- /dev/null
+++ b/lpcraft/plugin/lib.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from lpcraft.config import Job
+from lpcraft.plugin import hookimpl
+
+
+class HookImplementations:
+ def __init__(self, config: Job) -> None:
+ self.config = config
+
+ @hookimpl # type: ignore
+ def lpcraft_install_packages(self) -> list[str]:
+ if self.config.packages:
+ return self.config.packages
+ return []
diff --git a/lpcraft/plugin/manager.py b/lpcraft/plugin/manager.py
new file mode 100644
index 0000000..8a1213c
--- /dev/null
+++ b/lpcraft/plugin/manager.py
@@ -0,0 +1,15 @@
+import pluggy
+
+from lpcraft.config import Job
+from lpcraft.plugin import hookspecs
+from lpcraft.plugin.lib import HookImplementations
+
+
+def get_plugin_manager(job: Job) -> pluggy.PluginManager:
+ pm = pluggy.PluginManager("lpcraft")
+ pm.add_hookspecs(hookspecs)
+ # load internal plugins
+ pm.register(HookImplementations(job))
+ # load external plugins
+ pm.load_setuptools_entrypoints("lpcraft")
+ return pm
diff --git a/lpcraft/plugin/testing/lp_tox/lp_tox.py b/lpcraft/plugin/testing/lp_tox/lp_tox.py
new file mode 100644
index 0000000..f5613b6
--- /dev/null
+++ b/lpcraft/plugin/testing/lp_tox/lp_tox.py
@@ -0,0 +1,8 @@
+from __future__ import annotations
+
+from lpcraft.plugin import hookimpl
+
+
+@hookimpl # type: ignore
+def lpcraft_install_packages() -> list[str]:
+ return ["tox"]
diff --git a/lpcraft/plugin/tests/__init__.py b/lpcraft/plugin/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lpcraft/plugin/tests/__init__.py
diff --git a/lpcraft/plugin/tests/test_external_plugin.py b/lpcraft/plugin/tests/test_external_plugin.py
new file mode 100644
index 0000000..aa7c521
--- /dev/null
+++ b/lpcraft/plugin/tests/test_external_plugin.py
@@ -0,0 +1,85 @@
+import subprocess
+import sys
+from pathlib import Path, PosixPath
+from textwrap import dedent
+from unittest.mock import ANY, Mock, call, patch
+
+from craft_providers.lxd import LXC, launch
+
+from lpcraft.commands.tests import CommandBaseTestCase
+from lpcraft.providers._lxd import LXDProvider, _LXDLauncher
+from lpcraft.providers.tests import FakeLXDInstaller
+
+
+class TestLoadExternalPlugin(CommandBaseTestCase):
+ def setUp(self) -> None:
+ super().setUp()
+ sys.path.append(
+ str(Path(__file__).parent.parent / "testing" / "lp_tox")
+ )
+
+ def tearDown(self) -> None:
+ super().tearDown()
+ sys.path.pop()
+
+ def makeLXDProvider(
+ self,
+ is_ready=True,
+ lxd_launcher=_LXDLauncher,
+ ):
+ lxc = Mock(spec=LXC)
+ lxc.remote_list.return_value = {}
+ lxd_installer = FakeLXDInstaller(is_ready=is_ready)
+ return LXDProvider(
+ lxc=lxc,
+ lxd_installer=lxd_installer,
+ lxd_launcher=lxd_launcher,
+ )
+
+ @patch("lpcraft.commands.run.get_provider")
+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+ def test_load_external_plugin(
+ self, mock_get_host_architecture, mock_get_provider
+ ):
+ launcher = Mock(spec=launch)
+ provider = self.makeLXDProvider(lxd_launcher=launcher)
+ mock_get_provider.return_value = provider
+ execute_run = launcher.return_value.execute_run
+ execute_run.return_value = subprocess.CompletedProcess([], 0)
+ config = dedent(
+ """
+ pipeline:
+ - test
+
+ jobs:
+ test:
+ series: focal
+ architectures: amd64
+ run: tox
+ packages: [nginx, apache2]
+ """
+ )
+ Path(".launchpad.yaml").write_text(config)
+
+ self.run_command("run")
+
+ # the requirement to install `tox` is added by the external plugin
+ self.assertEqual(
+ [
+ call(
+ ["apt", "install", "-y", "tox", "nginx", "apache2"],
+ cwd=PosixPath("/root/project"),
+ env=None,
+ stdout=ANY,
+ stderr=ANY,
+ ),
+ call(
+ ["bash", "--noprofile", "--norc", "-ec", "tox"],
+ cwd=PosixPath("/root/project"),
+ env=None,
+ stdout=ANY,
+ stderr=ANY,
+ ),
+ ],
+ execute_run.call_args_list,
+ )
diff --git a/setup.cfg b/setup.cfg
index 7f9e2d8..02c6816 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -25,6 +25,7 @@ install_requires =
PyYAML
craft-cli
craft-providers
+ pluggy
pydantic
python-dotenv
python_requires = >=3.8