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 @@ -107,6 +107,8 @@ the API stability policy and migration guide before upgrading from `0.3.x`.
- Extend the optional Typer adapter through Typer 0.27.x by selecting the
command tree's matching public or vendored Click dialect, with a Python 3.10
through 3.14 compatibility matrix covering Typer 0.25.1, 0.26.0, and 0.27.1.
- Document the intentional opaque `Context.user_config` boundary and the
compatibility requirements for any future fourth context type parameter.

### Planned

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ The supported facade is `import base_cli`. It exports the command lifecycle
(`App`, `Context`, `run_app`, decorators, and logging helpers), command filters,
and the structured command protocol helpers. Consumer-owned user configuration
is passed through `Context.user_config`; the library does not impose a schema.
See [`docs/user-config-typing.md`](docs/user-config-typing.md) for the
intentional opaque boundary and the recommended typed accessor pattern.
The corresponding modules are also available as
`base_cli.command_filters`, `base_cli.command_protocol`, and
`base_cli.history`.
Expand Down
11 changes: 10 additions & 1 deletion docs/consumer-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,10 @@ The following behaviors should not be added to generic lifecycle modules:
- assumptions about a downstream repository's directory layout.

The generic profile and typed `Context` are now the stable framework boundary;
consumer-specific conventions belong in an opt-in profile or adapter.
consumer-specific conventions belong in an opt-in profile or adapter. The
intentional typing boundary for `Context.user_config` and the recommended
consumer accessor pattern are documented in
[`user-config-typing.md`](user-config-typing.md).

The package rename is deliberately separate from this refactor. Names can be
changed after the dependency boundary is stable.
Expand All @@ -186,6 +189,12 @@ 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.

`user_config` is intentionally opaque (`object | None`) because its schema is
consumer-owned. Define one typed accessor in the consumer instead of casting it
in every command; see [`user-config-typing.md`](user-config-typing.md). A fourth
generic parameter is reserved for a future compatibility boundary and is not
part of the 0.4.x API.

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
Expand Down
105 changes: 105 additions & 0 deletions docs/user-config-typing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Typed user configuration

This proposal resolves the typing boundary for `Context.user_config` without
making a consumer's configuration schema part of the generic lifecycle.

## Decision for the 0.4.x line

Keep the existing public shape:

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

and keep `Context.user_config` annotated as `object | None`. The value is
loaded and owned by `CliProfile`; base-cli only passes it to the optional
workspace-root resolver and stores it on the active context. This opacity is
intentional: a generic framework must not impose a mapping, dataclass, schema,
serialization format, or validation policy on consumers.

Do not add a fourth `Context` type parameter in a 0.4.x patch or minor release.
Adding `UserConfigT` would require changing every public callback protocol,
`ContextVar`, history callback, attachment factory, example, and consumer type
alias at once. It would also not infer a type from an arbitrary
`CliProfile` instance without making the profile generic as well.

## Recommended consumer pattern

Consumers should define one typed accessor at their product boundary and use it
inside commands. The cast is intentionally centralized and does not leak into
every command:

```python
from dataclasses import dataclass
from typing import Any, cast

import base_cli


@dataclass(frozen=True)
class UserSettings:
workspace: str
preferred_environment: str = "dev"


def user_settings(
context: base_cli.Context[Any, Any, Any],
) -> UserSettings | None:
return cast(UserSettings | None, context.user_config)


@app.command()
def status(context: base_cli.Context[Any, Any, Any]) -> None:
settings = user_settings(context)
if settings is not None:
context.log.info("workspace=%s", settings.workspace)
```

The accessor is the appropriate place for consumer-owned validation if the
profile can receive data from an untrusted or mutable source. A consumer that
needs runtime validation should parse into `UserSettings` in
`load_user_config()` and make the accessor a narrow assertion rather than
re-parsing on every command.

The framework's existing `WorkspaceRootResolver` remains deliberately typed as
`object | None -> Path | None`; it is a projection boundary, not a schema
owner. Consumers may close over their typed loader or use a typed helper before
passing the profile to `App`.

## Why not a protocol-only fix?

A `UserConfigProtocol` would not solve the core problem. Consumer settings may
be a dataclass, mapping, immutable model, or `None`, and a protocol would either
be too broad to provide useful editor support or would force unrelated
consumers to implement framework-owned members. Structural typing is useful in
the consumer accessor, but not as a required base-cli schema.

## Future `0.5.0` option

If several independent consumers demonstrate that the accessor pattern is
insufficient, a major compatibility boundary may introduce:

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

That change must be designed as an end-to-end generic flow, not just a field
annotation. The proposal must cover:

1. a generic `UserConfigLoader[UserConfigT]` and a generic `CliProfile`;
2. inference and explicit type aliases for `App` and every callback protocol;
3. compatibility defaults for existing `Context[A, B, C]` annotations;
4. `ContextVar` and history/attachment callback typing;
5. strict mypy fixtures for typed and untyped consumers; and
6. migration guidance and a deprecation window for the three-parameter form.

No fourth type parameter should ship until those questions have a reviewed
answer and at least two independent typed consumer fixtures exercise it.

## Follow-up implementation

This document is the design decision for issue [#105](https://github.com/basefoundry/base-cli/issues/105).
The next implementation can add an optional framework helper only if repeated
consumer accessors show a common, stable runtime contract. Such a helper must
remain additive, preserve the opaque field, and never change the existing
`Context` generic arity.
1 change: 1 addition & 0 deletions tests/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ required_files=(
.github/ISSUE_TEMPLATE/support.md
docs/releasing.md
docs/api-stability.md
docs/user-config-typing.md
docs/migrations.md
docs/security-threat-model.md
docs/security-review.md
Expand Down
Loading