From df1fe787287c30b3e05cab98b83d7742ccc8dee7 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:07:10 -0700 Subject: [PATCH] docs: define typed user config boundary Refs #105 --- CHANGELOG.md | 2 + README.md | 2 + docs/consumer-profiles.md | 11 +++- docs/user-config-typing.md | 105 +++++++++++++++++++++++++++++++++++++ tests/validate.sh | 1 + 5 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 docs/user-config-typing.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0006426..816cc4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index ee258cd..7622f54 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/docs/consumer-profiles.md b/docs/consumer-profiles.md index bfaab10..fb5389e 100644 --- a/docs/consumer-profiles.md +++ b/docs/consumer-profiles.md @@ -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. @@ -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 diff --git a/docs/user-config-typing.md b/docs/user-config-typing.md new file mode 100644 index 0000000..4e08f2d --- /dev/null +++ b/docs/user-config-typing.md @@ -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. diff --git a/tests/validate.sh b/tests/validate.sh index 4f47194..8b63b60 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -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