diff --git a/docs/consumer-profiles.md b/docs/consumer-profiles.md index 9c86d3a..af8e12d 100644 --- a/docs/consumer-profiles.md +++ b/docs/consumer-profiles.md @@ -17,6 +17,7 @@ base_cli.App + Context + logging + cleanup +-- user configuration -> CliProfile.load_user_config +-- project/explicit config -> CliProfile.load_config +-- runtime placement -> CliProfile.resolve_runtime + +-- history command labels -> CliProfile.history_display_command +-- optional history -> CliProfile.history_writer ``` @@ -70,6 +71,11 @@ 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. +If a consumer persists history, it can provide `history_display_command` to +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. + ## Compatibility profile For the migration period, `App()` without an explicit profile selects @@ -84,7 +90,8 @@ The legacy profile contains the current Base conventions, including: - `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. +- Base history persistence and delegation metadata. Base's command-label policy + is supplied by Base rather than encoded in `base_cli.history`. These conventions are intentionally isolated behind one profile so they can be moved into the Base consumer without changing command lifecycle code. diff --git a/lib/python/base_cli/app.py b/lib/python/base_cli/app.py index 11679b4..c0653b1 100644 --- a/lib/python/base_cli/app.py +++ b/lib/python/base_cli/app.py @@ -277,6 +277,7 @@ def _create_context(self, standard: dict[str, Any], sensitive_options: set[str], keep_temp=keep_temp, log=logger, user_config=user_config, + history_display_command=self.profile.history_display_command, dry_run=dry_run, history_scope=runtime.history_scope, history_parent_run_id=runtime.history_parent_run_id, diff --git a/lib/python/base_cli/config.py b/lib/python/base_cli/config.py index 07a76d5..5e2ee5c 100644 --- a/lib/python/base_cli/config.py +++ b/lib/python/base_cli/config.py @@ -7,7 +7,6 @@ from typing import Any from ._dependencies import require_yaml -from .ide_schema import SUPPORTED_IDES from .ide_schema import parse_ide_extensions from .ide_schema import parse_ide_settings from .paths import base_state_root @@ -82,14 +81,18 @@ def load_user_config(home: Path | None = None) -> dict[str, Any]: return load_yaml_file(user_config_path(home)) -def read_user_config(home: Path | None = None) -> UserConfig: +def read_user_config( + home: Path | None = None, + *, + supported_ides: frozenset[str] | None = None, +) -> UserConfig: raw = load_user_config(home) path = user_config_path(home) return UserConfig( raw=raw, workspace=_read_user_workspace_config(path, raw.get("workspace")), github=_read_user_github_config(path, raw.get("github")), - ide=_read_user_ide_config(path, raw.get("ide")), + ide=_read_user_ide_config(path, raw.get("ide"), supported_ides=supported_ides), ) @@ -174,21 +177,35 @@ def _optional_non_empty_string(path: Path, key: str, value: Any) -> str | None: return value.strip() -def _read_user_ide_config(path: Path, ide_data: Any) -> UserIdeConfig: +def _read_user_ide_config( + path: Path, + ide_data: Any, + *, + supported_ides: frozenset[str] | None, +) -> UserIdeConfig: if ide_data is None: return UserIdeConfig(enabled=None, preferences={}) if not isinstance(ide_data, dict): raise ValueError(f"{path}: ide must be a mapping when provided.") - allowed_keys = SUPPORTED_IDES | {"enabled"} - unknown_keys = sorted(set(ide_data) - allowed_keys) + invalid_keys = sorted( + str(key) for key in ide_data if not isinstance(key, str) or not key.strip() + ) + if invalid_keys: + raise ValueError(f"{path}: ide keys must be non-empty strings.") + + unknown_keys = ( + sorted(set(ide_data) - supported_ides - {"enabled"}) + if supported_ides is not None + else [] + ) if unknown_keys: raise ValueError(f"{path}: unsupported ide keys: {', '.join(unknown_keys)}.") enabled = _optional_bool(path, "ide.enabled", ide_data.get("enabled")) preferences = { ide_name: _read_user_ide_preference(path, ide_name, ide_data.get(ide_name)) - for ide_name in sorted(SUPPORTED_IDES) + for ide_name in sorted(set(ide_data) - {"enabled"}) if ide_name in ide_data } return UserIdeConfig(enabled=enabled, preferences=preferences) diff --git a/lib/python/base_cli/context.py b/lib/python/base_cli/context.py index 2b531dc..959cd01 100644 --- a/lib/python/base_cli/context.py +++ b/lib/python/base_cli/context.py @@ -20,6 +20,10 @@ 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("_", "-") + + @dataclass class Context: """Runtime state and cleanup hooks available to an active CLI command.""" @@ -45,6 +49,7 @@ class Context: history_scope: str = "primary" history_parent_run_id: str | None = None user_config: UserConfig = field(default_factory=_default_user_config) + 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 quiet: bool = False diff --git a/lib/python/base_cli/history.py b/lib/python/base_cli/history.py index 1a8d11f..767e1e4 100644 --- a/lib/python/base_cli/history.py +++ b/lib/python/base_cli/history.py @@ -18,7 +18,6 @@ _msvcrt = None # type: ignore[assignment] from ._private_files import restrict_file, write_private_json -from .config import load_yaml_file from .context import Context from .paths import base_cache_root from .redaction import REDACTED, is_secret_key, option_name_to_parameter, redact_argv, redact_text_value @@ -29,7 +28,6 @@ "HISTORY_SCOPE_INTERNAL", "HISTORY_SCOPE_PRIMARY", "SCHEMA_VERSION", - "base_setup_action", "base_version", "build_finished_record", "compact_home_text", @@ -58,45 +56,6 @@ HISTORY_SCOPE_PRIMARY = "primary" HISTORY_SCOPE_INTERNAL = "internal" -# Only these names are Base-owned Python entry points. A standalone caller -# may legitimately choose a name beginning with ``base_`` and should not have -# that name rewritten by the shared framework. -_BASE_DISPLAY_COMMANDS = frozenset( - { - "base_activate", - "base_build", - "base_check", - "base_ci", - "base_clean", - "base_config", - "base_demo", - "base_dev", - "base_devcontainer", - "base_devenv", - "base_devenv_report", - "base_docs", - "base_export_context", - "base_gh", - "base_github_projects", - "base_history", - "base_logs", - "base_onboard", - "base_pr_policy", - "base_projects", - "base_prompt", - "base_release", - "base_repo", - "base_run", - "base_setup", - "base_test", - "base_trust", - "base_update", - "base_update_profile", - "base_workspace", - } -) - - def utc_now() -> datetime: return datetime.now(timezone.utc) @@ -134,7 +93,7 @@ def build_finished_record( "schema_version": SCHEMA_VERSION, "run_id": context.run_id, "event": "finished", - "command": display_command(context.cli_name, argv), + "command": context.history_display_command(context.cli_name, argv), "raw_command": context.cli_name, "argv": redact_history_argv(argv, sensitive_options), "started_at": format_timestamp(started_at), @@ -320,10 +279,7 @@ def duration_ms(started_at: datetime, ended_at: datetime) -> int: def display_command(cli_name: str, argv: list[str]) -> str: - if cli_name == "base_setup": - return base_setup_action(argv) or "setup" - if cli_name in _BASE_DISPLAY_COMMANDS: - return cli_name.removeprefix("base_").replace("_", "-") + del argv return cli_name.replace("_", "-") @@ -356,21 +312,14 @@ def optional_int(value: Any) -> int | None: return value if isinstance(value, int) else None -def base_setup_action(argv: list[str]) -> str | None: - for index, arg in enumerate(argv): - if arg == "--action" and index + 1 < len(argv): - return argv[index + 1] - if arg.startswith("--action="): - return arg.partition("=")[2] - return None - - def project_name(context: Context) -> str | None: if context.project_name: return context.project_name if context.manifest_path is None: return None try: + from .config import load_yaml_file + data = load_yaml_file(context.manifest_path) except (OSError, RuntimeError, ValueError): return None diff --git a/lib/python/base_cli/ide_schema.py b/lib/python/base_cli/ide_schema.py index 62ad8dc..d42b6b3 100644 --- a/lib/python/base_cli/ide_schema.py +++ b/lib/python/base_cli/ide_schema.py @@ -1,38 +1,13 @@ from __future__ import annotations -from dataclasses import dataclass import json from typing import Any -@dataclass(frozen=True) -class IdeDefinition: - name: str - label: str - cli: str - cask: str - settings_app_dir: str - - -IDE_DEFINITIONS = { - "vscode": IdeDefinition( - name="vscode", - label="VS Code", - cli="code", - cask="visual-studio-code", - settings_app_dir="Code", - ), - "cursor": IdeDefinition( - name="cursor", - label="Cursor", - cli="cursor", - cask="cursor", - settings_app_dir="Cursor", - ), -} - -SUPPORTED_IDES = frozenset(IDE_DEFINITIONS) -PROJECT_AUTO_SETTING_KEYS = frozenset({"python.defaultInterpreterPath"}) +__all__ = [ + "parse_ide_extensions", + "parse_ide_settings", +] def parse_ide_extensions(context: str, extensions_data: Any) -> tuple[str, ...]: diff --git a/lib/python/base_cli/profile.py b/lib/python/base_cli/profile.py index 43bc141..9412d56 100644 --- a/lib/python/base_cli/profile.py +++ b/lib/python/base_cli/profile.py @@ -46,12 +46,17 @@ class RuntimeBinding: RuntimeResolver = Callable[[str, ProjectInfo | None], RuntimeBinding] HistoryWriter = Callable[[Any, list[str], set[str], datetime, int], None] DisplayCommandResolver = Callable[[], str | None] +HistoryDisplayResolver = Callable[[str, list[str]], str] def _no_display_command() -> str | None: return None +def _generic_history_display_command(cli_name: str, _argv: list[str]) -> str: + return cli_name.replace("_", "-") + + @dataclass(frozen=True) class CliProfile: """Policy boundary between the generic CLI lifecycle and its consumer. @@ -68,6 +73,7 @@ class CliProfile: resolve_runtime: RuntimeResolver history_writer: HistoryWriter | None = None display_command: DisplayCommandResolver = _no_display_command + history_display_command: HistoryDisplayResolver = _generic_history_display_command @classmethod def generic( @@ -78,6 +84,7 @@ def generic( discover_project: ProjectDiscovery | None = None, load_user_config: UserConfigLoader | None = None, load_config: ConfigLoader | None = None, + history_display_command: HistoryDisplayResolver | None = None, ) -> CliProfile: """Create a profile with consumer-neutral defaults. @@ -90,6 +97,7 @@ def generic( load_config=load_config or _load_explicit_config, 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, ) @classmethod @@ -179,6 +187,7 @@ def resolve_runtime(cli_name: str, project: ProjectInfo | None) -> RuntimeBindin resolve_runtime=resolve_runtime, history_writer=write_finished_record, display_command=_legacy_display_command, + history_display_command=_generic_history_display_command, ) diff --git a/tests/test_base_cli.py b/tests/test_base_cli.py index 65986da..129ba27 100644 --- a/tests/test_base_cli.py +++ b/tests/test_base_cli.py @@ -402,7 +402,7 @@ def test_read_user_config_rejects_non_mapping_ide(self) -> None: with self.assertRaisesRegex(ValueError, "ide must be a mapping"): read_user_config(home) - def test_read_user_config_rejects_unknown_ide_key(self) -> None: + def test_read_user_config_accepts_consumer_defined_ide_key(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: home = Path(tmpdir) path = user_config_path(home) @@ -418,8 +418,19 @@ def test_read_user_config_rejects_unknown_ide_key(self) -> None: encoding="utf-8", ) + config = read_user_config(home) + + self.assertTrue(config.ide.preferences["windswept"].enabled) + + def test_read_user_config_can_validate_consumer_supported_ide_names(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) + path = user_config_path(home) + path.parent.mkdir(parents=True) + path.write_text("ide:\n windswept:\n enabled: true\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "unsupported ide keys: windswept"): - read_user_config(home) + read_user_config(home, supported_ides=frozenset({"vscode"})) def test_read_user_config_rejects_non_boolean_ide_enabled(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: diff --git a/tests/test_history.py b/tests/test_history.py index 7ffe7e4..e8e2c7b 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -45,8 +45,8 @@ def test_shared_history_helpers_parse_records_and_display_paths(self) -> None: json.dumps({**payload, "event": "started"}) ) ) - self.assertEqual(history_helpers.display_command("base_setup", ["--action", "check"]), "check") - self.assertEqual(history_helpers.display_command("base_history", []), "history") + self.assertEqual(history_helpers.display_command("base_setup", ["--action", "check"]), "base-setup") + self.assertEqual(history_helpers.display_command("base_history", []), "base-history") self.assertEqual(history_helpers.display_command("base_something", []), "base-something") self.assertEqual(history_helpers.display_command("my_tool", []), "my-tool") self.assertEqual(history_helpers.compact_path(inside_home), "~/logs/run.log") diff --git a/tests/test_ide_schema.py b/tests/test_ide_schema.py index 6db4389..5ddb5d5 100644 --- a/tests/test_ide_schema.py +++ b/tests/test_ide_schema.py @@ -2,19 +2,11 @@ import unittest -from base_cli.ide_schema import IDE_DEFINITIONS -from base_cli.ide_schema import PROJECT_AUTO_SETTING_KEYS -from base_cli.ide_schema import SUPPORTED_IDES from base_cli.ide_schema import parse_ide_extensions from base_cli.ide_schema import parse_ide_settings class IdeSchemaTests(unittest.TestCase): - def test_supported_ide_names_are_derived_from_definitions(self) -> None: - self.assertEqual(SUPPORTED_IDES, frozenset(IDE_DEFINITIONS)) - self.assertEqual(IDE_DEFINITIONS["vscode"].cli, "code") - self.assertEqual(IDE_DEFINITIONS["cursor"].settings_app_dir, "Cursor") - def test_parse_ide_extensions_trims_and_rejects_empty_values(self) -> None: self.assertEqual( parse_ide_extensions("ide.vscode.extensions", [" ms-python.python "]), @@ -30,12 +22,13 @@ def test_parse_ide_settings_allows_auto_as_literal_by_default(self) -> None: {"editor.defaultFormatter": "auto"}, ) - def test_parse_ide_settings_can_restrict_project_auto_values(self) -> None: + def test_parse_ide_settings_can_restrict_consumer_auto_values(self) -> None: + project_auto_setting_keys = frozenset({"python.defaultInterpreterPath"}) self.assertEqual( parse_ide_settings( "ide.vscode.settings", {"python.defaultInterpreterPath": "auto"}, - auto_setting_keys=PROJECT_AUTO_SETTING_KEYS, + auto_setting_keys=project_auto_setting_keys, ), {"python.defaultInterpreterPath": "auto"}, ) @@ -44,5 +37,5 @@ def test_parse_ide_settings_can_restrict_project_auto_values(self) -> None: parse_ide_settings( "ide.vscode.settings", {"editor.defaultFormatter": "auto"}, - auto_setting_keys=PROJECT_AUTO_SETTING_KEYS, + auto_setting_keys=project_auto_setting_keys, ) diff --git a/tests/test_profile.py b/tests/test_profile.py index e763f0f..70c773e 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -105,3 +105,9 @@ def main(ctx: base_cli.Context) -> None: self.assertEqual(seen["manifest_path"], manifest) self.assertEqual(seen["project_name"], "demo") self.assertEqual(seen["config"], {"project": "demo", "explicit": None}) + + def test_generic_profile_accepts_consumer_history_display_policy(self) -> None: + formatter = lambda cli_name, argv: f"tool {cli_name} {' '.join(argv)}" # noqa: E731 + profile = base_cli.CliProfile.generic(history_display_command=formatter) + + self.assertIs(profile.history_display_command, formatter)