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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and versions are tracked in the repo-root `VERSION` file.
- Move manifest discovery, implicit configuration, owner-aware runtime layout,
and history persistence out of the generic package. Consumers now provide
those policies through an explicit `CliProfile`.
- Make `Context.user_config` an opaque consumer-owned value and remove the
Base-shaped `UserConfig` types from the public package facade.

### Migration notes

Expand All @@ -23,6 +25,9 @@ and versions are tracked in the repo-root `VERSION` file.
- `base_cli.history.write_history_record()` and
`base_cli.history.write_primary_record()` now require a consumer-selected
history path; they never choose an application cache location themselves.
- Consumers that need a workspace root should provide the optional
`CliProfile.resolve_workspace_root` projection; the generic lifecycle no
longer reads fields from a prescribed user-configuration schema.

### Added

Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ migration guidance.

The supported facade is `import base_cli`. It exports the command lifecycle
(`App`, `Context`, `run_app`, decorators, and logging helpers), command filters,
the structured command protocol helpers, and the user configuration types used
by `Context.user_config`. The corresponding modules are also available as
and the structured command protocol helpers. Consumer-owned user configuration
is passed through `Context.user_config`; the library does not impose a schema.
The corresponding modules are also available as
`base_cli.command_filters`, `base_cli.command_protocol`, and
`base_cli.history`.

Expand Down Expand Up @@ -301,7 +302,8 @@ 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 returned by the profile.
- `ctx.user_config`: opaque consumer-owned user configuration returned by the
profile, or `None` for the generic default.
- `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.
Expand Down Expand Up @@ -414,10 +416,10 @@ configuration file.


`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`.
exposes the opaque 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`; `base_cli` does not define the value's fields.

## Project Discovery

Expand Down
8 changes: 6 additions & 2 deletions docs/consumer-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ base_cli.App + Context + logging + cleanup
|
+-- project discovery -> CliProfile.discover_project
+-- user configuration -> CliProfile.load_user_config
+-- workspace projection -> CliProfile.resolve_workspace_root
+-- project/explicit config -> CliProfile.load_config
+-- runtime placement -> CliProfile.resolve_runtime
+-- history command labels -> CliProfile.history_display_command
Expand Down Expand Up @@ -68,8 +69,11 @@ profile = base_cli.CliProfile.generic(
```

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.
project library, use a different serialization format, return no project
metadata, or keep its typed user-configuration object entirely in the consumer.
`Context.user_config` is opaque to `base_cli`; `resolve_workspace_root` is an
optional projection used when commands need a workspace root without exposing
the consumer's configuration schema to the generic lifecycle.

If a consumer persists history, it can provide `history_display_command` to
translate internal entry-point names into user-facing labels. The generic
Expand Down
6 changes: 0 additions & 6 deletions lib/python/base_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ def _resolve_version() -> str:
loads_records,
register_record_schema,
)
from .config import UserConfig, UserGithubConfig, UserIdeConfig, UserIdePreference, UserWorkspaceConfig
from .context import Context, get_current_context
from .exit_codes import ExitCode
from .inspection import inspection_envelope, render_inspection_json
Expand All @@ -71,11 +70,6 @@ def _resolve_version() -> str:
"FieldSpec",
"NULLABLE_STRING",
"STRING",
"UserConfig",
"UserGithubConfig",
"UserIdeConfig",
"UserIdePreference",
"UserWorkspaceConfig",
"command_filters",
"command_matches",
"command_protocol",
Expand Down
6 changes: 3 additions & 3 deletions lib/python/base_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str],
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 = self.profile.load_user_config()
workspace_root = self.profile.resolve_workspace_root(user_config)
config = self.profile.load_config(project, explicit_config)

environment = standard.get("environment") or config.get("environment") or "dev"
Expand Down Expand Up @@ -228,7 +229,7 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str],
"project": selected_project_name,
"project_root": str(selected_project_root) if selected_project_root else None,
"manifest": str(manifest_path) if manifest_path else None,
"workspace_root": str(user_config.workspace.root) if user_config.workspace.root else None,
"workspace_root": str(workspace_root) if workspace_root else None,
}
run_metadata_path = layout.run_root / "run.json"
write_private_json(run_metadata_path, run_metadata)
Expand Down Expand Up @@ -262,10 +263,9 @@ 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=runtime.application_home,
application_home=runtime.application_home,
project_root=selected_project_root,
workspace_root=user_config.workspace.root,
workspace_root=workspace_root,
manifest_path=manifest_path,
project_name=selected_project_name,
state_dir=layout.state_dir,
Expand Down
41 changes: 0 additions & 41 deletions lib/python/base_cli/config.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,16 @@
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any

from ._dependencies import require_yaml


__all__ = [
"UserConfig",
"UserGithubConfig",
"UserIdeConfig",
"UserIdePreference",
"UserWorkspaceConfig",
"load_yaml_file",
]


@dataclass(frozen=True)
class UserIdePreference:
enabled: bool | None
install: bool | None
extra_extensions: tuple[str, ...]
settings: dict[str, Any]


@dataclass(frozen=True)
class UserIdeConfig:
enabled: bool | None
preferences: dict[str, UserIdePreference]


@dataclass(frozen=True)
class UserWorkspaceConfig:
root: Path | None
manifest: Path | None = None
manifest_source: str | None = None


@dataclass(frozen=True)
class UserGithubConfig:
default_owner: str | None
clone_protocol: str | None


@dataclass(frozen=True)
class UserConfig:
raw: dict[str, Any]
ide: UserIdeConfig
workspace: UserWorkspaceConfig = UserWorkspaceConfig(root=None)
github: UserGithubConfig = UserGithubConfig(default_owner=None, clone_protocol=None)


def load_yaml_file(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
Expand Down
15 changes: 1 addition & 14 deletions lib/python/base_cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,13 @@
from pathlib import Path
from typing import Any, Callable

from .config import UserConfig, UserIdeConfig


_current_context: contextvars.ContextVar[Context | None] = contextvars.ContextVar(
"base_cli_current_context",
default=None,
)


def _default_user_config() -> UserConfig:
return UserConfig(raw={}, ide=UserIdeConfig(enabled=None, preferences={}))


def _default_history_display_command(cli_name: str, _argv: list[str]) -> str:
return cli_name.replace("_", "-")

Expand All @@ -41,14 +35,13 @@ class Context:
keep_temp: bool
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
history_scope: str = "primary"
history_parent_run_id: str | None = None
user_config: UserConfig = field(default_factory=_default_user_config)
user_config: object | None = None
history_display_command: Callable[[str, list[str]], str] = _default_history_display_command
cleanup_hooks: list[Callable[[], None]] = field(default_factory=list)
workspace_root: Path | None = None
Expand All @@ -57,12 +50,6 @@ 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)

Expand Down
16 changes: 12 additions & 4 deletions lib/python/base_cli/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typing import Any

from ._runtime import RuntimeLayout, runtime_layout
from .config import UserConfig, UserIdeConfig, load_yaml_file
from .config import load_yaml_file
from .paths import make_run_id


Expand Down Expand Up @@ -41,9 +41,10 @@ class RuntimeBinding:


ProjectDiscovery = Callable[[Path], ProjectInfo | None]
UserConfigLoader = Callable[[], UserConfig]
UserConfigLoader = Callable[[], object | None]
ConfigLoader = Callable[[ProjectInfo | None, Path | None], dict[str, Any]]
RuntimeResolver = Callable[[str, ProjectInfo | None], RuntimeBinding]
WorkspaceRootResolver = Callable[[object | None], Path | None]
HistoryWriter = Callable[[Any, list[str], set[str], datetime, int], None]
DisplayCommandResolver = Callable[[], str | None]
HistoryDisplayResolver = Callable[[str, list[str]], str]
Expand All @@ -53,6 +54,10 @@ def _no_display_command() -> str | None:
return None


def _no_workspace_root(_user_config: object | None) -> Path | None:
return None


def _generic_history_display_command(cli_name: str, _argv: list[str]) -> str:
return cli_name.replace("_", "-")

Expand All @@ -73,6 +78,7 @@ class CliProfile:
history_writer: HistoryWriter | None = None
display_command: DisplayCommandResolver = _no_display_command
history_display_command: HistoryDisplayResolver = _generic_history_display_command
resolve_workspace_root: WorkspaceRootResolver = _no_workspace_root

@classmethod
def generic(
Expand All @@ -84,6 +90,7 @@ def generic(
load_user_config: UserConfigLoader | None = None,
load_config: ConfigLoader | None = None,
history_display_command: HistoryDisplayResolver | None = None,
resolve_workspace_root: WorkspaceRootResolver | None = None,
) -> CliProfile:
"""Create a profile with consumer-neutral defaults.

Expand All @@ -97,14 +104,15 @@ def generic(
resolve_runtime=_generic_runtime_resolver(cache_root, application_home),
display_command=_no_display_command,
history_display_command=history_display_command or _generic_history_display_command,
resolve_workspace_root=resolve_workspace_root or _no_workspace_root,
)

def _discover_no_project(_cwd: Path) -> ProjectInfo | None:
return None


def _empty_user_config() -> UserConfig:
return UserConfig(raw={}, ide=UserIdeConfig(enabled=None, preferences={}))
def _empty_user_config() -> None:
return None


def _load_explicit_config(_project: ProjectInfo | None, explicit: Path | None) -> dict[str, Any]:
Expand Down
23 changes: 13 additions & 10 deletions tests/test_context_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,22 @@
import importlib.util
import tempfile
import unittest
from dataclasses import dataclass
from pathlib import Path

import base_cli
from base_cli.config import UserConfig, UserIdeConfig, UserWorkspaceConfig


@dataclass(frozen=True)
class ConsumerSettings:
workspace_root: Path | None


def configured_app(workspace: Path | None, **kwargs: object) -> base_cli.App:
settings = ConsumerSettings(workspace.resolve() if workspace is not None else None)
profile = base_cli.CliProfile.generic(
load_user_config=lambda: UserConfig(
raw={},
ide=UserIdeConfig(enabled=None, preferences={}),
workspace=UserWorkspaceConfig(root=workspace.resolve() if workspace is not None else None),
)
load_user_config=lambda: settings,
resolve_workspace_root=lambda value: value.workspace_root if isinstance(value, ConsumerSettings) else None,
)
return base_cli.App(profile=profile, **kwargs)

Expand All @@ -35,15 +38,15 @@ def test_context_exposes_workspace_root_when_configured(self) -> None:
@app.command()
def main(ctx: base_cli.Context) -> None:
seen["workspace_root"] = ctx.workspace_root
seen["user_config_workspace_root"] = ctx.user_config.workspace.root
seen["user_config"] = ctx.user_config

from base_cli.testing import invoke

result = invoke(app, [], home=home)

self.assertEqual(result.exit_code, 0, result.output)
self.assertEqual(seen["workspace_root"], workspace.resolve())
self.assertEqual(seen["user_config_workspace_root"], workspace.resolve())
self.assertEqual(seen["user_config"], ConsumerSettings(workspace.resolve()))

def test_context_workspace_root_is_none_without_configured_root(self) -> None:
app = configured_app(None, name="workspace-root-default", log_to_file=False)
Expand All @@ -52,7 +55,7 @@ def test_context_workspace_root_is_none_without_configured_root(self) -> None:
@app.command()
def main(ctx: base_cli.Context) -> None:
seen["workspace_root"] = ctx.workspace_root
seen["user_config_workspace_root"] = ctx.user_config.workspace.root
seen["user_config"] = ctx.user_config

with tempfile.TemporaryDirectory() as tmpdir:
home = Path(tmpdir)
Expand All @@ -63,4 +66,4 @@ def main(ctx: base_cli.Context) -> None:

self.assertEqual(result.exit_code, 0, result.output)
self.assertIsNone(seen["workspace_root"])
self.assertIsNone(seen["user_config_workspace_root"])
self.assertEqual(seen["user_config"], ConsumerSettings(None))
2 changes: 2 additions & 0 deletions tests/test_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ def test_app_defaults_to_generic_profile(self) -> None:

self.assertIsNone(app.profile.history_writer)
self.assertIsNone(app.profile.display_command())
self.assertIsNone(app.profile.load_user_config())
self.assertIsNone(app.profile.resolve_workspace_root(None))
self.assertEqual(app.profile.resolve_runtime("plain-tool", None).runtime_owner, "default")

def test_generic_profile_accepts_consumer_project_and_config_policies(self) -> None:
Expand Down
Loading