diff --git a/CHANGELOG.md b/CHANGELOG.md index effe82e..acea8a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,20 @@ and versions are tracked in the repo-root `VERSION` file. ### Changed -- Make `base_cli.App()` use the consumer-neutral profile by default; the - temporary Base compatibility profile is now explicit. +- Make `base_cli.App()` use the consumer-neutral profile by default. +- 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`. + +### Migration notes + +- The removed implicit Base profile and Base path/config/history helpers are no + longer available from `base_cli`. Existing Base integrations should use the + adapter modules in the Base repository and pass `base_cli_profile()` to + `base_cli.App`. +- `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. ### Added diff --git a/README.md b/README.md index e09ce84..3f1ccf7 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,9 @@ app = base_cli.App( 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 compatibility profile, `CliProfile.legacy_base()`, preserves the -historical Base behavior only for callers that opt into it explicitly while -Base completes its adapter extraction. See +consumer-owned adapters should supply any product-specific policies. See [`docs/consumer-profiles.md`](docs/consumer-profiles.md) for the boundary and -migration plan. +migration guidance. ## Public API @@ -285,7 +283,6 @@ 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.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. @@ -422,11 +419,6 @@ need user files, project files, environment variables, or a merge precedence must implement those policies in `CliProfile.load_config` and `CliProfile.load_user_config`. -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 The generic profile does not discover projects or assume a manifest filename. @@ -436,9 +428,7 @@ from a manifest, workspace, repository metadata, or any other application-owned source and return a `ProjectInfo` value. 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. +explicitly and return a clear usage error or actionable message. ## Runtime Directories @@ -450,9 +440,7 @@ 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 -bundle's cache directory. The legacy Base profile retains the owner-aware -`base/` and `projects//` layout for existing -callers. +bundle's cache directory. Use `ctx.on_cleanup()` for cleanup work that should happen even when helper code does not own the main command wrapper: @@ -487,7 +475,6 @@ def test_command(tmp_path: Path) -> None: ["--name", "Ada"], home=tmp_path, cwd=project, - manifest={"project": {"name": "demo"}, "artifacts": []}, ) assert result.exit_code == 0 @@ -500,11 +487,11 @@ The helper wraps Click's `CliRunner`, sets `HOME` when requested, and supplies 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. +`discover_project` callback. The helper does not create or interpret any +product-specific manifest fixture. When `home` is supplied, `invoke()` provides an isolated default cache -environment for tests. Pass `env={"BASE_CACHE_DIR": str(path)}` when a test +environment for tests. Pass `env={"BASE_CLI_CACHE_DIR": str(path)}` when a test needs an explicit cache location. ## When To Use `base_cli` @@ -514,11 +501,6 @@ 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. -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: - project discovery commands diff --git a/docs/cache-ownership-and-layout.md b/docs/cache-ownership-and-layout.md index 476d200..3be89fa 100644 --- a/docs/cache-ownership-and-layout.md +++ b/docs/cache-ownership-and-layout.md @@ -1,11 +1,11 @@ # Cache ownership and layout -Runtime state is rooted at `~/Library/Caches/base` on macOS and `~/.cache/base` -elsewhere. Set `BASE_CACHE_DIR` to override the root. +Runtime state is rooted at the cache root supplied to `CliProfile.generic()` or +the platform cache directory. The generic profile places each application in a +sanitized application namespace and does not impose a product-wide cache name. -The `base` owner stores Base control-plane runs directly below `base/`. A -project-owned runtime uses `projects///` so separate -checkouts do not share mutable run state accidentally. +Consumer profiles may choose a different cache root or owner-aware layout when +their application needs stronger isolation between projects or checkouts. Each invocation has a private run bundle containing: diff --git a/docs/consumer-profiles.md b/docs/consumer-profiles.md index 9635ae0..d433c39 100644 --- a/docs/consumer-profiles.md +++ b/docs/consumer-profiles.md @@ -76,25 +76,17 @@ 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 +## Consumer-owned adapters `App()` uses `CliProfile.generic()` when no profile is supplied. This keeps the -standalone default consumer-neutral. During the migration, Base and other -existing integrations that still need these conventions can opt into -`CliProfile.legacy_base()` explicitly while they move their adapters out of the -generic package. +standalone default consumer-neutral. A product consumer that needs manifest +discovery, implicit configuration, owner-aware runtime placement, or history +should implement those policies in its own adapter module and pass the resulting +profile to `App`. -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. 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. +The generic history helpers likewise do not select a product-owned history +path. Consumers resolve that path in their adapter and pass it to +`write_history_record()` or `write_primary_record()`. ## Refactoring boundary @@ -108,13 +100,8 @@ The following behaviors should not be added to generic lifecycle modules: - product-specific command lists or history schema; - assumptions about a downstream repository's directory layout. -The remaining migration phases are: - -1. Move Base discovery, config, runtime, and history adapters into Base. -2. Generalize the remaining context/config types where their names still encode - Base concepts. -3. Remove the compatibility profile and keep `base_cli` focused on the generic - lifecycle. +The next migration step is to generalize the remaining context/config types +whose compatibility names still reflect one historical consumer. The package rename is deliberately separate from this refactor. Names can be changed after the dependency boundary is stable. diff --git a/docs/local-config.md b/docs/local-config.md index b4e48d2..7901956 100644 --- a/docs/local-config.md +++ b/docs/local-config.md @@ -1,11 +1,9 @@ # Local configuration -`base-cli` reads machine-local configuration from `~/.base.d/config.yaml`. -Project configuration is read from `/.base/config.yaml`, and an -explicit `--config` file can provide the final project-specific override. +`base-cli` does not read machine-local or project configuration implicitly. +Standalone applications can accept an explicit `--config` file through the +generic profile, or provide their own `load_user_config` and `load_config` +callbacks for application-owned configuration sources. -The package owns the configuration schema and merge semantics. Users own the -operational choice of whether to back up or synchronize the machine-local file, -using tools such as iCloud, chezmoi, a dotfiles repository, Time Machine, or a -manual copy. The file can contain paths and other machine-specific values and -should not be synchronized blindly across incompatible machines. +The consumer owns the configuration schema, merge semantics, and operational +choice of whether to back up or synchronize its machine-local files. diff --git a/lib/python/base_cli/_runtime.py b/lib/python/base_cli/_runtime.py index 36b4455..e1117f7 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, runtime_slug +from .paths import runtime_run_directory_name, runtime_slug @dataclass(frozen=True) @@ -28,17 +28,11 @@ def runtime_layout( cli_name: str, 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_namespace_root(cache_root, namespace) - if namespace is not None - else runtime_owner_root(cache_root, owner, project_name, project_root) - ) + owner_root = runtime_namespace_root(cache_root, namespace or cli_name) 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. @@ -130,7 +124,7 @@ def _is_within(path: Path, root: Path) -> bool: def _runtime_directory_error(path: Path, cache_root: Path, exc: OSError) -> str: return ( - f"Unable to create Base runtime directory '{path}': {exc}. " - f"Check permissions on that directory. If the Base cache root '{cache_root}' is unusable, " - "set BASE_CACHE_DIR to a writable directory." + f"Unable to create runtime directory '{path}': {exc}. " + f"Check permissions on that directory. If the cache root '{cache_root}' is unusable, " + "configure the application's cache root." ) diff --git a/lib/python/base_cli/app.py b/lib/python/base_cli/app.py index d912f21..798a155 100644 --- a/lib/python/base_cli/app.py +++ b/lib/python/base_cli/app.py @@ -61,7 +61,7 @@ def __init__( self.max_log_files = max_log_files # Standalone applications must not inherit a consumer's product # conventions. Consumers with an existing integration should pass an - # explicit profile, such as Base's temporary legacy adapter. + # Consumers with product-specific policies should pass an explicit profile. self.profile = profile or CliProfile.generic() self._click_command = None self._command_func: Callable[..., Any] | None = None diff --git a/lib/python/base_cli/command_filters.py b/lib/python/base_cli/command_filters.py index ad5a41b..eb5a0f4 100644 --- a/lib/python/base_cli/command_filters.py +++ b/lib/python/base_cli/command_filters.py @@ -1,4 +1,4 @@ -"""Shared command-name filter normalization for Base reports.""" +"""Shared command-name filter normalization for CLI reports.""" from __future__ import annotations diff --git a/lib/python/base_cli/config.py b/lib/python/base_cli/config.py index 5e2ee5c..6d0b371 100644 --- a/lib/python/base_cli/config.py +++ b/lib/python/base_cli/config.py @@ -1,15 +1,10 @@ from __future__ import annotations from dataclasses import dataclass -import os from pathlib import Path -import re from typing import Any from ._dependencies import require_yaml -from .ide_schema import parse_ide_extensions -from .ide_schema import parse_ide_settings -from .paths import base_state_root __all__ = [ @@ -18,6 +13,7 @@ "UserIdeConfig", "UserIdePreference", "UserWorkspaceConfig", + "load_yaml_file", ] @@ -56,15 +52,11 @@ class UserConfig: github: UserGithubConfig = UserGithubConfig(default_owner=None, clone_protocol=None) -def user_config_path(home: Path | None = None) -> Path: - return base_state_root(home) / "config.yaml" - - def load_yaml_file(path: Path) -> dict[str, Any]: if not path.is_file(): return {} - yaml = require_yaml("PyYAML is required to load base_cli configuration.") + yaml = require_yaml("PyYAML is required to load the explicit CLI configuration file.") try: data = yaml.safe_load(path.read_text(encoding="utf-8")) @@ -75,211 +67,3 @@ def load_yaml_file(path: Path) -> dict[str, Any]: if not isinstance(data, dict): raise ValueError(f"Config file '{path}' must contain a YAML mapping.") return data - - -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, - *, - 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"), supported_ides=supported_ides), - ) - - -def _read_user_workspace_config(path: Path, workspace_data: Any) -> UserWorkspaceConfig: - if workspace_data is None: - return UserWorkspaceConfig(root=None) - if not isinstance(workspace_data, dict): - raise ValueError(f"{path}: workspace must be a mapping when provided.") - - allowed_keys = {"root", "manifest", "manifest_source"} - unknown_keys = sorted(set(workspace_data) - allowed_keys) - if unknown_keys: - raise ValueError(f"{path}: workspace has unsupported keys: {', '.join(unknown_keys)}.") - - return UserWorkspaceConfig( - root=_optional_path(path, "workspace.root", workspace_data.get("root")), - manifest=_optional_path(path, "workspace.manifest", workspace_data.get("manifest")), - manifest_source=_optional_non_empty_string( - path, - "workspace.manifest_source", - workspace_data.get("manifest_source"), - ), - ) - - -def _read_user_github_config(path: Path, github_data: Any) -> UserGithubConfig: - if github_data is None: - return UserGithubConfig(default_owner=None, clone_protocol=None) - if not isinstance(github_data, dict): - raise ValueError(f"{path}: github must be a mapping when provided.") - - allowed_keys = {"default_owner", "clone_protocol"} - unknown_keys = sorted(set(github_data) - allowed_keys) - if unknown_keys: - raise ValueError(f"{path}: github has unsupported keys: {', '.join(unknown_keys)}.") - - return UserGithubConfig( - default_owner=_optional_github_owner(path, github_data.get("default_owner")), - clone_protocol=_optional_github_clone_protocol(path, github_data.get("clone_protocol")), - ) - - -def _optional_github_owner(path: Path, value: Any) -> str | None: - owner = _optional_non_empty_string(path, "github.default_owner", value) - if owner is None: - return None - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9-]*", owner): - raise ValueError( - f"{path}: github.default_owner must start with a letter or digit and contain only " - "letters, digits, and dash." - ) - return owner - - -def _optional_github_clone_protocol(path: Path, value: Any) -> str | None: - protocol = _optional_non_empty_string(path, "github.clone_protocol", value) - if protocol is None: - return None - if protocol not in {"ssh", "https"}: - raise ValueError(f"{path}: github.clone_protocol must be 'ssh' or 'https'.") - return protocol - - -def _optional_path(path: Path, key: str, value: Any) -> Path | None: - if value is None: - return None - candidate = _optional_non_empty_string(path, key, value) - if candidate is None: - return None - - candidate_path = Path(candidate).expanduser() - if not candidate_path.is_absolute(): - raise ValueError(f"{path}: {key} must be an absolute path or start with '~'.") - return candidate_path.resolve(strict=False) - - -def _optional_non_empty_string(path: Path, key: str, value: Any) -> str | None: - if value is None: - return None - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{path}: {key} must be a non-empty string when provided.") - return value.strip() - - -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.") - - 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(set(ide_data) - {"enabled"}) - if ide_name in ide_data - } - return UserIdeConfig(enabled=enabled, preferences=preferences) - - -def _read_user_ide_preference(path: Path, ide_name: str, preference_data: Any) -> UserIdePreference: - if preference_data is None: - preference_data = {} - if not isinstance(preference_data, dict): - raise ValueError(f"{path}: ide.{ide_name} must be a mapping when provided.") - - allowed_keys = {"enabled", "install", "extra_extensions", "settings"} - unknown_keys = sorted(set(preference_data) - allowed_keys) - if unknown_keys: - raise ValueError(f"{path}: ide.{ide_name} has unsupported keys: {', '.join(unknown_keys)}.") - - enabled = _optional_bool(path, f"ide.{ide_name}.enabled", preference_data.get("enabled")) - install = _optional_bool(path, f"ide.{ide_name}.install", preference_data.get("install")) - extra_extensions = _read_extra_extensions(path, ide_name, preference_data.get("extra_extensions", [])) - settings = _read_user_ide_settings(path, ide_name, preference_data.get("settings", {})) - return UserIdePreference( - enabled=enabled, - install=install, - extra_extensions=extra_extensions, - settings=settings, - ) - - -def _optional_bool(path: Path, key: str, value: Any) -> bool | None: - if value is None: - return None - if not isinstance(value, bool): - raise ValueError(f"{path}: {key} must be a boolean when provided.") - return value - - -def _read_extra_extensions(path: Path, ide_name: str, extensions_data: Any) -> tuple[str, ...]: - return parse_ide_extensions(f"{path}: ide.{ide_name}.extra_extensions", extensions_data) - - -def _read_user_ide_settings(path: Path, ide_name: str, settings_data: Any) -> dict[str, Any]: - return parse_ide_settings(f"{path}: ide.{ide_name}.settings", settings_data) - - -def merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: - merged = dict(base) - for key, value in override.items(): - if isinstance(value, dict) and isinstance(merged.get(key), dict): - merged[key] = merge_dicts(merged[key], value) - else: - merged[key] = value - return merged - - -def load_config( - project_root: Path | None, - explicit_config: Path | None, - home: Path | None = None, -) -> dict[str, Any]: - root = home or Path.home() - config: dict[str, Any] = {} - config = merge_dicts(config, load_user_config(root)) - if project_root is not None: - config = merge_dicts(config, load_yaml_file(project_root / ".base" / "config.yaml")) - if explicit_config is not None: - config = merge_dicts(config, load_yaml_file(explicit_config)) - - env_config: dict[str, Any] = {} - if "BASE_CLI_ENVIRONMENT" in os.environ: - env_config["environment"] = os.environ["BASE_CLI_ENVIRONMENT"] - if "BASE_CLI_LOG_LEVEL" in os.environ: - env_config["log_level"] = os.environ["BASE_CLI_LOG_LEVEL"] - elif os.environ.get("LOG_DEBUG", "").lower() in ("1", "true"): - env_config["log_level"] = "debug" - if "BASE_CLI_KEEP_TEMP" in os.environ: - env_config["keep_temp"] = os.environ["BASE_CLI_KEEP_TEMP"].lower() == "true" - return merge_dicts(config, env_config) diff --git a/lib/python/base_cli/context.py b/lib/python/base_cli/context.py index 959cd01..9484a08 100644 --- a/lib/python/base_cli/context.py +++ b/lib/python/base_cli/context.py @@ -53,7 +53,7 @@ class Context: cleanup_hooks: list[Callable[[], None]] = field(default_factory=list) workspace_root: Path | None = None quiet: bool = False - runtime_owner: str = "base" + runtime_owner: str = "default" owner_root: Path | None = None run_root: Path | None = None diff --git a/lib/python/base_cli/exit_codes.py b/lib/python/base_cli/exit_codes.py index e788c0b..75d989b 100644 --- a/lib/python/base_cli/exit_codes.py +++ b/lib/python/base_cli/exit_codes.py @@ -2,7 +2,7 @@ class ExitCode: - """Base-standard command exit code constants.""" + """Standard command exit code constants.""" SUCCESS = 0 FAILURE = 1 diff --git a/lib/python/base_cli/history.py b/lib/python/base_cli/history.py index 767e1e4..61b1e1f 100644 --- a/lib/python/base_cli/history.py +++ b/lib/python/base_cli/history.py @@ -19,16 +19,13 @@ from ._private_files import restrict_file, write_private_json 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 __all__ = [ - "HISTORY_PATH", "HISTORY_SCOPE_INTERNAL", "HISTORY_SCOPE_PRIMARY", "SCHEMA_VERSION", - "base_version", "build_finished_record", "compact_home_text", "compact_optional_path", @@ -40,47 +37,23 @@ "optional_string", "parse_finished_history_record_line", "parse_positive_int", - "project_name", "redact_history_argv", "redact_history_text", - "runtime_bundle_path", "utc_now", - "write_finished_record", "write_history_record", "write_primary_record", ] SCHEMA_VERSION = 1 -HISTORY_PATH = Path("base") / "history" / "runs.jsonl" HISTORY_SCOPE_PRIMARY = "primary" HISTORY_SCOPE_INTERNAL = "internal" + def utc_now() -> datetime: return datetime.now(timezone.utc) -def write_finished_record( - context: Context, - argv: list[str], - sensitive_options: set[str], - started_at: datetime, - exit_code: int, -) -> None: - # Base-dispatched child commands share the parent's run bundle and - # diagnostic stream. Their completion is an implementation detail, so - # keep history at the public-invocation level as well. - if context.dry_run or context.log_file is None or context.history_scope == HISTORY_SCOPE_INTERNAL: - return - try: - record = build_finished_record(context, argv, sensitive_options, started_at, exit_code) - write_history_record(record) - if context.run_root is not None: - update_run_metadata(context.run_root, record) - except Exception as exc: # pylint: disable=broad-exception-caught - context.log.debug("Unable to write command history record: %s", exc) - - def build_finished_record( context: Context, argv: list[str], @@ -107,11 +80,10 @@ def build_finished_record( "os": normalized_os(), } optional_fields = { - "project": project_name(context), + "project": context.project_name, "project_root": compact_optional_path(context.project_root), "manifest": compact_optional_path(context.manifest_path), "workspace_root": compact_optional_path(context.workspace_root), - "base_version": base_version(context.base_home), "shell": os.environ.get("SHELL"), "scope": context.history_scope, "parent_run_id": context.history_parent_run_id, @@ -122,6 +94,7 @@ def build_finished_record( # pylint: disable=too-many-arguments,too-many-positional-arguments def write_primary_record( + path: Path, command: str, argv: list[str], started_at: datetime, @@ -131,13 +104,13 @@ def write_primary_record( project: str | None = None, project_root: str | None = None, manifest: str | None = None, - log_path: str | None = None, - owner: str = "base", - bundle_path: str | None = None, + log_path: str | Path | None = None, + owner: str = "default", + bundle_path: str | Path | None = None, *, - raw_command: str = "basectl", + raw_command: str = "cli", ) -> None: - """Write the user-facing record for a Bash-dispatched command.""" + """Build and append a user-facing command record to ``path``.""" ended_at = utc_now() record: dict[str, Any] = { "schema_version": SCHEMA_VERSION, @@ -154,7 +127,7 @@ def write_primary_record( "os": normalized_os(), "scope": scope, } - resolved_bundle = Path(bundle_path).expanduser() if bundle_path else runtime_bundle_path() + resolved_bundle = Path(bundle_path).expanduser() if bundle_path else None resolved_log = Path(log_path).expanduser() if log_path else ( resolved_bundle / "logs" / "primary.log" if resolved_bundle is not None else None ) @@ -167,25 +140,18 @@ def write_primary_record( "bundle_path": compact_optional_path(resolved_bundle), } record.update({key: value for key, value in optional_fields.items() if value}) - write_history_record(record) + write_history_record(path, record) if resolved_bundle is not None: update_run_metadata(resolved_bundle, record) -def write_history_record(record: dict[str, Any]) -> None: - path = base_cache_root() / HISTORY_PATH +def write_history_record(path: Path, record: dict[str, Any]) -> None: + """Append one serialized record to a consumer-selected history path.""" path.parent.mkdir(parents=True, exist_ok=True) append_history_line(path, f"{json.dumps(record, sort_keys=True)}\n") restrict_file(path) -def runtime_bundle_path() -> Path | None: - value = os.environ.get("BASE_CLI_RUN_ROOT") - if not value: - return None - return Path(value).expanduser().resolve(strict=False) - - def update_run_metadata(run_root: Path, record: dict[str, Any]) -> None: metadata_path = run_root / "run.json" metadata: dict[str, Any] = {} @@ -197,7 +163,7 @@ def update_run_metadata(run_root: Path, record: dict[str, Any]) -> None: metadata.update( { "run_id": record.get("run_id"), - "owner": record.get("owner", metadata.get("owner", "base")), + "owner": record.get("owner", metadata.get("owner", "default")), "status": record.get("status"), "exit_code": record.get("exit_code"), "ended_at": record.get("ended_at"), @@ -312,34 +278,6 @@ def optional_int(value: Any) -> int | None: return value if isinstance(value, int) else 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 - project_data = data.get("project") - if not isinstance(project_data, dict): - return None - value = project_data.get("name") - return value if isinstance(value, str) and value else None - - -def base_version(base_home: Path | None) -> str | None: - if base_home is None: - return None - try: - version = (base_home / "VERSION").read_text(encoding="utf-8").splitlines()[0].strip() - except (IndexError, OSError): - return None - return version or None - - def normalized_os() -> str: system = platform.system().lower() if system == "darwin": diff --git a/lib/python/base_cli/logging.py b/lib/python/base_cli/logging.py index 4b49b35..0809f2d 100644 --- a/lib/python/base_cli/logging.py +++ b/lib/python/base_cli/logging.py @@ -132,9 +132,9 @@ def _level_name(record: logging.LogRecord) -> str: def _source_path(record: logging.LogRecord) -> str: path = Path(record.pathname) candidates = [] - base_home = os.environ.get("BASE_HOME") - if base_home: - candidates.append(Path(base_home)) + application_home = _active_application_home() + if application_home is not None: + candidates.append(application_home) project_root = _active_project_root() if project_root is not None: candidates.append(project_root) @@ -156,6 +156,14 @@ def _active_project_root() -> Path | None: return context.project_root +def _active_application_home() -> Path | None: + try: + context = get_current_context() + except RuntimeError: + return None + return context.application_home + + def log_invocation(logger: logging.Logger, argv: list[str], sensitive_options: set[str]) -> None: logger.debug("argv=%s", redact_argv(argv, sensitive_options)) logger.debug("platform=%s %s", platform.system(), platform.machine()) diff --git a/lib/python/base_cli/output.py b/lib/python/base_cli/output.py index 5ca4d61..753ae05 100644 --- a/lib/python/base_cli/output.py +++ b/lib/python/base_cli/output.py @@ -1,4 +1,4 @@ -"""Shared output-format resolution and rendering for Base CLIs.""" +"""Shared output-format resolution and rendering for Python CLIs.""" from __future__ import annotations diff --git a/lib/python/base_cli/paths.py b/lib/python/base_cli/paths.py index c36ca7d..c801c8a 100644 --- a/lib/python/base_cli/paths.py +++ b/lib/python/base_cli/paths.py @@ -2,10 +2,7 @@ import contextlib import contextvars -import hashlib -import os import re -import sys import time import uuid from collections.abc import Iterator @@ -17,20 +14,6 @@ ) -def base_state_root(home: Path | None = None) -> Path: - return (home or Path.home()) / ".base.d" - - -def base_cache_root(home: Path | None = None) -> Path: - value = os.environ.get("BASE_CACHE_DIR") - if value: - return Path(value).expanduser() - root = home or Path.home() - if sys.platform == "darwin": - return root / "Library" / "Caches" / "base" - return root / ".cache" / "base" - - def current_working_dir() -> Path: return _WORKING_DIRECTORY_OVERRIDE.get() or Path.cwd() @@ -60,26 +43,6 @@ def normalize_cli_name(name: str) -> str: return stem.replace(" ", "-") -def normalize_runtime_owner(value: str | None = None) -> str: - """Return the runtime owner namespace for a command invocation.""" - owner = (value or os.environ.get("BASE_CLI_RUNTIME_OWNER") or "base").strip().lower() - if owner not in {"base", "project"}: - raise ValueError("BASE_CLI_RUNTIME_OWNER must be 'base' or 'project'.") - return owner - - -def runtime_project_name(value: str | None = None) -> str | None: - name = (value or os.environ.get("BASE_CLI_PROJECT_NAME") or "").strip() - return name or None - - -def runtime_project_root(value: Path | str | None = None) -> Path | None: - candidate = value or os.environ.get("BASE_CLI_PROJECT_ROOT") - if not candidate: - return None - return Path(candidate).expanduser().resolve() - - def runtime_slug(value: str, fallback: str = "unnamed") -> str: normalized = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip()).strip(".-_").lower() return normalized or fallback @@ -91,46 +54,3 @@ def runtime_run_directory_name(run_id: str, cli_name: str, project_name: str | N if project_name: labels.append(runtime_slug(project_name)) return f"{run_id}__{'__'.join(labels)}" - - -def checkout_id(project_root: Path | None) -> str | None: - if project_root is None: - return None - digest = hashlib.sha256(str(project_root.expanduser().resolve()).encode("utf-8")).hexdigest() - return digest[:12] - - -def runtime_owner_root( - cache_root: Path, - owner: str = "base", - project_name: str | None = None, - project_root: Path | None = None, -) -> Path: - normalized_owner = normalize_runtime_owner(owner) - if normalized_owner == "base": - return cache_root / "base" - - name = runtime_slug(project_name or "unnamed") - checkout = checkout_id(project_root) or "unknown" - return cache_root / "projects" / name / checkout - - -def discover_manifest(start: Path) -> Path | None: - current = start.resolve() - if current.is_file(): - current = current.parent - - while True: - candidate = current / "base_manifest.yaml" - if candidate.is_file(): - return candidate - if current.parent == current: - return None - current = current.parent - - -def resolve_base_home() -> Path | None: - value = os.environ.get("BASE_HOME") - if not value: - return None - return Path(value).expanduser().resolve() diff --git a/lib/python/base_cli/profile.py b/lib/python/base_cli/profile.py index 9412d56..2e7d8e1 100644 --- a/lib/python/base_cli/profile.py +++ b/lib/python/base_cli/profile.py @@ -63,8 +63,7 @@ class CliProfile: 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. + no product-owned config files, and no history writer. """ discover_project: ProjectDiscovery @@ -100,97 +99,6 @@ def generic( history_display_command=history_display_command or _generic_history_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, - history_display_command=_generic_history_display_command, - ) - - def _discover_no_project(_cwd: Path) -> ProjectInfo | None: return None @@ -220,7 +128,6 @@ def resolve_runtime(cli_name: str, project: ProjectInfo | None) -> RuntimeBindin run_id, namespace=cli_name, project_name=project_name, - project_root=project_root, ), application_home=application_home, runtime_owner="default", @@ -235,12 +142,10 @@ def resolve_runtime(cli_name: str, project: ProjectInfo | None) -> RuntimeBindin def _default_cache_root() -> Path: + configured = os.environ.get("BASE_CLI_CACHE_DIR") + if configured: + return Path(configured).expanduser().resolve() 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/lib/python/base_cli/testing.py b/lib/python/base_cli/testing.py index 38e057a..986eced 100644 --- a/lib/python/base_cli/testing.py +++ b/lib/python/base_cli/testing.py @@ -2,13 +2,11 @@ import inspect import os -from collections.abc import Mapping from pathlib import Path from threading import RLock from typing import TYPE_CHECKING, Any from .paths import use_working_dir -from ._dependencies import require_yaml if TYPE_CHECKING: from click.testing import Result @@ -24,14 +22,8 @@ def invoke( home: Path | None = None, cwd: Path | str | None = None, env: dict[str, str] | None = None, - *, - manifest: Mapping[str, Any] | None = None, ) -> Result: cwd_path = Path(cwd).expanduser().resolve() if cwd is not None else None - if manifest is not None: - if cwd_path is None: - raise ValueError("manifest requires cwd so base_manifest.yaml has a target directory.") - _write_manifest_fixture(cwd_path, manifest) try: from click.testing import CliRunner @@ -41,7 +33,7 @@ def invoke( invoke_env = dict(env or {}) if home is not None: invoke_env.setdefault("HOME", str(home)) - invoke_env.setdefault("BASE_CACHE_DIR", str(home / ".cache" / "base")) + invoke_env.setdefault("BASE_CLI_CACHE_DIR", str(home / ".cache")) runner_kwargs = {} if "mix_stderr" in inspect.signature(CliRunner).parameters: runner_kwargs["mix_stderr"] = False @@ -58,15 +50,3 @@ def invoke( return runner.invoke(app.click_command, args or [], env=invoke_env) finally: os.chdir(original_cwd) - - -def _write_manifest_fixture(cwd: Path, manifest: Mapping[str, Any]) -> None: - yaml = require_yaml( - "PyYAML is required to write base_cli.testing manifest fixtures. " - "Install it with 'pip install PyYAML'." - ) - - (cwd / "base_manifest.yaml").write_text( - yaml.safe_dump(dict(manifest), sort_keys=False), - encoding="utf-8", - ) diff --git a/pyproject.toml b/pyproject.toml index cd877cc..718e29e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "base-cli" dynamic = ["version"] -description = "A small, consistent Python CLI framework for Base and Base-supported projects" +description = "A small, consumer-neutral Python CLI framework" readme = "README.md" requires-python = ">=3.10" license = { text = "Apache-2.0" } diff --git a/tests/test_app_log_retention.py b/tests/test_app_log_retention.py index 1bf9260..6c3844d 100644 --- a/tests/test_app_log_retention.py +++ b/tests/test_app_log_retention.py @@ -14,8 +14,8 @@ from base_cli.testing import invoke -def legacy_app(**kwargs: object) -> base_cli.App: - return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs) +def generic_app(**kwargs: object) -> base_cli.App: + return base_cli.App(profile=base_cli.CliProfile.generic(), **kwargs) def write_log_file(path: Path, mtime: int) -> None: @@ -60,7 +60,7 @@ def test_ignores_stale_paths_in_retention_index(self) -> None: @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_uses_retention_index_after_initial_discovery(self) -> None: - app = legacy_app(name="retention-index", max_log_files=2) + app = generic_app(name="retention-index", max_log_files=2) @app.command() def main(ctx: base_cli.Context) -> None: @@ -81,7 +81,7 @@ def main(ctx: base_cli.Context) -> None: @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_prunes_oldest_default_logs(self) -> None: - app = legacy_app(name="retention-demo", max_log_files=2) + app = generic_app(name="retention-demo", max_log_files=2) seen = {} @app.command() @@ -91,7 +91,7 @@ def main(ctx: base_cli.Context) -> None: with tempfile.TemporaryDirectory() as tmpdir: home = Path(tmpdir) - log_dir = home / ".cache" / "base" / "base" / "runs" / "seed" / "logs" + log_dir = home / ".cache" / "retention-demo" / "runs" / "seed" / "logs" oldest = log_dir / "20260620T120000_oldest.log" newest = log_dir / "20260621T120000_newest.log" write_log_file(oldest, 1) @@ -104,11 +104,11 @@ def main(ctx: base_cli.Context) -> None: self.assertTrue(newest.exists()) self.assertIsNotNone(seen["log_file"]) self.assertTrue(seen["log_file"].exists()) - self.assertEqual(len(tuple((home / ".cache" / "base" / "base" / "runs").rglob("*.log"))), 2) + self.assertEqual(len(tuple((home / ".cache" / "retention-demo" / "runs").rglob("*.log"))), 2) @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_prunes_by_filename_when_mtimes_disagree(self) -> None: - app = legacy_app(name="retention-filename", max_log_files=2) + app = generic_app(name="retention-filename", max_log_files=2) seen = {} @app.command() @@ -118,7 +118,7 @@ def main(ctx: base_cli.Context) -> None: with tempfile.TemporaryDirectory() as tmpdir: home = Path(tmpdir) - log_dir = home / ".cache" / "base" / "base" / "runs" / "seed" / "logs" + log_dir = home / ".cache" / "retention-filename" / "runs" / "seed" / "logs" older_by_name = log_dir / "20260620T120000_old.log" newer_by_name = log_dir / "20260621T120000_new.log" write_log_file(older_by_name, 2) @@ -131,11 +131,11 @@ def main(ctx: base_cli.Context) -> None: self.assertTrue(newer_by_name.exists()) self.assertIsNotNone(seen["log_file"]) self.assertTrue(seen["log_file"].exists()) - self.assertEqual(len(tuple((home / ".cache" / "base" / "base" / "runs").rglob("*.log"))), 2) + self.assertEqual(len(tuple((home / ".cache" / "retention-filename" / "runs").rglob("*.log"))), 2) @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_preserves_current_log_file(self) -> None: - app = legacy_app(name="retention-current", max_log_files=1) + app = generic_app(name="retention-current", max_log_files=1) seen = {} @app.command() @@ -145,7 +145,7 @@ def main(ctx: base_cli.Context) -> None: with tempfile.TemporaryDirectory() as tmpdir: home = Path(tmpdir) - log_dir = home / ".cache" / "base" / "base" / "runs" / "seed" / "logs" + log_dir = home / ".cache" / "retention-current" / "runs" / "seed" / "logs" old_a = log_dir / "old-a.log" old_b = log_dir / "old-b.log" write_log_file(old_a, 1) @@ -158,11 +158,14 @@ def main(ctx: base_cli.Context) -> None: self.assertFalse(old_b.exists()) self.assertIsNotNone(seen["log_file"]) self.assertTrue(seen["log_file"].exists()) - self.assertEqual(tuple((home / ".cache" / "base" / "base" / "runs").rglob("*.log")), (seen["log_file"],)) + self.assertEqual( + tuple(path.resolve() for path in (home / ".cache" / "retention-current" / "runs").rglob("*.log")), + (seen["log_file"].resolve(),), + ) @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_keeps_logs_when_count_is_within_limit(self) -> None: - app = legacy_app(name="retention-within-limit", max_log_files=3) + app = generic_app(name="retention-within-limit", max_log_files=3) seen = {} @app.command() @@ -172,7 +175,7 @@ def main(ctx: base_cli.Context) -> None: with tempfile.TemporaryDirectory() as tmpdir: home = Path(tmpdir) - log_dir = home / ".cache" / "base" / "base" / "runs" / "seed" / "logs" + log_dir = home / ".cache" / "retention-within-limit" / "runs" / "seed" / "logs" old_a = log_dir / "20260620T120000_a.log" old_b = log_dir / "20260621T120000_b.log" write_log_file(old_a, 1) @@ -185,11 +188,11 @@ def main(ctx: base_cli.Context) -> None: self.assertTrue(old_b.exists()) self.assertIsNotNone(seen["log_file"]) self.assertTrue(seen["log_file"].exists()) - self.assertEqual(len(tuple((home / ".cache" / "base" / "base" / "runs").rglob("*.log"))), 3) + self.assertEqual(len(tuple((home / ".cache" / "retention-within-limit" / "runs").rglob("*.log"))), 3) @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_is_disabled_by_default(self) -> None: - app = legacy_app(name="retention-unset") + app = generic_app(name="retention-unset") seen = {} @app.command() @@ -199,7 +202,7 @@ def main(ctx: base_cli.Context) -> None: with tempfile.TemporaryDirectory() as tmpdir: home = Path(tmpdir) - log_dir = home / ".cache" / "base" / "base" / "runs" / "seed" / "logs" + log_dir = home / ".cache" / "retention-unset" / "runs" / "seed" / "logs" old_a = log_dir / "old-a.log" old_b = log_dir / "old-b.log" write_log_file(old_a, 1) @@ -212,11 +215,11 @@ def main(ctx: base_cli.Context) -> None: self.assertTrue(old_b.exists()) self.assertIsNotNone(seen["log_file"]) self.assertTrue(seen["log_file"].exists()) - self.assertEqual(len(tuple((home / ".cache" / "base" / "base" / "runs").rglob("*.log"))), 3) + self.assertEqual(len(tuple((home / ".cache" / "retention-unset" / "runs").rglob("*.log"))), 3) @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_skips_no_durable_write_modes(self) -> None: - dry_run_app = legacy_app(name="retention-dry-run", max_log_files=1) + dry_run_app = generic_app(name="retention-dry-run", max_log_files=1) dry_seen = {} @dry_run_app.command() @@ -226,7 +229,7 @@ def dry_run_main(ctx: base_cli.Context, dry_run: bool) -> None: dry_seen["log_file"] = ctx.log_file ctx.log.info("dry retention") - no_file_app = legacy_app( + no_file_app = generic_app( name="retention-no-file", log_to_file=False, max_log_files=1, @@ -240,8 +243,8 @@ def no_file_main(ctx: base_cli.Context) -> None: with tempfile.TemporaryDirectory() as tmpdir: home = Path(tmpdir) - dry_log_dir = home / ".cache" / "base" / "base" / "runs" / "dry-seed" / "logs" - no_file_log_dir = home / ".cache" / "base" / "base" / "runs" / "no-file-seed" / "logs" + dry_log_dir = home / ".cache" / "retention-dry-run" / "runs" / "dry-seed" / "logs" + no_file_log_dir = home / ".cache" / "retention-no-file" / "runs" / "no-file-seed" / "logs" dry_old = dry_log_dir / "old.log" no_file_old = no_file_log_dir / "old.log" write_log_file(dry_old, 1) diff --git a/tests/test_app_run.py b/tests/test_app_run.py index 1b68167..e01a142 100644 --- a/tests/test_app_run.py +++ b/tests/test_app_run.py @@ -6,21 +6,26 @@ import tempfile import unittest from contextlib import redirect_stderr +from dataclasses import replace from pathlib import Path from unittest import mock import base_cli -from base_cli.config import user_config_path -def legacy_app(**kwargs: object) -> base_cli.App: - return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs) +def generic_app(**kwargs: object) -> base_cli.App: + return base_cli.App(profile=base_cli.CliProfile.generic(), **kwargs) class RunAppTests(unittest.TestCase): @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_run_app_reports_config_errors_without_traceback(self) -> None: - app = legacy_app(name="bad-config", log_to_file=False) + profile = base_cli.CliProfile.generic( + load_config=lambda _project, _explicit: (_ for _ in ()).throw( + ValueError("workspace must be a mapping when provided.") + ) + ) + app = base_cli.App(profile=profile, name="bad-config", log_to_file=False) seen = {} @app.command() @@ -30,15 +35,12 @@ def main(ctx: base_cli.Context) -> None: with tempfile.TemporaryDirectory() as tmpdir: home = Path(tmpdir) - config_path = user_config_path(home) - config_path.parent.mkdir(parents=True) - config_path.write_text("workspace: [not-a-mapping]\n", encoding="utf-8") stderr = io.StringIO() with mock.patch.dict( os.environ, { "HOME": str(home), - "BASE_CACHE_DIR": str(home / ".cache" / "base"), + "BASE_CLI_CACHE_DIR": str(home / ".cache"), }, ), redirect_stderr(stderr): status = base_cli.run_app(app, []) @@ -63,7 +65,7 @@ def main(ctx: base_cli.Context) -> None: os.environ, { "HOME": str(home), - "BASE_CACHE_DIR": str(home / ".cache" / "base"), + "BASE_CLI_CACHE_DIR": str(home / ".cache"), }, ): with self.assertRaisesRegex(RuntimeError, "boom"): @@ -85,7 +87,7 @@ def main(ctx: base_cli.Context) -> dict[str, str]: os.environ, { "HOME": str(home), - "BASE_CACHE_DIR": str(home / ".cache" / "base"), + "BASE_CLI_CACHE_DIR": str(home / ".cache"), }, ), redirect_stderr(stderr): status = base_cli.run_app(app, []) @@ -111,7 +113,7 @@ def main(ctx: base_cli.Context, name: str) -> None: os.environ, { "HOME": str(home), - "BASE_CACHE_DIR": str(home / ".cache" / "base"), + "BASE_CLI_CACHE_DIR": str(home / ".cache"), }, ), redirect_stderr(stderr): status = base_cli.run_app(app, ["--name=demo"]) @@ -126,7 +128,11 @@ def main(ctx: base_cli.Context, name: str) -> None: @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_run_app_uses_delegated_display_command_for_usage_errors(self) -> None: - app = legacy_app(name="internal-cli", log_to_file=False) + profile = replace( + base_cli.CliProfile.generic(), + display_command=lambda: "tool demo", + ) + app = base_cli.App(profile=profile, name="internal-cli", log_to_file=False) @app.command(context_settings={"help_option_names": ["-h", "--help"]}) def main(ctx: base_cli.Context) -> None: @@ -135,12 +141,12 @@ def main(ctx: base_cli.Context) -> None: stderr = io.StringIO() with mock.patch.dict( os.environ, - {"BASE_CLI_DISPLAY_COMMAND": "basectl demo"}, + {"BASE_CLI_DISPLAY_COMMAND": "tool demo"}, ), redirect_stderr(stderr): status = base_cli.run_app(app, ["--bad-option"]) self.assertEqual(status, 2) - self.assertIn("Usage: basectl demo", stderr.getvalue()) + self.assertIn("Usage: tool demo", stderr.getvalue()) self.assertIn("No such option '--bad-option'.", stderr.getvalue()) self.assertNotIn("internal-cli", stderr.getvalue()) diff --git a/tests/test_app_runtime_boundary.py b/tests/test_app_runtime_boundary.py index ffb2c53..26b0428 100644 --- a/tests/test_app_runtime_boundary.py +++ b/tests/test_app_runtime_boundary.py @@ -10,17 +10,17 @@ from base_cli import app -def test_runtime_layout_names_base_cli_directories() -> None: +def test_runtime_layout_names_consumer_neutral_directories() -> None: assert importlib.util.find_spec("base_cli._runtime") is not None runtime = importlib.import_module("base_cli._runtime") layout = runtime.runtime_layout(Path("/tmp/base-cache"), "demo", "run-123") - assert layout.owner_root == Path("/tmp/base-cache/base") - assert layout.run_root == Path("/tmp/base-cache/base/runs/run-123__demo") - assert layout.state_dir == Path("/tmp/base-cache/base") - assert layout.log_dir == Path("/tmp/base-cache/base/runs/run-123__demo/logs") - assert layout.cache_dir == Path("/tmp/base-cache/base/cache/components/demo") - assert layout.temp_dir == Path("/tmp/base-cache/base/runs/run-123__demo/tmp/demo/run-123") + assert layout.owner_root == Path("/tmp/base-cache/demo") + assert layout.run_root == Path("/tmp/base-cache/demo/runs/run-123__demo") + assert layout.state_dir == Path("/tmp/base-cache/demo") + assert layout.log_dir == Path("/tmp/base-cache/demo/runs/run-123__demo/logs") + assert layout.cache_dir == Path("/tmp/base-cache/demo/cache/components/demo") + assert layout.temp_dir == Path("/tmp/base-cache/demo/runs/run-123__demo/tmp/demo/run-123") def test_runtime_helpers_stay_out_of_public_api() -> None: @@ -28,26 +28,23 @@ def test_runtime_helpers_stay_out_of_public_api() -> None: assert "runtime_layout" not in base_cli.__all__ -def test_runtime_layout_is_checkout_scoped_for_project_owner() -> None: +def test_runtime_layout_accepts_a_consumer_namespace() -> None: runtime = importlib.import_module("base_cli._runtime") layout = runtime.runtime_layout( Path("/tmp/base-cache"), "native-cli", "run-123", - owner="project", - project_name="banyanlabs", - project_root=Path("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/work/banyanlabs"), + namespace="project-tool", ) - assert layout.owner_root.parent.parent == Path("/tmp/base-cache/projects") - assert layout.owner_root.parent.name == "banyanlabs" - assert layout.run_root == layout.owner_root / "runs" / "run-123__native-cli__banyanlabs" + assert layout.owner_root == Path("/tmp/base-cache/project-tool") + assert layout.run_root == layout.owner_root / "runs" / "run-123__native-cli" assert layout.log_dir == layout.run_root / "logs" def test_runtime_layout_places_inherited_base_children_in_the_shared_logs_dir() -> None: runtime = importlib.import_module("base_cli._runtime") - parent = Path("/tmp/base-cache/base/runs/parent") + parent = Path("/tmp/base-cache/parent-tool/runs/parent") layout = runtime.runtime_layout( Path("/tmp/base-cache"), "base_projects", diff --git a/tests/test_app_runtime_errors.py b/tests/test_app_runtime_errors.py index 6cfcd2b..bc55dfa 100644 --- a/tests/test_app_runtime_errors.py +++ b/tests/test_app_runtime_errors.py @@ -14,8 +14,8 @@ from base_cli.testing import invoke -def legacy_app(**kwargs: object) -> base_cli.App: - return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs) +def generic_app(**kwargs: object) -> base_cli.App: + return base_cli.App(profile=base_cli.CliProfile.generic(), **kwargs) class AppRuntimeErrorTests(unittest.TestCase): @@ -43,7 +43,7 @@ def main(ctx: base_cli.Context) -> None: @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") def test_run_app_reports_unwritable_cache_root_without_traceback(self) -> None: - app = legacy_app(name="cache-failure", version="0.1.0") + app = generic_app(name="cache-failure", version="0.1.0") @app.command() def main(ctx: base_cli.Context) -> None: @@ -59,7 +59,7 @@ def main(ctx: base_cli.Context) -> None: cache_root.chmod(0o500) stderr = io.StringIO() try: - with mock.patch.dict(os.environ, {"HOME": str(home), "BASE_CACHE_DIR": str(cache_root)}): + with mock.patch.dict(os.environ, {"HOME": str(home), "BASE_CLI_CACHE_DIR": str(cache_root)}): with redirect_stderr(stderr): try: exit_code = base_cli.run_app(app, []) @@ -71,7 +71,6 @@ def main(ctx: base_cli.Context) -> None: error = stderr.getvalue() self.assertEqual(exit_code, 1) self.assertIn("Error:", error) - self.assertIn("Unable to create Base runtime directory", error) - self.assertIn(str(cache_root / "base" / "runs"), error) - self.assertIn("BASE_CACHE_DIR", error) + self.assertIn("Unable to create runtime directory", error) + self.assertIn(str(cache_root / "cache-failure" / "runs"), error) self.assertNotIn("Traceback", error) diff --git a/tests/test_base_cli.py b/tests/test_base_cli.py deleted file mode 100644 index 601b3d2..0000000 --- a/tests/test_base_cli.py +++ /dev/null @@ -1,1030 +0,0 @@ -from __future__ import annotations - -# pylint: disable=too-many-lines,too-many-public-methods - -import importlib.util -import io -import json -import logging -import os -import tempfile -import time -import unittest -from contextlib import contextmanager, redirect_stderr -from pathlib import Path -from unittest import mock - -import base_cli -from base_cli import config as config_module -from base_cli.config import UserConfig, load_config, load_user_config, read_user_config, user_config_path -from base_cli.context import reset_current_context, set_current_context -from base_cli.logging import BaseCliFormatter -from base_cli.paths import base_cache_root, base_state_root, discover_manifest, normalize_cli_name -from base_cli.redaction import redact_argv - - -@contextmanager -def change_directory(path: Path): - original = Path.cwd() - os.chdir(path) - try: - yield - finally: - os.chdir(original) - - -def legacy_app(**kwargs: object) -> base_cli.App: - return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs) - - -class BaseCliTests(unittest.TestCase): - @staticmethod - def make_context(tmpdir: str) -> tuple[base_cli.Context, mock.Mock]: - root = Path(tmpdir) - temp_dir = root / "tmp" - temp_dir.mkdir() - log = mock.Mock() - log.handlers = [] - context = base_cli.Context( - cli_name="demo", - run_id="run", - state_dir=root / "state", - log_dir=root / "logs", - cache_dir=root / "cache", - temp_dir=temp_dir, - log_file=root / "logs" / "run.log", - config={}, - environment="dev", - debug=False, - keep_temp=False, - log=log, - ) - return context, log - - def test_import_has_no_runtime_directory_side_effect(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - with mock.patch.dict(os.environ, {"HOME": str(home), "BASE_CACHE_DIR": ""}): - self.assertFalse((home / ".base.d").exists()) - self.assertFalse((home / "Library" / "Caches" / "base").exists()) - - def test_path_helpers(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - nested = root / "a" / "b" - nested.mkdir(parents=True) - (root / "base_manifest.yaml").write_text("project:\n name: demo\n", encoding="utf-8") - - self.assertEqual(base_state_root(root), root / ".base.d") - self.assertEqual(normalize_cli_name("/tmp/demo.py"), "demo") - self.assertEqual(discover_manifest(nested), (root / "base_manifest.yaml").resolve()) - - def test_base_cache_root_uses_macos_cache_directory(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - with mock.patch.dict(os.environ, {"BASE_CACHE_DIR": ""}), mock.patch( - "base_cli.paths.sys.platform", - "darwin", - ): - self.assertEqual(base_cache_root(root), root / "Library" / "Caches" / "base") - - def test_base_cache_root_uses_xdg_cache_directory_off_macos(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - with mock.patch.dict(os.environ, {"BASE_CACHE_DIR": ""}), mock.patch( - "base_cli.paths.sys.platform", - "linux", - ): - self.assertEqual(base_cache_root(root), root / ".cache" / "base") - - def test_base_cache_root_honors_environment_override(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - with mock.patch.dict(os.environ, {"BASE_CACHE_DIR": str(root / "custom-cache")}): - self.assertEqual(base_cache_root(root), root / "custom-cache") - - def test_redacts_sensitive_option_values(self) -> None: - argv = ["tool", "--api-key", "secret", "--token=hidden", "--name", "visible"] - - self.assertEqual( - redact_argv(argv, {"api_key", "token"}), - ["tool", "--api-key", "[REDACTED]", "--token=[REDACTED]", "--name", "visible"], - ) - - def test_log_source_prefers_base_home(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - base_home = root / "base-home" - project = root / "project" - source = base_home / "lib" / "python" / "tool.py" - base_home.mkdir() - project.mkdir() - source.parent.mkdir(parents=True) - source.write_text("# test\n", encoding="utf-8") - context, _ = self.make_context(tmpdir) - context.project_root = project - record = logging.LogRecord( - name="base_cli.test", - level=logging.INFO, - pathname=str(source), - lineno=11, - msg="hello", - args=(), - exc_info=None, - ) - - token = set_current_context(context) - try: - with mock.patch.dict(os.environ, {"BASE_HOME": str(base_home)}): - formatted = BaseCliFormatter().format(record) - finally: - reset_current_context(token) - - self.assertIn("lib/python/tool.py:11 hello", formatted) - - def test_log_source_uses_context_project_root_before_cwd(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - project = root / "project" - cwd = root / "runner" - source = project / "src" / "tool.py" - project.mkdir() - cwd.mkdir() - source.parent.mkdir(parents=True) - source.write_text("# test\n", encoding="utf-8") - context, _ = self.make_context(tmpdir) - context.project_root = project - record = logging.LogRecord( - name="base_cli.test", - level=logging.INFO, - pathname=str(source), - lineno=13, - msg="hello", - args=(), - exc_info=None, - ) - - token = set_current_context(context) - try: - with mock.patch.dict(os.environ, {"BASE_HOME": ""}), change_directory(cwd): - formatted = BaseCliFormatter().format(record) - finally: - reset_current_context(token) - - self.assertIn("src/tool.py:13 hello", formatted) - self.assertNotIn(str(source.resolve()), formatted) - - def test_log_source_fallback_uses_resolved_path(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - base_home = root / "base-home" - cwd = root / "cwd" - external = root / "external" / "same_name.py" - base_home.mkdir() - cwd.mkdir() - external.parent.mkdir() - external.write_text("# test\n", encoding="utf-8") - record = logging.LogRecord( - name="base_cli.test", - level=logging.INFO, - pathname=str(external), - lineno=7, - msg="hello", - args=(), - exc_info=None, - ) - - with mock.patch.dict(os.environ, {"BASE_HOME": str(base_home)}), change_directory(cwd): - formatted = BaseCliFormatter().format(record) - - self.assertIn(f"{external.resolve()}:7 hello", formatted) - - def test_base_cli_formatter_defaults_to_local_converter(self) -> None: - with mock.patch.dict(os.environ, {"LOG_UTC": ""}): - formatter = BaseCliFormatter() - - self.assertFalse(formatter.use_utc) - self.assertIs(formatter.converter, time.localtime) - - def test_base_cli_formatter_uses_utc_converter_when_requested(self) -> None: - with mock.patch.dict(os.environ, {"LOG_UTC": "1"}): - formatter = BaseCliFormatter() - - self.assertTrue(formatter.use_utc) - self.assertIs(formatter.converter, time.gmtime) - - def test_config_precedence_excludes_implicit_system_config(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - home = root / "home" - project = root / "project" - explicit = root / "explicit.yaml" - home_config = home / ".base.d" / "config.yaml" - project_config = project / ".base" / "config.yaml" - home_config.parent.mkdir(parents=True) - project_config.parent.mkdir(parents=True) - home_config.write_text("environment: user\nlog_level: info\n", encoding="utf-8") - project_config.write_text("environment: project\n", encoding="utf-8") - explicit.write_text("log_level: warning\n", encoding="utf-8") - - original_load_yaml_file = config_module.load_yaml_file - - def load_without_system_config(path: Path) -> dict: - if path == Path("/etc/base.d/config.yaml"): - raise AssertionError("system config should not be loaded") - return original_load_yaml_file(path) - - with mock.patch.dict(os.environ, {"BASE_CLI_ENVIRONMENT": "env"}), mock.patch( - "base_cli.config.load_yaml_file", - side_effect=load_without_system_config, - ): - config = load_config(project, explicit, home=home) - - self.assertEqual(config["environment"], "env") - self.assertEqual(config["log_level"], "warning") - - def test_user_config_path_defaults_to_base_state_root(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - - self.assertEqual(user_config_path(home), home / ".base.d" / "config.yaml") - - def test_load_user_config_missing_file_returns_empty_mapping(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - config = load_user_config(Path(tmpdir)) - - self.assertEqual(config, {}) - - def test_load_user_config_reads_mapping(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 enabled: true\n", encoding="utf-8") - - config = load_user_config(home) - - self.assertEqual(config, {"ide": {"enabled": True}}) - - def test_load_user_config_rejects_non_mapping(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text("- not\n- mapping\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "must contain a YAML mapping"): - load_user_config(home) - - def test_load_user_config_rejects_invalid_yaml(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text("ide: [unterminated\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "contains invalid YAML"): - load_user_config(home) - - def test_read_user_config_missing_file_returns_empty_ide_config(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - config = read_user_config(Path(tmpdir)) - - self.assertEqual(config.raw, {}) - self.assertIsNone(config.workspace.root) - self.assertIsNone(config.ide.enabled) - self.assertEqual(config.ide.preferences, {}) - - def test_read_user_config_parses_workspace_root(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - workspace = home / "work" - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text(f"workspace:\n root: {workspace}\n", encoding="utf-8") - - config = read_user_config(home) - - self.assertEqual(config.workspace.root, workspace.resolve(strict=False)) - - def test_read_user_config_parses_workspace_manifest(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - workspace = home / "work" - manifest = workspace / "base-workspace" / "workspace.yaml" - manifest_source = workspace / "base-workspace" / "canonical.yaml" - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text( - "\n".join( - [ - "workspace:", - f" root: {workspace}", - f" manifest: {manifest}", - f" manifest_source: {manifest_source}", - ] - ), - encoding="utf-8", - ) - - config = read_user_config(home) - - self.assertEqual(config.workspace.root, workspace.resolve(strict=False)) - self.assertEqual(config.workspace.manifest, manifest.resolve(strict=False)) - self.assertEqual(config.workspace.manifest_source, str(manifest_source)) - - def test_read_user_config_rejects_non_mapping_workspace(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text("workspace: true\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "workspace must be a mapping"): - read_user_config(home) - - def test_read_user_config_rejects_relative_workspace_root(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text("workspace:\n root: work\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "workspace.root must be an absolute path"): - read_user_config(home) - - def test_read_user_config_parses_ide_preferences(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text( - "\n".join( - [ - "ide:", - " enabled: true", - " vscode:", - " enabled: true", - " install: false", - " extra_extensions:", - " - eamodio.gitlens", - " - github.copilot", - " settings:", - " editor.fontSize: 14", - " editor.minimap.enabled: false", - " cursor:", - " enabled: false", - ] - ), - encoding="utf-8", - ) - - config = read_user_config(home) - - self.assertTrue(config.ide.enabled) - vscode = config.ide.preferences["vscode"] - self.assertTrue(vscode.enabled) - self.assertFalse(vscode.install) - self.assertEqual(vscode.extra_extensions, ("eamodio.gitlens", "github.copilot")) - self.assertEqual( - vscode.settings, - { - "editor.fontSize": 14, - "editor.minimap.enabled": False, - }, - ) - self.assertFalse(config.ide.preferences["cursor"].enabled) - self.assertIsNone(config.ide.preferences["cursor"].install) - - def test_read_user_config_rejects_non_mapping_ide(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text("ide: true\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "ide must be a mapping"): - read_user_config(home) - - 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) - path.parent.mkdir(parents=True) - path.write_text( - "\n".join( - [ - "ide:", - " windswept:", - " enabled: true", - ] - ), - 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, supported_ides=frozenset({"vscode"})) - - def test_read_user_config_rejects_non_boolean_ide_enabled(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 enabled: sometimes\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "ide.enabled must be a boolean"): - read_user_config(home) - - def test_read_user_config_rejects_non_boolean_per_ide_install(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 vscode:\n install: maybe\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "ide.vscode.install must be a boolean"): - read_user_config(home) - - def test_read_user_config_rejects_invalid_extra_extension(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text( - "\n".join( - [ - "ide:", - " cursor:", - " extra_extensions:", - " - github.copilot", - " - 17", - ] - ), - encoding="utf-8", - ) - - with self.assertRaisesRegex(ValueError, r"ide.cursor.extra_extensions\[2\]"): - read_user_config(home) - - def test_read_user_config_rejects_non_mapping_ide_settings(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 vscode:\n settings: true\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "ide.vscode.settings must be a mapping"): - read_user_config(home) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_context_exposes_default_typed_user_config(self) -> None: - app = base_cli.App(name="typed-config-default", log_to_file=False) - seen = {} - - @app.command() - def main(ctx: base_cli.Context) -> None: - seen["config"] = ctx.config - seen["user_config"] = ctx.user_config - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - from base_cli.testing import invoke - - result = invoke(app, [], home=home) - - self.assertEqual(result.exit_code, 0, result.output) - self.assertEqual(seen["config"], {}) - self.assertIsInstance(seen["user_config"], UserConfig) - self.assertEqual(seen["user_config"].raw, {}) - self.assertIsNone(seen["user_config"].workspace.root) - self.assertIsNone(seen["user_config"].ide.enabled) - self.assertEqual(seen["user_config"].ide.preferences, {}) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_context_exposes_populated_typed_user_config(self) -> None: - app = legacy_app(name="typed-config-populated", log_to_file=False) - seen = {} - - @app.command() - def main(ctx: base_cli.Context) -> None: - seen["config"] = ctx.config - seen["user_config"] = ctx.user_config - seen["workspace_root"] = ctx.user_config.workspace.root - seen["ide_enabled"] = ctx.user_config.ide.enabled - seen["vscode"] = ctx.user_config.ide.preferences["vscode"] - - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - home = root / "home" - workspace = root / "workspace" - path = user_config_path(home) - path.parent.mkdir(parents=True) - workspace.mkdir() - path.write_text( - "\n".join( - [ - "workspace:", - f" root: {workspace}", - "ide:", - " enabled: true", - " vscode:", - " install: false", - " extra_extensions:", - " - github.copilot", - ] - ), - encoding="utf-8", - ) - 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.assertIsNone(seen["user_config"].workspace.manifest) - self.assertTrue(seen["ide_enabled"]) - self.assertFalse(seen["vscode"].install) - self.assertEqual(seen["vscode"].extra_extensions, ("github.copilot",)) - self.assertEqual(seen["config"]["workspace"]["root"], str(workspace)) - self.assertTrue(seen["config"]["ide"]["enabled"]) - - def test_log_debug_enables_python_debug_logging(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - with mock.patch.dict(os.environ, {"LOG_DEBUG": "1"}): - config = load_config(None, None, home=Path(tmpdir)) - - self.assertEqual(config["log_level"], "debug") - - def test_exit_code_constants_match_base_conventions(self) -> None: - from base_cli import ExitCode - - self.assertEqual(ExitCode.SUCCESS, 0) - self.assertEqual(ExitCode.FAILURE, 1) - self.assertEqual(ExitCode.USAGE_ERROR, 2) - self.assertIs(ExitCode, base_cli.ExitCode) - - def test_app_rejects_duplicate_command_registration(self) -> None: - app = base_cli.App(name="demo") - - @app.command() - def first(ctx: base_cli.Context) -> None: - del ctx - - with self.assertRaisesRegex( - RuntimeError, - "App 'demo' already has a registered command.*subcommands", - ): - @app.command() - def second(ctx: base_cli.Context) -> None: - del ctx - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_app_runs_with_context_and_cleans_temp_dir(self) -> None: - app = legacy_app(name="demo", version="0.1.0") - seen = {} - - @app.command() - @base_cli.option("--name", required=True) - def main(ctx: base_cli.Context, name: str) -> None: - seen["name"] = name - seen["temp_dir"] = ctx.temp_dir - seen["cache_dir"] = ctx.cache_dir - ctx.log.info("hello %s", name) - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - from base_cli.testing import invoke - - stderr = io.StringIO() - with redirect_stderr(stderr), mock.patch.dict(os.environ, {"LOG_UTC": ""}): - result = invoke(app, ["--name", "Ada"], home=home) - - self.assertEqual(result.exit_code, 0, result.output) - self.assertEqual(seen["name"], "Ada") - self.assertFalse(seen["temp_dir"].exists()) - self.assertFalse(seen["temp_dir"].parent.exists()) - self.assertFalse(seen["temp_dir"].parent.parent.exists()) - self.assertTrue(seen["cache_dir"].is_dir()) - log_dir = home / ".cache" / "base" / "base" / "runs" / next( - path.name for path in (home / ".cache" / "base" / "base" / "runs").iterdir() - ) / "logs" - self.assertTrue(log_dir.is_dir()) - run_metadata = log_dir.parent / "run.json" - self.assertEqual(run_metadata.stat().st_mode & 0o777, 0o600) - self.assertEqual(json.loads(run_metadata.read_text(encoding="utf-8"))["status"], "ok") - log_files = tuple(log_dir.glob("*.log")) - self.assertEqual(len(log_files), 1) - self.assertEqual(log_files[0].stat().st_mode & 0o777, 0o600) - self.assertFalse((home / ".base.d" / "cli").exists()) - self.assertRegex(result.stderr, r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} (?:UTC|[+-]\d{4}) INFO\s+") - self.assertIn("hello Ada", result.stderr) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_app_dry_run_avoids_default_cache_writes(self) -> None: - app = base_cli.App(name="dry-run-demo", version="0.1.0") - seen = {} - - @app.command() - @base_cli.option("--dry-run", is_flag=True) - def main(ctx: base_cli.Context, dry_run: bool) -> None: - seen["dry_run"] = dry_run - seen["ctx_dry_run"] = ctx.dry_run - seen["temp_dir"] = ctx.temp_dir - seen["cache_dir"] = ctx.cache_dir - seen["log_dir"] = ctx.log_dir - seen["log_file"] = ctx.log_file - ctx.log.info("dry run") - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - from base_cli.testing import invoke - - result = invoke(app, ["--dry-run"], home=home) - - self.assertEqual(result.exit_code, 0, result.output) - self.assertTrue(seen["dry_run"]) - self.assertTrue(seen["ctx_dry_run"]) - self.assertIsNone(seen["log_file"]) - self.assertFalse(seen["temp_dir"].exists()) - self.assertFalse(seen["cache_dir"].exists()) - self.assertFalse(seen["log_dir"].exists()) - self.assertFalse((home / ".cache" / "base").exists()) - self.assertIn("dry run", result.stderr) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_app_custom_dry_run_option_sets_context_and_avoids_cache_writes(self) -> None: - app = base_cli.App(name="preview-demo", version="0.1.0") - seen = {} - - @app.command() - @base_cli.option("--preview", is_flag=True, dry_run=True) - def main(ctx: base_cli.Context, preview: bool) -> None: - seen["preview"] = preview - seen["ctx_dry_run"] = ctx.dry_run - seen["temp_dir"] = ctx.temp_dir - seen["cache_dir"] = ctx.cache_dir - seen["log_dir"] = ctx.log_dir - seen["log_file"] = ctx.log_file - ctx.log.info("preview") - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - from base_cli.testing import invoke - - result = invoke(app, ["--preview"], home=home) - - self.assertEqual(result.exit_code, 0, result.output) - self.assertTrue(seen["preview"]) - self.assertTrue(seen["ctx_dry_run"]) - self.assertIsNone(seen["log_file"]) - self.assertFalse(seen["temp_dir"].exists()) - self.assertFalse(seen["cache_dir"].exists()) - self.assertFalse(seen["log_dir"].exists()) - self.assertFalse((home / ".cache" / "base").exists()) - self.assertIn("preview", result.stderr) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_app_can_disable_default_persistent_logging(self) -> None: - app = base_cli.App(name="inspect-logs", log_to_file=False) - seen = {} - - @app.command() - def main(ctx: base_cli.Context) -> None: - seen["log_file"] = ctx.log_file - seen["state_dir"] = ctx.state_dir - seen["cache_dir"] = ctx.cache_dir - seen["temp_dir"] = ctx.temp_dir - ctx.log.debug("debug without persistent log") - ctx.log.info("info without persistent log") - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - from base_cli.testing import invoke - - result = invoke(app, ["--debug"], home=home) - - self.assertEqual(result.exit_code, 0, result.output) - self.assertIsNone(seen["log_file"]) - self.assertFalse(seen["state_dir"].exists()) - self.assertFalse(seen["cache_dir"].exists()) - self.assertFalse(seen["temp_dir"].exists()) - self.assertFalse((home / ".cache" / "base").exists()) - self.assertIn("debug without persistent log", result.stderr) - self.assertIn("info without persistent log", result.stderr) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_app_dry_run_honors_explicit_log_file_without_cache_dirs(self) -> None: - app = base_cli.App(name="dry-run-log-demo", version="0.1.0") - seen = {} - - @app.command() - @base_cli.option("--dry-run", is_flag=True) - def main(ctx: base_cli.Context, dry_run: bool) -> None: - seen["dry_run"] = dry_run - seen["ctx_dry_run"] = ctx.dry_run - seen["temp_dir"] = ctx.temp_dir - seen["cache_dir"] = ctx.cache_dir - seen["log_file"] = ctx.log_file - ctx.log.info("dry run with log") - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - log_file = home / "logs" / "dry-run.log" - from base_cli.testing import invoke - - result = invoke(app, ["--dry-run", "--log-file", str(log_file)], home=home) - - self.assertEqual(result.exit_code, 0, result.output) - self.assertTrue(seen["dry_run"]) - self.assertTrue(seen["ctx_dry_run"]) - self.assertEqual(seen["log_file"], log_file) - self.assertTrue(log_file.is_file()) - self.assertEqual(log_file.stat().st_mode & 0o777, 0o600) - self.assertFalse(seen["temp_dir"].exists()) - self.assertFalse(seen["cache_dir"].exists()) - self.assertFalse((home / ".cache" / "base").exists()) - self.assertIn("dry run with log", log_file.read_text(encoding="utf-8")) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_testing_invoke_captures_stderr_separately(self) -> None: - app = base_cli.App(name="streams", version="0.1.0") - - @app.command() - def main(ctx: base_cli.Context) -> None: - print("stdout text") - ctx.log.info("stderr text") - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - from base_cli.testing import invoke - - result = invoke(app, [], home=home) - - self.assertEqual(result.exit_code, 0, result.output) - self.assertIn("stdout text", result.stdout) - self.assertNotIn("stderr text", result.stdout) - self.assertIn("stderr text", result.stderr) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_testing_invoke_uses_supplied_cwd_for_manifest_discovery(self) -> None: - app = legacy_app(name="project-aware", log_to_file=False) - seen = {} - - @app.command() - def main(ctx: base_cli.Context) -> None: - seen["manifest_path"] = ctx.manifest_path - seen["project_root"] = ctx.project_root - - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - home = root / "home" - project = root / "project" - nested = project / "nested" - home.mkdir() - nested.mkdir(parents=True) - manifest_path = project / "base_manifest.yaml" - manifest_path.write_text("project:\n name: demo\n", encoding="utf-8") - original_cwd = Path.cwd() - - from base_cli.testing import invoke - - result = invoke(app, [], home=home, cwd=nested) - - self.assertEqual(result.exit_code, 0, result.output) - self.assertEqual(seen["manifest_path"], manifest_path.resolve()) - self.assertEqual(seen["project_root"], project.resolve()) - self.assertEqual(Path.cwd(), original_cwd) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_testing_invoke_uses_supplied_cwd_without_manifest(self) -> None: - app = base_cli.App(name="no-project", log_to_file=False) - seen = {} - - @app.command() - def main(ctx: base_cli.Context) -> None: - seen["manifest_path"] = ctx.manifest_path - seen["project_root"] = ctx.project_root - - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - home = root / "home" - isolated = root / "isolated" - home.mkdir() - isolated.mkdir() - original_cwd = Path.cwd() - - from base_cli.testing import invoke - - result = invoke(app, [], home=home, cwd=isolated) - - self.assertEqual(result.exit_code, 0, result.output) - self.assertIsNone(seen["manifest_path"]) - self.assertIsNone(seen["project_root"]) - self.assertEqual(Path.cwd(), original_cwd) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_testing_invoke_restores_cwd_after_failure_result(self) -> None: - app = base_cli.App(name="failing", log_to_file=False) - - @app.command() - def main(ctx: base_cli.Context) -> None: - del ctx - raise RuntimeError("boom") - - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - home = root / "home" - cwd = root / "cwd" - home.mkdir() - cwd.mkdir() - original_cwd = Path.cwd() - - from base_cli.testing import invoke - - result = invoke(app, [], home=home, cwd=cwd) - - self.assertNotEqual(result.exit_code, 0) - self.assertEqual(Path.cwd(), original_cwd) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_testing_invoke_defaults_base_cache_dir_under_home(self) -> None: - app = legacy_app(name="cache-default") - seen = {} - - @app.command() - def main(ctx: base_cli.Context) -> None: - seen["state_dir"] = ctx.state_dir - - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - home = root / "home" - inherited_cache = root / "real-cache" - home.mkdir() - - from base_cli.testing import invoke - - with mock.patch.dict(os.environ, {"BASE_CACHE_DIR": str(inherited_cache)}): - result = invoke(app, [], home=home) - - expected_cache_root = home / ".cache" / "base" - self.assertEqual(result.exit_code, 0, result.output) - self.assertEqual(seen["state_dir"], expected_cache_root / "base") - self.assertTrue(expected_cache_root.is_dir()) - self.assertFalse(inherited_cache.exists()) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_testing_invoke_preserves_explicit_environment_overrides(self) -> None: - app = legacy_app(name="cache-override") - seen = {} - - @app.command() - def main(ctx: base_cli.Context) -> None: - seen["home"] = Path.home() - seen["state_dir"] = ctx.state_dir - - with tempfile.TemporaryDirectory() as tmpdir: - root = Path(tmpdir) - home = root / "home" - override_home = root / "override-home" - override_cache = root / "override-cache" - home.mkdir() - override_home.mkdir() - - from base_cli.testing import invoke - - result = invoke( - app, - [], - home=home, - env={ - "HOME": str(override_home), - "BASE_CACHE_DIR": str(override_cache), - }, - ) - - self.assertEqual(result.exit_code, 0, result.output) - self.assertEqual(seen["home"], override_home) - self.assertEqual(seen["state_dir"], override_cache / "base") - self.assertTrue(override_cache.is_dir()) - self.assertFalse((home / ".cache" / "base").exists()) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_standard_options_manifest_context_and_sensitive_redaction(self) -> None: - app = legacy_app(name="secret-tool", version="0.1.0") - seen = {} - - @app.command() - @base_cli.option("--token", sensitive=True, required=True) - def main(ctx: base_cli.Context, token: str) -> None: - seen["token"] = token - seen["debug"] = ctx.debug - seen["temp_dir"] = ctx.temp_dir - seen["log_file"] = ctx.log_file - seen["manifest_path"] = ctx.manifest_path - seen["project_root"] = ctx.project_root - ctx.log.info("processed token") - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) / "home" - project = Path(tmpdir) / "project" - home.mkdir() - project.mkdir() - manifest_path = project / "base_manifest.yaml" - manifest_path.write_text("project:\n name: demo\n", encoding="utf-8") - log_file = home / "custom.log" - - with mock.patch.object( - os.sys, - "argv", - ["secret-tool", "--debug", "--token", "super-secret"], - ): - from base_cli.testing import invoke - - result = invoke( - app, - ["--debug", "--keep-temp", "--log-file", str(log_file), "--token", "super-secret"], - home=home, - cwd=project, - ) - - self.assertEqual(result.exit_code, 0, result.output) - self.assertEqual(seen["token"], "super-secret") - self.assertTrue(seen["debug"]) - self.assertTrue(seen["temp_dir"].exists()) - self.assertTrue(seen["temp_dir"].parent.exists()) - self.assertEqual(seen["log_file"], log_file) - self.assertTrue( - seen["temp_dir"].is_relative_to(home / ".cache" / "base" / "base" / "runs") - ) - self.assertEqual(seen["manifest_path"], manifest_path.resolve()) - self.assertEqual(seen["project_root"], project.resolve()) - - log_text = log_file.read_text(encoding="utf-8") - self.assertEqual(log_file.stat().st_mode & 0o777, 0o600) - self.assertIn("--token", log_text) - self.assertIn("[REDACTED]", log_text) - self.assertNotIn("super-secret", log_text) - self.assertIn("manifest_path=", log_text) - self.assertIn("project_root=", log_text) - - def test_cleanup_continues_after_hook_failure(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - context, log = self.make_context(tmpdir) - calls = [] - - def failing_hook() -> None: - calls.append("failing") - raise RuntimeError("hook exploded") - - def later_hook() -> None: - calls.append("later") - - context.on_cleanup(failing_hook) - context.on_cleanup(later_hook) - - context.cleanup() - - self.assertEqual(calls, ["failing", "later"]) - self.assertFalse(context.temp_dir.exists()) - log.warning.assert_any_call("Cleanup hook failed: %s", mock.ANY) - - def test_cleanup_logs_temp_removal_failure_without_raising(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - context, log = self.make_context(tmpdir) - - with mock.patch("base_cli.context.shutil.rmtree", side_effect=OSError("permission denied")): - context.cleanup() - - log.warning.assert_any_call( - "Temp directory cleanup failed for '%s': %s", - context.temp_dir, - mock.ANY, - ) - - def test_cleanup_removes_handler_after_flush_and_close_failures(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - context, log = self.make_context(tmpdir) - handler = mock.Mock() - handler.flush.side_effect = RuntimeError("flush exploded") - handler.close.side_effect = RuntimeError("close exploded") - log.handlers = [handler] - - context.cleanup() - - log.removeHandler.assert_called_once_with(handler) - self.assertEqual(log.warning.call_count, 2) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_command_protocol.py b/tests/test_command_protocol.py index f8c330f..a6e4510 100644 --- a/tests/test_command_protocol.py +++ b/tests/test_command_protocol.py @@ -18,7 +18,7 @@ def project_command_record(**overrides: object) -> dict[str, object]: record: dict[str, object] = { "project_name": "demo", "project_root": "/tmp/work space/demo", - "manifest_path": "/tmp/work space/demo/base_manifest.yaml", + "manifest_path": "/tmp/work space/demo/tool.manifest", "project_venv_dir": "/tmp/work space/demo/.venv", "uses_uv_manager": False, "manifest_command_trust_required": True, diff --git a/tests/test_context_workspace.py b/tests/test_context_workspace.py index ef05c19..74bf1a9 100644 --- a/tests/test_context_workspace.py +++ b/tests/test_context_workspace.py @@ -6,32 +6,36 @@ from pathlib import Path import base_cli -from base_cli.config import user_config_path +from base_cli.config import UserConfig, UserIdeConfig, UserWorkspaceConfig -def legacy_app(**kwargs: object) -> base_cli.App: - return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs) +def configured_app(workspace: Path | None, **kwargs: object) -> base_cli.App: + 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), + ) + ) + return base_cli.App(profile=profile, **kwargs) @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") class ContextWorkspaceRootTests(unittest.TestCase): def test_context_exposes_workspace_root_when_configured(self) -> None: - app = legacy_app(name="workspace-root-configured", log_to_file=False) seen: dict[str, Path | 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 - with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) home = root / "home" workspace = root / "workspace" workspace.mkdir() - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text(f"workspace:\n root: {workspace}\n", encoding="utf-8") + app = configured_app(workspace, name="workspace-root-configured", log_to_file=False) + + @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 from base_cli.testing import invoke @@ -42,7 +46,7 @@ def main(ctx: base_cli.Context) -> None: self.assertEqual(seen["user_config_workspace_root"], workspace.resolve()) def test_context_workspace_root_is_none_without_configured_root(self) -> None: - app = base_cli.App(name="workspace-root-default", log_to_file=False) + app = configured_app(None, name="workspace-root-default", log_to_file=False) seen: dict[str, Path | None] = {} @app.command() diff --git a/tests/test_generic_core.py b/tests/test_generic_core.py new file mode 100644 index 0000000..26a1cbb --- /dev/null +++ b/tests/test_generic_core.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +import logging +import tempfile +import unittest +from datetime import timedelta +from pathlib import Path + +import base_cli +from base_cli import history +from base_cli._runtime import runtime_layout +from base_cli.context import Context + + +class GenericCoreTests(unittest.TestCase): + def test_generic_runtime_layout_uses_application_namespace(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + layout = runtime_layout(root, "demo_tool", "run-1") + + self.assertEqual(layout.owner_root, root / "demo_tool") + self.assertIn("demo_tool", layout.run_root.name) + self.assertEqual(layout.cache_dir, root / "demo_tool" / "cache" / "components" / "demo_tool") + + def test_history_writer_requires_consumer_selected_path(self) -> None: + record = { + "schema_version": 1, + "event": "finished", + "run_id": "run-1", + "command": "demo", + "status": "ok", + "exit_code": 0, + } + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "history" / "runs.jsonl" + history.write_history_record(path, record) + loaded = json.loads(path.read_text(encoding="utf-8")) + mode = path.stat().st_mode & 0o777 + + self.assertEqual(loaded, record) + self.assertEqual(mode, 0o600) + + def test_finished_record_has_no_product_version_field(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + context = Context( + cli_name="demo_tool", + run_id="run-1", + state_dir=root / "state", + log_dir=root / "logs", + cache_dir=root / "cache", + temp_dir=root / "tmp", + log_file=root / "logs" / "run.log", + config={}, + environment="dev", + debug=False, + keep_temp=False, + log=logging.getLogger("generic-core-test"), + ) + started = history.utc_now() - timedelta(seconds=1) + record = history.build_finished_record(context, ["demo_tool"], set(), started, 0) + + self.assertEqual(record["command"], "demo-tool") + self.assertNotIn("base_version", record) + + def test_base_specific_path_helpers_are_not_in_generic_module(self) -> None: + import base_cli.paths as paths + + self.assertFalse(hasattr(paths, "base_cache_root")) + self.assertFalse(hasattr(paths, "discover_manifest")) + self.assertFalse(hasattr(paths, "normalize_runtime_owner")) + self.assertFalse(hasattr(base_cli.CliProfile, "legacy_base")) diff --git a/tests/test_history.py b/tests/test_history.py deleted file mode 100644 index 7db58f0..0000000 --- a/tests/test_history.py +++ /dev/null @@ -1,429 +0,0 @@ -from __future__ import annotations - -import importlib.util -import io -import json -import os -import tempfile -import unittest -from contextlib import redirect_stderr -from pathlib import Path -from unittest import mock - -import base_cli -from base_cli import history as history_helpers - - -def legacy_app(**kwargs: object) -> base_cli.App: - return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs) - - -def read_history_records(cache_root: Path) -> list[dict]: - history_path = cache_root / "base" / "history" / "runs.jsonl" - return [json.loads(line) for line in history_path.read_text(encoding="utf-8").splitlines()] - - -class BaseCliHistoryTests(unittest.TestCase): - def test_shared_history_helpers_parse_records_and_display_paths(self) -> None: - payload = { - "schema_version": 1, - "event": "finished", - "run_id": "run-1", - "command": "check", - "status": "ok", - "exit_code": 0, - } - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) / "home" - inside_home = home / "logs" / "run.log" - outside_home = Path(tmpdir) / "outside" / "run.log" - with mock.patch.dict(os.environ, {"HOME": str(home)}): - self.assertEqual( - history_helpers.parse_finished_history_record_line(json.dumps(payload)), - payload, - ) - self.assertIsNone(history_helpers.parse_finished_history_record_line("{not json")) - self.assertIsNone( - history_helpers.parse_finished_history_record_line( - json.dumps({**payload, "event": "started"}) - ) - ) - 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") - self.assertEqual( - history_helpers.compact_path(outside_home), - str(outside_home.expanduser().resolve(strict=False)), - ) - - def test_write_history_record_uses_locked_append_payload(self) -> None: - record = { - "schema_version": 1, - "event": "finished", - "run_id": "run-1", - "command": "check", - "status": "ok", - "exit_code": 0, - } - - with tempfile.TemporaryDirectory() as tmpdir: - cache_root = Path(tmpdir) / "cache" - real_os_write = os.write - with mock.patch.dict(os.environ, {"BASE_CACHE_DIR": str(cache_root)}): - with mock.patch("base_cli.history._fcntl") as fcntl_module: - fcntl_module.LOCK_EX = 1 - fcntl_module.LOCK_UN = 8 - with mock.patch("base_cli.history.os.write", wraps=real_os_write) as os_write: - history_helpers.write_history_record(record) - - history_path = cache_root / "base" / "history" / "runs.jsonl" - payloads = [call.args[1] for call in os_write.call_args_list] - history_mode = history_path.stat().st_mode & 0o777 - - fcntl_module.flock.assert_has_calls( - [ - mock.call(mock.ANY, fcntl_module.LOCK_EX), - mock.call(mock.ANY, fcntl_module.LOCK_UN), - ] - ) - self.assertEqual(len(payloads), 1) - self.assertTrue(payloads[0].endswith(b"\n")) - self.assertEqual(json.loads(payloads[0]), record) - self.assertEqual(history_mode, 0o600) - - def test_write_history_record_appends_without_fcntl(self) -> None: - record = { - "schema_version": 1, - "event": "finished", - "run_id": "run-1", - "command": "check", - "status": "ok", - "exit_code": 0, - } - - with tempfile.TemporaryDirectory() as tmpdir: - cache_root = Path(tmpdir) / "cache" - with mock.patch.dict(os.environ, {"BASE_CACHE_DIR": str(cache_root)}): - with mock.patch("base_cli.history._fcntl", None): - history_helpers.write_history_record(record) - - history_mode = (cache_root / "base" / "history" / "runs.jsonl").stat().st_mode & 0o777 - records = read_history_records(cache_root) - - self.assertEqual(records, [record]) - self.assertEqual(history_mode, 0o600) - - def test_windows_lock_fallback_uses_a_private_sidecar(self) -> None: - record = { - "schema_version": 1, - "event": "finished", - "run_id": "run-windows", - "command": "check", - "status": "ok", - "exit_code": 0, - } - - with tempfile.TemporaryDirectory() as tmpdir: - cache_root = Path(tmpdir) / "cache" - fake_msvcrt = mock.Mock(LK_LOCK=1, LK_UNLCK=2) - with mock.patch.dict(os.environ, {"BASE_CACHE_DIR": str(cache_root)}): - with mock.patch("base_cli.history._fcntl", None), mock.patch( - "base_cli.history._msvcrt", fake_msvcrt - ): - history_helpers.write_history_record(record) - - history_path = cache_root / "base" / "history" / "runs.jsonl" - sidecar_path = history_path.with_name(f".{history_path.name}.lock") - self.assertEqual(read_history_records(cache_root), [record]) - self.assertEqual(sidecar_path.read_text(encoding="utf-8"), "0") - fake_msvcrt.locking.assert_has_calls( - [mock.call(mock.ANY, fake_msvcrt.LK_LOCK, 1), mock.call(mock.ANY, fake_msvcrt.LK_UNLCK, 1)] - ) - - def test_write_primary_record_preserves_user_command_and_project_metadata(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - cache_root = Path(tmpdir) / "cache" - project_root = Path(tmpdir) / "work" / "demo" - manifest = project_root / "base_manifest.yaml" - bundle = cache_root / "base" / "runs" / "parent-1" - bundle.mkdir(parents=True) - (bundle / "run.json").write_text( - json.dumps({"run_id": "parent-1", "owner": "base", "status": "running"}) + "\n", - encoding="utf-8", - ) - with mock.patch.dict(os.environ, {"BASE_CACHE_DIR": str(cache_root)}): - history_helpers.write_primary_record( - command="test", - argv=["basectl", "test", "demo"], - started_at=history_helpers.utc_now(), - exit_code=1, - run_id="parent-1", - scope=history_helpers.HISTORY_SCOPE_INTERNAL, - project="demo", - project_root=str(project_root), - manifest=str(manifest), - bundle_path=str(bundle), - raw_command="base-history", - ) - record = read_history_records(cache_root)[0] - metadata = json.loads((bundle / "run.json").read_text(encoding="utf-8")) - metadata_mode = (bundle / "run.json").stat().st_mode & 0o777 - - self.assertEqual(record["command"], "test") - self.assertEqual(record["raw_command"], "base-history") - self.assertEqual(record["scope"], "internal") - self.assertEqual(record["run_id"], "parent-1") - self.assertEqual(record["project"], "demo") - self.assertEqual(record["project_root"], str(project_root.resolve())) - self.assertEqual(record["manifest"], str(manifest.resolve())) - self.assertEqual(record["status"], "error") - self.assertEqual(metadata["project"], "demo") - self.assertEqual(metadata["project_root"], str(project_root.resolve())) - self.assertEqual(metadata["manifest"], str(manifest.resolve())) - self.assertEqual(metadata["raw_command"], "base-history") - self.assertEqual(metadata["scope"], "internal") - self.assertEqual(metadata_mode, 0o600) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_app_records_successful_command_history_with_redacted_metadata(self) -> None: - app = legacy_app(name="history-demo", version="0.1.0") - seen = {} - - @app.command() - @base_cli.option("--endpoint", required=True) - @base_cli.option("--token", sensitive=True, required=True) - def main(ctx: base_cli.Context, endpoint: str, token: str) -> None: - seen["endpoint"] = endpoint - seen["token"] = token - seen["run_id"] = ctx.run_id - ctx.log.info("processed request") - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) / "home" - project = home / "work" / "demo" - home.mkdir() - project.mkdir(parents=True) - - from base_cli.testing import invoke - - with mock.patch.object( - os.sys, - "argv", - [ - "history-demo", - "--endpoint", - "https://user:super-secret@example.invalid/path", - "--token", - "super-secret", - ], - ): - result = invoke( - app, - [ - "--endpoint", - "https://user:super-secret@example.invalid/path", - "--token", - "super-secret", - ], - home=home, - cwd=project, - manifest={"project": {"name": "demo"}}, - ) - - records = read_history_records(home / ".cache" / "base") - - self.assertEqual(result.exit_code, 0, result.output) - self.assertEqual(len(records), 1) - record = records[0] - self.assertEqual(record["schema_version"], 1) - self.assertEqual(record["event"], "finished") - self.assertEqual(record["run_id"], seen["run_id"]) - self.assertEqual(record["command"], "history-demo") - self.assertEqual(record["raw_command"], "history-demo") - self.assertEqual(record["project"], "demo") - self.assertEqual(record["project_root"], "~/work/demo") - self.assertEqual(record["manifest"], "~/work/demo/base_manifest.yaml") - self.assertEqual(record["exit_code"], 0) - self.assertEqual(record["status"], "ok") - self.assertTrue(record["duration_ms"] >= 0) - self.assertTrue(record["log_path"].startswith("~/.cache/base/base/runs/")) - self.assertTrue(record["log_path"].endswith("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/logs/primary.log")) - self.assertEqual(record["owner"], "base") - self.assertTrue(record["bundle_path"].startswith("~/.cache/base/base/runs/")) - self.assertIn("[REDACTED]", record["argv"]) - self.assertIn("https://[REDACTED]@example.invalid/path", record["argv"]) - self.assertNotIn("super-secret", json.dumps(record)) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_app_reuses_parent_run_and_does_not_record_internal_history(self) -> None: - app = legacy_app(name="history-internal") - - seen = {} - - @app.command() - def main(ctx: base_cli.Context) -> None: - self.assertEqual(ctx.history_scope, "internal") - self.assertEqual(ctx.history_parent_run_id, "parent-1") - seen["run_id"] = ctx.run_id - seen["log_file"] = ctx.log_file - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) / "home" - run_root = home / ".cache" / "base" / "base" / "runs" / "parent-1" - primary_log = run_root / "logs" / "primary.log" - with mock.patch.dict( - os.environ, - { - "HOME": str(home), - "BASE_CACHE_DIR": str(home / ".cache" / "base"), - "BASE_CLI_RUN_ROOT": str(run_root), - "BASE_CLI_RUN_ID": "parent-1", - "BASE_CLI_PRIMARY_LOG": str(primary_log), - "BASE_CLI_HISTORY_SCOPE": "internal", - "BASE_CLI_HISTORY_PARENT_RUN_ID": "parent-1", - }, - ): - status = base_cli.run_app(app, []) - - self.assertEqual(status, 0) - self.assertEqual(seen["run_id"], "parent-1") - self.assertEqual(seen["log_file"], primary_log) - self.assertFalse((home / ".cache" / "base" / "base" / "history" / "runs.jsonl").exists()) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_project_owner_uses_checkout_scoped_run_bundle(self) -> None: - app = legacy_app(name="project-native") - - @app.command() - def main(ctx: base_cli.Context) -> None: - self.assertEqual(ctx.runtime_owner, "project") - self.assertEqual(ctx.project_name, "demo") - self.assertTrue(ctx.owner_root.parts[-2] == "demo") - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) / "home" - project = home / "work" / "demo" - home.mkdir() - project.mkdir(parents=True) - with mock.patch.dict( - os.environ, - { - "BASE_CLI_RUNTIME_OWNER": "project", - "BASE_CLI_PROJECT_NAME": "demo", - "BASE_CLI_PROJECT_ROOT": str(project), - }, - ): - from base_cli.testing import invoke - - result = invoke( - app, - [], - home=home, - cwd=project, - manifest={"project": {"name": "demo"}}, - ) - records = read_history_records(home / ".cache" / "base") - - self.assertEqual(result.exit_code, 0, result.output) - self.assertEqual(records[0]["owner"], "project") - self.assertIn("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/projects/demo/", records[0]["bundle_path"]) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_run_app_uses_explicit_argv_for_history_and_log_metadata(self) -> None: - app = legacy_app(name="history-explicit", version="0.1.0") - seen = {} - current_endpoint = "https://current.example/path" - stale_endpoint = "https://stale.invalid/path" - - @app.command() - @base_cli.option("--endpoint", required=True) - @base_cli.option("--token", sensitive=True, required=True) - def main(ctx: base_cli.Context, endpoint: str, token: str) -> None: - seen["endpoint"] = endpoint - seen["token"] = token - seen["log_file"] = ctx.log_file - ctx.log.info("processed request") - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) / "home" - home.mkdir() - stderr = io.StringIO() - with mock.patch.dict( - os.environ, - {"HOME": str(home), "BASE_CACHE_DIR": str(home / ".cache" / "base")}, - ): - with mock.patch.object( - os.sys, - "argv", - ["stale-wrapper", "--debug", "--endpoint", stale_endpoint, "--token", "stale-secret"], - ): - with redirect_stderr(stderr): - status = base_cli.run_app( - app, - ["--debug", "--endpoint", current_endpoint, "--token", "super-secret"], - ) - records = read_history_records(home / ".cache" / "base") - log_text = seen["log_file"].read_text(encoding="utf-8") - - self.assertEqual(status, 0, stderr.getvalue()) - self.assertEqual(seen["endpoint"], current_endpoint) - self.assertEqual(seen["token"], "super-secret") - self.assertEqual(len(records), 1) - record_text = json.dumps(records[0]) - self.assertIn(current_endpoint, record_text) - self.assertNotIn(stale_endpoint, record_text) - self.assertNotIn("super-secret", record_text) - self.assertNotIn("stale-secret", record_text) - self.assertIn(current_endpoint, log_text) - self.assertNotIn(stale_endpoint, log_text) - self.assertNotIn("super-secret", log_text) - self.assertNotIn("stale-secret", log_text) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_app_records_failed_command_history(self) -> None: - app = legacy_app(name="failing-history") - - @app.command() - def main(ctx: base_cli.Context) -> int: - ctx.log.error("planned failure") - return 7 - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - stderr = io.StringIO() - with mock.patch.dict( - os.environ, - {"HOME": str(home), "BASE_CACHE_DIR": str(home / ".cache" / "base")}, - ): - with redirect_stderr(stderr): - status = base_cli.run_app(app, []) - records = read_history_records(home / ".cache" / "base") - - self.assertEqual(status, 7, stderr.getvalue()) - self.assertEqual(records[0]["command"], "failing-history") - self.assertEqual(records[0]["exit_code"], 7) - self.assertEqual(records[0]["status"], "error") - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_app_history_write_failures_do_not_fail_command(self) -> None: - app = legacy_app(name="history-best-effort") - - @app.command() - def main(ctx: base_cli.Context) -> None: - ctx.log.info("still succeeds") - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - from base_cli.testing import invoke - - with mock.patch("base_cli.history.write_history_record", side_effect=OSError("permission denied")): - result = invoke(app, [], home=home) - - self.assertEqual(result.exit_code, 0, result.output) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_profile.py b/tests/test_profile.py index 0d90dc4..7559134 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -41,7 +41,6 @@ def main(ctx: base_cli.Context) -> None: env={ "BASE_CLI_PRIMARY_LOG": str(root / "base.log"), "BASE_CLI_HISTORY_SCOPE": "internal", - "BASE_CLI_DISPLAY_COMMAND": "basectl", }, ) @@ -53,8 +52,6 @@ def main(ctx: base_cli.Context) -> None: 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_app_defaults_to_generic_profile(self) -> None: app = base_cli.App(name="plain-tool", log_to_file=False) diff --git a/tests/test_testing.py b/tests/test_testing.py index d8a28c2..eb9379a 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -15,8 +15,15 @@ from base_cli.testing import invoke -def legacy_app(**kwargs: object) -> base_cli.App: - return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs) +def manifest_app(**kwargs: object) -> base_cli.App: + def discover(cwd: Path) -> base_cli.ProjectInfo | None: + manifest = cwd / "tool.manifest" + if not manifest.is_file(): + return None + return base_cli.ProjectInfo(root=cwd, manifest=manifest, name="demo") + + profile = base_cli.CliProfile.generic(discover_project=discover) + return base_cli.App(profile=profile, **kwargs) class PackageExportTests(unittest.TestCase): @@ -55,7 +62,7 @@ def test_invoke_declares_click_result_return_type(self) -> None: self.assertIn("Result", str(return_annotation)) def test_invoke_writes_manifest_fixture_into_cwd(self) -> None: - app = legacy_app(name="testing-manifest", log_to_file=False) + app = manifest_app(name="testing-manifest", log_to_file=False) seen: dict[str, Path | None] = {} @app.command() @@ -68,29 +75,26 @@ def main(ctx: base_cli.Context) -> None: home = root / "home" project = root / "project" project.mkdir() + (project / "tool.manifest").write_text( + "name: demo\n", + encoding="utf-8", + ) result = invoke( app, [], home=home, cwd=project, - manifest={"project": {"name": "demo"}, "artifacts": []}, ) - manifest_path = project / "base_manifest.yaml" + manifest_path = project / "tool.manifest" self.assertEqual(result.exit_code, 0, result.output) self.assertEqual(seen["project_root"], project.resolve()) self.assertEqual(seen["manifest_path"], manifest_path.resolve()) - def test_invoke_rejects_manifest_without_cwd(self) -> None: - app = legacy_app(name="testing-manifest-without-cwd", log_to_file=False) - - with self.assertRaisesRegex(ValueError, "manifest requires cwd"): - invoke(app, [], manifest={"project": {"name": "demo"}}) - def test_invoke_with_cwd_exposes_process_cwd_and_restores_it(self) -> None: - app = legacy_app(name="testing-cwd-isolation", log_to_file=False) + app = manifest_app(name="testing-cwd-isolation", log_to_file=False) seen: dict[str, Path | None | str] = {} @app.command() @@ -106,8 +110,8 @@ def main(ctx: base_cli.Context) -> None: project = root / "project" home.mkdir() project.mkdir() - manifest_path = project / "base_manifest.yaml" - manifest_path.write_text("project:\n name: demo\n", encoding="utf-8") + manifest_path = project / "tool.manifest" + manifest_path.write_text("name: demo\n", encoding="utf-8") (project / "relative.txt").write_text("cwd works\n", encoding="utf-8") original_cwd = Path.cwd() diff --git a/tests/test_user_config_github.py b/tests/test_user_config_github.py deleted file mode 100644 index a785de0..0000000 --- a/tests/test_user_config_github.py +++ /dev/null @@ -1,99 +0,0 @@ -from __future__ import annotations - -import importlib.util -import tempfile -import unittest -from pathlib import Path - -import base_cli -from base_cli.config import read_user_config, user_config_path - - -def legacy_app(**kwargs: object) -> base_cli.App: - return base_cli.App(profile=base_cli.CliProfile.legacy_base(), **kwargs) - - -class GithubUserConfigTests(unittest.TestCase): - def test_read_user_config_defaults_github_settings_to_none(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - config = read_user_config(Path(tmpdir)) - - self.assertIsNone(config.github.default_owner) - self.assertIsNone(config.github.clone_protocol) - - def test_read_user_config_parses_github_defaults(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text( - "\n".join( - [ - "github:", - " default_owner: codeforester", - " clone_protocol: https", - ] - ), - encoding="utf-8", - ) - - config = read_user_config(home) - - self.assertEqual(config.github.default_owner, "codeforester") - self.assertEqual(config.github.clone_protocol, "https") - - def test_read_user_config_rejects_non_mapping_github(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text("github: true\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "github must be a mapping"): - read_user_config(home) - - def test_read_user_config_rejects_invalid_github_default_owner(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text("github:\n default_owner: bad_owner!\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "github.default_owner must start with"): - read_user_config(home) - - def test_read_user_config_rejects_invalid_github_clone_protocol(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text("github:\n clone_protocol: ftp\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "github.clone_protocol must be 'ssh' or 'https'"): - read_user_config(home) - - @unittest.skipUnless(importlib.util.find_spec("click"), "Click is not installed") - def test_context_exposes_github_typed_user_config(self) -> None: - app = legacy_app(name="typed-config-github", log_to_file=False) - seen = {} - - @app.command() - def main(ctx: base_cli.Context) -> None: - seen["github"] = ctx.user_config.github - - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text("github:\n default_owner: codeforester\n clone_protocol: ssh\n", encoding="utf-8") - from base_cli.testing import invoke - - result = invoke(app, [], home=home) - - self.assertEqual(result.exit_code, 0, result.output) - self.assertEqual(seen["github"].default_owner, "codeforester") - self.assertEqual(seen["github"].clone_protocol, "ssh") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_user_config_workspace.py b/tests/test_user_config_workspace.py deleted file mode 100644 index ba2c698..0000000 --- a/tests/test_user_config_workspace.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -import tempfile -import unittest -from pathlib import Path - -from base_cli.config import read_user_config -from base_cli.config import user_config_path - - -class WorkspaceUserConfigTests(unittest.TestCase): - def test_read_user_config_rejects_empty_workspace_manifest_source(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) - path = user_config_path(home) - path.parent.mkdir(parents=True) - path.write_text("workspace:\n manifest_source: ''\n", encoding="utf-8") - - with self.assertRaisesRegex(ValueError, "workspace.manifest_source must be a non-empty string"): - read_user_config(home) - - -if __name__ == "__main__": - unittest.main()