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 .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jobs:
run: python -m pip install ".[dev]"
- name: Run Python tests
run: python -m pytest
- name: Type-check public contract sample
run: python -m mypy --strict examples/typed_consumer.py

linux-distributions:
name: Validate (${{ matrix.name }})
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ and versions are tracked in the repo-root `VERSION` file.
Click parameters with domain-specific secret names.
- Add `ConfigurationError` so consumer profiles can explicitly mark
user-correctable configuration messages as safe usage errors.
- Add public typed runtime/profile/context contracts, generic attachment
factories, isolated command-schema registries/codecs, and a strict consumer
typing example.

### Changed

Expand All @@ -32,6 +35,8 @@ and versions are tracked in the repo-root `VERSION` file.
- Route `base_cli.testing.invoke()` through the production `run_app()` boundary
and add a keyword-only `reraise_unexpected` opt-in for tests that need the
original exception.
- Reject native async callbacks explicitly and preserve Click command subtypes
through typed `attach()` decorators and adapters.

### Fixed

Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,41 @@ consumer-owned adapters should supply any product-specific policies. See
[`docs/consumer-profiles.md`](docs/consumer-profiles.md) for the boundary and
migration guidance.

### Typed extension contracts

The public profile contract includes `ProjectDiscovery`, `ConfigLoader`,
`RuntimeResolver`, `HistoryWriter`, and the other resolver protocols exported
from `base_cli`. `RuntimeBinding.layout` uses the public immutable
`RuntimeLayout` type; consumers do not need to import private runtime modules.

`Context` is generic over the validated configuration, application state, and
service payloads owned by a consumer:

```python
Config = dict[str, object]
context: base_cli.Context[Config, ApplicationState, Services]
```

`App.command()`, `App.subcommand()`, `@base_cli.command()`, `@base_cli.option()`,
and `@base_cli.argument()` preserve the decorated callable's `ParamSpec`
signature. `base_cli.attach()` and `App.attach()` preserve the concrete Click
command subtype in their return type.

`AttachmentAdapter`, `AttachmentContract`, and the typed context/service
factories define the Click attachment boundary for adapters that compose or
wrap an attached command.

Command protocol schemas can be isolated per consumer with
`CommandSchemaRegistry` and `CommandCodec`. The module-level registration and
codec helpers remain compatible defaults backed by `RECORD_SCHEMAS`, but new
integrations should prefer an instance-owned registry when multiple protocol
boundaries share a process.

Native async callbacks are intentionally rejected with an actionable error.
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.

## Public API

The supported facade is `import base_cli`. It exports the command lifecycle
Expand All @@ -101,6 +136,10 @@ Low-level implementation helpers are intentionally not included in the
module `__all__` surfaces. Downstream code should use the documented facade or
the explicitly supported symbols from those modules.

The repository includes [`examples/typed_consumer.py`](examples/typed_consumer.py),
a strict-typechecked consumer showing the public profile, runtime, and generic
context contracts. CI runs `mypy --strict` against that sample.

## Minimal Command

```python
Expand Down
27 changes: 27 additions & 0 deletions docs/consumer-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,30 @@ whose compatibility names still reflect one historical consumer.

The package rename is deliberately separate from this refactor. Names can be
changed after the dependency boundary is stable.

## Typed extension contracts

The supported callback contracts are exported from `base_cli` as typed protocols
for static analyzers: `ProjectDiscovery`, `UserConfigLoader`,
`ConfigLoader`, `RuntimeResolver`, `WorkspaceRootResolver`, `HistoryWriter`,
`DisplayCommandResolver`, and `HistoryDisplayResolver`. A custom runtime
resolver returns `RuntimeBinding`, whose immutable `layout` is the public
`RuntimeLayout` dataclass. No consumer needs to import `_runtime`.

`Context` accepts three consumer payload types:

```python
Context[ConfigT, ApplicationStateT, ServicesT]
```

`config` is the validated configuration payload; `application_context` and
`services` are optional state and service payloads initialized by an attached
consumer. `AttachmentAdapter` and `AttachmentContract` describe the typed
boundary used by `App.attach()`. Attachment returns the same concrete Click
command object, so aliases, lazy groups, and custom Click subclasses remain
owned by the consumer.

The core lifecycle is synchronous by design. Native `async def` callbacks and
callbacks that return awaitables are rejected with an actionable error. An
adapter that owns an event loop may run asynchronous work explicitly at its
boundary and return a normal synchronous callback result to base-cli.
77 changes: 77 additions & 0 deletions examples/typed_consumer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Small strict-typing example for the public base-cli extension contracts."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any

import base_cli


@dataclass(frozen=True)
class ApplicationState:
invocation_count: int = 0


@dataclass(frozen=True)
class Services:
workspace: Path


Config = dict[str, Any]
TypedContext = base_cli.Context[Config, ApplicationState, Services]


def profile() -> base_cli.CliProfile:
"""Return a profile whose extension points use only public contracts."""

def resolve_runtime(
cli_name: str,
project: base_cli.ProjectInfo | None,
) -> base_cli.RuntimeBinding:
root = Path(".base-cli-cache").resolve()
run_id = "sample-run"
layout = base_cli.RuntimeLayout(
owner_root=root / cli_name,
run_root=root / cli_name / "runs" / run_id,
state_dir=root / cli_name,
log_dir=root / cli_name / "runs" / run_id / "logs",
cache_dir=root / cli_name / "cache",
temp_dir=root / cli_name / "runs" / run_id / "tmp",
)
return base_cli.RuntimeBinding(
cache_root=root,
layout=layout,
application_home=None,
runtime_owner="typed-sample",
project_root=project.root if project is not None else None,
project_name=project.name if project is not None else None,
inherited_path=None,
history_parent_run_id=None,
run_id=run_id,
)

return base_cli.CliProfile.generic(resolve_runtime=resolve_runtime)


app = base_cli.App(
name="typed-sample",
profile=profile(),
log_to_file=False,
)


@app.command()
@base_cli.option("--verbose", is_flag=True)
def main(ctx: TypedContext, verbose: bool) -> None:
"""Use a consumer-owned context payload without private imports."""

del verbose
assert isinstance(ctx.config, dict)
_ = ctx.application_context
_ = ctx.services


if __name__ == "__main__":
raise SystemExit(base_cli.run_app(app))
53 changes: 51 additions & 2 deletions lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ def _resolve_version() -> str:
__version__ = _resolve_version()

from . import command_filters, command_protocol, history, testing
from .attachment import (
AttachmentAdapter,
AttachmentContextFactory,
AttachmentContract,
AttachmentServiceFactory,
)
from .app import (
App,
argument,
Expand All @@ -44,16 +50,26 @@ def _resolve_version() -> str:
from .command_filters import CommandFilterNormalizer, command_matches, normalize_command_filter, normalize_command_filters
from .command_protocol import (
BOOLEAN,
DEFAULT_SCHEMA_REGISTRY,
CommandCodec,
NULLABLE_STRING,
STRING,
CommandProtocolError,
CommandSchemaRegistry,
FieldSpec,
RECORD_SCHEMAS,
dumps_record,
dumps_records,
loads_records,
register_record_schema,
)
from .context import Context, get_current_context
from .context import (
ApplicationStateT,
ConfigT,
Context,
ServicesT,
get_current_context,
)
from .errors import ConfigurationError
from .exit_codes import ExitCode
from .inspection import inspection_envelope, render_inspection_json
Expand All @@ -74,17 +90,41 @@ def _resolve_version() -> str:
render_records,
resolve_output_format,
)
from .profile import CliProfile, ProjectInfo, RuntimeBinding
from .profile import (
CliProfile,
ConfigLoader,
DisplayCommandResolver,
HistoryDisplayResolver,
HistoryWriter,
ProjectDiscovery,
ProjectInfo,
RuntimeBinding,
RuntimeResolver,
UserConfigLoader,
WorkspaceRootResolver,
)
from .runtime import RuntimeLayout

__all__ = [
"App",
"__version__",
"AttachmentAdapter",
"AttachmentContextFactory",
"AttachmentContract",
"AttachmentServiceFactory",
"BOOLEAN",
"ApplicationStateT",
"CliProfile",
"ConfigLoader",
"CommandFilterNormalizer",
"CommandCodec",
"CommandProtocolError",
"CommandSchemaRegistry",
"ConfigurationError",
"Context",
"ConfigT",
"DEFAULT_SCHEMA_REGISTRY",
"DisplayCommandResolver",
"ExitCode",
"FieldSpec",
"LIFECYCLE_META_KEY",
Expand Down Expand Up @@ -121,6 +161,10 @@ def _resolve_version() -> str:
"OutputFormatError",
"PUBLIC_OUTPUT_FORMATS",
"ProjectInfo",
"ProjectDiscovery",
"RECORD_SCHEMAS",
"RuntimeLayout",
"RuntimeResolver",
"is_terminal",
"output_format_choices",
"option",
Expand All @@ -130,4 +174,9 @@ def _resolve_version() -> str:
"resolve_output_format",
"run_app",
"RuntimeBinding",
"ServicesT",
"HistoryWriter",
"HistoryDisplayResolver",
"UserConfigLoader",
"WorkspaceRootResolver",
]
2 changes: 1 addition & 1 deletion lib/python/base_cli/_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class InvocationOutcome:
class RunRecorder:
"""Write core-owned lifecycle snapshots for one Context."""

context: Context
context: Context[Any, Any, Any]
started_at: datetime
started_monotonic_ns: int

Expand Down
12 changes: 1 addition & 11 deletions lib/python/base_cli/_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,11 @@
import logging
import os
import stat
from dataclasses import dataclass
from pathlib import Path

from ._private_files import PRIVATE_DIRECTORY_MODE, restrict_directory, write_private_json
from .paths import runtime_run_directory_name, runtime_slug


@dataclass(frozen=True)
class RuntimeLayout:
owner_root: Path
run_root: Path
state_dir: Path
log_dir: Path
cache_dir: Path
temp_dir: Path
from .runtime import RuntimeLayout


_LOG_INDEX_NAME = ".base-cli-log-index.json"
Expand Down
Loading