Coverage for /home/runner/work/bijux-cli/bijux-cli/src/bijux_cli/services/plugins/entrypoints.py: 100%
62 statements
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 23:36 +0000
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 23:36 +0000
1# SPDX-License-Identifier: MIT
2# Copyright © 2025 Bijan Mousavi
4"""Discovers and loads plugins distributed as Python packages.
6This module provides the `load_entrypoints` function, which is responsible for
7finding and loading plugins that have been installed into the Python
8environment and registered under the `bijux_cli.plugins` entry point group.
9This enables a distributable plugin ecosystem where plugins can be managed
10via tools like `pip`.
11"""
13from __future__ import annotations
15import asyncio
16import contextlib
17import importlib.metadata as im
18import logging
19import traceback
20from typing import Any
22from packaging.specifiers import SpecifierSet
23from packaging.version import Version as PkgVersion
25from bijux_cli.contracts import (
26 ObservabilityProtocol,
27 RegistryProtocol,
28 TelemetryProtocol,
29)
30from bijux_cli.core.di import DIContainer
32_LOG = logging.getLogger("bijux_cli.plugin_loader")
35def _iter_plugin_eps() -> list[im.EntryPoint]:
36 """Returns all entry points in the 'bijux_cli.plugins' group.
38 Returns:
39 list[importlib.metadata.EntryPoint]: A list of all discovered plugin
40 entry points.
41 """
42 try:
43 eps = im.entry_points()
44 return list(eps.select(group="bijux_cli.plugins"))
45 except Exception:
46 return []
49def _compatible(plugin: Any) -> bool:
50 """Determines if a plugin is compatible with the current CLI API version.
52 Args:
53 plugin (Any): The plugin instance or class, which should have a
54 `requires_api_version` attribute string (e.g., ">=1.2.0").
56 Returns:
57 bool: True if the plugin's version requirement is met by the host CLI's
58 API version, otherwise False.
59 """
60 import bijux_cli
62 spec = getattr(plugin, "requires_api_version", ">=0.0.0")
63 try:
64 apiv = bijux_cli.api_version
65 host_api_version = PkgVersion(str(apiv))
66 return SpecifierSet(spec).contains(host_api_version)
67 except Exception:
68 return False
71async def load_entrypoints(
72 di: DIContainer | None = None,
73 registry: RegistryProtocol | None = None,
74) -> None:
75 """Discovers, loads, and registers all entry point-based plugins.
77 This function iterates through all entry points in the 'bijux_cli.plugins'
78 group. For each one, it attempts to load, instantiate, and register the
79 plugin. It also performs an API version compatibility check and runs the
80 plugin's `startup` hook if present.
82 Note:
83 All exceptions during the loading or startup of a single plugin are
84 caught, logged, and reported via telemetry. A failed plugin will be
85 deregistered and will not prevent other plugins from loading.
87 Args:
88 di (DIContainer | None): The dependency injection container. If None,
89 the current global container is used.
90 registry (RegistryProtocol | None): The plugin registry. If None, it is
91 resolved from the DI container.
93 Returns:
94 None:
95 """
96 import bijux_cli
98 di = di or DIContainer.current()
99 registry = registry or di.resolve(RegistryProtocol)
101 obs = di.resolve(ObservabilityProtocol, None)
102 tel = di.resolve(TelemetryProtocol, None)
104 for ep in _iter_plugin_eps():
105 try:
106 plugin_class = ep.load()
107 plugin = plugin_class()
109 if not _compatible(plugin):
110 raise RuntimeError(
111 f"Plugin '{ep.name}' requires API {getattr(plugin, 'requires_api_version', 'N/A')}, "
112 f"host is {bijux_cli.api_version}"
113 )
115 for tgt in (plugin_class, plugin):
116 raw = getattr(tgt, "version", None)
117 if raw is not None and not isinstance(raw, str):
118 tgt.version = str(raw)
120 registry.register(ep.name, plugin, version=plugin.version)
122 startup = getattr(plugin, "startup", None)
123 if asyncio.iscoroutinefunction(startup):
124 await startup(di)
125 elif callable(startup):
126 startup(di)
128 if obs:
129 obs.log("info", f"Loaded plugin '{ep.name}'")
130 if tel:
131 tel.event("entrypoint_plugin_loaded", {"name": ep.name})
133 except Exception as exc:
134 with contextlib.suppress(Exception):
135 registry.deregister(ep.name)
137 if obs:
138 obs.log(
139 "error",
140 f"Failed to load plugin '{ep.name}'",
141 extra={"trace": traceback.format_exc(limit=5)},
142 )
143 if tel:
144 tel.event(
145 "entrypoint_plugin_failed", {"name": ep.name, "error": str(exc)}
146 )
148 _LOG.debug("Skipped plugin %s: %s", ep.name, exc, exc_info=True)
151__all__ = ["load_entrypoints"]