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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ The core lifecycle is synchronous so cleanup, Click resource unwinding, and
outcome finalization remain deterministic; an adapter may provide an explicit
async runner without changing the core contract.

### Optional entry-point extensions

Applications that want package-distributed commands, profiles, or integrations
can opt into `base_cli.ExtensionDiscovery`. It recognizes the documented
`base_cli.commands`, `base_cli.profiles`, and `base_cli.plugins` entry-point
groups. Discovery is lazy and cached, duplicate names fail explicitly, broken
extensions are isolated by `load_all()`, and consumers can disable discovery or
provide an allowlist. See [`docs/extensions.md`](docs/extensions.md) for the
entry-point contracts and deterministic ordering rules.

## Public API

The supported facade is `import base_cli`. It exports the command lifecycle
Expand Down
49 changes: 49 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Entry-point extensions

`base_cli.ExtensionDiscovery` provides an optional, lazy discovery boundary for
framework extensions. The core package only reads package metadata when a
consumer creates a discovery instance; third-party code is imported only when
an extension is explicitly loaded.

The supported entry-point groups are:

| Group | Contract | Purpose |
| --- | --- | --- |
| `base_cli.commands` | A callable command registrar | Add commands to a consumer-owned `App` or Click tree |
| `base_cli.profiles` | A callable profile factory | Supply a `CliProfile` for a named consumer |
| `base_cli.plugins` | A callable plugin installer | Register a coordinated extension with a consumer |

For example, a package can publish:

```toml
[project.entry-points."base_cli.commands"]
audit = "acme_cli.audit:register"

[project.entry-points."base_cli.profiles"]
acme = "acme_cli.profile:build_profile"

[project.entry-points."base_cli.plugins"]
telemetry = "acme_cli.telemetry:install"
```

The loaded callable receives the arguments documented by the consuming
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.

## Determinism and safety

Descriptors are ordered by group, entry-point name, distribution, version, and
target value. Duplicate names are an error; installation order is never an
implicit precedence rule. Each descriptor retains distribution/version/extras
metadata for diagnostics and policy decisions.

Discovery is cached per `ExtensionDiscovery` instance. Call `refresh()` after a
runtime environment change. `load_all()` isolates broken third-party imports
and returns an `ExtensionLoadResult` for every descriptor so one broken plugin
does not hide healthy extensions.

Use `allowlist={"base_cli.commands:audit"}` to restrict names, or
`ExtensionDiscovery(disabled=True)` to disable discovery entirely. Allowlist
entries may be a bare entry-point name, a fully-qualified `group:name`, or a
distribution name.
26 changes: 25 additions & 1 deletion lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def _resolve_version() -> str:

__version__ = _resolve_version()

from . import command_filters, command_protocol, history, json_contracts, testing
from . import command_filters, command_protocol, extensions, history, json_contracts, testing
from .attachment import (
AttachmentAdapter,
AttachmentContextFactory,
Expand Down Expand Up @@ -72,6 +72,19 @@ def _resolve_version() -> str:
get_current_context,
)
from .errors import ConfigurationError
from .extensions import (
COMMAND_ENTRY_POINT_GROUP,
ENTRY_POINT_GROUPS,
PLUGIN_ENTRY_POINT_GROUP,
PROFILE_ENTRY_POINT_GROUP,
ExtensionCollisionError,
ExtensionDescriptor,
ExtensionDiscovery,
ExtensionDiscoveryError,
ExtensionLoadError,
ExtensionLoadResult,
ExtensionsDisabledError,
)
from .exit_codes import ExitCode
from .inspection import inspection_envelope, render_inspection_json
from .json_contracts import (
Expand Down Expand Up @@ -139,8 +152,17 @@ def _resolve_version() -> str:
"CommandProtocolError",
"CommandSchemaRegistry",
"ConfigurationError",
"COMMAND_ENTRY_POINT_GROUP",
"Context",
"ConfigT",
"ENTRY_POINT_GROUPS",
"ExtensionCollisionError",
"ExtensionDescriptor",
"ExtensionDiscovery",
"ExtensionDiscoveryError",
"ExtensionLoadError",
"ExtensionLoadResult",
"ExtensionsDisabledError",
"DEFAULT_SCHEMA_REGISTRY",
"DisplayCommandResolver",
"EnvironmentConfigLoader",
Expand Down Expand Up @@ -195,6 +217,8 @@ def _resolve_version() -> str:
"ProjectDiscovery",
"RECORD_SCHEMAS",
"RuntimeLayout",
"PLUGIN_ENTRY_POINT_GROUP",
"PROFILE_ENTRY_POINT_GROUP",
"RetentionPolicy",
"RuntimeResolver",
"is_terminal",
Expand Down
Loading