|
| 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") |
0 commit comments