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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ and versions are tracked in the repo-root `VERSION` file.
- Add public typed runtime/profile/context contracts, generic attachment
factories, isolated command-schema registries/codecs, and a strict consumer
typing example.
- Add opt-in `CliProfile.batteries_included()` layered configuration with
platform-aware user paths, project and environment files, provenance, and
validated framework settings.

### Changed

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ command subtype in their return type.
factories define the Click attachment boundary for adapters that compose or
wrap an attached command.

`ConfigSnapshot` and `FrameworkConfig` provide the typed result boundary for
the opt-in batteries-included profile: consumer configuration, validated
lifecycle settings, and per-key provenance remain separate.

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
Expand Down Expand Up @@ -647,6 +651,10 @@ Important fields include:
- `ctx.log_file`: the run's shared `logs/primary.log`, or `None` when persistent
logging is disabled.
- `ctx.config`: merged configuration dictionary.
- `ctx.framework_config`: validated lifecycle settings supplied by the
batteries-included profile, or `None` for generic/custom dictionary loaders.
- `ctx.config_provenance`: winning source layer for each dotted configuration
key when a layered snapshot is used.
- `ctx.application_context`: optional application state returned by an
attachment's `context_factory`, or `None`.
- `ctx.services`: optional services returned by an attachment's
Expand Down Expand Up @@ -776,6 +784,21 @@ that need user files, project files, environment variables, or a merge
precedence must implement those policies in `CliProfile.load_config` and
`CliProfile.load_user_config`; `base_cli` does not define the value's fields.

Applications that want a standard opt-in policy can use
`CliProfile.batteries_included("tool")`. It discovers optional platform-aware
user (`config.yaml`), project (`.base-cli.yaml`), and environment
(`environments/<name>.yaml`) layers before the explicit file. The precedence is
defaults → user → project → user environment → project environment → explicit
file → lifecycle command-line options. The selected environment comes from
`--environment`, then the explicit/project/user base files, and defaults to
`dev`. Mappings merge recursively; scalar and list values replace lower layers.

The batteries-included profile validates the framework keys `environment`,
`log_level`, and `keep_temp` into `ctx.framework_config`, keeps consumer keys in
`ctx.config`, and records winning dotted-key sources in
`ctx.config_provenance`. Missing implicit files remain harmless; explicit
`--config` paths retain strict validation.

## Project Discovery

The generic profile does not discover projects or assume a manifest filename.
Expand Down
44 changes: 42 additions & 2 deletions docs/consumer-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,46 @@ translate internal entry-point names into user-facing labels. The generic
default only replaces underscores with hyphens; it does not know any product's
command aliases.

## Batteries-included profile

Applications that want conventional configuration discovery can opt in without
changing the generic defaults:

```python
profile = base_cli.CliProfile.batteries_included("tool")
app = base_cli.App(name="tool", profile=profile)
```

The profile uses platform-aware user configuration roots (`XDG_CONFIG_HOME` or
`~/.config` on Linux, `~/Library/Application Support` on macOS, and `%APPDATA%`
on Windows). `BASE_CLI_CONFIG_DIR` overrides that root. User files live under
`<root>/<cli-name>/config.yaml`; a discovered project may provide
`.base-cli.yaml` and `environments/<name>.yaml` files. All of these layers are
optional and their filenames can be customized by the profile factory. An
explicit `--config` path remains strict and must exist as a readable regular
file.

Configuration precedence is deterministic, from lowest to highest:

1. framework default (`environment: dev`);
2. user base configuration;
3. project base configuration;
4. user environment configuration;
5. project environment configuration;
6. explicit `--config` configuration;
7. command-line lifecycle options.

The environment is selected by `--environment` when supplied. Otherwise the
explicit, project, or user base `environment` value is used, falling back to
`dev`. Mapping values merge recursively; scalar and list values replace the
lower-precedence value. `Context.config_provenance` records the winning source
for each dotted key.

The reserved framework keys `environment`, `log_level`, and `keep_temp` are
validated into `Context.framework_config` and are excluded from the consumer
configuration dictionary. All other keys remain consumer-owned and are exposed
through `Context.config`.

## Safe profile errors

Plain exceptions from profile callbacks are treated as unexpected internal
Expand Down Expand Up @@ -118,8 +158,8 @@ The following behaviors should not be added to generic lifecycle modules:
- product-specific command lists or history schema;
- assumptions about a downstream repository's directory layout.

The next migration step is to generalize the remaining context/config types
whose compatibility names still reflect one historical consumer.
The generic profile and typed `Context` are now the stable framework boundary;
consumer-specific conventions belong in an opt-in profile or adapter.

The package rename is deliberately separate from this refactor. Names can be
changed after the dependency boundary is stable.
Expand Down
8 changes: 8 additions & 0 deletions docs/local-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,11 @@ call site represents an explicit user request.

The consumer owns the configuration schema, merge semantics, and operational
choice of whether to back up or synchronize its machine-local files.

Applications that prefer conventional policy can opt into
`CliProfile.batteries_included("tool")`. It loads optional platform-aware user,
project, environment, and explicit YAML layers with documented precedence and
records the winning source for each key in `Context.config_provenance`. Its
reserved lifecycle keys are validated separately as `Context.framework_config`;
consumer-owned keys remain in `Context.config`. `CliProfile.generic()` remains
the convention-free default.
6 changes: 6 additions & 0 deletions lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def _resolve_version() -> str:
AttachmentContract,
AttachmentServiceFactory,
)
from .config import BatteriesIncludedConfigLoader, ConfigSnapshot, FrameworkConfig
from .app import (
App,
argument,
Expand Down Expand Up @@ -94,6 +95,7 @@ def _resolve_version() -> str:
CliProfile,
ConfigLoader,
DisplayCommandResolver,
EnvironmentConfigLoader,
HistoryDisplayResolver,
HistoryWriter,
ProjectDiscovery,
Expand All @@ -112,10 +114,12 @@ def _resolve_version() -> str:
"AttachmentContextFactory",
"AttachmentContract",
"AttachmentServiceFactory",
"BatteriesIncludedConfigLoader",
"BOOLEAN",
"ApplicationStateT",
"CliProfile",
"ConfigLoader",
"ConfigSnapshot",
"CommandFilterNormalizer",
"CommandCodec",
"CommandProtocolError",
Expand All @@ -125,8 +129,10 @@ def _resolve_version() -> str:
"ConfigT",
"DEFAULT_SCHEMA_REGISTRY",
"DisplayCommandResolver",
"EnvironmentConfigLoader",
"ExitCode",
"FieldSpec",
"FrameworkConfig",
"LIFECYCLE_META_KEY",
"LifecycleOption",
"LifecycleOptions",
Expand Down
45 changes: 41 additions & 4 deletions lib/python/base_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
prune_log_files,
)
from .attachment import AttachmentContract
from .config import ConfigSnapshot
from .context import Context, recover_current_context, reset_current_context, set_current_context
from .errors import ConfigurationError
from .exit_codes import ExitCode
Expand Down Expand Up @@ -1054,12 +1055,46 @@ def _create_context(
explicit_config = Path(standard["config"]).expanduser() if standard.get("config") else None
user_config = self.profile.load_user_config()
workspace_root = self.profile.resolve_workspace_root(user_config)
config = self.profile.load_config(project, explicit_config)
requested_environment = standard.get("environment")
if (
self.profile.load_config_for_environment is not None
and requested_environment is not None
):
loaded_config = self.profile.load_config_for_environment(
project,
explicit_config,
str(requested_environment),
)
else:
loaded_config = self.profile.load_config(project, explicit_config)

environment = standard.get("environment") or config.get("environment") or "dev"
debug = bool(standard.get("debug") or str(config.get("log_level", "")).lower() == "debug")
if isinstance(loaded_config, ConfigSnapshot):
config = loaded_config.config
framework_config = loaded_config.framework
config_provenance = loaded_config.provenance
else:
config = loaded_config
framework_config = None
config_provenance = {}

environment = (
standard.get("environment")
or (framework_config.environment if framework_config is not None else None)
or config.get("environment")
or "dev"
)
log_level = (
framework_config.log_level
if framework_config is not None
else str(config.get("log_level", "")).lower()
)
debug = bool(standard.get("debug") or log_level == "debug")
quiet = bool(standard.get("quiet"))
keep_temp = bool(standard.get("keep_temp") or config.get("keep_temp"))
keep_temp = bool(
standard.get("keep_temp")
or (framework_config.keep_temp if framework_config is not None else None)
or config.get("keep_temp")
)
_capture_effective_output_options(
owner_app=self,
debug=debug,
Expand Down Expand Up @@ -1101,6 +1136,8 @@ def _create_context(
temp_dir=layout.temp_dir,
log_file=log_file,
config=config,
framework_config=framework_config,
config_provenance=config_provenance,
environment=environment,
debug=debug,
quiet=quiet,
Expand Down
Loading