Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>` 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
Expand Down
10 changes: 10 additions & 0 deletions lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -252,6 +260,8 @@ def _resolve_version() -> str:
"RECORD_SCHEMAS",
"RuntimeLayout",
"PLUGIN_ENTRY_POINT_GROUP",
"PluginExtension",
"ProfileExtension",
"PROFILE_ENTRY_POINT_GROUP",
"RetentionPolicy",
"RuntimeResolver",
Expand Down
64 changes: 64 additions & 0 deletions lib/python/base_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,7 +26,10 @@

__all__ = [
"COMMAND_ENTRY_POINT_GROUP",
"CommandExtension",
"ENTRY_POINT_GROUPS",
"EXTENSION_API_VERSION",
"ExtensionCompatibilityError",
"ExtensionCollisionError",
"ExtensionDescriptor",
"ExtensionDiscovery",
Expand All @@ -34,6 +38,8 @@
"ExtensionLoadResult",
"ExtensionsDisabledError",
"PLUGIN_ENTRY_POINT_GROUP",
"PluginExtension",
"ProfileExtension",
"PROFILE_ENTRY_POINT_GROUP",
]

Expand Down Expand Up @@ -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."""

Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand All @@ -130,13 +169,17 @@ 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")
self.disabled = disabled
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, ...]] = {}
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -301,13 +348,30 @@ 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),
value=cast(str, entry_point.value),
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,
)


Expand Down
5 changes: 5 additions & 0 deletions tests/test_api_stability.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@
"CommandSchemaRegistry",
"ConfigurationError",
"COMMAND_ENTRY_POINT_GROUP",
"CommandExtension",
"Context",
"ConfigT",
"ENTRY_POINT_GROUPS",
"EXTENSION_API_VERSION",
"ExtensionCompatibilityError",
"ExtensionCollisionError",
"ExtensionDescriptor",
"ExtensionDiscovery",
Expand Down Expand Up @@ -104,6 +107,8 @@
"RECORD_SCHEMAS",
"RuntimeLayout",
"PLUGIN_ENTRY_POINT_GROUP",
"PluginExtension",
"ProfileExtension",
"PROFILE_ENTRY_POINT_GROUP",
"RetentionPolicy",
"RuntimeResolver",
Expand Down
28 changes: 27 additions & 1 deletion tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)

Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
Loading