Skip to content

Commit 22b4a15

Browse files
committed
Reorganize package-handler support
Signed-off-by: Matthew Ballance <matt.ballance@gmail.com>
1 parent c38142d commit 22b4a15

18 files changed

+1585
-76
lines changed

docs/source/extending_ivpm.rst

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
###############
2+
Extending IVPM
3+
###############
4+
5+
IVPM is designed to be extended with custom **package handlers**. A handler is a
6+
Python class that observes packages as they are loaded and performs actions — such
7+
as setting up a virtual environment, writing IDE integration files, or invoking a
8+
downstream tool. IVPM discovers handlers through Python `entry points`_, so any
9+
installed package can contribute new handlers without modifying IVPM itself.
10+
11+
.. _entry points: https://packaging.python.org/en/latest/specifications/entry-points/
12+
13+
14+
Overview
15+
========
16+
17+
IVPM handlers participate in two phases of every ``update``/``clone`` run:
18+
19+
**Leaf phase**
20+
Called once per package, on a worker thread, as each package is fetched and
21+
made available on disk. Leaf callbacks run concurrently — one per fetched
22+
package — so they are well-suited to lightweight per-package detection tasks.
23+
24+
**Root phase**
25+
Called once per run, on the main thread, after *all* packages have been
26+
fetched. Root callbacks see the full package list and are used for heavier
27+
work such as creating virtual environments or generating toolchain files.
28+
29+
Both phases are optional — a handler may implement only the one(s) it needs.
30+
31+
32+
The ``PackageHandler`` Base Class
33+
==================================
34+
35+
All handlers extend ``ivpm.handlers.PackageHandler``:
36+
37+
.. code-block:: python
38+
39+
import dataclasses as dc
40+
from typing import ClassVar, List, Optional
41+
from ivpm.handlers import PackageHandler, HandlerFatalError, ALWAYS, HasType
42+
43+
@dc.dataclass
44+
class MyHandler(PackageHandler):
45+
46+
# --- Metadata (class-level, not instance attributes) ---
47+
name: ClassVar[str] = "my-handler"
48+
description: ClassVar[str] = "Does something useful"
49+
phase: ClassVar[int] = 0 # lower = earlier in root phase
50+
51+
# --- When to activate (see Conditions section below) ---
52+
leaf_when: ClassVar[Optional[List]] = None # None = always run as leaf
53+
root_when: ClassVar[Optional[List]] = None # None = always run as root
54+
55+
# --- Per-run state (cleared by reset()) ---
56+
_found_pkgs: list = dc.field(default_factory=list, init=False, repr=False)
57+
58+
def reset(self):
59+
"""Called automatically at the start of each run."""
60+
self._found_pkgs = []
61+
62+
# --- Leaf callback ---
63+
def on_leaf_post_load(self, pkg, update_info):
64+
if (pkg.path / "my-marker.txt").exists():
65+
with self._lock:
66+
self._found_pkgs.append(pkg)
67+
68+
# --- Root callback ---
69+
def on_root_post_load(self, update_info):
70+
for pkg in self._found_pkgs:
71+
print(f"Processing {pkg.name}")
72+
73+
74+
Class-level Metadata
75+
---------------------
76+
77+
``name``
78+
Short identifier for the handler, used in log messages and entry-point
79+
registration. Required.
80+
81+
``description``
82+
Human-readable description shown in verbose output.
83+
84+
``phase``
85+
Integer ordering key for the **root** phase. Handlers with lower phase
86+
numbers run first. Leaf phase ordering is determined by package fetch order,
87+
not by this value. Default: ``0``.
88+
89+
``leaf_when``
90+
A list of **leaf conditions** (see below), or ``None`` to always run as a
91+
leaf handler. Use ``[]`` (empty list) to opt out of leaf dispatch entirely.
92+
93+
``root_when``
94+
A list of **root conditions** (see below), or ``None`` to always run as a
95+
root handler. Use ``[]`` to opt out of root dispatch entirely.
96+
97+
98+
Callbacks
99+
----------
100+
101+
``reset()``
102+
Clear per-run accumulated state. Called automatically by
103+
``on_root_pre_load()`` at the start of every run. Override this to reset
104+
any lists or counters that accumulate across leaf callbacks.
105+
106+
``on_leaf_pre_load(pkg, update_info)``
107+
Called before a package is fetched. Rarely needed; ``on_leaf_post_load`` is
108+
usually the right choice.
109+
110+
``on_leaf_post_load(pkg, update_info)``
111+
Called after a package is ready on disk. The package directory exists and can
112+
be inspected. Runs concurrently — always use ``with self._lock:`` when writing
113+
to shared handler state.
114+
115+
``on_root_pre_load(update_info)``
116+
Called before any packages start loading. Calls ``reset()`` automatically.
117+
Override this only if you need additional setup before leaf callbacks begin.
118+
119+
``on_root_post_load(update_info)``
120+
Called after all packages have been fetched. Runs on the main thread. This is
121+
where long-running work (venv creation, codegen, etc.) belongs.
122+
123+
``get_lock_entries(deps_dir) -> dict``
124+
Return extra top-level keys to merge into the project's lock file. Called
125+
after ``on_root_post_load()``. Default returns ``{}``.
126+
127+
``build(build_info)``
128+
Called by ``ivpm build``. Override to perform package build steps.
129+
130+
``add_options(subcommands)``
131+
Register handler-specific CLI flags. ``subcommands`` is a ``dict`` mapping
132+
subcommand name → argparse subparser. Called during CLI parser setup.
133+
134+
135+
Conditions
136+
==========
137+
138+
Conditions control when a handler is active. They are plain callables stored in
139+
``leaf_when`` / ``root_when`` class variables. IVPM provides three built-in
140+
conditions:
141+
142+
.. code-block:: python
143+
144+
from ivpm.handlers import ALWAYS, HasType, HasSourceType
145+
146+
``ALWAYS``
147+
Sentinel condition that always returns ``True``. Useful as an explicit
148+
marker that a handler is intentionally unconditional.
149+
150+
``HasType(type_name)``
151+
**Root condition.** Returns ``True`` if any loaded package has the given
152+
type, determined by either:
153+
154+
* ``pkg.pkg_type`` — set dynamically by a leaf handler
155+
* ``pkg.type_data`` — set from the ``type:`` field in ``ivpm.yaml``
156+
157+
Example — only run the root phase when at least one Python package was
158+
detected:
159+
160+
.. code-block:: python
161+
162+
root_when = [HasType("python")]
163+
164+
``HasSourceType(src_type)``
165+
**Dual-mode condition.** When used in ``leaf_when``, receives a single
166+
package and returns ``True`` if its source type matches. When used in
167+
``root_when``, receives the full package list and returns ``True`` if any
168+
package matches.
169+
170+
Example — only inspect git-sourced packages:
171+
172+
.. code-block:: python
173+
174+
leaf_when = [HasSourceType("git")]
175+
176+
You may also write your own conditions as any callable:
177+
178+
.. code-block:: python
179+
180+
def has_cmake(pkg):
181+
"""True if the package contains a CMakeLists.txt."""
182+
return (pkg.path / "CMakeLists.txt").exists()
183+
184+
class MyCMakeHandler(PackageHandler):
185+
leaf_when = [has_cmake]
186+
root_when = [HasType("cmake")]
187+
188+
189+
All conditions in a list are **AND'd** — all must be ``True`` for the handler to
190+
be active.
191+
192+
193+
Thread Safety
194+
=============
195+
196+
Leaf callbacks run concurrently. The base class provides ``self._lock``
197+
(a ``threading.Lock``) for synchronising writes to accumulated state:
198+
199+
.. code-block:: python
200+
201+
def on_leaf_post_load(self, pkg, update_info):
202+
if self._is_relevant(pkg):
203+
with self._lock: # ← required when writing shared state
204+
self._found_pkgs.append(pkg)
205+
206+
Read-only access inside a single leaf callback does not require the lock.
207+
208+
209+
Progress Reporting
210+
==================
211+
212+
Handlers can report progress to the TUI using ``task_context()``:
213+
214+
.. code-block:: python
215+
216+
def on_root_post_load(self, update_info):
217+
steps = list(self._found_pkgs)
218+
with self.task_context(update_info, "my-handler-setup", "Setting up MyTool") as task:
219+
for i, pkg in enumerate(steps):
220+
task.progress(f"Processing {pkg.name}", step=i + 1, total=len(steps))
221+
self._process(pkg)
222+
223+
``task_context(info, task_id, task_name)``
224+
Context manager that emits ``HANDLER_TASK_START`` on entry,
225+
``HANDLER_TASK_END`` on clean exit, and ``HANDLER_TASK_ERROR`` on exception
226+
(then re-raises). Returns a ``TaskHandle``.
227+
228+
``task.progress(message, step=None, total=None)``
229+
Emit a ``HANDLER_TASK_PROGRESS`` event. The TUI displays the most recent
230+
message and, when ``step``/``total`` are provided, a fraction like ``2/5``.
231+
232+
``task.task_context(task_id, task_name)``
233+
Create a **nested** child task displayed under the parent in the TUI.
234+
235+
If no TUI is active (e.g. in non-interactive mode), ``task_context()`` and
236+
``task.progress()`` are no-ops — it is always safe to call them.
237+
238+
Fatal Errors
239+
============
240+
241+
To abort an entire update run from inside a leaf callback, raise
242+
``HandlerFatalError``:
243+
244+
.. code-block:: python
245+
246+
from ivpm.handlers import HandlerFatalError
247+
248+
def on_leaf_post_load(self, pkg, update_info):
249+
if not self._check(pkg):
250+
raise HandlerFatalError(f"Required file missing in {pkg.name}")
251+
252+
Non-fatal exceptions logged inside a leaf callback are caught and reported as
253+
warnings; the run continues with remaining packages.
254+
255+
256+
Registering a Handler via Entry Points
257+
=======================================
258+
259+
IVPM discovers handlers through the ``ivpm.handlers`` entry-point group.
260+
Add the following to your ``pyproject.toml``:
261+
262+
.. code-block:: toml
263+
264+
[project.entry-points."ivpm.handlers"]
265+
my-handler = "mypkg.my_handler:MyHandler"
266+
267+
Or, if you use ``setup.cfg``:
268+
269+
.. code-block:: ini
270+
271+
[options.entry_points]
272+
ivpm.handlers =
273+
my-handler = mypkg.my_handler:MyHandler
274+
275+
Each value must point to a **class** that extends ``PackageHandler``.
276+
IVPM instantiates the class once per update run.
277+
278+
After installing your package (``pip install -e .``), IVPM will automatically
279+
load ``MyHandler`` on every ``update`` or ``clone`` run.
280+
281+
282+
Complete Example
283+
================
284+
285+
The following example shows a handler that detects packages containing
286+
FuseSoC ``.core`` files and writes a consolidated library list:
287+
288+
.. code-block:: python
289+
290+
# src/myext/fusesoc_handler.py
291+
import dataclasses as dc
292+
import pathlib
293+
from typing import ClassVar, List, Optional
294+
295+
from ivpm.handlers import PackageHandler, HasType
296+
297+
@dc.dataclass
298+
class FuseSocHandler(PackageHandler):
299+
300+
name: ClassVar[str] = "fusesoc"
301+
description: ClassVar[str] = "Collect FuseSoC core libraries"
302+
phase: ClassVar[int] = 10
303+
304+
# Activate root phase only when FuseSoC packages were detected
305+
root_when: ClassVar[Optional[List]] = [HasType("fusesoc")]
306+
307+
_lib_paths: list = dc.field(default_factory=list, init=False, repr=False)
308+
309+
def reset(self):
310+
self._lib_paths = []
311+
312+
def on_leaf_post_load(self, pkg, update_info):
313+
cores = list(pathlib.Path(pkg.path).rglob("*.core"))
314+
if cores:
315+
pkg.pkg_type = "fusesoc" # marks package for HasType("fusesoc")
316+
with self._lock:
317+
self._lib_paths.append(str(pkg.path))
318+
319+
def on_root_post_load(self, update_info):
320+
out = pathlib.Path(update_info.deps_dir) / ".." / "fusesoc.conf"
321+
with self.task_context(update_info, "fusesoc-write", "Writing FuseSoC config") as task:
322+
task.progress(f"Writing {len(self._lib_paths)} library paths")
323+
with open(out, "w") as f:
324+
for p in self._lib_paths:
325+
f.write(f"[cores]\nlocation = {p}\n\n")
326+
327+
Register it:
328+
329+
.. code-block:: toml
330+
331+
[project.entry-points."ivpm.handlers"]
332+
fusesoc = "myext.fusesoc_handler:FuseSocHandler"
333+
334+
335+
Handler Ordering and the Extension Group
336+
=========================================
337+
338+
IVPM loads handlers in this order:
339+
340+
1. Built-in handlers (Python, Direnv, Skills) — all at phase ``0``
341+
2. Extension handlers discovered via ``ivpm.handlers`` entry points, in
342+
installation order
343+
344+
Within the root phase, handlers with the same phase number run in the order they
345+
were registered. Leaf callbacks always run concurrently with no guaranteed
346+
ordering.
347+
348+
To run after all built-in handlers, use ``phase = 10`` or higher. To run before
349+
a built-in, use a negative phase (though this is rarely needed).
350+
351+
352+
Testing Your Handler
353+
====================
354+
355+
The simplest way to test a handler in isolation is with the stubs already used
356+
by IVPM's own test suite:
357+
358+
.. code-block:: python
359+
360+
import threading, unittest
361+
from ivpm.handlers import PackageHandler
362+
363+
class FakeUpdateInfo:
364+
def __init__(self):
365+
self.event_dispatcher = None
366+
self.deps_dir = "/tmp/fake-deps"
367+
368+
class FakePkg:
369+
def __init__(self, name, path="/tmp/pkg"):
370+
self.name = name
371+
self.path = pathlib.Path(path)
372+
self.pkg_type = None
373+
374+
class TestMyHandler(unittest.TestCase):
375+
def test_detects_marker(self):
376+
h = MyHandler()
377+
pkg = FakePkg("test-pkg", path="/path/with/marker")
378+
info = FakeUpdateInfo()
379+
h.on_leaf_post_load(pkg, info)
380+
self.assertEqual(pkg.pkg_type, "my-type")

docs/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Welcome to IVPM's documentation!
2323
workflows
2424
git_integration
2525
integrations
26+
extending_ivpm
2627
troubleshooting
2728
reference
2829

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,8 @@ ivpm = "ivpm.__main__:main"
2626
[tool.setuptools.package-data]
2727
ivpm = ['scripts/*', 'templates/*', 'share/*', 'share/*.md', 'share/cmake/*']
2828

29+
[project.entry-points."ivpm.handlers"]
30+
python = "ivpm.handlers.package_handler_python:PackageHandlerPython"
31+
direnv = "ivpm.handlers.package_handler_direnv:PackageHandlerDirenv"
32+
skills = "ivpm.handlers.package_handler_skills:PackageHandlerSkills"
33+

0 commit comments

Comments
 (0)