Merge ~jugmac00/lpci:use-pluggy-for-plugins into lpci:main

Proposed by Jürgen Gmach
Status: Merged
Merge reported by: Jürgen Gmach
Merged at revision: 74aa47d9bb72fbe6889174c0f6d4351b4dc9ddee
Proposed branch: ~jugmac00/lpci:use-pluggy-for-plugins
Merge into: lpci:main
Diff against target: 413 lines (+257/-4)
17 files modified
.mypy.ini (+1/-2)
docs/index.rst (+1/-0)
docs/plugins.rst (+22/-0)
lpcraft/commands/run.py (+6/-2)
lpcraft/config.py (+1/-0)
lpcraft/plugin/__init__.py (+6/-0)
lpcraft/plugin/hookspecs.py (+13/-0)
lpcraft/plugin/lib.py (+25/-0)
lpcraft/plugin/manager.py (+23/-0)
lpcraft/plugin/tests/__init__.py (+0/-0)
lpcraft/plugin/tests/test_builtin_plugins.py (+112/-0)
lpcraft/plugins/__init__.py (+5/-0)
lpcraft/plugins/tox.py (+17/-0)
lpcraft/tests/test_config.py (+21/-0)
requirements.in (+1/-0)
requirements.txt (+2/-0)
setup.cfg (+1/-0)
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+413280@code.launchpad.net

Commit message

Introduce pluggy to manage internal and external plugins

Description of the change

- 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

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

What I'm not sure I understand here is how this is going to hook into the configuration file syntax. The spec says that `.launchpad.yaml` can say something like `plugin: tox` to cause the `tox` plugin, and only the `tox` plugin, to be used for the current build. This implementation seems to load and execute all discoverable plugins, which certainly makes sense for some plugin systems but it's not what we're looking for here.

This might be something you're going to address in a later branch; could you elaborate on your plans?

review: Needs Information
Revision history for this message
Colin Watson (cjwatson) wrote :

Incidentally, I can definitely see the appeal of being able to load more than one plugin and have it take effect; that seems like a good composable kind of design. It's just the discovery mechanic that I'm unsure about here.

Revision history for this message
Jürgen Gmach (jugmac00) wrote (last edit ):
Download full text (3.8 KiB)

My plan was as outlined in LP-568 and LP-584:
- decide which functionality should be exposed via hooks (e.g. here the list of system packages)
- convert internal functions into plugins (e.g. lpcraft/plugin/lib.py -> lpcraft_install_packages)

And here the work for the current MP should have stopped, but I was nosy how external plugins work, so I added one, and then there was the discussion about how to test external plugins, and so I added a test for it.

Until then, I had a vague idea how to install plugins on runtime, which seems to be necessary if we ever want to support third party plugins. My idea, originating back from the very first thoughts about a plugin system was to use a subprocess to install the plugins via pip or similar, but as lpcraft no longer runs in the guest and has to run different jobs with different plugins, this is no longer viable.

Let's quickly define what I mean with internal and external plugins:

internal plugin = replace data/function with a hook, and provide the original data/function via an implementation

external plugin = every plugin, no matter whether created by us (e.g. tox plugin), or a future third party plugin, which can add data or even change behavior

It is true, that all hooks of all installed plugins are loaded, but not necessarily executed.

All (external) hooks have names, and they can be filtered by e.g. name.

```
(Pdb++) list(pm.get_plugins())[0].__name__
'lp_tox'
```

So when there is the `tox` plugin mentioned in the configuration file, all other plugins could be filtered out, and only the tox hook gets executed.

This may sound a bit complicated, but this way all external plugins could have the same structure (and currently we could just install all plugins in the same environment).

For this approach I assumed that all external plugins are outside the lpcraft repository.

An alternative approach could be to dynamically load plugins from a module/class from within lpcraft, e.g. we add a tox plugin to the lpcraft repository.

Additionally to loading "internal" plugins via `pm.register(HookImplementations(job))` we could also load additional plugins, e.g. `pm.register(<plugin>(job))` - for this approach we'd possibly need a mapping from strings from the configuration file (plugin: tox) to class names, where the classes provide all the implementations for the relevant hooks.

This all may or may not be too complex, and maybe even a simple data structure would be enough for a plugin? e.g. a list of additional deb and snap packages, a list of environment variables... Then a plugin could be a simple dictionary, or a data class, or an ini file? This should be clarified, too.

From LP-568 there is at least one question unanswered - how will we install third party plugins?

Maybe something like this?

```
pipeline:
    - test

jobs:
    test:
        series: focal
        architectures: amd64
        run: check-python-versions
        plugin: lp/check-python-versions
```

... where `lp/check-python-versions` would translate to a git repository on Launchpad, which would be cloned and installed -> and then the above approach with setuptools entry_points would pick it up.

While third party plugins ar...

Read more...

Revision history for this message
Jürgen Gmach (jugmac00) :
Revision history for this message
Colin Watson (cjwatson) wrote :

I think I get it now, and this all looks basically fine - it just needs a few comment improvements. Thanks for working on this.

Could you add copyright/licence statements to the top of all the new files?

review: Approve
Revision history for this message
Colin Watson (cjwatson) :
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

Thanks for the review!

I applied all the suggestions.

> Could you add copyright/licence statements to the top of all the new files?

This is the third time... oO

... almost got it... :-)

```
- repo: local
    hooks:
    - id: python-check-license-statements
      name: Check license statements
      types: [python]
      entry: "Canonical"
      language: pygrep
      args: [--negate]
```

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.mypy.ini b/.mypy.ini
2index 3b71bdb..60a899d 100644
3--- a/.mypy.ini
4+++ b/.mypy.ini
5@@ -1,5 +1,4 @@
6 [mypy]
7-plugins = pydantic.mypy
8 python_version = 3.8
9
10 [mypy-*.tests.*]
11@@ -7,7 +6,7 @@ disallow_subclassing_any = false
12 disallow_untyped_calls = false
13 disallow_untyped_defs = false
14
15-[mypy-fixtures.*,systemfixtures.*,testtools.*]
16+[mypy-fixtures.*,systemfixtures.*,testtools.*,pluggy.*]
17 ignore_missing_imports = true
18
19 [mypy-craft_cli.*]
20diff --git a/docs/index.rst b/docs/index.rst
21index 8d171d8..c37c2fc 100644
22--- a/docs/index.rst
23+++ b/docs/index.rst
24@@ -46,4 +46,5 @@ Example configuration
25
26 self
27 configuration
28+ plugins
29 CONTRIBUTING
30diff --git a/docs/plugins.rst b/docs/plugins.rst
31new file mode 100644
32index 0000000..4c644d6
33--- /dev/null
34+++ b/docs/plugins.rst
35@@ -0,0 +1,22 @@
36+Extending lpcraft
37+=================
38+
39+lpcraft uses `pluggy <https://pluggy.readthedocs.io/>`_ to customize the
40+default behaviour. For example the following code snippet would extend the
41+packages which need to be installed into an environment by additional
42+requirements:
43+
44+.. code-block:: python
45+
46+ from lpcraft.plugin import hookimpl
47+
48+
49+ @hookimpl
50+ def lpcraft_install_packages():
51+ return ["tox"]
52+
53+Plugin discovery
54+----------------
55+
56+As currently only builtin plugins are supported,
57+you need to define a plugin in ``lpcraft/plugins/<plugin>``.
58diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
59index ec99c11..3c53eef 100644
60--- a/lpcraft/commands/run.py
61+++ b/lpcraft/commands/run.py
62@@ -3,6 +3,7 @@
63
64 import fnmatch
65 import io
66+import itertools
67 import json
68 import os
69 from argparse import Namespace
70@@ -17,6 +18,7 @@ from dotenv import dotenv_values
71 from lpcraft import env
72 from lpcraft.config import Config, Job, Output
73 from lpcraft.errors import CommandError
74+from lpcraft.plugin.manager import get_plugin_manager
75 from lpcraft.providers import Provider, get_provider
76 from lpcraft.utils import get_host_architecture
77
78@@ -193,8 +195,10 @@ def _run_job(
79 channel="stable",
80 classic=True,
81 )
82- if job.packages:
83- packages_cmd = ["apt", "install", "-y"] + job.packages
84+ pm = get_plugin_manager(job)
85+ packages = list(itertools.chain(*pm.hook.lpcraft_install_packages()))
86+ if packages:
87+ packages_cmd = ["apt", "install", "-y"] + packages
88 emit.progress("Installing system packages")
89 with emit.open_stream(f"Running {packages_cmd}") as stream:
90 proc = instance.execute_run(
91diff --git a/lpcraft/config.py b/lpcraft/config.py
92index cdfda54..ed6ae7f 100644
93--- a/lpcraft/config.py
94+++ b/lpcraft/config.py
95@@ -67,6 +67,7 @@ class Job(ModelConfigDefaults):
96 output: Optional[Output]
97 snaps: Optional[List[StrictStr]]
98 packages: Optional[List[StrictStr]]
99+ plugin: Optional[StrictStr]
100
101 @pydantic.validator("architectures", pre=True)
102 def validate_architectures(
103diff --git a/lpcraft/plugin/__init__.py b/lpcraft/plugin/__init__.py
104new file mode 100644
105index 0000000..d514ac6
106--- /dev/null
107+++ b/lpcraft/plugin/__init__.py
108@@ -0,0 +1,6 @@
109+# Copyright 2021 Canonical Ltd. This software is licensed under the
110+# GNU General Public License version 3 (see the file LICENSE).
111+
112+import pluggy
113+
114+hookimpl = pluggy.HookimplMarker("lpcraft")
115diff --git a/lpcraft/plugin/hookspecs.py b/lpcraft/plugin/hookspecs.py
116new file mode 100644
117index 0000000..f16f909
118--- /dev/null
119+++ b/lpcraft/plugin/hookspecs.py
120@@ -0,0 +1,13 @@
121+# Copyright 2021 Canonical Ltd. This software is licensed under the
122+# GNU General Public License version 3 (see the file LICENSE).
123+
124+from __future__ import annotations
125+
126+import pluggy
127+
128+hookspec = pluggy.HookspecMarker("lpcraft")
129+
130+
131+@hookspec # type: ignore
132+def lpcraft_install_packages() -> list[str]:
133+ """system packages to be installed"""
134diff --git a/lpcraft/plugin/lib.py b/lpcraft/plugin/lib.py
135new file mode 100644
136index 0000000..b22be5a
137--- /dev/null
138+++ b/lpcraft/plugin/lib.py
139@@ -0,0 +1,25 @@
140+"""`InternalPlugins` is the namespace for the internal plugins.
141+
142+see https://pluggy.readthedocs.io/en/stable/#define-and-collect-hooks
143+
144+Internal plugins provide the base functionality for lpcraft, which means that
145+these plugins cannot be removed without breaking the application.
146+
147+The provided functionality can be extended by additional plugins.
148+"""
149+
150+from __future__ import annotations
151+
152+from lpcraft.config import Job
153+from lpcraft.plugin import hookimpl
154+
155+
156+class InternalPlugins:
157+ def __init__(self, config: Job) -> None:
158+ self.config = config
159+
160+ @hookimpl # type: ignore
161+ def lpcraft_install_packages(self) -> list[str]:
162+ if self.config.packages:
163+ return self.config.packages
164+ return []
165diff --git a/lpcraft/plugin/manager.py b/lpcraft/plugin/manager.py
166new file mode 100644
167index 0000000..ad8ec66
168--- /dev/null
169+++ b/lpcraft/plugin/manager.py
170@@ -0,0 +1,23 @@
171+import pluggy
172+
173+from lpcraft.config import Job
174+from lpcraft.errors import YAMLError
175+from lpcraft.plugin import hookspecs
176+from lpcraft.plugin.lib import InternalPlugins
177+from lpcraft.plugins import PLUGINS
178+
179+
180+def get_plugin_manager(job: Job) -> pluggy.PluginManager:
181+ pm = pluggy.PluginManager("lpcraft")
182+ pm.add_hookspecs(hookspecs)
183+
184+ # register internal plugins
185+ pm.register(InternalPlugins(job))
186+
187+ # register builtin plugins
188+ if job.plugin:
189+ if job.plugin not in PLUGINS:
190+ raise YAMLError("Unknown plugin")
191+ pm.register(PLUGINS[job.plugin](job))
192+
193+ return pm
194diff --git a/lpcraft/plugin/tests/__init__.py b/lpcraft/plugin/tests/__init__.py
195new file mode 100644
196index 0000000..e69de29
197--- /dev/null
198+++ b/lpcraft/plugin/tests/__init__.py
199diff --git a/lpcraft/plugin/tests/test_builtin_plugins.py b/lpcraft/plugin/tests/test_builtin_plugins.py
200new file mode 100644
201index 0000000..79b0a1d
202--- /dev/null
203+++ b/lpcraft/plugin/tests/test_builtin_plugins.py
204@@ -0,0 +1,112 @@
205+# Copyright 2021 Canonical Ltd. This software is licensed under the
206+# GNU General Public License version 3 (see the file LICENSE).
207+
208+import subprocess
209+from pathlib import Path, PosixPath
210+from textwrap import dedent
211+from unittest.mock import ANY, Mock, call, patch
212+
213+from craft_providers.lxd import LXC, launch
214+
215+from lpcraft.commands.tests import CommandBaseTestCase
216+from lpcraft.errors import YAMLError
217+from lpcraft.providers._lxd import LXDProvider, _LXDLauncher
218+from lpcraft.providers.tests import FakeLXDInstaller
219+
220+
221+class TestBuiltinPlugins(CommandBaseTestCase):
222+ def makeLXDProvider(
223+ self,
224+ is_ready=True,
225+ lxd_launcher=_LXDLauncher,
226+ ):
227+ lxc = Mock(spec=LXC)
228+ lxc.remote_list.return_value = {}
229+ lxd_installer = FakeLXDInstaller(is_ready=is_ready)
230+ return LXDProvider(
231+ lxc=lxc,
232+ lxd_installer=lxd_installer,
233+ lxd_launcher=lxd_launcher,
234+ )
235+
236+ @patch("lpcraft.commands.run.get_provider")
237+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
238+ def test_builtin_plugin(
239+ self, mock_get_host_architecture, mock_get_provider
240+ ):
241+ launcher = Mock(spec=launch)
242+ provider = self.makeLXDProvider(lxd_launcher=launcher)
243+ mock_get_provider.return_value = provider
244+ execute_run = launcher.return_value.execute_run
245+ execute_run.return_value = subprocess.CompletedProcess([], 0)
246+ # XXX jugmac00 2021-12-16: the current implementation of the `tox`
247+ # plugin does not provide a run command, so we need to specify it
248+ # here in the configuration file
249+ config = dedent(
250+ """
251+ pipeline:
252+ - test
253+
254+ jobs:
255+ test:
256+ series: focal
257+ architectures: amd64
258+ packages: [nginx, apache2]
259+ run: tox
260+ plugin: tox
261+ """
262+ )
263+ Path(".launchpad.yaml").write_text(config)
264+
265+ self.run_command("run")
266+
267+ self.assertEqual(
268+ [
269+ call(
270+ ["apt", "install", "-y", "tox", "nginx", "apache2"],
271+ cwd=PosixPath("/root/project"),
272+ env=None,
273+ stdout=ANY,
274+ stderr=ANY,
275+ ),
276+ call(
277+ ["bash", "--noprofile", "--norc", "-ec", "tox"],
278+ cwd=PosixPath("/root/project"),
279+ env=None,
280+ stdout=ANY,
281+ stderr=ANY,
282+ ),
283+ ],
284+ execute_run.call_args_list,
285+ )
286+
287+ @patch("lpcraft.commands.run.get_provider")
288+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
289+ def test_execute_unknown_plugin(
290+ self, mock_get_host_architecture, mock_get_provider
291+ ):
292+ launcher = Mock(spec=launch)
293+ provider = self.makeLXDProvider(lxd_launcher=launcher)
294+ mock_get_provider.return_value = provider
295+ execute_run = launcher.return_value.execute_run
296+ execute_run.return_value = subprocess.CompletedProcess([], 0)
297+ config = dedent(
298+ """
299+ pipeline:
300+ - test
301+
302+ jobs:
303+ test:
304+ series: focal
305+ architectures: amd64
306+ packages: [nginx, apache2]
307+ run: non-existing
308+ plugin: non-existing
309+ """
310+ )
311+ Path(".launchpad.yaml").write_text(config)
312+
313+ result = self.run_command("run")
314+
315+ self.assertEqual(1, result.exit_code)
316+ self.assertEqual([YAMLError("Unknown plugin")], result.errors)
317diff --git a/lpcraft/plugins/__init__.py b/lpcraft/plugins/__init__.py
318new file mode 100644
319index 0000000..58966af
320--- /dev/null
321+++ b/lpcraft/plugins/__init__.py
322@@ -0,0 +1,5 @@
323+from lpcraft.plugins.tox import ToxPlugin
324+
325+# XXX jugmac00 2021-12-16: The plugin mapping should be autogenerated by a
326+# decorator, e.g. @register_plugin(name="<name>")
327+PLUGINS = {"tox": ToxPlugin}
328diff --git a/lpcraft/plugins/tox.py b/lpcraft/plugins/tox.py
329new file mode 100644
330index 0000000..2952371
331--- /dev/null
332+++ b/lpcraft/plugins/tox.py
333@@ -0,0 +1,17 @@
334+# Copyright 2021 Canonical Ltd. This software is licensed under the
335+# GNU General Public License version 3 (see the file LICENSE).
336+
337+from __future__ import annotations
338+
339+from lpcraft.config import Job
340+from lpcraft.plugin import hookimpl
341+
342+
343+class ToxPlugin:
344+ # XXX jugmac00 2021-12-16: this plugin is not yet fully implemented
345+ def __init__(self, config: Job) -> None:
346+ self.config = config
347+
348+ @hookimpl # type: ignore
349+ def lpcraft_install_packages(self) -> list[str]:
350+ return ["tox"]
351diff --git a/lpcraft/tests/test_config.py b/lpcraft/tests/test_config.py
352index 8280223..f07136d 100644
353--- a/lpcraft/tests/test_config.py
354+++ b/lpcraft/tests/test_config.py
355@@ -339,3 +339,24 @@ class TestConfig(TestCase):
356 )
357 config = Config.load(path)
358 self.assertEqual(None, config.jobs["test"][0].packages)
359+
360+ def test_load_plugin(self):
361+ path = self.create_config(
362+ dedent(
363+ """
364+ pipeline:
365+ - test
366+
367+ jobs:
368+ test:
369+ series: focal
370+ architectures: amd64
371+ packages: [nginx, apache2]
372+ plugin: tox
373+ """
374+ )
375+ )
376+
377+ config = Config.load(path)
378+
379+ self.assertEqual("tox", config.jobs["test"][0].plugin)
380diff --git a/requirements.in b/requirements.in
381index 422bc41..b721260 100644
382--- a/requirements.in
383+++ b/requirements.in
384@@ -3,3 +3,4 @@ pydantic
385 PyYAML
386 python-dotenv
387 git+git://github.com/canonical/craft-cli.git@93db67c7bc357f0b6d9f5793b7c2381c306f9ca3
388+pluggy
389diff --git a/requirements.txt b/requirements.txt
390index f81e3ac..ee685db 100644
391--- a/requirements.txt
392+++ b/requirements.txt
393@@ -16,6 +16,8 @@ idna==3.3
394 # via requests
395 platformdirs==2.4.0
396 # via craft-cli
397+pluggy==1.0.0
398+ # via -r requirements.in
399 pydantic==1.8.2
400 # via
401 # -r requirements.in
402diff --git a/setup.cfg b/setup.cfg
403index 7f9e2d8..02c6816 100644
404--- a/setup.cfg
405+++ b/setup.cfg
406@@ -25,6 +25,7 @@ install_requires =
407 PyYAML
408 craft-cli
409 craft-providers
410+ pluggy
411 pydantic
412 python-dotenv
413 python_requires = >=3.8

Subscribers

People subscribed via source and target branches