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
274 changes: 140 additions & 134 deletions README.md

Large diffs are not rendered by default.

114 changes: 114 additions & 0 deletions docs/consumer-profiles.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -98,6 +100,7 @@ def _resolve_version() -> str:
"normalize_command_filters",
"OutputFormatError",
"PUBLIC_OUTPUT_FORMATS",
"ProjectInfo",
"is_terminal",
"output_format_choices",
"option",
Expand All @@ -106,4 +109,5 @@ def _resolve_version() -> str:
"register_record_schema",
"resolve_output_format",
"run_app",
"RuntimeBinding",
]
14 changes: 12 additions & 2 deletions lib/python/base_cli/_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
99 changes: 36 additions & 63 deletions lib/python/base_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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():
Expand All @@ -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__(
Expand All @@ -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.")
Expand All @@ -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, ...] = ()
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)


Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
Loading