From d3f4c7e0a24a3220b56de00854e3f44fbbadb192 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:03:53 -0700 Subject: [PATCH] refactor: add consumer-neutral CLI profiles --- README.md | 274 ++++++++++++++++---------------- docs/consumer-profiles.md | 114 +++++++++++++ lib/python/base_cli/__init__.py | 4 + lib/python/base_cli/_runtime.py | 14 +- lib/python/base_cli/app.py | 99 +++++------- lib/python/base_cli/context.py | 9 +- lib/python/base_cli/profile.py | 237 +++++++++++++++++++++++++++ tests/test_profile.py | 107 +++++++++++++ 8 files changed, 658 insertions(+), 200 deletions(-) create mode 100644 docs/consumer-profiles.md create mode 100644 lib/python/base_cli/profile.py create mode 100644 tests/test_profile.py diff --git a/README.md b/README.md index 6a8805d..20217e4 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,18 @@ documented in [`docs/releasing.md`](docs/releasing.md). The package exposes The package is distributed under the Apache License 2.0. Base itself remains licensed separately under AGPL-3.0-or-later. -`base_cli` is Base's small Python framework for writing command-line tools that -feel consistent across Base and Base-supported projects. +`base_cli` is a small Python framework for writing command-line tools with +a consistent lifecycle. It is designed to be embedded by applications rather +than to define an application's project model. Base is one consumer of the +library, not part of its generic contract. It is intentionally thin. Click still owns argument parsing and command -execution, while `base_cli` adds the Base-specific behavior every project CLI -should get by default: +execution, while `base_cli` provides reusable lifecycle behavior: - standard command options such as `--debug`, `--quiet`, `--environment`, `--config`, `--keep-temp`, and `--log-file` - structured logging to stderr and, by default, to a persistent per-run log file -- Base project discovery through `base_manifest.yaml` -- config loading with predictable precedence +- optional project discovery and configuration policies supplied by the consumer - per-run temp directories, persistent cache directories, and cleanup hooks - sensitive argument redaction in debug invocation logs - a command context object shared by command code and helper functions @@ -34,7 +34,7 @@ should get by default: ## Design Goals -Base CLI tools should be easy to write, but not magical. A command should be +CLI tools should be easy to write, but not magical. A command should be explicitly registered, receive an explicit `Context`, and use standard Python functions instead of import-time side effects. @@ -44,12 +44,37 @@ The package follows these rules: decorating a function. - **Logs go to stderr**: user-facing program output can stay on stdout, while logs remain redirectable and skippable. -- **Every run has a context**: logs, paths, config, environment, manifest, and - cleanup are available through one object. +- **Every run has a context**: logs, paths, configuration, environment, and + cleanup are available through one object. Project metadata is available when + the selected consumer profile supplies it. - **No import-time filesystem writes**: state directories are created only when a command runs. -- **Base-aware, Click-compatible**: command authors keep using familiar Click - concepts such as options and arguments. +- **Consumer-neutral, Click-compatible**: command authors keep using familiar + Click concepts such as options and arguments. + +## Consumer Profiles + +`App` accepts a `CliProfile` that supplies the policies which vary +between applications: project discovery, configuration, runtime placement, and +optional history persistence. + +Standalone consumers should opt into the generic profile explicitly: + +```python +app = base_cli.App( + name="hello", + version="0.1.0", + profile=base_cli.CliProfile.generic(), +) +``` + +The generic profile has no manifest filename convention, no product-owned +configuration directory, and no implicit history writer. Applications can +provide those policies through callbacks or build their own profile. The +temporary default, `CliProfile.legacy_base()`, preserves the historical +Base behavior for existing callers while Base migrates to an explicit adapter. +See [`docs/consumer-profiles.md`](docs/consumer-profiles.md) for the boundary +and migration plan. ## Public API @@ -72,7 +97,11 @@ from __future__ import annotations import base_cli -app = base_cli.App(name="hello", version="0.1.0") +app = base_cli.App( + name="hello", + version="0.1.0", + profile=base_cli.CliProfile.generic(), +) @app.command() @@ -87,7 +116,7 @@ if __name__ == "__main__": ``` Running this command directly as a Python package automatically adds the -standard Base options: +standard options: ```bash hello --name Ada @@ -100,19 +129,16 @@ hello --log-file /tmp/hello.log --name Ada Long options with values use space-separated syntax. `base_cli.run_app()` rejects equals-form values such as `--name=Ada` before Click parses arguments. -These are direct package options. Public `basectl` launchers expose `-v` for -command-level debug logs and command-specific flags from -`basectl --help`; they do not expose `--debug`, `--quiet`, -`--log-file`, `--config`, or `--environment` as public `basectl` options. -The wrapper-level `basectl --keep-temp ` option preserves the -complete temporary tree for that run. +These options belong to the application-level lifecycle. A consumer may expose +them through its own launcher or compose them with a higher-level command +wrapper. ## Command Registration Use `App` when you want a named command: ```python -app = base_cli.App(name="base-projects", version="0.1.0") +app = base_cli.App(name="workspace-tools", version="0.1.0") ``` Register the command function explicitly: @@ -124,7 +150,7 @@ def main(ctx: base_cli.Context) -> None: ``` The command function always receives `ctx` as its first argument. User-defined -options and arguments are passed after the Base standard options have been +options and arguments are passed after the standard lifecycle options have been removed from Click's keyword arguments. For small scripts, the module-level decorators are available: @@ -135,10 +161,10 @@ def main(ctx: base_cli.Context) -> None: ... ``` -In Base itself, prefer an explicit `App` so command names and versions are -obvious at the top of the module. +Prefer an explicit `App` when command names, versions, or consumer +policies should be visible at the top of the module. -Use `@app.subcommand()` when one CLI needs multiple verbs while keeping Base's +Use `@app.subcommand()` when one CLI needs multiple verbs while keeping the standard context, logging, redaction, and cleanup lifecycle for each invocation: ```python @@ -164,7 +190,7 @@ def sync_project(ctx: base_cli.Context, dry_run: bool) -> None: Subcommands use the same `base_cli.option()` and `base_cli.argument()` metadata as single commands. `App(help=...)` appears in the command group's `--help` -output. For subcommand apps, prefer standard Base options before the subcommand +output. For subcommand apps, prefer standard options before the subcommand name, for example `workspace-tools --debug status demo`. The post-subcommand form, such as `workspace-tools status --debug demo`, remains accepted for compatibility. Use either `@app.command()` for a single-command CLI or @@ -193,11 +219,11 @@ def main(ctx: base_cli.Context, token: str) -> None: ``` Both `--token secret` and an externally supplied `--token=secret` token are -redacted in debug logs, even though Base command invocation rejects equals-form -option values before Click parses them. +redacted in debug logs. The lifecycle rejects equals-form option values before +Click parses them. Use `dry_run=True` when a nonstandard option should drive `ctx.dry_run` and -Base's default durable-write suppression: +the lifecycle's default durable-write suppression: ```python @base_cli.option("--preview", is_flag=True, dry_run=True) @@ -229,7 +255,7 @@ are consumed before the command function is called. ## Exit Codes -Use `base_cli.ExitCode` when command code or tests need to name Base's standard +Use `base_cli.ExitCode` when command code or tests need to name standard command result meanings: - `ExitCode.SUCCESS` (`0`): the command completed successfully. @@ -243,28 +269,31 @@ constants when it makes intent clearer: ```python if ctx.project_root is None: - ctx.log.error("run this command from a Base project") + ctx.log.error("run this command from a project recognized by the consumer") return base_cli.ExitCode.USAGE_ERROR ``` ## Context `Context` is the object command code should pass around instead of rediscovering -Base paths or global settings. +runtime paths or global settings. Important fields include: - `ctx.cli_name`: normalized CLI name used for state paths and logger names. - `ctx.run_id`: timestamp plus short random suffix for this invocation. -- `ctx.base_home`: resolved `BASE_HOME`, when available. -- `ctx.project_root`: directory containing the nearest `base_manifest.yaml`. -- `ctx.workspace_root`: configured workspace root from `~/.base.d/config.yaml`. -- `ctx.manifest_path`: nearest discovered Base manifest. -- `ctx.history_scope`: compatibility scope marker; delegated children are not - written as separate history events. -- `ctx.history_parent_run_id`: shared parent `basectl` invocation ID, when delegated. -- `ctx.runtime_owner`: `base` or `project`. -- `ctx.owner_root`: owner namespace root under the Base cache root. +- `ctx.application_home`: optional application home supplied by the profile. +- `ctx.base_home`: compatibility alias for `ctx.application_home`. +- `ctx.project_root`: project root returned by the profile, when any. +- `ctx.workspace_root`: optional workspace root supplied by user configuration. +- `ctx.manifest_path`: project metadata path returned by the profile, when any. +- `ctx.history_scope`: history scope supplied by the profile or its + compatibility adapter. +- `ctx.history_parent_run_id`: optional parent invocation ID supplied by + the consumer. +- `ctx.runtime_owner`: consumer-defined runtime owner; the generic + profile uses `default`. +- `ctx.owner_root`: application namespace root under the configured cache root. - `ctx.run_root`: this invocation's run bundle. - `ctx.state_dir`: owner root (compatibility alias). - `ctx.log_dir`: run-bundle log directory. @@ -273,13 +302,13 @@ 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.user_config`: typed user configuration from `~/.base.d/config.yaml`. +- `ctx.user_config`: typed user configuration returned by the profile. - `ctx.environment`: active environment, defaulting to `dev`. - `ctx.debug`: whether debug logging is enabled for the stderr stream. - `ctx.quiet`: whether INFO logs are suppressed on the stderr stream. - `ctx.dry_run`: whether the command is running in a no-durable-write mode. - `ctx.keep_temp`: whether `ctx.temp_dir` should survive cleanup. -- `ctx.log`: standard Python logger configured by Base. +- `ctx.log`: standard Python logger configured by `base_cli`. Helpers can retrieve the active context without threading it through every call: @@ -308,8 +337,8 @@ warnings and errors. `--debug` and `--quiet` cannot be used together. Persistent log files still receive DEBUG-level detail, including INFO messages suppressed from stderr. User-facing logs use colors automatically on interactive terminals; persistent log files remain plain text. Set `NO_COLOR=1` or -`BASE_CLI_COLOR=0` to disable colors. The Base wrapper's `--color` option -remains compatible with this behavior. +`BASE_CLI_COLOR=0` to disable colors. A consumer wrapper may add its own color +option and map it to the environment variable. Click also provides shell completion. For an app named `hello`, request a completion script with `_HELLO_COMPLETE=bash_source hello`, replacing `bash` @@ -318,31 +347,27 @@ shell startup files remain under user control. Advanced tests and CI wrappers can call `base_cli.configure_logger(..., stream=..., formatter=...)` to capture user-facing logs or apply a custom -formatter without replacing Base's logger setup. Leave those arguments as -`None` to keep the default stderr stream and `BaseCliFormatter`. Base CLI log -timestamps use the host's local timezone and include its numeric offset by -default. When the wrapper sets `LOG_UTC=1` (for example via -`basectl --utc-wrapper`), they use UTC and include an explicit `UTC` marker. +formatter. Leave those arguments as `None` to keep the default stderr stream +and formatter. Log timestamps use the host's local timezone and include its +numeric offset by default. A consumer can set `LOG_UTC=1` to use UTC and +include an explicit `UTC` marker. This setting affects log presentation only. Run metadata, history records, and run IDs retain their canonical UTC representation. Commands that inspect runtime artifacts can use `base_cli.App(log_to_file=False)` to keep the standard context, `--debug`, and `--quiet` behavior without creating -default `logs/`, `cache/`, or `tmp//` directories. `base_logs` uses this -mode so `basectl logs` does not appear in its own output; `base_history` does -the same for `basectl history`. An explicit `--log-file ` still enables -file logging for that invocation. +default `logs/`, `cache/`, or `tmp//` directories. An explicit +`--log-file ` still enables file logging for that invocation. Commands running with `ctx.dry_run` also skip default `logs/`, `cache/`, and `tmp//` creation. Passing `--log-file ` still writes to that explicit file so tests and diagnostics can inspect dry-run logs when needed. -For Python-backed commands with persistent logs, `base_cli.App` also writes a -best-effort final history record to `/base/history/runs.jsonl`. -History records contain redacted command metadata, timing, exit status, project -context when known, and a pointer to the raw log file. History writes are local -only and do not fail the user command when the index cannot be updated. +The generic profile does not write command history. A profile may provide a +history writer to persist redacted command metadata, timing, exit status, +project context, and a pointer to the raw log file. History writes should be +best-effort and should not fail the user command when an index cannot be updated. High-frequency tools can set `base_cli.App(max_log_files=)` to keep at most that many default persistent log files across the owner's run bundles. @@ -351,10 +376,10 @@ resolved, and the current run's log file is never pruned. The policy is skipped for `ctx.dry_run`, `log_to_file=False`, and explicit `--log-file` paths so no-durable-write modes and caller-selected log locations stay under caller control. Use this as a -small guardrail for busy local tools; `basectl clean` remains the broader -maintenance command for caches, logs, and retained temp files. +small guardrail for busy local tools; an application can provide broader +maintenance commands for caches, logs, and retained temp files. -Logs use the same general shape as Base Bash logs: +Logs use a stable, human-readable shape: ```text 2026-05-26 12:34:56 INFO path/to/file.py:42 message @@ -381,65 +406,51 @@ the real command output. ## Config Precedence -Configuration is loaded from YAML files and environment variables in this order: - -1. user config: `~/.base.d/config.yaml` -2. project config: `/.base/config.yaml` -3. explicit config from `--config` -4. environment variables -5. direct command-line standard options - -Environment variables currently recognized by the config layer: +The generic profile has no implicit configuration files. It loads the file +passed through `--config`, when present, and otherwise starts with an empty +configuration dictionary. Standard command-line options are applied by the +lifecycle after the profile's configuration is loaded; for example, +`--environment prod` overrides `environment: dev` from an explicit +configuration file. -- `BASE_CLI_ENVIRONMENT` -- `BASE_CLI_LOG_LEVEL` -- `BASE_CLI_KEEP_TEMP` -`LOG_DEBUG=1` or `LOG_DEBUG=true` is also accepted as an internal compatibility -fallback for wrapper/debug paths when `BASE_CLI_LOG_LEVEL` is unset. Prefer -`BASE_CLI_LOG_LEVEL=debug` for user-facing Python CLI debug logging. +`ctx.config` exposes the dictionary returned by the profile. `ctx.user_config` +exposes the user-configuration value returned by the profile. Consumers 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`. -Command-line standard options are applied after config is loaded. For example, -`--environment prod` overrides `environment: dev` from config. - -`ctx.config` exposes the merged raw configuration after user, project, -explicit, and environment layers are applied. `ctx.user_config` exposes only the -typed machine-local user config, including `workspace.root`, -`workspace.manifest`, and IDE -preferences, so command code does not need to re-read `~/.base.d/config.yaml` -for those structured values. - -The user config file is machine-local by default. Base owns the semantics of -`~/.base.d/config.yaml`, while users own backup and sync choices such as iCloud, -chezmoi, dotfiles repositories, Time Machine, or manual copy. See -[`docs/local-config.md`](docs/local-config.md) for the product-level boundary. +The legacy Base profile retains its historical `~/.base.d` and `.base` +conventions temporarily; those paths are not part of the generic API. The +Base-specific details remain documented in +[`docs/local-config.md`](docs/local-config.md). ## Project Discovery -When a command runs, `base_cli` walks upward from the current working directory -looking for `base_manifest.yaml`. - -If found: +The generic profile does not discover projects or assume a manifest filename. +Its `ctx.project_root` and `ctx.manifest_path` fields are `None` unless the +consumer supplies a `discover_project` policy. A profile can discover projects +from a manifest, workspace, repository metadata, or any other application-owned +source and return a `ProjectInfo` value. -- `ctx.manifest_path` points to the manifest -- `ctx.project_root` points to the manifest's parent directory - -If no manifest is found, both fields are `None`. Commands that require a Base -project should validate this explicitly and return a clear usage error or -actionable message. +Commands that require a project should validate the profile-provided value +explicitly and return a clear usage error or actionable message. The legacy +Base profile retains upward discovery of `base_manifest.yaml` for existing +callers. ## Runtime Directories -Runtime state is rooted at `~/Library/Caches/base` on macOS and `~/.cache/base` -elsewhere. `BASE_CACHE_DIR` overrides the root. See -[`docs/cache-ownership-and-layout.md`](docs/cache-ownership-and-layout.md) -for the owner-aware layout. Base control-plane commands use `base/`; a -Base-compliant project's own commands use `projects///`. +The generic profile uses the configured cache root and an application namespace +to create per-run logs, caches, and temporary directories. Pass +`cache_root` to `CliProfile.generic()` for deterministic placement in tests or +applications; otherwise the platform cache directory is used. The generic +profile does not prescribe a product-wide cache name or cleanup command. + Each invocation is a run bundle containing private (`0600`) `run.json`, -`logs/`, and `tmp/`, -while persistent component caches live in the owner's `cache/components/`. -`basectl clean --older-than ` removes old bundles and component caches; -`--keep-last ` retains the newest completed bundles per owner. +`logs/`, and `tmp/`, while persistent component caches live in the +bundle's cache directory. The legacy Base profile retains the owner-aware +`base/` and `projects//` layout for existing +callers. Use `ctx.on_cleanup()` for cleanup work that should happen even when helper code does not own the main command wrapper: @@ -482,34 +493,29 @@ def test_command(tmp_path: Path) -> None: ``` The helper wraps Click's `CliRunner`, sets `HOME` when requested, and supplies -`cwd` to Base's context discovery by temporarily changing process-global cwd -for the duration of the invocation. Calls that use `cwd` are serialized and -the caller's cwd is restored afterward, but this remains process-global: do not -use it concurrently with code that changes cwd outside `invoke()` or from -threads spawned by the invoked command. Use `cwd` for commands whose behavior -depends on project discovery, including tests that intentionally run outside a -Base project. Pass -`manifest={...}` with `cwd` to write a temporary `base_manifest.yaml` before -the command runs. - -When `home` is supplied, `invoke()` also defaults `BASE_CACHE_DIR` to -`/.cache/base` so helper-based tests do not inherit a developer's real -cache root. Pass `env={"BASE_CACHE_DIR": str(path)}` when a test needs an -explicit cache location. +`cwd` to the invocation for the duration of the test. Calls that use +`cwd` are serialized and the caller's cwd is restored afterward, but this +remains process-global: do not use it concurrently with code that changes cwd +outside `invoke()` or from threads spawned by the invoked command. A +generic profile should receive project fixtures through its +`discover_project` callback. The `manifest={...}` convenience is a legacy +compatibility helper for the Base profile. + +When `home` is supplied, `invoke()` provides an isolated default cache +environment for tests. Pass `env={"BASE_CACHE_DIR": str(path)}` when a test +needs an explicit cache location. ## When To Use `base_cli` -Use `base_cli` for Python commands that are part of Base or a Base-supported -project and need standard Base behavior. +Use `base_cli` for Python commands that need a predictable command +lifecycle: standard options, logging, redaction, runtime state, cleanup, and +test helpers. Standalone consumers should use `CliProfile.generic()` or +provide an explicit profile with their own project and configuration policies. -Base public command engines under `cli/python/base_*/engine.py` should -instantiate `base_cli.App` so standard options, logging, redaction, runtime -state, and local command history stay consistent. If a future public Python -engine intentionally bypasses this lifecycle, document the reason in code and -in this guide, then add it as an explicit lifecycle-audit exemption. Shell-only -helpers that avoid Python startup, such as `basectl config path`, do not create -Python logs or history records; once a `basectl` path enters a Python command -package, it should participate in `base_cli.App`. +The legacy Base profile exists only for compatibility while Base's command +engines migrate to an explicit consumer adapter. Base-specific behavior such as +manifest discovery, `.base` configuration, IDE settings, and command history +should eventually live in that adapter rather than in the generic package. It is a good fit for: @@ -519,5 +525,5 @@ It is a good fit for: - CLIs that need predictable logs, temp directories, and config precedence It is not meant to replace Click, Typer, argparse, or rich terminal UI -frameworks. It is the Base layer around command lifecycle, context, logging, +frameworks. It is the reusable layer around command lifecycle, context, logging, configuration, and state. diff --git a/docs/consumer-profiles.md b/docs/consumer-profiles.md new file mode 100644 index 0000000..9c86d3a --- /dev/null +++ b/docs/consumer-profiles.md @@ -0,0 +1,114 @@ +# Consumer Profiles + +`base_cli` provides a reusable command lifecycle. It does not own a project's +manifest format, configuration directory, workspace model, cache policy, or +history product. + +Those decisions are supplied by a `CliProfile`. The profile is the boundary +between the generic lifecycle and an application-specific consumer: + +```text +Click command + | + v +base_cli.App + Context + logging + cleanup + | + +-- project discovery -> CliProfile.discover_project + +-- user configuration -> CliProfile.load_user_config + +-- project/explicit config -> CliProfile.load_config + +-- runtime placement -> CliProfile.resolve_runtime + +-- optional history -> CliProfile.history_writer +``` + +## Generic profile + +Use `CliProfile.generic()` for a standalone application: + +```python +from pathlib import Path + +import base_cli + + +app = base_cli.App( + name="hello", + profile=base_cli.CliProfile.generic( + cache_root=Path.home() / ".cache" / "hello", + ), +) +``` + +The generic profile: + +- does not search for a manifest; +- does not read product-owned user or project configuration files; +- loads only an explicitly supplied `--config` file by default; +- places runtime state under the configured cache root and application namespace; +- does not write command history unless a history policy is supplied. + +An application can add its own policies without changing the lifecycle: + +```python +from pathlib import Path + +import base_cli + + +def discover_project(cwd: Path) -> base_cli.ProjectInfo | None: + manifest = cwd / "tool.manifest" + if not manifest.exists(): + return None + return base_cli.ProjectInfo(root=cwd, manifest=manifest, name="demo") + + +profile = base_cli.CliProfile.generic( + discover_project=discover_project, +) +``` + +The callback types are deliberately small. A consumer can wrap an existing +project library, use a different serialization format, or return no project +metadata at all. + +## Compatibility profile + +For the migration period, `App()` without an explicit profile selects +`CliProfile.legacy_base()`. This preserves existing Base consumers while they +move their integration code out of the generic package. New standalone +consumers should pass `CliProfile.generic()` explicitly so their behavior does +not depend on the compatibility default. + +The legacy profile contains the current Base conventions, including: + +- upward discovery of `base_manifest.yaml`; +- `BASE_HOME`, `BASE_CACHE_DIR`, and Base owner/runtime environment variables; +- `~/.base.d/config.yaml` and project `.base/config.yaml`; +- Base's owner-aware cache and run layout; +- Base history persistence and delegation metadata. + +These conventions are intentionally isolated behind one profile so they can be +moved into the Base consumer without changing command lifecycle code. + +## Refactoring boundary + +The following behaviors should not be added to generic lifecycle modules: + +- a required product name or launcher name; +- a product-specific manifest filename; +- a product-specific home or cache directory; +- product-specific configuration keys or environment variables; +- IDE/editor settings; +- product-specific command lists or history schema; +- assumptions about a downstream repository's directory layout. + +The next migration phases are: + +1. Change Base command engines to construct and pass an explicit legacy profile. +2. Move Base discovery, config, runtime, and history adapters into Base. +3. Generalize the remaining context/config types where their names still encode + Base concepts. +4. Remove the compatibility default and keep `base_cli` focused on the generic + lifecycle. + +The package rename is deliberately separate from this refactor. Names can be +changed after the dependency boundary is stable. diff --git a/lib/python/base_cli/__init__.py b/lib/python/base_cli/__init__.py index 6847601..36b5d0b 100644 --- a/lib/python/base_cli/__init__.py +++ b/lib/python/base_cli/__init__.py @@ -58,11 +58,13 @@ def _resolve_version() -> str: render_records, resolve_output_format, ) +from .profile import CliProfile, ProjectInfo, RuntimeBinding __all__ = [ "App", "__version__", "BOOLEAN", + "CliProfile", "CommandProtocolError", "Context", "ExitCode", @@ -98,6 +100,7 @@ def _resolve_version() -> str: "normalize_command_filters", "OutputFormatError", "PUBLIC_OUTPUT_FORMATS", + "ProjectInfo", "is_terminal", "output_format_choices", "option", @@ -106,4 +109,5 @@ def _resolve_version() -> str: "register_record_schema", "resolve_output_format", "run_app", + "RuntimeBinding", ] diff --git a/lib/python/base_cli/_runtime.py b/lib/python/base_cli/_runtime.py index e358126..36b4455 100644 --- a/lib/python/base_cli/_runtime.py +++ b/lib/python/base_cli/_runtime.py @@ -6,7 +6,7 @@ from pathlib import Path from ._private_files import write_private_json -from .paths import runtime_owner_root, runtime_run_directory_name +from .paths import runtime_owner_root, runtime_run_directory_name, runtime_slug @dataclass(frozen=True) @@ -29,11 +29,16 @@ def runtime_layout( run_id: str, *, owner: str = "base", + namespace: str | None = None, project_name: str | None = None, project_root: Path | None = None, inherited_run_root: Path | None = None, ) -> RuntimeLayout: - owner_root = runtime_owner_root(cache_root, owner, project_name, project_root) + owner_root = ( + runtime_namespace_root(cache_root, namespace) + if namespace is not None + else runtime_owner_root(cache_root, owner, project_name, project_root) + ) run_root = inherited_run_root or owner_root / "runs" / runtime_run_directory_name(run_id, cli_name, project_name) state_dir = owner_root # Every public invocation owns one run bundle and one diagnostic log. @@ -65,6 +70,11 @@ def create_runtime_directory(path: Path, cache_root: Path) -> None: raise RuntimeError(_runtime_directory_error(path, cache_root, exc)) from exc +def runtime_namespace_root(cache_root: Path, namespace: str) -> Path: + """Return an application-owned runtime namespace without product assumptions.""" + return cache_root / runtime_slug(namespace, fallback="application") + + def prune_log_files( log_dir: Path, current_log_file: Path, diff --git a/lib/python/base_cli/app.py b/lib/python/base_cli/app.py index e1fc857..11679b4 100644 --- a/lib/python/base_cli/app.py +++ b/lib/python/base_cli/app.py @@ -7,24 +7,17 @@ from pathlib import Path from typing import Any, Callable -from ._runtime import create_runtime_directory, prune_log_files, runtime_layout +from ._runtime import create_runtime_directory, prune_log_files from ._private_files import write_private_json -from .config import load_config, read_user_config from .context import Context, reset_current_context, set_current_context from .exit_codes import ExitCode -from .history import HISTORY_SCOPE_INTERNAL, utc_now, write_finished_record +from .history import utc_now from .logging import configure_logger, log_invocation from .paths import ( - base_cache_root, current_working_dir, - discover_manifest, - make_run_id, normalize_cli_name, - normalize_runtime_owner, - runtime_project_name, - runtime_project_root, - resolve_base_home, ) +from .profile import CliProfile from .redaction import parameter_name_from_decls _STANDARD_OPTION_KEYS = ("debug", "quiet", "environment", "config", "keep_temp", "log_file") @@ -33,22 +26,8 @@ _INVOCATION_ARGV: ContextVar[list[str] | None] = ContextVar("base_cli_invocation_argv", default=None) -def _default_log_file(layout: Any, inherited_path: Path | None) -> Path: - if inherited_path is not None: - return Path( - os.environ.get( - "BASE_CLI_PRIMARY_LOG", - str(layout.log_dir / "primary.log"), - ) - ).expanduser() - return layout.log_dir / "primary.log" - - -def _history_scope(inherited_path: Path | None) -> str: - return os.environ.get( - "BASE_CLI_HISTORY_SCOPE", - HISTORY_SCOPE_INTERNAL if inherited_path is not None else "primary", - ) +def _default_log_file(layout: Any, configured_log_file: Path | None) -> Path: + return configured_log_file or layout.log_dir / "primary.log" def _require_click(): @@ -61,7 +40,7 @@ def _require_click(): # pylint: disable=too-many-statements class App: - """Define a Click-backed command with Base's shared runtime lifecycle.""" + """Define a Click-backed command with a shared runtime lifecycle.""" # pylint: disable=too-many-arguments,too-many-positional-arguments def __init__( @@ -71,6 +50,7 @@ def __init__( help: str | None = None, # pylint: disable=redefined-builtin log_to_file: bool = True, max_log_files: int | None = None, + profile: CliProfile | None = None, ) -> None: if max_log_files is not None and max_log_files < 1: raise ValueError("max_log_files must be greater than 0 when set.") @@ -79,6 +59,7 @@ def __init__( self.help = help self.log_to_file = log_to_file self.max_log_files = max_log_files + self.profile = profile or CliProfile.legacy_base() self._click_command = None self._command_func: Callable[..., Any] | None = None self._command_args: tuple[Any, ...] = () @@ -179,7 +160,14 @@ def wrapper(**kwargs: Any): exit_code = ExitCode.FAILURE raise finally: - write_finished_record(context, invocation_argv, sensitive_options, started_at, exit_code) + if self.profile.history_writer is not None: + self.profile.history_writer( + context, + invocation_argv, + sensitive_options, + started_at, + exit_code, + ) reset_current_context(token) context.cleanup() @@ -193,41 +181,25 @@ def wrapper(**kwargs: Any): def _create_context(self, standard: dict[str, Any], sensitive_options: set[str], dry_run: bool = False) -> Context: del sensitive_options - manifest_override = os.environ.get("BASE_CLI_PROJECT_MANIFEST") - manifest_path = ( - Path(manifest_override).expanduser().resolve() - if manifest_override - else discover_manifest(current_working_dir()) - ) - project_root = manifest_path.parent if manifest_path is not None else None + project = self.profile.discover_project(current_working_dir()) + manifest_path = project.manifest if project is not None else None explicit_config = Path(standard["config"]).expanduser() if standard.get("config") else None - user_config = read_user_config() - config = load_config(project_root, explicit_config) + user_config = self.profile.load_user_config() + 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") quiet = bool(standard.get("quiet")) keep_temp = bool(standard.get("keep_temp") or config.get("keep_temp")) - cache_root = base_cache_root() - runtime_owner = normalize_runtime_owner() - selected_project_root = runtime_project_root() or project_root - selected_project_name = runtime_project_name() or ( - selected_project_root.name if selected_project_root else None - ) - inherited_run_root = os.environ.get("BASE_CLI_RUN_ROOT") if runtime_owner == "base" else None - inherited_path = Path(inherited_run_root).expanduser().resolve() if inherited_run_root else None - inherited_run_id = os.environ.get("BASE_CLI_RUN_ID") if inherited_path is not None else None - run_id = inherited_run_id or (inherited_path.name if inherited_path is not None else make_run_id()) - layout = runtime_layout( - cache_root, - self.name, - run_id, - owner=runtime_owner, - project_name=selected_project_name, - project_root=selected_project_root, - inherited_run_root=inherited_path, - ) + runtime = self.profile.resolve_runtime(self.name, project) + cache_root = runtime.cache_root + runtime_owner = runtime.runtime_owner + selected_project_root = runtime.project_root + selected_project_name = runtime.project_name + inherited_path = runtime.inherited_path + run_id = runtime.run_id + layout = runtime.layout log_file = Path(standard["log_file"]).expanduser() if standard.get("log_file") else None uses_default_log_file = log_file is None @@ -238,7 +210,7 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str], for directory in (layout.log_dir, layout.cache_dir, layout.temp_dir): create_runtime_directory(directory, cache_root) if log_file is None: - log_file = _default_log_file(layout, inherited_path) + log_file = _default_log_file(layout, runtime.primary_log_file) create_runtime_directory(log_file.parent, cache_root) if inherited_path is None and not dry_run and self.log_to_file: create_runtime_directory(layout.owner_root, cache_root) @@ -259,7 +231,7 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str], write_private_json(run_metadata_path, run_metadata) except OSError: pass - if runtime_owner == "project" and selected_project_root is not None and not dry_run and self.log_to_file: + if runtime.write_identity and selected_project_root is not None and not dry_run and self.log_to_file: try: create_runtime_directory(layout.owner_root, cache_root) identity_path = layout.owner_root / "identity.json" @@ -287,7 +259,8 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str], runtime_owner=runtime_owner, owner_root=layout.owner_root, run_root=layout.run_root, - base_home=resolve_base_home(), + base_home=runtime.application_home, + application_home=runtime.application_home, project_root=selected_project_root, workspace_root=user_config.workspace.root, manifest_path=manifest_path, @@ -305,8 +278,8 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str], log=logger, user_config=user_config, dry_run=dry_run, - history_scope=_history_scope(inherited_path), - history_parent_run_id=os.environ.get("BASE_CLI_HISTORY_PARENT_RUN_ID") or None, + history_scope=runtime.history_scope, + history_parent_run_id=runtime.history_parent_run_id, ) @@ -323,7 +296,7 @@ def run_app(app: App, argv: list[str] | None = None) -> int: args = list(sys.argv[1:] if argv is None else argv) try: _reject_equals_option_values(click, args) - display_command = delegated_display_command() + display_command = app.profile.display_command() invocation_argv = _effective_invocation_argv(app, args, explicit_argv, display_command) invocation_token = _INVOCATION_ARGV.set(invocation_argv) try: @@ -420,7 +393,7 @@ def _decorate_standard_options(click: Any, func: Callable[..., Any], version: st func = click.option("--log-file", type=click.Path(dir_okay=False), help="Override the persistent log file.")(func) func = click.option("--keep-temp", is_flag=True, default=None, help="Preserve this run's temp directory.")(func) func = click.option("--config", type=click.Path(dir_okay=False), help="Load an additional config file.")(func) - func = click.option("--environment", help="Set the Base CLI environment.")(func) + func = click.option("--environment", help="Set the CLI environment.")(func) func = click.option( "--debug", is_flag=True, diff --git a/lib/python/base_cli/context.py b/lib/python/base_cli/context.py index e5edefd..2b531dc 100644 --- a/lib/python/base_cli/context.py +++ b/lib/python/base_cli/context.py @@ -22,7 +22,7 @@ def _default_user_config() -> UserConfig: @dataclass class Context: - """Runtime state and cleanup hooks available to an active Base CLI command.""" + """Runtime state and cleanup hooks available to an active CLI command.""" cli_name: str run_id: str @@ -38,6 +38,7 @@ class Context: log: logging.Logger dry_run: bool = False base_home: Path | None = None + application_home: Path | None = None project_root: Path | None = None manifest_path: Path | None = None project_name: str | None = None @@ -51,6 +52,12 @@ class Context: owner_root: Path | None = None run_root: Path | None = None + def __post_init__(self) -> None: + if self.application_home is None: + self.application_home = self.base_home + if self.base_home is None: + self.base_home = self.application_home + def on_cleanup(self, hook: Callable[[], None]) -> None: self.cleanup_hooks.append(hook) diff --git a/lib/python/base_cli/profile.py b/lib/python/base_cli/profile.py new file mode 100644 index 0000000..43bc141 --- /dev/null +++ b/lib/python/base_cli/profile.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import os +import sys +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +from ._runtime import RuntimeLayout, runtime_layout +from .config import UserConfig, UserIdeConfig, load_yaml_file +from .paths import make_run_id + + +@dataclass(frozen=True) +class ProjectInfo: + """Consumer-neutral project information discovered for an invocation.""" + + root: Path | None = None + manifest: Path | None = None + name: str | None = None + + +@dataclass(frozen=True) +class RuntimeBinding: + """Runtime decisions supplied by a consumer profile.""" + + cache_root: Path + layout: RuntimeLayout + application_home: Path | None + runtime_owner: str + project_root: Path | None + project_name: str | None + inherited_path: Path | None + history_parent_run_id: str | None + run_id: str + primary_log_file: Path | None = None + history_scope: str = "primary" + write_identity: bool = False + + +ProjectDiscovery = Callable[[Path], ProjectInfo | None] +UserConfigLoader = Callable[[], UserConfig] +ConfigLoader = Callable[[ProjectInfo | None, Path | None], dict[str, Any]] +RuntimeResolver = Callable[[str, ProjectInfo | None], RuntimeBinding] +HistoryWriter = Callable[[Any, list[str], set[str], datetime, int], None] +DisplayCommandResolver = Callable[[], str | None] + + +def _no_display_command() -> str | None: + return None + + +@dataclass(frozen=True) +class CliProfile: + """Policy boundary between the generic CLI lifecycle and its consumer. + + A profile supplies project discovery, configuration, runtime placement, and + optional history persistence. The generic profile has no manifest convention, + no product-owned config files, and no history writer. The legacy Base profile + remains available temporarily so existing consumers can migrate explicitly. + """ + + discover_project: ProjectDiscovery + load_user_config: UserConfigLoader + load_config: ConfigLoader + resolve_runtime: RuntimeResolver + history_writer: HistoryWriter | None = None + display_command: DisplayCommandResolver = _no_display_command + + @classmethod + def generic( + cls, + *, + cache_root: Path | None = None, + application_home: Path | None = None, + discover_project: ProjectDiscovery | None = None, + load_user_config: UserConfigLoader | None = None, + load_config: ConfigLoader | None = None, + ) -> CliProfile: + """Create a profile with consumer-neutral defaults. + + Generic commands do not discover a manifest, load implicit config, or + write command history unless the caller supplies those policies. + """ + return cls( + discover_project=discover_project or _discover_no_project, + load_user_config=load_user_config or _empty_user_config, + load_config=load_config or _load_explicit_config, + resolve_runtime=_generic_runtime_resolver(cache_root, application_home), + display_command=_no_display_command, + ) + + @classmethod + def legacy_base(cls) -> CliProfile: + """Return the pre-profile Base behavior during the migration period.""" + from .config import load_config as load_base_config + from .config import read_user_config + from .history import write_finished_record + from .paths import ( + base_cache_root, + discover_manifest, + normalize_runtime_owner, + resolve_base_home, + runtime_project_name, + runtime_project_root, + ) + + def discover(cwd: Path) -> ProjectInfo | None: + manifest_override = os.environ.get("BASE_CLI_PROJECT_MANIFEST") + manifest = ( + Path(manifest_override).expanduser().resolve() + if manifest_override + else discover_manifest(cwd) + ) + if manifest is None: + return None + + name: str | None = None + try: + project = load_yaml_file(manifest).get("project") + if isinstance(project, dict) and isinstance(project.get("name"), str): + name = project["name"] + except (OSError, RuntimeError, ValueError): + pass + return ProjectInfo(root=manifest.parent, manifest=manifest, name=name) + + def resolve_runtime(cli_name: str, project: ProjectInfo | None) -> RuntimeBinding: + runtime_owner = normalize_runtime_owner() + selected_project_root = runtime_project_root() or (project.root if project else None) + selected_project_name = runtime_project_name() or ( + project.name if project else (selected_project_root.name if selected_project_root else None) + ) + inherited_run_root = os.environ.get("BASE_CLI_RUN_ROOT") if runtime_owner == "base" else None + inherited_path = Path(inherited_run_root).expanduser().resolve() if inherited_run_root else None + inherited_run_id = os.environ.get("BASE_CLI_RUN_ID") if inherited_path is not None else None + run_id = inherited_run_id or ( + inherited_path.name if inherited_path is not None else make_run_id() + ) + cache_root = base_cache_root() + return RuntimeBinding( + cache_root=cache_root, + layout=runtime_layout( + cache_root, + cli_name, + run_id, + owner=runtime_owner, + project_name=selected_project_name, + project_root=selected_project_root, + inherited_run_root=inherited_path, + ), + application_home=resolve_base_home(), + runtime_owner=runtime_owner, + project_root=selected_project_root, + project_name=selected_project_name, + inherited_path=inherited_path, + history_parent_run_id=os.environ.get("BASE_CLI_HISTORY_PARENT_RUN_ID") or None, + run_id=run_id, + primary_log_file=( + Path(os.environ["BASE_CLI_PRIMARY_LOG"]).expanduser() + if inherited_path is not None and os.environ.get("BASE_CLI_PRIMARY_LOG") + else None + ), + history_scope=os.environ.get( + "BASE_CLI_HISTORY_SCOPE", + "internal" if inherited_path is not None else "primary", + ), + write_identity=runtime_owner == "project", + ) + + return cls( + discover_project=discover, + load_user_config=read_user_config, + load_config=lambda project, explicit: load_base_config( + project.root if project is not None else None, + explicit, + ), + resolve_runtime=resolve_runtime, + history_writer=write_finished_record, + display_command=_legacy_display_command, + ) + + +def _discover_no_project(_cwd: Path) -> ProjectInfo | None: + return None + + +def _empty_user_config() -> UserConfig: + return UserConfig(raw={}, ide=UserIdeConfig(enabled=None, preferences={})) + + +def _load_explicit_config(_project: ProjectInfo | None, explicit: Path | None) -> dict[str, Any]: + return load_yaml_file(explicit) if explicit is not None else {} + + +def _generic_runtime_resolver( + cache_root: Path | None, + application_home: Path | None, +) -> RuntimeResolver: + def resolve_runtime(cli_name: str, project: ProjectInfo | None) -> RuntimeBinding: + root = cache_root.expanduser().resolve() if cache_root is not None else _default_cache_root() + run_id = make_run_id() + project_root = project.root if project is not None else None + project_name = project.name if project is not None else None + return RuntimeBinding( + cache_root=root, + layout=runtime_layout( + root, + cli_name, + run_id, + namespace=cli_name, + project_name=project_name, + project_root=project_root, + ), + application_home=application_home, + runtime_owner="default", + project_root=project_root, + project_name=project_name, + inherited_path=None, + history_parent_run_id=None, + run_id=run_id, + ) + + return resolve_runtime + + +def _default_cache_root() -> Path: + root = Path.home() + if sys.platform == "darwin": + return root / "Library" / "Caches" + return root / ".cache" + + +def _legacy_display_command() -> str | None: + value = os.environ.get("BASE_CLI_DISPLAY_COMMAND", "").strip() + return value or None diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 0000000..e763f0f --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import importlib.util +import tempfile +import unittest +from pathlib import Path + +import base_cli +from base_cli.testing import invoke + + +@unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") +class GenericProfileTests(unittest.TestCase): + def test_generic_profile_has_no_base_runtime_defaults(self) -> None: + seen: dict[str, object] = {} + + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + home = root / "home" + cache = root / "cache" + project = root / "project" + project.mkdir() + + app = base_cli.App( + name="plain-tool", + profile=base_cli.CliProfile.generic(cache_root=cache), + ) + + @app.command() + def main(ctx: base_cli.Context) -> None: + seen["project_root"] = ctx.project_root + seen["manifest_path"] = ctx.manifest_path + seen["runtime_owner"] = ctx.runtime_owner + seen["history_scope"] = ctx.history_scope + seen["cache_dir"] = ctx.cache_dir + + result = invoke( + app, + home=home, + cwd=project, + env={ + "BASE_CLI_PRIMARY_LOG": str(root / "base.log"), + "BASE_CLI_HISTORY_SCOPE": "internal", + "BASE_CLI_DISPLAY_COMMAND": "basectl", + }, + ) + + self.assertEqual(result.exit_code, 0, f"{result.output} {result.exception!r}") + self.assertIsNone(app.profile.history_writer) + self.assertIsNone(app.profile.display_command()) + self.assertIsNone(seen["project_root"]) + self.assertIsNone(seen["manifest_path"]) + self.assertEqual(seen["runtime_owner"], "default") + self.assertEqual(seen["history_scope"], "primary") + self.assertTrue(Path(seen["cache_dir"]).resolve().is_relative_to(cache.resolve())) + self.assertFalse((home / ".base.d").exists()) + self.assertFalse((home / ".cache" / "base").exists()) + + def test_generic_profile_accepts_consumer_project_and_config_policies(self) -> None: + seen: dict[str, object] = {} + + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + home = root / "home" + cache = root / "cache" + project = root / "project" + manifest = project / "tool.manifest" + project.mkdir() + manifest.write_text("name: demo\n", encoding="utf-8") + + def discover(_cwd: Path) -> base_cli.ProjectInfo: + return base_cli.ProjectInfo( + root=project, + manifest=manifest, + name="demo", + ) + + def load_config( + project_info: base_cli.ProjectInfo | None, + explicit: Path | None, + ) -> dict[str, object]: + return { + "project": project_info.name if project_info is not None else None, + "explicit": str(explicit) if explicit is not None else None, + } + + profile = base_cli.CliProfile.generic( + cache_root=cache, + discover_project=discover, + load_config=load_config, + ) + app = base_cli.App(name="policy-tool", profile=profile, log_to_file=False) + + @app.command() + def main(ctx: base_cli.Context) -> None: + seen["project_root"] = ctx.project_root + seen["manifest_path"] = ctx.manifest_path + seen["project_name"] = ctx.project_name + seen["config"] = ctx.config + + result = invoke(app, home=home, cwd=project) + + self.assertEqual(result.exit_code, 0, f"{result.output} {result.exception!r}") + self.assertEqual(seen["project_root"], project) + self.assertEqual(seen["manifest_path"], manifest) + self.assertEqual(seen["project_name"], "demo") + self.assertEqual(seen["config"], {"project": "demo", "explicit": None})