From 29562ad169d3b63c6e70e3f7b85f26b0cc585782 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:46:15 -0700 Subject: [PATCH] feat: formalize extension SDK compatibility --- CHANGELOG.md | 2 + docs/extensions.md | 24 ++++++++++++ lib/python/base_cli/__init__.py | 10 +++++ lib/python/base_cli/extensions.py | 64 +++++++++++++++++++++++++++++++ tests/test_api_stability.py | 5 +++ tests/test_extensions.py | 28 +++++++++++++- 6 files changed, 132 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f89b8de..35a1a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and versions are tracked in the repo-root `VERSION` file. deterministic async callbacks without changing the synchronous core. - Add versioned NDJSON output and typed writer protocols for bounded, flush-per-record machine output. +- Formalize typed extension callback protocols, entry-point capability metadata, + and pre-load API-version negotiation. ### Added diff --git a/docs/extensions.md b/docs/extensions.md index f04f6aa..120c652 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -31,6 +31,30 @@ application. `base-cli` intentionally discovers metadata without imposing a single command-tree or profile-construction shape; this keeps Click, Typer, and consumer-owned composition boundaries independent. +## Typed SDK and compatibility + +The supported callable protocols are exported as `CommandExtension`, +`ProfileExtension`, and `PluginExtension`. They are intentionally small: +consumers own the concrete `App`, `CliProfile`, and service composition types, +while plugin packages type-check against a stable callback boundary. + +Declare the SDK version and capabilities in the entry-point extras. The +`base-cli-api-v1` extra selects the current protocol version; additional +`base-cli-cap-` extras advertise optional behavior without importing the +plugin: + +```toml +[project.entry-points."base_cli.plugins"] +telemetry = "acme_cli.telemetry:install [base-cli-api-v1, base-cli-cap-tracing]" +``` + +`ExtensionDescriptor.api_version` and `.capabilities` expose this metadata. +`ExtensionDiscovery` rejects an unsupported API version before loading the +entry point and reports the supported versions in the error. This is a +negotiation boundary, not an installation-order heuristic; the consumer can +select another plugin release or widen its `supported_api_versions` policy +after running its compatibility suite. + ## Determinism and safety Descriptors are ordered by group, entry-point name, distribution, version, and diff --git a/lib/python/base_cli/__init__.py b/lib/python/base_cli/__init__.py index 5673b1e..5b7774b 100644 --- a/lib/python/base_cli/__init__.py +++ b/lib/python/base_cli/__init__.py @@ -91,15 +91,20 @@ def _resolve_version() -> str: from .extensions import ( COMMAND_ENTRY_POINT_GROUP, ENTRY_POINT_GROUPS, + EXTENSION_API_VERSION, PLUGIN_ENTRY_POINT_GROUP, PROFILE_ENTRY_POINT_GROUP, + CommandExtension, ExtensionCollisionError, + ExtensionCompatibilityError, ExtensionDescriptor, ExtensionDiscovery, ExtensionDiscoveryError, ExtensionLoadError, ExtensionLoadResult, ExtensionsDisabledError, + PluginExtension, + ProfileExtension, ) from .inspection import inspection_envelope, render_inspection_json from .integrations import TelemetryOptions, TelemetrySession, try_render_rich_table @@ -176,9 +181,12 @@ def _resolve_version() -> str: "CommandSchemaRegistry", "ConfigurationError", "COMMAND_ENTRY_POINT_GROUP", + "CommandExtension", "Context", "ConfigT", "ENTRY_POINT_GROUPS", + "EXTENSION_API_VERSION", + "ExtensionCompatibilityError", "ExtensionCollisionError", "ExtensionDescriptor", "ExtensionDiscovery", @@ -252,6 +260,8 @@ def _resolve_version() -> str: "RECORD_SCHEMAS", "RuntimeLayout", "PLUGIN_ENTRY_POINT_GROUP", + "PluginExtension", + "ProfileExtension", "PROFILE_ENTRY_POINT_GROUP", "RetentionPolicy", "RuntimeResolver", diff --git a/lib/python/base_cli/extensions.py b/lib/python/base_cli/extensions.py index 33d729b..602e69b 100644 --- a/lib/python/base_cli/extensions.py +++ b/lib/python/base_cli/extensions.py @@ -17,6 +17,7 @@ COMMAND_ENTRY_POINT_GROUP = "base_cli.commands" PROFILE_ENTRY_POINT_GROUP = "base_cli.profiles" PLUGIN_ENTRY_POINT_GROUP = "base_cli.plugins" +EXTENSION_API_VERSION = "1" ENTRY_POINT_GROUPS = ( COMMAND_ENTRY_POINT_GROUP, PROFILE_ENTRY_POINT_GROUP, @@ -25,7 +26,10 @@ __all__ = [ "COMMAND_ENTRY_POINT_GROUP", + "CommandExtension", "ENTRY_POINT_GROUPS", + "EXTENSION_API_VERSION", + "ExtensionCompatibilityError", "ExtensionCollisionError", "ExtensionDescriptor", "ExtensionDiscovery", @@ -34,6 +38,8 @@ "ExtensionLoadResult", "ExtensionsDisabledError", "PLUGIN_ENTRY_POINT_GROUP", + "PluginExtension", + "ProfileExtension", "PROFILE_ENTRY_POINT_GROUP", ] @@ -63,6 +69,19 @@ def __init__(self, group: str, name: str, descriptors: Sequence[ExtensionDescrip ) +class ExtensionCompatibilityError(ExtensionDiscoveryError): + """Raised when an extension declares an unsupported SDK version.""" + + def __init__(self, descriptor: ExtensionDescriptor, supported: Sequence[str]) -> None: + self.descriptor = descriptor + self.supported = tuple(supported) + expected = ", ".join(self.supported) + super().__init__( + f"Extension '{descriptor.key}' declares API version {descriptor.api_version!r}; " + f"supported versions are: {expected}. Install a compatible extension release." + ) + + class ExtensionLoadError(ExtensionDiscoveryError): """Wrap an extension import failure without hiding its source metadata.""" @@ -86,6 +105,8 @@ class ExtensionDescriptor: distribution: str | None version: str | None extras: tuple[str, ...] = () + api_version: str = EXTENSION_API_VERSION + capabilities: tuple[str, ...] = () @property def key(self) -> str: @@ -113,6 +134,24 @@ class EntryPointProvider(Protocol): def __call__(self) -> Iterable[Any]: ... +class CommandExtension(Protocol): + """Callable contract for ``base_cli.commands`` entry points.""" + + def __call__(self, app: Any) -> None: ... + + +class ProfileExtension(Protocol): + """Callable contract for ``base_cli.profiles`` entry points.""" + + def __call__(self, name: str) -> Any: ... + + +class PluginExtension(Protocol): + """Callable contract for ``base_cli.plugins`` entry points.""" + + def __call__(self, app: Any) -> None: ... + + class ExtensionDiscovery: """Discover and lazily load command, profile, and plugin entry points. @@ -130,6 +169,7 @@ def __init__( allowlist: Iterable[str] | None = None, entry_points: Iterable[Any] | EntryPointProvider | None = None, paths: Iterable[Path] | None = None, + supported_api_versions: Iterable[str] = (EXTENSION_API_VERSION,), ) -> None: if entry_points is not None and paths is not None: raise ValueError("pass either entry_points or paths, not both") @@ -137,6 +177,9 @@ def __init__( self.allowlist = frozenset(allowlist) if allowlist is not None else None self._entry_points = entry_points self._paths = tuple(Path(path) for path in paths) if paths is not None else None + self.supported_api_versions = frozenset(str(version) for version in supported_api_versions) + if not self.supported_api_versions: + raise ValueError("supported_api_versions must contain at least one version") self._raw_cache: tuple[Any, ...] | None = None self._metadata_cache: tuple[ExtensionDescriptor, ...] | None = None self._descriptor_cache: dict[str, tuple[ExtensionDescriptor, ...]] = {} @@ -190,6 +233,8 @@ def load(self, group: str, name: str) -> Any: if key in self._loaded_cache: return self._loaded_cache[key] descriptor = matches[0] + if descriptor.api_version not in self.supported_api_versions: + raise ExtensionCompatibilityError(descriptor, tuple(sorted(self.supported_api_versions))) try: value = self._load_descriptor(descriptor) except BaseException as exc: # isolate third-party import failures @@ -212,6 +257,8 @@ def load_all(self, group: str) -> tuple[ExtensionLoadResult, ...]: results.append(ExtensionLoadResult(descriptor, error=exc)) except ExtensionCollisionError as exc: results.append(ExtensionLoadResult(descriptor, error=ExtensionLoadError(descriptor, exc))) + except ExtensionCompatibilityError as exc: + results.append(ExtensionLoadResult(descriptor, error=ExtensionLoadError(descriptor, exc))) return tuple(results) def refresh(self) -> None: @@ -301,6 +348,21 @@ def _descriptor_from_entry_point(entry_point: Any) -> ExtensionDescriptor: if distribution_metadata is not None: distribution_name = distribution_metadata.get("Name") extras = getattr(entry_point, "extras", ()) or () + api_versions = tuple( + extra.removeprefix("base-cli-api-v") + for extra in extras + if isinstance(extra, str) and extra.startswith("base-cli-api-v") + ) + if len(api_versions) > 1: + raise ValueError("an extension entry point may declare only one base-cli-api-vN extra") + api_version = api_versions[0] if api_versions else EXTENSION_API_VERSION + capabilities = tuple( + sorted( + extra.removeprefix("base-cli-cap-") + for extra in extras + if isinstance(extra, str) and extra.startswith("base-cli-cap-") + ) + ) return ExtensionDescriptor( group=cast(str, entry_point.group), name=cast(str, entry_point.name), @@ -308,6 +370,8 @@ def _descriptor_from_entry_point(entry_point: Any) -> ExtensionDescriptor: distribution=distribution_name, version=str(version) if version is not None else None, extras=tuple(str(extra) for extra in extras), + api_version=api_version, + capabilities=capabilities, ) diff --git a/tests/test_api_stability.py b/tests/test_api_stability.py index 86701cb..87bc7b5 100644 --- a/tests/test_api_stability.py +++ b/tests/test_api_stability.py @@ -29,9 +29,12 @@ "CommandSchemaRegistry", "ConfigurationError", "COMMAND_ENTRY_POINT_GROUP", + "CommandExtension", "Context", "ConfigT", "ENTRY_POINT_GROUPS", + "EXTENSION_API_VERSION", + "ExtensionCompatibilityError", "ExtensionCollisionError", "ExtensionDescriptor", "ExtensionDiscovery", @@ -104,6 +107,8 @@ "RECORD_SCHEMAS", "RuntimeLayout", "PLUGIN_ENTRY_POINT_GROUP", + "PluginExtension", + "ProfileExtension", "PROFILE_ENTRY_POINT_GROUP", "RetentionPolicy", "RuntimeResolver", diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 4e143ab..f40f713 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -18,12 +18,13 @@ def _entry_point( group: str = base_cli.COMMAND_ENTRY_POINT_GROUP, distribution: str = "sample-package", version: str = "1.0", + extras: tuple[str, ...] = (), ) -> SimpleNamespace: return SimpleNamespace( name=name, value=value, group=group, - extras=(), + extras=extras, dist=SimpleNamespace(name=distribution, version=version), ) @@ -43,6 +44,8 @@ def provider() -> tuple[SimpleNamespace]: descriptors = discovery.list_commands() self.assertEqual(descriptors[0].key, "base_cli.commands:audit") self.assertEqual(descriptors[0].distribution, "sample-package") + self.assertEqual(descriptors[0].api_version, base_cli.EXTENSION_API_VERSION) + self.assertEqual(descriptors[0].capabilities, ()) self.assertEqual(calls, [None]) self.assertEqual(discovery.load(base_cli.COMMAND_ENTRY_POINT_GROUP, "audit"), "command") self.assertEqual(discovery.load(base_cli.COMMAND_ENTRY_POINT_GROUP, "audit"), "command") @@ -87,6 +90,29 @@ def test_load_all_isolates_broken_extensions(self) -> None: self.assertFalse(results[0].ok) self.assertIn("missing optional dependency", str(results[0].error)) + def test_api_version_and_capabilities_are_negotiated_from_entry_point_extras(self) -> None: + compatible = _entry_point( + "telemetry", + "one:install", + group=base_cli.PLUGIN_ENTRY_POINT_GROUP, + extras=("base-cli-api-v1", "base-cli-cap-tracing", "base-cli-cap-metrics"), + ) + discovery = base_cli.ExtensionDiscovery(entry_points=(compatible,)) + descriptor = discovery.list_plugins()[0] + self.assertEqual(descriptor.api_version, "1") + self.assertEqual(descriptor.capabilities, ("metrics", "tracing")) + + incompatible = _entry_point( + "future", + "two:install", + group=base_cli.PLUGIN_ENTRY_POINT_GROUP, + extras=("base-cli-api-v2",), + ) + incompatible.load = lambda: self.fail("incompatible extensions must not load") + future = base_cli.ExtensionDiscovery(entry_points=(incompatible,)) + with self.assertRaisesRegex(base_cli.ExtensionCompatibilityError, "API version '2'"): + future.load(base_cli.PLUGIN_ENTRY_POINT_GROUP, "future") + def test_real_distribution_metadata_is_discovered_from_a_path(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir)