From 5a9b0df0031910f9e0fb86d2e0a208aaddc13819 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Fri, 29 May 2026 15:13:29 -0700 Subject: [PATCH 1/2] Split base_setup engine modules --- cli/python/base_dev/engine.py | 4 +- cli/python/base_setup/artifacts.py | 240 ++++++ cli/python/base_setup/checks.py | 40 + cli/python/base_setup/delegates.py | 145 ++++ cli/python/base_setup/engine.py | 913 +-------------------- cli/python/base_setup/errors.py | 5 + cli/python/base_setup/ide.py | 459 +++++++++++ cli/python/base_setup/process.py | 44 + cli/python/base_setup/tests/test_engine.py | 269 +++--- 9 files changed, 1096 insertions(+), 1023 deletions(-) create mode 100644 cli/python/base_setup/artifacts.py create mode 100644 cli/python/base_setup/checks.py create mode 100644 cli/python/base_setup/delegates.py create mode 100644 cli/python/base_setup/errors.py create mode 100644 cli/python/base_setup/ide.py create mode 100644 cli/python/base_setup/process.py diff --git a/cli/python/base_dev/engine.py b/cli/python/base_dev/engine.py index ad5f0eb..7897e70 100644 --- a/cli/python/base_dev/engine.py +++ b/cli/python/base_dev/engine.py @@ -5,8 +5,10 @@ from pathlib import Path import base_cli -from base_setup.engine import ArtifactError, reconcile_artifact, resolve_artifact_definitions, run_check +from base_setup.artifacts import reconcile_artifact, resolve_artifact_definitions +from base_setup.errors import ArtifactError from base_setup.manifest import ArtifactRequest, BaseManifest, ManifestError, read_manifest +from base_setup.process import run_check from base_setup.registry import ArtifactDefinition diff --git a/cli/python/base_setup/artifacts.py b/cli/python/base_setup/artifacts.py new file mode 100644 index 0000000..fba3554 --- /dev/null +++ b/cli/python/base_setup/artifacts.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import os +import subprocess +import venv +from pathlib import Path + +import base_cli + +from . import process +from .checks import ArtifactCheck +from .errors import ArtifactError +from .manifest import ArtifactRequest +from .registry import ArtifactDefinition, get_artifact_definition + + +def resolve_artifact_definitions(artifacts: tuple[ArtifactRequest, ...]) -> tuple[ArtifactDefinition, ...]: + definitions: list[ArtifactDefinition] = [] + for artifact in artifacts: + definition = get_artifact_definition(artifact.artifact_type, artifact.name) + if definition is None: + raise ArtifactError( + "Unsupported artifact " + f"'{artifact.name}' of type '{artifact.artifact_type}'. " + "Base does not know how to manage this artifact yet." + ) + definitions.append(definition) + return tuple(definitions) + + +def merge_artifacts( + default_artifacts: tuple[ArtifactRequest, ...], + manifest_artifacts: tuple[ArtifactRequest, ...], +) -> tuple[ArtifactRequest, ...]: + merged: dict[tuple[str, str], ArtifactRequest] = {} + + for artifact in default_artifacts: + merged[(artifact.artifact_type, artifact.name)] = artifact + + for artifact in manifest_artifacts: + key = (artifact.artifact_type, artifact.name) + existing = merged.get(key) + if existing is not None and existing.version != artifact.version: + raise ArtifactError( + "Artifact " + f"'{artifact.name}' of type '{artifact.artifact_type}' is declared by defaults " + f"as version '{existing.version}' and by the project manifest as version '{artifact.version}'." + ) + if existing is not None: + artifact = ArtifactRequest( + artifact_type=artifact.artifact_type, + name=artifact.name, + version=artifact.version, + bootstrap=existing.bootstrap or artifact.bootstrap, + ) + merged[key] = artifact + + return tuple(merged.values()) + + +def check_artifact( + project: str, + artifact: ArtifactRequest, + definition: ArtifactDefinition, +) -> ArtifactCheck: + if definition.manager == "homebrew": + return check_homebrew_artifact(project, artifact, definition) + if definition.manager == "pip": + return check_python_artifact(project, artifact, definition) + return ArtifactCheck( + name=artifact.name, + ok=False, + message=f"Artifact manager '{definition.manager}' is not implemented.", + fix=f"basectl setup {project}", + ) + + +def check_homebrew_artifact( + project: str, + artifact: ArtifactRequest, + definition: ArtifactDefinition, +) -> ArtifactCheck: + if artifact.version != "latest": + return ArtifactCheck( + name=artifact.name, + ok=False, + message=( + f"Homebrew artifact '{artifact.name}' specifies version '{artifact.version}', " + "but Base only supports Homebrew artifact version 'latest' right now." + ), + fix=f"Update '{artifact.name}' in the project manifest to use version 'latest'.", + ) + if not process.command_exists("brew"): + return ArtifactCheck( + name=artifact.name, + ok=False, + message=f"Homebrew is required to check artifact '{artifact.name}'.", + fix="basectl setup", + ) + ok = process.run_check(["brew", "list", definition.package]) + if ok: + return ArtifactCheck( + name=artifact.name, + ok=True, + message=f"Artifact '{artifact.name}' is installed via Homebrew package '{definition.package}'.", + fix="", + ) + return ArtifactCheck( + name=artifact.name, + ok=False, + message=f"Artifact '{artifact.name}' is not installed via Homebrew package '{definition.package}'.", + fix=f"basectl setup {project}", + ) + + +def check_python_artifact( + project: str, + artifact: ArtifactRequest, + definition: ArtifactDefinition, +) -> ArtifactCheck: + venv_dir = project_venv_dir(project) + python_bin = venv_dir / "bin" / "python" + if python_artifact_installed(python_bin, definition.package, artifact.version): + return ArtifactCheck( + name=artifact.name, + ok=True, + message=f"Python artifact '{artifact.name}' is installed in the project virtual environment.", + fix="", + ) + return ArtifactCheck( + name=artifact.name, + ok=False, + message=f"Python artifact '{artifact.name}' is not installed in the project virtual environment.", + fix=f"basectl setup {project}", + ) + + +def reconcile_artifact( + ctx: base_cli.Context, + definition: ArtifactDefinition, + version: str, + project: str, + dry_run: bool, +) -> None: + if definition.manager == "homebrew": + reconcile_homebrew_artifact(ctx, definition, version, dry_run=dry_run) + return + if definition.manager == "pip": + reconcile_python_artifact(ctx, definition, version, project, dry_run=dry_run) + return + raise ArtifactError(f"Artifact manager '{definition.manager}' is not implemented.") + + +def reconcile_homebrew_artifact( + ctx: base_cli.Context, + definition: ArtifactDefinition, + version: str, + dry_run: bool, +) -> None: + if version != "latest": + raise ArtifactError( + "Homebrew artifact " + f"'{definition.name}' specifies version '{version}', but Base only supports " + "Homebrew artifact version 'latest' right now." + ) + + command = ["brew", "install", definition.package] + if dry_run: + process.dry_run_command(ctx, command) + return + + if not process.command_exists("brew"): + raise ArtifactError(f"Homebrew is required to install artifact '{definition.name}'.") + + if process.run_check(["brew", "list", definition.package]): + ctx.log.info( + "Artifact '%s' is already installed via Homebrew package '%s'.", + definition.name, + definition.package, + ) + return + + ctx.log.info( + "Installing artifact '%s' via Homebrew package '%s' (%s).", + definition.name, + definition.package, + version, + ) + process.run_command(ctx, command) + + +def reconcile_python_artifact( + ctx: base_cli.Context, + definition: ArtifactDefinition, + version: str, + project: str, + dry_run: bool, +) -> None: + venv_dir = project_venv_dir(project) + python_bin = venv_dir / "bin" / "python" + requirement = f"{definition.package}=={version}" if version != "latest" else definition.package + + if python_artifact_installed(python_bin, definition.package, version): + ctx.log.info("Python artifact '%s' is already installed in the project virtual environment.", definition.name) + return + + if dry_run: + if not python_bin.exists(): + ctx.log.info("[DRY-RUN] Would create project virtual environment at '%s'.", venv_dir) + process.dry_run_command(ctx, [str(python_bin), "-m", "pip", "install", requirement]) + return + + if not python_bin.exists(): + ctx.log.info("Creating project virtual environment at '%s'.", venv_dir) + venv.create(venv_dir, with_pip=True) + + ctx.log.info("Installing Python artifact '%s' into project virtual environment.", definition.name) + process.run_command(ctx, [str(python_bin), "-m", "pip", "install", requirement]) + + +def project_venv_dir(project: str) -> Path: + override = os.environ.get("BASE_PROJECT_VENV_DIR") + if override: + return Path(override).expanduser() + return Path.home() / ".base.d" / project / ".venv" + + +def python_artifact_installed(python_bin: Path, package: str, version: str) -> bool: + if not python_bin.exists(): + return False + command = [str(python_bin), "-m", "pip", "show", package] + completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False) + if completed.returncode: + return False + if version == "latest": + return True + for line in completed.stdout.splitlines(): + if line.startswith("Version: "): + return line.removeprefix("Version: ").strip() == version + return False diff --git a/cli/python/base_setup/checks.py b/cli/python/base_setup/checks.py new file mode 100644 index 0000000..926bbc5 --- /dev/null +++ b/cli/python/base_setup/checks.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ArtifactCheck: + name: str + ok: bool + message: str + fix: str + status: str = "" + + +def check_to_json(check: ArtifactCheck) -> dict[str, str | bool]: + return { + "name": check.name, + "ok": check.ok, + "message": check.message, + "fix": check.fix, + } + + +def check_to_doctor_json(check: ArtifactCheck) -> dict[str, str]: + return { + "status": doctor_status(check), + "name": check.name, + "message": check.message, + "fix": check.fix, + } + + +def doctor_status(check: ArtifactCheck) -> str: + return check.status or ("ok" if check.ok else "error") + + +def print_doctor_finding(status: str, name: str, message: str, fix: str = "") -> None: + print(f"{status:<5} {name:<26} {message}") + if fix: + print(f" Fix: {fix}") diff --git a/cli/python/base_setup/delegates.py b/cli/python/base_setup/delegates.py new file mode 100644 index 0000000..faff5a0 --- /dev/null +++ b/cli/python/base_setup/delegates.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from pathlib import Path + +import base_cli + +from . import process +from .checks import ArtifactCheck +from .errors import ArtifactError +from .manifest import BaseManifest + + +def check_brewfile(manifest: BaseManifest) -> ArtifactCheck: + try: + brewfile_path = resolve_brewfile_path(manifest) + except ArtifactError as exc: + return ArtifactCheck( + name="brewfile", + ok=False, + message=str(exc), + fix=f"Update '{manifest.path}' or run 'basectl setup {manifest.project_name}'.", + ) + + if not process.command_exists("brew"): + return ArtifactCheck( + name="brewfile", + ok=False, + message=f"Homebrew is required to check Brewfile dependencies from '{brewfile_path}'.", + fix="basectl setup", + ) + + ok = process.run_check(["brew", "bundle", "check", f"--file={brewfile_path}"]) + if ok: + return ArtifactCheck( + name="brewfile", + ok=True, + message=f"Brewfile dependencies are satisfied for '{brewfile_path}'.", + fix="", + ) + return ArtifactCheck( + name="brewfile", + ok=False, + message=f"Brewfile dependencies are not satisfied for '{brewfile_path}'.", + fix=f"basectl setup {manifest.project_name}", + ) + + +def check_mise(manifest: BaseManifest) -> ArtifactCheck: + try: + mise_path = resolve_mise_path(manifest) + except ArtifactError as exc: + return ArtifactCheck( + name="mise", + ok=False, + message=str(exc), + fix=f"Update '{manifest.path}' or run 'basectl setup {manifest.project_name}'.", + ) + + if not process.command_exists("mise"): + return ArtifactCheck( + name="mise", + ok=False, + message=f"mise is required for project config '{mise_path}'.", + fix="Install mise, then run 'basectl setup'.", + ) + + return ArtifactCheck( + name="mise", + ok=False, + message=( + f"mise config '{mise_path}' is present and the mise CLI is available, " + "but installed mise tools are not verified." + ), + fix=f"Run 'basectl setup {manifest.project_name}' to install declared mise tools.", + status="warn", + ) + + +def reconcile_brewfile(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: + if manifest.brewfile is None: + return + + brewfile_path = resolve_brewfile_path(manifest) + command = ["brew", "bundle", f"--file={brewfile_path}"] + + if dry_run: + process.dry_run_command(ctx, command) + return + + if not process.command_exists("brew"): + raise ArtifactError(f"Homebrew is required to install Brewfile dependencies from '{brewfile_path}'.") + + ctx.log.info("Installing Homebrew dependencies from Brewfile '%s'.", brewfile_path) + process.run_command(ctx, command) + + +def reconcile_mise(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: + if manifest.mise is None: + return + + mise_path = resolve_mise_path(manifest) + project_root = manifest.path.parent.resolve() + command = ["mise", "install"] + if dry_run: + process.dry_run_command(ctx, command, cwd=project_root) + return + + if not process.command_exists("mise"): + raise ArtifactError(f"mise is required to install project tool versions from '{mise_path}'.") + + ctx.log.info("Installing mise-managed tools from '%s'.", mise_path) + process.run_command(ctx, command, cwd=project_root) + + +def resolve_brewfile_path(manifest: BaseManifest) -> Path: + if manifest.brewfile is None: + raise ArtifactError(f"{manifest.path}: brewfile is not configured.") + + brewfile = Path(manifest.brewfile) + if brewfile.is_absolute(): + raise ArtifactError(f"{manifest.path}: brewfile must be relative to the project root.") + + project_root = manifest.path.parent.resolve() + brewfile_path = (project_root / brewfile).resolve() + if not brewfile_path.is_relative_to(project_root): + raise ArtifactError(f"{manifest.path}: brewfile must stay inside the project root.") + if not brewfile_path.is_file(): + raise ArtifactError(f"{manifest.path}: brewfile '{manifest.brewfile}' does not exist.") + return brewfile_path + + +def resolve_mise_path(manifest: BaseManifest) -> Path: + if manifest.mise is None: + raise ArtifactError(f"{manifest.path}: mise is not configured.") + + mise = Path(manifest.mise) + if mise.is_absolute(): + raise ArtifactError(f"{manifest.path}: mise must be relative to the project root.") + project_root = manifest.path.parent.resolve() + mise_path = (project_root / mise).resolve() + if not mise_path.is_relative_to(project_root): + raise ArtifactError(f"{manifest.path}: mise must stay inside the project root.") + if not mise_path.is_file(): + raise ArtifactError(f"{manifest.path}: mise config '{manifest.mise}' does not exist.") + return mise_path diff --git a/cli/python/base_setup/engine.py b/cli/python/base_setup/engine.py index 1bc0626..937ba1e 100644 --- a/cli/python/base_setup/engine.py +++ b/cli/python/base_setup/engine.py @@ -1,15 +1,6 @@ from __future__ import annotations -# pylint: disable=too-many-lines - import json -import os -import shlex -import shutil -import subprocess -import sys -import tempfile -import venv from dataclasses import dataclass from pathlib import Path @@ -17,22 +8,35 @@ from base_cli.config import UserConfig, read_user_config from base_cli.paths import discover_manifest -from .manifest import ArtifactRequest, BaseManifest, IdeConfig, ManifestError, read_manifest -from .registry import ArtifactDefinition, get_artifact_definition +from .artifacts import check_artifact +from .artifacts import merge_artifacts +from .artifacts import reconcile_artifact +from .artifacts import resolve_artifact_definitions +from .checks import ArtifactCheck +from .checks import check_to_doctor_json +from .checks import check_to_json +from .checks import doctor_status +from .checks import print_doctor_finding +from .delegates import check_brewfile +from .delegates import check_mise +from .delegates import reconcile_brewfile +from .delegates import reconcile_mise +from .errors import ArtifactError +from .ide import check_ide_extensions +from .ide import check_ide_installs +from .ide import check_ide_settings +from .ide import effective_ide_config +from .ide import ide_preference_warning_checks +from .ide import log_ide_preference_warnings +from .ide import reconcile_ide_extensions +from .ide import reconcile_ide_installs +from .ide import reconcile_ide_settings +from .manifest import BaseManifest, ManifestError, read_manifest app = base_cli.App(name="base_setup") -@dataclass(frozen=True) -class ArtifactCheck: - name: str - ok: bool - message: str - fix: str - status: str = "" - - @dataclass(frozen=True) class ManifestAction: action: str @@ -40,33 +44,6 @@ class ManifestAction: output_format: str -@dataclass(frozen=True) -class IdeDefinition: - name: str - label: str - cli: str - cask: str - settings_app_dir: str - - -IDE_DEFINITIONS = { - "vscode": IdeDefinition( - name="vscode", - label="VS Code", - cli="code", - cask="visual-studio-code", - settings_app_dir="Code", - ), - "cursor": IdeDefinition( - name="cursor", - label="Cursor", - cli="cursor", - cask="cursor", - settings_app_dir="Cursor", - ), -} - - def main(argv: list[str] | None = None) -> int: result = app.click_command.main(args=argv, standalone_mode=False) return int(result or 0) @@ -138,10 +115,6 @@ def run_manifest_action( return 2 -class ArtifactError(RuntimeError): - pass - - def validate_project_name(manifest: BaseManifest, expected_project: str | None) -> None: if expected_project and manifest.project_name != expected_project: raise ManifestError( @@ -287,191 +260,6 @@ def manifest_checks(default_manifest: BaseManifest, manifest: BaseManifest) -> t return tuple(checks) -def check_brewfile(manifest: BaseManifest) -> ArtifactCheck: - try: - brewfile_path = resolve_brewfile_path(manifest) - except ArtifactError as exc: - return ArtifactCheck( - name="brewfile", - ok=False, - message=str(exc), - fix=f"Update '{manifest.path}' or run 'basectl setup {manifest.project_name}'.", - ) - - if not command_exists("brew"): - return ArtifactCheck( - name="brewfile", - ok=False, - message=f"Homebrew is required to check Brewfile dependencies from '{brewfile_path}'.", - fix="basectl setup", - ) - - ok = run_check(["brew", "bundle", "check", f"--file={brewfile_path}"]) - if ok: - return ArtifactCheck( - name="brewfile", - ok=True, - message=f"Brewfile dependencies are satisfied for '{brewfile_path}'.", - fix="", - ) - return ArtifactCheck( - name="brewfile", - ok=False, - message=f"Brewfile dependencies are not satisfied for '{brewfile_path}'.", - fix=f"basectl setup {manifest.project_name}", - ) - - -def check_mise(manifest: BaseManifest) -> ArtifactCheck: - try: - mise_path = resolve_mise_path(manifest) - except ArtifactError as exc: - return ArtifactCheck( - name="mise", - ok=False, - message=str(exc), - fix=f"Update '{manifest.path}' or run 'basectl setup {manifest.project_name}'.", - ) - - if not command_exists("mise"): - return ArtifactCheck( - name="mise", - ok=False, - message=f"mise is required for project config '{mise_path}'.", - fix="Install mise, then run 'basectl setup'.", - ) - - return ArtifactCheck( - name="mise", - ok=False, - message=( - f"mise config '{mise_path}' is present and the mise CLI is available, " - "but installed mise tools are not verified." - ), - fix=f"Run 'basectl setup {manifest.project_name}' to install declared mise tools.", - status="warn", - ) - - -def check_artifact( - project: str, - artifact: ArtifactRequest, - definition: ArtifactDefinition, -) -> ArtifactCheck: - if definition.manager == "homebrew": - return check_homebrew_artifact(project, artifact, definition) - if definition.manager == "pip": - return check_python_artifact(project, artifact, definition) - return ArtifactCheck( - name=artifact.name, - ok=False, - message=f"Artifact manager '{definition.manager}' is not implemented.", - fix=f"basectl setup {project}", - ) - - -def check_homebrew_artifact( - project: str, - artifact: ArtifactRequest, - definition: ArtifactDefinition, -) -> ArtifactCheck: - if artifact.version != "latest": - return ArtifactCheck( - name=artifact.name, - ok=False, - message=( - f"Homebrew artifact '{artifact.name}' specifies version '{artifact.version}', " - "but Base only supports Homebrew artifact version 'latest' right now." - ), - fix=f"Update '{artifact.name}' in the project manifest to use version 'latest'.", - ) - if not command_exists("brew"): - return ArtifactCheck( - name=artifact.name, - ok=False, - message=f"Homebrew is required to check artifact '{artifact.name}'.", - fix="basectl setup", - ) - ok = run_check(["brew", "list", definition.package]) - if ok: - return ArtifactCheck( - name=artifact.name, - ok=True, - message=f"Artifact '{artifact.name}' is installed via Homebrew package '{definition.package}'.", - fix="", - ) - return ArtifactCheck( - name=artifact.name, - ok=False, - message=f"Artifact '{artifact.name}' is not installed via Homebrew package '{definition.package}'.", - fix=f"basectl setup {project}", - ) - - -def check_python_artifact( - project: str, - artifact: ArtifactRequest, - definition: ArtifactDefinition, -) -> ArtifactCheck: - venv_dir = project_venv_dir(project) - python_bin = venv_dir / "bin" / "python" - if python_artifact_installed(python_bin, definition.package, artifact.version): - return ArtifactCheck( - name=artifact.name, - ok=True, - message=f"Python artifact '{artifact.name}' is installed in the project virtual environment.", - fix="", - ) - return ArtifactCheck( - name=artifact.name, - ok=False, - message=f"Python artifact '{artifact.name}' is not installed in the project virtual environment.", - fix=f"basectl setup {project}", - ) - - -def check_to_json(check: ArtifactCheck) -> dict[str, str | bool]: - return { - "name": check.name, - "ok": check.ok, - "message": check.message, - "fix": check.fix, - } - - -def check_to_doctor_json(check: ArtifactCheck) -> dict[str, str]: - return { - "status": doctor_status(check), - "name": check.name, - "message": check.message, - "fix": check.fix, - } - - -def doctor_status(check: ArtifactCheck) -> str: - return check.status or ("ok" if check.ok else "error") - - -def print_doctor_finding(status: str, name: str, message: str, fix: str = "") -> None: - print(f"{status:<5} {name:<26} {message}") - if fix: - print(f" Fix: {fix}") - - -def resolve_artifact_definitions(artifacts: tuple[ArtifactRequest, ...]) -> tuple[ArtifactDefinition, ...]: - definitions: list[ArtifactDefinition] = [] - for artifact in artifacts: - definition = get_artifact_definition(artifact.artifact_type, artifact.name) - if definition is None: - raise ArtifactError( - "Unsupported artifact " - f"'{artifact.name}' of type '{artifact.artifact_type}'. " - "Base does not know how to manage this artifact yet." - ) - definitions.append(definition) - return tuple(definitions) - - def effective_manifest_with_user_config(manifest: BaseManifest, user_config: UserConfig) -> BaseManifest: return BaseManifest( path=manifest.path, @@ -482,656 +270,3 @@ def effective_manifest_with_user_config(manifest: BaseManifest, user_config: Use mise=manifest.mise, test=manifest.test, ) - - -def effective_ide_config(project_ide: dict[str, IdeConfig], user_config: UserConfig) -> dict[str, IdeConfig]: - if user_config.ide.enabled is False: - return {} - - effective: dict[str, IdeConfig] = {} - ide_names = sorted(set(project_ide) | set(user_config.ide.preferences)) - for ide_name in ide_names: - user_preference = user_config.ide.preferences.get(ide_name) - if user_preference is not None and user_preference.enabled is False: - continue - - project_config = project_ide.get(ide_name, IdeConfig(install=False, extensions=(), settings={})) - install = project_config.install - if user_preference is not None and user_preference.install is not None: - install = user_preference.install - - extensions = list(project_config.extensions) - if user_preference is not None: - for extension in user_preference.extra_extensions: - if extension not in extensions: - extensions.append(extension) - - settings = {} - if user_preference is not None: - settings.update(user_preference.settings) - settings.update(project_config.settings) - - if install or extensions or settings: - effective[ide_name] = IdeConfig( - install=install, - extensions=tuple(extensions), - settings=settings, - ) - return effective - - -def ide_preference_warning_checks(manifest: BaseManifest, user_config: UserConfig) -> list[ArtifactCheck]: - checks: list[ArtifactCheck] = [] - if user_config.ide.enabled is False and manifest.ide: - checks.append( - ArtifactCheck( - name="user IDE config", - ok=False, - message="User config disables all IDE setup and checks for this machine.", - fix="Remove or change 'ide.enabled: false' in ~/.base.d/config.yaml to re-enable IDE work.", - status="warn", - ) - ) - - for ide_name, project_config in manifest.ide.items(): - user_preference = user_config.ide.preferences.get(ide_name) - if user_preference is None: - continue - if user_preference.enabled is False: - checks.append( - ArtifactCheck( - name=f"user IDE config: {ide_name}", - ok=False, - message=f"User config disables {ide_name} IDE setup and checks for this machine.", - fix=f"Remove or change 'ide.{ide_name}.enabled: false' in ~/.base.d/config.yaml to re-enable it.", - status="warn", - ) - ) - continue - conflicting_settings = sorted(set(project_config.settings) & set(user_preference.settings)) - for key in conflicting_settings: - if project_config.settings[key] == user_preference.settings[key]: - continue - checks.append( - ArtifactCheck( - name=f"user IDE setting: {ide_name}.{key}", - ok=False, - message=( - f"User config setting 'ide.{ide_name}.settings.{key}' is ignored because " - "the project manifest declares the same setting." - ), - fix=( - f"Remove 'ide.{ide_name}.settings.{key}' from ~/.base.d/config.yaml " - "or update the project manifest." - ), - status="warn", - ) - ) - return checks - - -def log_ide_preference_warnings(ctx: base_cli.Context, checks: list[ArtifactCheck]) -> None: - for check in checks: - ctx.log.warning(check.message) - if check.fix: - ctx.log.warning("Fix: %s", check.fix) - - -def merge_artifacts( - default_artifacts: tuple[ArtifactRequest, ...], - manifest_artifacts: tuple[ArtifactRequest, ...], -) -> tuple[ArtifactRequest, ...]: - merged: dict[tuple[str, str], ArtifactRequest] = {} - - for artifact in default_artifacts: - merged[(artifact.artifact_type, artifact.name)] = artifact - - for artifact in manifest_artifacts: - key = (artifact.artifact_type, artifact.name) - existing = merged.get(key) - if existing is not None and existing.version != artifact.version: - raise ArtifactError( - "Artifact " - f"'{artifact.name}' of type '{artifact.artifact_type}' is declared by defaults " - f"as version '{existing.version}' and by the project manifest as version '{artifact.version}'." - ) - if existing is not None: - artifact = ArtifactRequest( - artifact_type=artifact.artifact_type, - name=artifact.name, - version=artifact.version, - bootstrap=existing.bootstrap or artifact.bootstrap, - ) - merged[key] = artifact - - return tuple(merged.values()) - - -def reconcile_brewfile(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: - if manifest.brewfile is None: - return - - brewfile_path = resolve_brewfile_path(manifest) - command = ["brew", "bundle", f"--file={brewfile_path}"] - - if dry_run: - dry_run_command(ctx, command) - return - - if not command_exists("brew"): - raise ArtifactError(f"Homebrew is required to install Brewfile dependencies from '{brewfile_path}'.") - - ctx.log.info("Installing Homebrew dependencies from Brewfile '%s'.", brewfile_path) - run_command(ctx, command) - - -def reconcile_mise(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: - if manifest.mise is None: - return - - mise_path = resolve_mise_path(manifest) - project_root = manifest.path.parent.resolve() - command = ["mise", "install"] - if dry_run: - dry_run_command(ctx, command, cwd=project_root) - return - - if not command_exists("mise"): - raise ArtifactError(f"mise is required to install project tool versions from '{mise_path}'.") - - ctx.log.info("Installing mise-managed tools from '%s'.", mise_path) - run_command(ctx, command, cwd=project_root) - - -def resolve_brewfile_path(manifest: BaseManifest) -> Path: - if manifest.brewfile is None: - raise ArtifactError(f"{manifest.path}: brewfile is not configured.") - - brewfile = Path(manifest.brewfile) - if brewfile.is_absolute(): - raise ArtifactError(f"{manifest.path}: brewfile must be relative to the project root.") - - project_root = manifest.path.parent.resolve() - brewfile_path = (project_root / brewfile).resolve() - if not brewfile_path.is_relative_to(project_root): - raise ArtifactError(f"{manifest.path}: brewfile must stay inside the project root.") - if not brewfile_path.is_file(): - raise ArtifactError(f"{manifest.path}: brewfile '{manifest.brewfile}' does not exist.") - return brewfile_path - - -def resolve_mise_path(manifest: BaseManifest) -> Path: - if manifest.mise is None: - raise ArtifactError(f"{manifest.path}: mise is not configured.") - - mise = Path(manifest.mise) - if mise.is_absolute(): - raise ArtifactError(f"{manifest.path}: mise must be relative to the project root.") - project_root = manifest.path.parent.resolve() - mise_path = (project_root / mise).resolve() - if not mise_path.is_relative_to(project_root): - raise ArtifactError(f"{manifest.path}: mise must stay inside the project root.") - if not mise_path.is_file(): - raise ArtifactError(f"{manifest.path}: mise config '{manifest.mise}' does not exist.") - return mise_path - - - -def reconcile_ide_installs(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: - for ide_name, ide_config in manifest.ide.items(): - definition = IDE_DEFINITIONS[ide_name] - if not ide_config.install: - ctx.log.debug("IDE '%s' does not request installation; skipping cask install.", ide_name) - continue - reconcile_ide_install(ctx, definition, dry_run=dry_run) - - -def reconcile_ide_install(ctx: base_cli.Context, definition: IdeDefinition, dry_run: bool) -> None: - command = ["brew", "install", "--cask", definition.cask] - if dry_run: - dry_run_command(ctx, command) - return - - if not command_exists("brew"): - raise ArtifactError(f"Homebrew is required to install {definition.label}.") - - if run_check(["brew", "list", "--cask", definition.cask]): - ctx.log.info("%s is already installed via Homebrew cask '%s'.", definition.label, definition.cask) - else: - ctx.log.info("Installing %s via Homebrew cask '%s'.", definition.label, definition.cask) - run_command(ctx, command) - - if command_exists(definition.cli): - ctx.log.info("%s CLI '%s' is available on PATH.", definition.label, definition.cli) - else: - ctx.log.warning( - "%s is installed, but CLI '%s' is not on PATH. Enable the IDE shell command before extension setup.", - definition.label, - definition.cli, - ) - - -def check_ide_installs(manifest: BaseManifest) -> list[ArtifactCheck]: - checks: list[ArtifactCheck] = [] - for ide_name, ide_config in manifest.ide.items(): - definition = IDE_DEFINITIONS[ide_name] - if ide_config.install: - checks.append(check_ide_install(manifest.project_name, definition)) - return checks - - -def reconcile_ide_extensions(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: - for ide_name, ide_config in manifest.ide.items(): - if not ide_config.extensions: - continue - definition = IDE_DEFINITIONS[ide_name] - if dry_run: - for extension in ide_config.extensions: - dry_run_command(ctx, [definition.cli, "--install-extension", extension]) - continue - if not command_exists(definition.cli): - ctx.log.warning( - "%s CLI '%s' is not on PATH; skipping extension setup.", - definition.label, - definition.cli, - ) - continue - installed_extensions = list_ide_extensions(definition) - for extension in ide_config.extensions: - if extension in installed_extensions: - ctx.log.debug( - "%s extension '%s' is already installed.", - definition.label, - extension, - ) - continue - ctx.log.info("Installing %s extension '%s'.", definition.label, extension) - run_command(ctx, [definition.cli, "--install-extension", extension]) - - -def list_ide_extensions(definition: IdeDefinition) -> set[str]: - completed = subprocess.run( - [definition.cli, "--list-extensions"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - check=False, - ) - if completed.returncode: - stderr = (completed.stderr or "").strip() - message = f"Unable to list {definition.label} extensions with '{definition.cli} --list-extensions'." - if stderr: - message = f"{message}\n{stderr}" - raise ArtifactError(message) - return {line.strip() for line in completed.stdout.splitlines() if line.strip()} - - -def check_ide_extensions(manifest: BaseManifest) -> list[ArtifactCheck]: - checks: list[ArtifactCheck] = [] - for ide_name, ide_config in manifest.ide.items(): - if not ide_config.extensions: - continue - definition = IDE_DEFINITIONS[ide_name] - checks.extend( - check_ide_extension(manifest.project_name, definition, extension) - for extension in ide_config.extensions - ) - return checks - - -def check_ide_extension(project: str, definition: IdeDefinition, extension: str) -> ArtifactCheck: - if not command_exists(definition.cli): - return ArtifactCheck( - name=extension, - ok=False, - message=( - f"Cannot check {definition.label} extension '{extension}' " - f"because CLI '{definition.cli}' is not on PATH." - ), - fix=( - f"Enable the '{definition.cli}' shell command from {definition.label}, " - f"then run 'basectl setup {project}'." - ), - ) - - try: - installed_extensions = list_ide_extensions(definition) - except ArtifactError as exc: - return ArtifactCheck( - name=extension, - ok=False, - message=str(exc), - fix=f"basectl setup {project}", - ) - - if extension in installed_extensions: - return ArtifactCheck( - name=extension, - ok=True, - message=f"{definition.label} extension '{extension}' is installed.", - fix="", - ) - return ArtifactCheck( - name=extension, - ok=False, - message=f"{definition.label} extension '{extension}' is not installed.", - fix=f"basectl setup {project}", - ) - - -def reconcile_ide_settings(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: - for ide_name, ide_config in manifest.ide.items(): - if not ide_config.settings: - continue - definition = IDE_DEFINITIONS[ide_name] - resolved_settings = resolve_ide_settings(manifest.project_name, ide_config.settings) - merge_ide_settings(ctx, definition, resolved_settings, dry_run=dry_run) - - -def resolve_ide_settings(project: str, settings: dict[str, object]) -> dict[str, object]: - resolved: dict[str, object] = {} - for key, value in settings.items(): - if key == "python.defaultInterpreterPath" and value == "auto": - resolved[key] = str(project_venv_dir(project) / "bin" / "python") - else: - resolved[key] = value - return resolved - - -def ide_settings_file(definition: IdeDefinition) -> Path: - home = Path(os.environ.get("HOME") or Path.home()).expanduser() - if sys.platform == "darwin": - return home / "Library" / "Application Support" / definition.settings_app_dir / "User" / "settings.json" - config_home = Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config").expanduser() - return config_home / definition.settings_app_dir / "User" / "settings.json" - - -def read_ide_settings(definition: IdeDefinition) -> dict[str, object]: - settings_file = ide_settings_file(definition) - if not settings_file.exists(): - return {} - try: - data = json.loads(settings_file.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - raise ArtifactError(f"{settings_file}: invalid JSON: {exc}") from exc - if not isinstance(data, dict): - raise ArtifactError(f"{settings_file}: expected a JSON object.") - return data - - -def merge_ide_settings( - ctx: base_cli.Context, - definition: IdeDefinition, - desired_settings: dict[str, object], - dry_run: bool, -) -> None: - settings_file = ide_settings_file(definition) - current_settings = read_ide_settings(definition) - merged_settings = dict(current_settings) - added: dict[str, object] = {} - - for key, value in desired_settings.items(): - if key not in current_settings: - merged_settings[key] = value - added[key] = value - elif current_settings[key] != value: - ctx.log.info( - "%s setting '%s' already set by user; leaving intact.", - definition.label, - key, - ) - - if not added: - ctx.log.debug("%s user settings already contain all Base-managed keys.", definition.label) - return - - if dry_run: - for key, value in added.items(): - ctx.log.info( - "[DRY-RUN] Would set %s user setting '%s' to %s.", - definition.label, - key, - json.dumps(value, sort_keys=True), - ) - return - - settings_file.parent.mkdir(parents=True, exist_ok=True) - write_json_atomic(settings_file, merged_settings) - ctx.log.info("Updated %s user settings at '%s'.", definition.label, settings_file) - - -def write_json_atomic(path: Path, data: dict[str, object]) -> None: - with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as tmp_file: - json.dump(data, tmp_file, indent=2, sort_keys=True) - tmp_file.write("\n") - tmp_path = Path(tmp_file.name) - tmp_path.replace(path) - - -def check_ide_settings(manifest: BaseManifest) -> list[ArtifactCheck]: - checks: list[ArtifactCheck] = [] - for ide_name, ide_config in manifest.ide.items(): - if not ide_config.settings: - continue - definition = IDE_DEFINITIONS[ide_name] - resolved_settings = resolve_ide_settings(manifest.project_name, ide_config.settings) - checks.extend( - check_ide_setting(manifest.project_name, definition, key, value) - for key, value in resolved_settings.items() - ) - return checks - - -def check_ide_setting( - project: str, - definition: IdeDefinition, - key: str, - expected_value: object, -) -> ArtifactCheck: - settings_file = ide_settings_file(definition) - try: - current_settings = read_ide_settings(definition) - except ArtifactError as exc: - return ArtifactCheck( - name=f"{definition.label} setting: {key}", - ok=False, - message=str(exc), - fix=f"Repair '{settings_file}' and run 'basectl setup {project}'.", - ) - - if key not in current_settings: - return ArtifactCheck( - name=f"{definition.label} setting: {key}", - ok=False, - message=f"{definition.label} setting '{key}' is absent from '{settings_file}'.", - fix=f"basectl setup {project}", - ) - if current_settings[key] == expected_value: - return ArtifactCheck( - name=f"{definition.label} setting: {key}", - ok=True, - message=f"{definition.label} setting '{key}' matches the Base manifest.", - fix="", - ) - return ArtifactCheck( - name=f"{definition.label} setting: {key}", - ok=False, - message=( - f"{definition.label} setting '{key}' is set to {json.dumps(current_settings[key], sort_keys=True)}; " - f"expected {json.dumps(expected_value, sort_keys=True)}. Base will not overwrite user settings." - ), - fix=f"Update '{settings_file}' manually or remove the key and run 'basectl setup {project}'.", - ) - - -def check_ide_install(project: str, definition: IdeDefinition) -> ArtifactCheck: - if not command_exists("brew"): - return ArtifactCheck( - name=f"{definition.label} app", - ok=False, - message=f"Homebrew is required to check {definition.label} installation.", - fix="basectl setup", - ) - - cask_installed = run_check(["brew", "list", "--cask", definition.cask]) - cli_available = command_exists(definition.cli) - - if cask_installed and cli_available: - return ArtifactCheck( - name=f"{definition.label} app", - ok=True, - message=f"{definition.label} is installed and CLI '{definition.cli}' is on PATH.", - fix="", - ) - if not cask_installed: - return ArtifactCheck( - name=f"{definition.label} app", - ok=False, - message=f"{definition.label} is not installed via Homebrew cask '{definition.cask}'.", - fix=f"basectl setup {project}", - ) - return ArtifactCheck( - name=f"{definition.label} CLI", - ok=False, - message=f"{definition.label} is installed, but CLI '{definition.cli}' is not on PATH.", - fix=f"Enable the '{definition.cli}' shell command from {definition.label}.", - ) - - -def reconcile_artifact( - ctx: base_cli.Context, - definition: ArtifactDefinition, - version: str, - project: str, - dry_run: bool, -) -> None: - if definition.manager == "homebrew": - reconcile_homebrew_artifact(ctx, definition, version, dry_run=dry_run) - return - if definition.manager == "pip": - reconcile_python_artifact(ctx, definition, version, project, dry_run=dry_run) - return - raise ArtifactError(f"Artifact manager '{definition.manager}' is not implemented.") - - -def reconcile_homebrew_artifact( - ctx: base_cli.Context, - definition: ArtifactDefinition, - version: str, - dry_run: bool, -) -> None: - if version != "latest": - raise ArtifactError( - "Homebrew artifact " - f"'{definition.name}' specifies version '{version}', but Base only supports " - "Homebrew artifact version 'latest' right now." - ) - - command = ["brew", "install", definition.package] - if dry_run: - dry_run_command(ctx, command) - return - - if not command_exists("brew"): - raise ArtifactError(f"Homebrew is required to install artifact '{definition.name}'.") - - if run_check(["brew", "list", definition.package]): - ctx.log.info( - "Artifact '%s' is already installed via Homebrew package '%s'.", - definition.name, - definition.package, - ) - return - - ctx.log.info( - "Installing artifact '%s' via Homebrew package '%s' (%s).", - definition.name, - definition.package, - version, - ) - run_command(ctx, command) - - -def reconcile_python_artifact( - ctx: base_cli.Context, - definition: ArtifactDefinition, - version: str, - project: str, - dry_run: bool, -) -> None: - venv_dir = project_venv_dir(project) - python_bin = venv_dir / "bin" / "python" - requirement = f"{definition.package}=={version}" if version != "latest" else definition.package - - if python_artifact_installed(python_bin, definition.package, version): - ctx.log.info("Python artifact '%s' is already installed in the project virtual environment.", definition.name) - return - - if dry_run: - if not python_bin.exists(): - ctx.log.info("[DRY-RUN] Would create project virtual environment at '%s'.", venv_dir) - dry_run_command(ctx, [str(python_bin), "-m", "pip", "install", requirement]) - return - - if not python_bin.exists(): - ctx.log.info("Creating project virtual environment at '%s'.", venv_dir) - venv.create(venv_dir, with_pip=True) - - ctx.log.info("Installing Python artifact '%s' into project virtual environment.", definition.name) - run_command(ctx, [str(python_bin), "-m", "pip", "install", requirement]) - - -def project_venv_dir(project: str) -> Path: - override = os.environ.get("BASE_PROJECT_VENV_DIR") - if override: - return Path(override).expanduser() - return Path.home() / ".base.d" / project / ".venv" - - -def python_artifact_installed(python_bin: Path, package: str, version: str) -> bool: - if not python_bin.exists(): - return False - command = [str(python_bin), "-m", "pip", "show", package] - completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False) - if completed.returncode: - return False - if version == "latest": - return True - for line in completed.stdout.splitlines(): - if line.startswith("Version: "): - return line.removeprefix("Version: ").strip() == version - return False - - -def command_exists(name: str) -> bool: - return shutil.which(name) is not None - - -def run_check(command: list[str]) -> bool: - return subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False).returncode == 0 - - -def run_command(ctx: base_cli.Context, command: list[str], cwd: Path | None = None) -> None: - # Keep stdout live for installer progress; capture stderr for persistent failure logs. - completed = subprocess.run(command, cwd=cwd, stderr=subprocess.PIPE, text=True, check=False) - if completed.returncode: - stderr = (completed.stderr or "").strip() - message = f"Command failed with exit {completed.returncode}: {format_command(command)}" - if stderr: - message = f"{message}\n{stderr}" - raise ArtifactError(message) - if cwd is not None: - ctx.log.debug("Command succeeded in '%s': %s", cwd, format_command(command)) - else: - ctx.log.debug("Command succeeded: %s", format_command(command)) - - -def dry_run_command(ctx: base_cli.Context, command: list[str], cwd: Path | None = None) -> None: - if cwd is not None: - ctx.log.info("[DRY-RUN] Would run in '%s': %s", cwd, format_command(command)) - return - ctx.log.info("[DRY-RUN] Would run: %s", format_command(command)) - - -def format_command(command: list[str]) -> str: - return shlex.join(command) diff --git a/cli/python/base_setup/errors.py b/cli/python/base_setup/errors.py new file mode 100644 index 0000000..c79db66 --- /dev/null +++ b/cli/python/base_setup/errors.py @@ -0,0 +1,5 @@ +from __future__ import annotations + + +class ArtifactError(RuntimeError): + pass diff --git a/cli/python/base_setup/ide.py b/cli/python/base_setup/ide.py new file mode 100644 index 0000000..c4a1c2c --- /dev/null +++ b/cli/python/base_setup/ide.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +import base_cli +from base_cli.config import UserConfig + +from . import artifacts +from . import process +from .checks import ArtifactCheck +from .errors import ArtifactError +from .manifest import BaseManifest, IdeConfig + + +@dataclass(frozen=True) +class IdeDefinition: + name: str + label: str + cli: str + cask: str + settings_app_dir: str + + +IDE_DEFINITIONS = { + "vscode": IdeDefinition( + name="vscode", + label="VS Code", + cli="code", + cask="visual-studio-code", + settings_app_dir="Code", + ), + "cursor": IdeDefinition( + name="cursor", + label="Cursor", + cli="cursor", + cask="cursor", + settings_app_dir="Cursor", + ), +} + + +def effective_ide_config(project_ide: dict[str, IdeConfig], user_config: UserConfig) -> dict[str, IdeConfig]: + if user_config.ide.enabled is False: + return {} + + effective: dict[str, IdeConfig] = {} + ide_names = sorted(set(project_ide) | set(user_config.ide.preferences)) + for ide_name in ide_names: + user_preference = user_config.ide.preferences.get(ide_name) + if user_preference is not None and user_preference.enabled is False: + continue + + project_config = project_ide.get(ide_name, IdeConfig(install=False, extensions=(), settings={})) + install = project_config.install + if user_preference is not None and user_preference.install is not None: + install = user_preference.install + + extensions = list(project_config.extensions) + if user_preference is not None: + for extension in user_preference.extra_extensions: + if extension not in extensions: + extensions.append(extension) + + settings = {} + if user_preference is not None: + settings.update(user_preference.settings) + settings.update(project_config.settings) + + if install or extensions or settings: + effective[ide_name] = IdeConfig( + install=install, + extensions=tuple(extensions), + settings=settings, + ) + return effective + + +def ide_preference_warning_checks(manifest: BaseManifest, user_config: UserConfig) -> list[ArtifactCheck]: + checks: list[ArtifactCheck] = [] + if user_config.ide.enabled is False and manifest.ide: + checks.append( + ArtifactCheck( + name="user IDE config", + ok=False, + message="User config disables all IDE setup and checks for this machine.", + fix="Remove or change 'ide.enabled: false' in ~/.base.d/config.yaml to re-enable IDE work.", + status="warn", + ) + ) + + for ide_name, project_config in manifest.ide.items(): + user_preference = user_config.ide.preferences.get(ide_name) + if user_preference is None: + continue + if user_preference.enabled is False: + checks.append( + ArtifactCheck( + name=f"user IDE config: {ide_name}", + ok=False, + message=f"User config disables {ide_name} IDE setup and checks for this machine.", + fix=f"Remove or change 'ide.{ide_name}.enabled: false' in ~/.base.d/config.yaml to re-enable it.", + status="warn", + ) + ) + continue + conflicting_settings = sorted(set(project_config.settings) & set(user_preference.settings)) + for key in conflicting_settings: + if project_config.settings[key] == user_preference.settings[key]: + continue + checks.append( + ArtifactCheck( + name=f"user IDE setting: {ide_name}.{key}", + ok=False, + message=( + f"User config setting 'ide.{ide_name}.settings.{key}' is ignored because " + "the project manifest declares the same setting." + ), + fix=( + f"Remove 'ide.{ide_name}.settings.{key}' from ~/.base.d/config.yaml " + "or update the project manifest." + ), + status="warn", + ) + ) + return checks + + +def log_ide_preference_warnings(ctx: base_cli.Context, checks: list[ArtifactCheck]) -> None: + for check in checks: + ctx.log.warning(check.message) + if check.fix: + ctx.log.warning("Fix: %s", check.fix) + + +def reconcile_ide_installs(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: + for ide_name, ide_config in manifest.ide.items(): + definition = IDE_DEFINITIONS[ide_name] + if not ide_config.install: + ctx.log.debug("IDE '%s' does not request installation; skipping cask install.", ide_name) + continue + reconcile_ide_install(ctx, definition, dry_run=dry_run) + + +def reconcile_ide_install(ctx: base_cli.Context, definition: IdeDefinition, dry_run: bool) -> None: + command = ["brew", "install", "--cask", definition.cask] + if dry_run: + process.dry_run_command(ctx, command) + return + + if not process.command_exists("brew"): + raise ArtifactError(f"Homebrew is required to install {definition.label}.") + + if process.run_check(["brew", "list", "--cask", definition.cask]): + ctx.log.info("%s is already installed via Homebrew cask '%s'.", definition.label, definition.cask) + else: + ctx.log.info("Installing %s via Homebrew cask '%s'.", definition.label, definition.cask) + process.run_command(ctx, command) + + if process.command_exists(definition.cli): + ctx.log.info("%s CLI '%s' is available on PATH.", definition.label, definition.cli) + else: + ctx.log.warning( + "%s is installed, but CLI '%s' is not on PATH. Enable the IDE shell command before extension setup.", + definition.label, + definition.cli, + ) + + +def check_ide_installs(manifest: BaseManifest) -> list[ArtifactCheck]: + checks: list[ArtifactCheck] = [] + for ide_name, ide_config in manifest.ide.items(): + definition = IDE_DEFINITIONS[ide_name] + if ide_config.install: + checks.append(check_ide_install(manifest.project_name, definition)) + return checks + + +def reconcile_ide_extensions(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: + for ide_name, ide_config in manifest.ide.items(): + if not ide_config.extensions: + continue + definition = IDE_DEFINITIONS[ide_name] + if dry_run: + for extension in ide_config.extensions: + process.dry_run_command(ctx, [definition.cli, "--install-extension", extension]) + continue + if not process.command_exists(definition.cli): + ctx.log.warning( + "%s CLI '%s' is not on PATH; skipping extension setup.", + definition.label, + definition.cli, + ) + continue + installed_extensions = list_ide_extensions(definition) + for extension in ide_config.extensions: + if extension in installed_extensions: + ctx.log.debug( + "%s extension '%s' is already installed.", + definition.label, + extension, + ) + continue + ctx.log.info("Installing %s extension '%s'.", definition.label, extension) + process.run_command(ctx, [definition.cli, "--install-extension", extension]) + + +def list_ide_extensions(definition: IdeDefinition) -> set[str]: + completed = subprocess.run( + [definition.cli, "--list-extensions"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if completed.returncode: + stderr = (completed.stderr or "").strip() + message = f"Unable to list {definition.label} extensions with '{definition.cli} --list-extensions'." + if stderr: + message = f"{message}\n{stderr}" + raise ArtifactError(message) + return {line.strip() for line in completed.stdout.splitlines() if line.strip()} + + +def check_ide_extensions(manifest: BaseManifest) -> list[ArtifactCheck]: + checks: list[ArtifactCheck] = [] + for ide_name, ide_config in manifest.ide.items(): + if not ide_config.extensions: + continue + definition = IDE_DEFINITIONS[ide_name] + checks.extend( + check_ide_extension(manifest.project_name, definition, extension) + for extension in ide_config.extensions + ) + return checks + + +def check_ide_extension(project: str, definition: IdeDefinition, extension: str) -> ArtifactCheck: + if not process.command_exists(definition.cli): + return ArtifactCheck( + name=extension, + ok=False, + message=( + f"Cannot check {definition.label} extension '{extension}' " + f"because CLI '{definition.cli}' is not on PATH." + ), + fix=( + f"Enable the '{definition.cli}' shell command from {definition.label}, " + f"then run 'basectl setup {project}'." + ), + ) + + try: + installed_extensions = list_ide_extensions(definition) + except ArtifactError as exc: + return ArtifactCheck( + name=extension, + ok=False, + message=str(exc), + fix=f"basectl setup {project}", + ) + + if extension in installed_extensions: + return ArtifactCheck( + name=extension, + ok=True, + message=f"{definition.label} extension '{extension}' is installed.", + fix="", + ) + return ArtifactCheck( + name=extension, + ok=False, + message=f"{definition.label} extension '{extension}' is not installed.", + fix=f"basectl setup {project}", + ) + + +def reconcile_ide_settings(ctx: base_cli.Context, manifest: BaseManifest, dry_run: bool) -> None: + for ide_name, ide_config in manifest.ide.items(): + if not ide_config.settings: + continue + definition = IDE_DEFINITIONS[ide_name] + resolved_settings = resolve_ide_settings(manifest.project_name, ide_config.settings) + merge_ide_settings(ctx, definition, resolved_settings, dry_run=dry_run) + + +def resolve_ide_settings(project: str, settings: dict[str, object]) -> dict[str, object]: + resolved: dict[str, object] = {} + for key, value in settings.items(): + if key == "python.defaultInterpreterPath" and value == "auto": + resolved[key] = str(artifacts.project_venv_dir(project) / "bin" / "python") + else: + resolved[key] = value + return resolved + + +def ide_settings_file(definition: IdeDefinition) -> Path: + home = Path(os.environ.get("HOME") or Path.home()).expanduser() + if sys.platform == "darwin": + return home / "Library" / "Application Support" / definition.settings_app_dir / "User" / "settings.json" + config_home = Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config").expanduser() + return config_home / definition.settings_app_dir / "User" / "settings.json" + + +def read_ide_settings(definition: IdeDefinition) -> dict[str, object]: + settings_file = ide_settings_file(definition) + if not settings_file.exists(): + return {} + try: + data = json.loads(settings_file.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ArtifactError(f"{settings_file}: invalid JSON: {exc}") from exc + if not isinstance(data, dict): + raise ArtifactError(f"{settings_file}: expected a JSON object.") + return data + + +def merge_ide_settings( + ctx: base_cli.Context, + definition: IdeDefinition, + desired_settings: dict[str, object], + dry_run: bool, +) -> None: + settings_file = ide_settings_file(definition) + current_settings = read_ide_settings(definition) + merged_settings = dict(current_settings) + added: dict[str, object] = {} + + for key, value in desired_settings.items(): + if key not in current_settings: + merged_settings[key] = value + added[key] = value + elif current_settings[key] != value: + ctx.log.info( + "%s setting '%s' already set by user; leaving intact.", + definition.label, + key, + ) + + if not added: + ctx.log.debug("%s user settings already contain all Base-managed keys.", definition.label) + return + + if dry_run: + for key, value in added.items(): + ctx.log.info( + "[DRY-RUN] Would set %s user setting '%s' to %s.", + definition.label, + key, + json.dumps(value, sort_keys=True), + ) + return + + settings_file.parent.mkdir(parents=True, exist_ok=True) + write_json_atomic(settings_file, merged_settings) + ctx.log.info("Updated %s user settings at '%s'.", definition.label, settings_file) + + +def write_json_atomic(path: Path, data: dict[str, object]) -> None: + with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as tmp_file: + json.dump(data, tmp_file, indent=2, sort_keys=True) + tmp_file.write("\n") + tmp_path = Path(tmp_file.name) + tmp_path.replace(path) + + +def check_ide_settings(manifest: BaseManifest) -> list[ArtifactCheck]: + checks: list[ArtifactCheck] = [] + for ide_name, ide_config in manifest.ide.items(): + if not ide_config.settings: + continue + definition = IDE_DEFINITIONS[ide_name] + resolved_settings = resolve_ide_settings(manifest.project_name, ide_config.settings) + checks.extend( + check_ide_setting(manifest.project_name, definition, key, value) + for key, value in resolved_settings.items() + ) + return checks + + +def check_ide_setting( + project: str, + definition: IdeDefinition, + key: str, + expected_value: object, +) -> ArtifactCheck: + settings_file = ide_settings_file(definition) + try: + current_settings = read_ide_settings(definition) + except ArtifactError as exc: + return ArtifactCheck( + name=f"{definition.label} setting: {key}", + ok=False, + message=str(exc), + fix=f"Repair '{settings_file}' and run 'basectl setup {project}'.", + ) + + if key not in current_settings: + return ArtifactCheck( + name=f"{definition.label} setting: {key}", + ok=False, + message=f"{definition.label} setting '{key}' is absent from '{settings_file}'.", + fix=f"basectl setup {project}", + ) + if current_settings[key] == expected_value: + return ArtifactCheck( + name=f"{definition.label} setting: {key}", + ok=True, + message=f"{definition.label} setting '{key}' matches the Base manifest.", + fix="", + ) + return ArtifactCheck( + name=f"{definition.label} setting: {key}", + ok=False, + message=( + f"{definition.label} setting '{key}' is set to {json.dumps(current_settings[key], sort_keys=True)}; " + f"expected {json.dumps(expected_value, sort_keys=True)}. Base will not overwrite user settings." + ), + fix=f"Update '{settings_file}' manually or remove the key and run 'basectl setup {project}'.", + ) + + +def check_ide_install(project: str, definition: IdeDefinition) -> ArtifactCheck: + if not process.command_exists("brew"): + return ArtifactCheck( + name=f"{definition.label} app", + ok=False, + message=f"Homebrew is required to check {definition.label} installation.", + fix="basectl setup", + ) + + cask_installed = process.run_check(["brew", "list", "--cask", definition.cask]) + cli_available = process.command_exists(definition.cli) + + if cask_installed and cli_available: + return ArtifactCheck( + name=f"{definition.label} app", + ok=True, + message=f"{definition.label} is installed and CLI '{definition.cli}' is on PATH.", + fix="", + ) + if not cask_installed: + return ArtifactCheck( + name=f"{definition.label} app", + ok=False, + message=f"{definition.label} is not installed via Homebrew cask '{definition.cask}'.", + fix=f"basectl setup {project}", + ) + return ArtifactCheck( + name=f"{definition.label} CLI", + ok=False, + message=f"{definition.label} is installed, but CLI '{definition.cli}' is not on PATH.", + fix=f"Enable the '{definition.cli}' shell command from {definition.label}.", + ) diff --git a/cli/python/base_setup/process.py b/cli/python/base_setup/process.py new file mode 100644 index 0000000..d796b99 --- /dev/null +++ b/cli/python/base_setup/process.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import shlex +import shutil +import subprocess +from pathlib import Path + +import base_cli + +from .errors import ArtifactError + + +def command_exists(name: str) -> bool: + return shutil.which(name) is not None + + +def run_check(command: list[str]) -> bool: + return subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False).returncode == 0 + + +def run_command(ctx: base_cli.Context, command: list[str], cwd: Path | None = None) -> None: + # Keep stdout live for installer progress; capture stderr for persistent failure logs. + completed = subprocess.run(command, cwd=cwd, stderr=subprocess.PIPE, text=True, check=False) + if completed.returncode: + stderr = (completed.stderr or "").strip() + message = f"Command failed with exit {completed.returncode}: {format_command(command)}" + if stderr: + message = f"{message}\n{stderr}" + raise ArtifactError(message) + if cwd is not None: + ctx.log.debug("Command succeeded in '%s': %s", cwd, format_command(command)) + else: + ctx.log.debug("Command succeeded: %s", format_command(command)) + + +def dry_run_command(ctx: base_cli.Context, command: list[str], cwd: Path | None = None) -> None: + if cwd is not None: + ctx.log.info("[DRY-RUN] Would run in '%s': %s", cwd, format_command(command)) + return + ctx.log.info("[DRY-RUN] Would run: %s", format_command(command)) + + +def format_command(command: list[str]) -> str: + return shlex.join(command) diff --git a/cli/python/base_setup/tests/test_engine.py b/cli/python/base_setup/tests/test_engine.py index bf529da..e1460a4 100644 --- a/cli/python/base_setup/tests/test_engine.py +++ b/cli/python/base_setup/tests/test_engine.py @@ -13,8 +13,11 @@ from unittest import mock from base_cli.config import UserConfig, UserIdeConfig, UserIdePreference -from base_setup import engine -from base_setup.engine import ArtifactError, format_command, main, merge_artifacts +from base_setup import artifacts, checks, delegates, engine, ide, process +from base_setup.artifacts import merge_artifacts +from base_setup.errors import ArtifactError +from base_setup.engine import main +from base_setup.process import format_command from base_setup.manifest import ArtifactRequest, BaseManifest, IdeConfig, ManifestError from base_setup.manifest import read_manifest from base_setup.registry import get_artifact_definition @@ -516,18 +519,18 @@ def test_homebrew_artifact_rejects_non_latest_version(self) -> None: self.assertIsNotNone(definition) with self.assertRaisesRegex(ArtifactError, "only supports Homebrew artifact version 'latest'"): - engine.reconcile_homebrew_artifact(fake_context(), definition, "1.8.5", dry_run=True) + artifacts.reconcile_homebrew_artifact(fake_context(), definition, "1.8.5", dry_run=True) def test_homebrew_artifact_latest_invokes_brew_install(self) -> None: definition = get_artifact_definition("tool", "terraform") self.assertIsNotNone(definition) ctx = fake_context() - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.run_check", + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.process.run_check", return_value=False, - ), mock.patch("base_setup.engine.run_command") as run_command: - engine.reconcile_homebrew_artifact(ctx, definition, "latest", dry_run=False) + ), mock.patch("base_setup.process.run_command") as run_command: + artifacts.reconcile_homebrew_artifact(ctx, definition, "latest", dry_run=False) run_command.assert_called_once_with(ctx, ["brew", "install", "terraform"]) @@ -541,8 +544,8 @@ def test_python_artifact_honors_project_venv_dir_override(self) -> None: with mock.patch.dict( os.environ, {"BASE_PROJECT": "wrong-project", "BASE_PROJECT_VENV_DIR": str(venv_dir)}, - ), mock.patch("base_setup.engine.python_artifact_installed", return_value=False): - engine.reconcile_python_artifact(ctx, definition, "latest", "demo", dry_run=True) + ), mock.patch("base_setup.artifacts.python_artifact_installed", return_value=False): + artifacts.reconcile_python_artifact(ctx, definition, "latest", "demo", dry_run=True) info_messages = [call.args[0] % call.args[1:] for call in ctx.log.info.call_args_list] self.assertIn( @@ -562,10 +565,10 @@ def test_python_artifact_uses_manifest_project_not_environment(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: venv_dir = Path(tmpdir) / "demo" / ".venv" with mock.patch.dict(os.environ, {"BASE_PROJECT": "wrong-project"}), mock.patch( - "base_setup.engine.project_venv_dir", + "base_setup.artifacts.project_venv_dir", return_value=venv_dir, - ) as project_venv_dir, mock.patch("base_setup.engine.python_artifact_installed", return_value=False): - engine.reconcile_python_artifact(ctx, definition, "latest", "demo", dry_run=True) + ) as project_venv_dir, mock.patch("base_setup.artifacts.python_artifact_installed", return_value=False): + artifacts.reconcile_python_artifact(ctx, definition, "latest", "demo", dry_run=True) project_venv_dir.assert_called_once_with("demo") @@ -573,20 +576,20 @@ def test_run_command_includes_stderr_on_failure(self) -> None: ctx = fake_context() with mock.patch( - "base_setup.engine.subprocess.run", + "base_setup.process.subprocess.run", return_value=mock.Mock(returncode=17, stderr="installer exploded\n"), ): with self.assertRaisesRegex(ArtifactError, "installer exploded"): - engine.run_command(ctx, ["installer", "--bad"]) + process.run_command(ctx, ["installer", "--bad"]) def test_run_command_logs_success_at_debug(self) -> None: ctx = fake_context() with mock.patch( - "base_setup.engine.subprocess.run", + "base_setup.process.subprocess.run", return_value=mock.Mock(returncode=0, stderr=""), ): - engine.run_command(ctx, ["installer", "--good", "two words"]) + process.run_command(ctx, ["installer", "--good", "two words"]) ctx.log.debug.assert_called_once_with( "Command succeeded: %s", @@ -788,11 +791,11 @@ def test_check_homebrew_artifact_reports_missing_package(self) -> None: definition = get_artifact_definition("tool", "terraform") self.assertIsNotNone(definition) - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.run_check", + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.process.run_check", return_value=False, ): - check = engine.check_homebrew_artifact("demo", artifact, definition) + check = artifacts.check_homebrew_artifact("demo", artifact, definition) self.assertFalse(check.ok) self.assertIn("not installed via Homebrew package 'terraform'", check.message) @@ -842,7 +845,7 @@ def test_doctor_manifest_supports_json_output(self) -> None: self.assertEqual(findings[0]["fix"], "basectl setup demo") def test_doctor_warning_status_does_not_fail(self) -> None: - check = engine.ArtifactCheck( + check = checks.ArtifactCheck( name="optional-artifact", ok=False, message="Optional project artifact is not installed.", @@ -851,7 +854,7 @@ def test_doctor_warning_status_does_not_fail(self) -> None: ) self.assertEqual(engine.doctor_status(check), "warn") - self.assertEqual(engine.check_to_doctor_json(check)["status"], "warn") + self.assertEqual(checks.check_to_doctor_json(check)["status"], "warn") default_manifest = BaseManifest( path=Path("default_manifest.yaml"), @@ -889,7 +892,7 @@ def test_brewfile_dry_run_invokes_brew_bundle(self) -> None: ) expected_brewfile = brewfile.resolve() - engine.reconcile_brewfile(ctx, manifest, dry_run=True) + delegates.reconcile_brewfile(ctx, manifest, dry_run=True) info_messages = [call.args[0] % call.args[1:] for call in ctx.log.info.call_args_list] self.assertIn(f"[DRY-RUN] Would run: brew bundle --file={expected_brewfile}", info_messages) @@ -908,10 +911,10 @@ def test_brewfile_invokes_brew_bundle(self) -> None: ) expected_brewfile = brewfile.resolve() - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.run_command" + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.process.run_command" ) as run_command: - engine.reconcile_brewfile(ctx, manifest, dry_run=False) + delegates.reconcile_brewfile(ctx, manifest, dry_run=False) run_command.assert_called_once_with(ctx, ["brew", "bundle", f"--file={expected_brewfile}"]) @@ -926,7 +929,7 @@ def test_brewfile_missing_file_fails(self) -> None: ) with self.assertRaisesRegex(ArtifactError, "does not exist"): - engine.resolve_brewfile_path(manifest) + delegates.resolve_brewfile_path(manifest) def test_brewfile_must_stay_inside_project_root(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -940,7 +943,7 @@ def test_brewfile_must_stay_inside_project_root(self) -> None: ) with self.assertRaisesRegex(ArtifactError, "must stay inside the project root"): - engine.resolve_brewfile_path(manifest) + delegates.resolve_brewfile_path(manifest) def test_mise_dry_run_invokes_mise_install_in_project_root(self) -> None: ctx = fake_context() @@ -956,7 +959,7 @@ def test_mise_dry_run_invokes_mise_install_in_project_root(self) -> None: artifacts=(), ) - engine.reconcile_mise(ctx, manifest, dry_run=True) + delegates.reconcile_mise(ctx, manifest, dry_run=True) info_messages = [call.args[0] % call.args[1:] for call in ctx.log.info.call_args_list] self.assertIn(f"[DRY-RUN] Would run in '{project_root.resolve()}': mise install", info_messages) @@ -975,10 +978,10 @@ def test_mise_invokes_install_in_project_root(self) -> None: artifacts=(), ) - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.run_command" + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.process.run_command" ) as run_command: - engine.reconcile_mise(ctx, manifest, dry_run=False) + delegates.reconcile_mise(ctx, manifest, dry_run=False) run_command.assert_called_once_with(ctx, ["mise", "install"], cwd=project_root.resolve()) @@ -995,7 +998,7 @@ def test_mise_missing_file_fails(self) -> None: ) with self.assertRaisesRegex(ArtifactError, "mise config '.mise.toml' does not exist"): - engine.resolve_mise_path(manifest) + delegates.resolve_mise_path(manifest) def test_mise_must_stay_inside_project_root(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -1010,7 +1013,7 @@ def test_mise_must_stay_inside_project_root(self) -> None: ) with self.assertRaisesRegex(ArtifactError, "mise must stay inside the project root"): - engine.resolve_mise_path(manifest) + delegates.resolve_mise_path(manifest) def test_manifest_checks_include_mise_config(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: @@ -1031,7 +1034,7 @@ def test_manifest_checks_include_mise_config(self) -> None: artifacts=(), ) - with mock.patch("base_setup.engine.command_exists", return_value=True): + with mock.patch("base_setup.process.command_exists", return_value=True): checks = engine.manifest_checks(default_manifest, manifest) self.assertIn("mise", [check.name for check in checks]) @@ -1056,7 +1059,7 @@ def test_ide_install_dry_run_invokes_homebrew_cask_install(self) -> None: }, ) - engine.reconcile_ide_installs(ctx, manifest, dry_run=True) + ide.reconcile_ide_installs(ctx, manifest, dry_run=True) info_messages = [call.args[0] % call.args[1:] for call in ctx.log.info.call_args_list] self.assertIn("[DRY-RUN] Would run: brew install --cask visual-studio-code", info_messages) @@ -1064,13 +1067,13 @@ def test_ide_install_dry_run_invokes_homebrew_cask_install(self) -> None: def test_ide_install_skips_existing_cask_and_reports_available_cli(self) -> None: ctx = fake_context() - definition = engine.IDE_DEFINITIONS["vscode"] + definition = ide.IDE_DEFINITIONS["vscode"] - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.run_check", + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.process.run_check", return_value=True, - ), mock.patch("base_setup.engine.run_command") as run_command: - engine.reconcile_ide_install(ctx, definition, dry_run=False) + ), mock.patch("base_setup.process.run_command") as run_command: + ide.reconcile_ide_install(ctx, definition, dry_run=False) run_command.assert_not_called() info_messages = [call.args[0] % call.args[1:] for call in ctx.log.info.call_args_list] @@ -1079,28 +1082,28 @@ def test_ide_install_skips_existing_cask_and_reports_available_cli(self) -> None def test_ide_install_installs_missing_cask(self) -> None: ctx = fake_context() - definition = engine.IDE_DEFINITIONS["cursor"] + definition = ide.IDE_DEFINITIONS["cursor"] - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.run_check", + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.process.run_check", return_value=False, - ), mock.patch("base_setup.engine.run_command") as run_command: - engine.reconcile_ide_install(ctx, definition, dry_run=False) + ), mock.patch("base_setup.process.run_command") as run_command: + ide.reconcile_ide_install(ctx, definition, dry_run=False) run_command.assert_called_once_with(ctx, ["brew", "install", "--cask", "cursor"]) def test_ide_install_warns_when_cli_is_missing_after_install(self) -> None: ctx = fake_context() - definition = engine.IDE_DEFINITIONS["vscode"] + definition = ide.IDE_DEFINITIONS["vscode"] def command_exists(name: str) -> bool: return name == "brew" - with mock.patch("base_setup.engine.command_exists", side_effect=command_exists), mock.patch( - "base_setup.engine.run_check", + with mock.patch("base_setup.process.command_exists", side_effect=command_exists), mock.patch( + "base_setup.process.run_check", return_value=True, ): - engine.reconcile_ide_install(ctx, definition, dry_run=False) + ide.reconcile_ide_install(ctx, definition, dry_run=False) warning_messages = [call.args[0] % call.args[1:] for call in ctx.log.warning.call_args_list] self.assertIn( @@ -1109,13 +1112,13 @@ def command_exists(name: str) -> bool: ) def test_check_ide_install_reports_missing_cask(self) -> None: - definition = engine.IDE_DEFINITIONS["vscode"] + definition = ide.IDE_DEFINITIONS["vscode"] - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.run_check", + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.process.run_check", return_value=False, ): - check = engine.check_ide_install("demo", definition) + check = ide.check_ide_install("demo", definition) self.assertFalse(check.ok) self.assertEqual(check.name, "VS Code app") @@ -1123,16 +1126,16 @@ def test_check_ide_install_reports_missing_cask(self) -> None: self.assertEqual(check.fix, "basectl setup demo") def test_check_ide_install_reports_missing_cli(self) -> None: - definition = engine.IDE_DEFINITIONS["cursor"] + definition = ide.IDE_DEFINITIONS["cursor"] def command_exists(name: str) -> bool: return name == "brew" - with mock.patch("base_setup.engine.command_exists", side_effect=command_exists), mock.patch( - "base_setup.engine.run_check", + with mock.patch("base_setup.process.command_exists", side_effect=command_exists), mock.patch( + "base_setup.process.run_check", return_value=True, ): - check = engine.check_ide_install("demo", definition) + check = ide.check_ide_install("demo", definition) self.assertFalse(check.ok) self.assertEqual(check.name, "Cursor CLI") @@ -1154,8 +1157,8 @@ def test_manifest_checks_include_requested_ide_installs(self) -> None: ide={"vscode": IdeConfig(install=True, extensions=(), settings={})}, ) - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.run_check", + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.process.run_check", return_value=True, ): checks = engine.manifest_checks(default_manifest, manifest) @@ -1182,7 +1185,7 @@ def test_ide_extensions_dry_run_prints_install_commands(self) -> None: }, ) - engine.reconcile_ide_extensions(ctx, manifest, dry_run=True) + ide.reconcile_ide_extensions(ctx, manifest, dry_run=True) info_messages = [call.args[0] % call.args[1:] for call in ctx.log.info.call_args_list] self.assertEqual( @@ -1209,11 +1212,11 @@ def test_ide_extensions_skip_installed_extensions(self) -> None: }, ) - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.list_ide_extensions", + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.ide.list_ide_extensions", return_value={"ms-python.python", "github.copilot"}, - ), mock.patch("base_setup.engine.run_command") as run_command: - engine.reconcile_ide_extensions(ctx, manifest, dry_run=False) + ), mock.patch("base_setup.process.run_command") as run_command: + ide.reconcile_ide_extensions(ctx, manifest, dry_run=False) run_command.assert_not_called() @@ -1233,11 +1236,11 @@ def test_ide_extensions_install_missing_extensions(self) -> None: }, ) - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.list_ide_extensions", + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.ide.list_ide_extensions", return_value={"ms-python.python"}, - ), mock.patch("base_setup.engine.run_command") as run_command: - engine.reconcile_ide_extensions(ctx, manifest, dry_run=False) + ), mock.patch("base_setup.process.run_command") as run_command: + ide.reconcile_ide_extensions(ctx, manifest, dry_run=False) run_command.assert_called_once_with(ctx, ["cursor", "--install-extension", "github.copilot"]) @@ -1257,64 +1260,64 @@ def test_ide_extensions_warn_when_cli_is_missing(self) -> None: }, ) - with mock.patch("base_setup.engine.command_exists", return_value=False): - engine.reconcile_ide_extensions(ctx, manifest, dry_run=False) + with mock.patch("base_setup.process.command_exists", return_value=False): + ide.reconcile_ide_extensions(ctx, manifest, dry_run=False) warning_messages = [call.args[0] % call.args[1:] for call in ctx.log.warning.call_args_list] self.assertIn("VS Code CLI 'code' is not on PATH; skipping extension setup.", warning_messages) def test_list_ide_extensions_returns_installed_extension_ids(self) -> None: - definition = engine.IDE_DEFINITIONS["vscode"] + definition = ide.IDE_DEFINITIONS["vscode"] with mock.patch( - "base_setup.engine.subprocess.run", + "base_setup.ide.subprocess.run", return_value=mock.Mock(returncode=0, stdout="ms-python.python\n\ngithub.copilot\n", stderr=""), ): - extensions = engine.list_ide_extensions(definition) + extensions = ide.list_ide_extensions(definition) self.assertEqual(extensions, {"ms-python.python", "github.copilot"}) def test_list_ide_extensions_includes_stderr_on_failure(self) -> None: - definition = engine.IDE_DEFINITIONS["cursor"] + definition = ide.IDE_DEFINITIONS["cursor"] with mock.patch( - "base_setup.engine.subprocess.run", + "base_setup.ide.subprocess.run", return_value=mock.Mock(returncode=1, stdout="", stderr="extensions unavailable\n"), ): with self.assertRaisesRegex(ArtifactError, "extensions unavailable"): - engine.list_ide_extensions(definition) + ide.list_ide_extensions(definition) def test_check_ide_extension_reports_installed_extension(self) -> None: - definition = engine.IDE_DEFINITIONS["vscode"] + definition = ide.IDE_DEFINITIONS["vscode"] - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.list_ide_extensions", + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.ide.list_ide_extensions", return_value={"ms-python.python"}, ): - check = engine.check_ide_extension("demo", definition, "ms-python.python") + check = ide.check_ide_extension("demo", definition, "ms-python.python") self.assertTrue(check.ok) self.assertEqual(check.name, "ms-python.python") self.assertIn("is installed", check.message) def test_check_ide_extension_reports_missing_extension(self) -> None: - definition = engine.IDE_DEFINITIONS["vscode"] + definition = ide.IDE_DEFINITIONS["vscode"] - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.list_ide_extensions", + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.ide.list_ide_extensions", return_value=set(), ): - check = engine.check_ide_extension("demo", definition, "ms-python.python") + check = ide.check_ide_extension("demo", definition, "ms-python.python") self.assertFalse(check.ok) self.assertEqual(check.fix, "basectl setup demo") self.assertIn("is not installed", check.message) def test_check_ide_extension_reports_missing_cli(self) -> None: - definition = engine.IDE_DEFINITIONS["cursor"] + definition = ide.IDE_DEFINITIONS["cursor"] - with mock.patch("base_setup.engine.command_exists", return_value=False): - check = engine.check_ide_extension("demo", definition, "github.copilot") + with mock.patch("base_setup.process.command_exists", return_value=False): + check = ide.check_ide_extension("demo", definition, "github.copilot") self.assertFalse(check.ok) self.assertIn("CLI 'cursor' is not on PATH", check.message) @@ -1341,8 +1344,8 @@ def test_manifest_checks_include_ide_extensions(self) -> None: }, ) - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.list_ide_extensions", + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.ide.list_ide_extensions", return_value={"ms-python.python"}, ): checks = engine.manifest_checks(default_manifest, manifest) @@ -1357,7 +1360,7 @@ def test_resolve_ide_settings_auto_interpreter_path(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: venv_dir = Path(tmpdir) / "demo-venv" with mock.patch.dict(os.environ, {"BASE_PROJECT_VENV_DIR": str(venv_dir)}): - settings = engine.resolve_ide_settings( + settings = ide.resolve_ide_settings( "demo", { "python.defaultInterpreterPath": "auto", @@ -1369,13 +1372,13 @@ def test_resolve_ide_settings_auto_interpreter_path(self) -> None: self.assertTrue(settings["editor.formatOnSave"]) def test_ide_settings_file_uses_macos_application_support(self) -> None: - definition = engine.IDE_DEFINITIONS["vscode"] + definition = ide.IDE_DEFINITIONS["vscode"] with tempfile.TemporaryDirectory() as home_dir: with mock.patch.dict(os.environ, {"HOME": home_dir}, clear=False), mock.patch( - "base_setup.engine.sys.platform", "darwin" + "base_setup.ide.sys.platform", "darwin" ): - settings_file = engine.ide_settings_file(definition) + settings_file = ide.ide_settings_file(definition) self.assertEqual( settings_file, @@ -1383,7 +1386,7 @@ def test_ide_settings_file_uses_macos_application_support(self) -> None: ) def test_ide_settings_file_uses_xdg_config_home_off_macos(self) -> None: - definition = engine.IDE_DEFINITIONS["cursor"] + definition = ide.IDE_DEFINITIONS["cursor"] with tempfile.TemporaryDirectory() as tmpdir: home_dir = Path(tmpdir) / "home" @@ -1393,53 +1396,53 @@ def test_ide_settings_file_uses_xdg_config_home_off_macos(self) -> None: os.environ, {"HOME": str(home_dir), "XDG_CONFIG_HOME": str(config_home)}, clear=False, - ), mock.patch("base_setup.engine.sys.platform", "linux"): - settings_file = engine.ide_settings_file(definition) + ), mock.patch("base_setup.ide.sys.platform", "linux"): + settings_file = ide.ide_settings_file(definition) self.assertEqual(settings_file, config_home / "Cursor" / "User" / "settings.json") def test_ide_settings_file_defaults_to_home_config_off_macos(self) -> None: - definition = engine.IDE_DEFINITIONS["vscode"] + definition = ide.IDE_DEFINITIONS["vscode"] with tempfile.TemporaryDirectory() as home_dir: with mock.patch.dict(os.environ, {"HOME": home_dir, "XDG_CONFIG_HOME": ""}, clear=False), mock.patch( - "base_setup.engine.sys.platform", "linux" + "base_setup.ide.sys.platform", "linux" ): - settings_file = engine.ide_settings_file(definition) + settings_file = ide.ide_settings_file(definition) self.assertEqual(settings_file, Path(home_dir) / ".config" / "Code" / "User" / "settings.json") def test_merge_ide_settings_writes_missing_keys(self) -> None: ctx = fake_context() - definition = engine.IDE_DEFINITIONS["vscode"] + definition = ide.IDE_DEFINITIONS["vscode"] with tempfile.TemporaryDirectory() as home_dir: with mock.patch.dict(os.environ, {"HOME": home_dir, "XDG_CONFIG_HOME": ""}): - engine.merge_ide_settings( + ide.merge_ide_settings( ctx, definition, {"editor.formatOnSave": True}, dry_run=False, ) - settings_file = engine.ide_settings_file(definition) + settings_file = ide.ide_settings_file(definition) settings = json.loads(settings_file.read_text(encoding="utf-8")) self.assertEqual(settings, {"editor.formatOnSave": True}) def test_merge_ide_settings_preserves_existing_user_value(self) -> None: ctx = fake_context() - definition = engine.IDE_DEFINITIONS["cursor"] + definition = ide.IDE_DEFINITIONS["cursor"] with tempfile.TemporaryDirectory() as home_dir: with mock.patch.dict(os.environ, {"HOME": home_dir, "XDG_CONFIG_HOME": ""}): - settings_file = engine.ide_settings_file(definition) + settings_file = ide.ide_settings_file(definition) settings_file.parent.mkdir(parents=True) settings_file.write_text( json.dumps({"editor.formatOnSave": False}), encoding="utf-8", ) - engine.merge_ide_settings( + ide.merge_ide_settings( ctx, definition, {"editor.formatOnSave": True, "editor.rulers": [100]}, @@ -1454,17 +1457,17 @@ def test_merge_ide_settings_preserves_existing_user_value(self) -> None: def test_merge_ide_settings_dry_run_does_not_write(self) -> None: ctx = fake_context() - definition = engine.IDE_DEFINITIONS["vscode"] + definition = ide.IDE_DEFINITIONS["vscode"] with tempfile.TemporaryDirectory() as home_dir: with mock.patch.dict(os.environ, {"HOME": home_dir, "XDG_CONFIG_HOME": ""}): - engine.merge_ide_settings( + ide.merge_ide_settings( ctx, definition, {"editor.formatOnSave": True}, dry_run=True, ) - settings_file = engine.ide_settings_file(definition) + settings_file = ide.ide_settings_file(definition) self.assertFalse(settings_file.exists()) info_messages = [call.args[0] % call.args[1:] for call in ctx.log.info.call_args_list] @@ -1489,49 +1492,49 @@ def test_reconcile_ide_settings_uses_manifest_settings(self) -> None: }, ) - with mock.patch("base_setup.engine.merge_ide_settings") as merge_settings: - engine.reconcile_ide_settings(ctx, manifest, dry_run=True) + with mock.patch("base_setup.ide.merge_ide_settings") as merge_settings: + ide.reconcile_ide_settings(ctx, manifest, dry_run=True) merge_settings.assert_called_once_with( ctx, - engine.IDE_DEFINITIONS["vscode"], + ide.IDE_DEFINITIONS["vscode"], {"editor.formatOnSave": True}, dry_run=True, ) def test_check_ide_setting_reports_absent_key(self) -> None: - definition = engine.IDE_DEFINITIONS["vscode"] + definition = ide.IDE_DEFINITIONS["vscode"] with tempfile.TemporaryDirectory() as home_dir: with mock.patch.dict(os.environ, {"HOME": home_dir, "XDG_CONFIG_HOME": ""}): - check = engine.check_ide_setting("demo", definition, "editor.formatOnSave", True) + check = ide.check_ide_setting("demo", definition, "editor.formatOnSave", True) self.assertFalse(check.ok) self.assertIn("is absent", check.message) self.assertEqual(check.fix, "basectl setup demo") def test_check_ide_setting_reports_matching_key(self) -> None: - definition = engine.IDE_DEFINITIONS["vscode"] + definition = ide.IDE_DEFINITIONS["vscode"] with tempfile.TemporaryDirectory() as home_dir: with mock.patch.dict(os.environ, {"HOME": home_dir, "XDG_CONFIG_HOME": ""}): - settings_file = engine.ide_settings_file(definition) + settings_file = ide.ide_settings_file(definition) settings_file.parent.mkdir(parents=True) settings_file.write_text(json.dumps({"editor.formatOnSave": True}), encoding="utf-8") - check = engine.check_ide_setting("demo", definition, "editor.formatOnSave", True) + check = ide.check_ide_setting("demo", definition, "editor.formatOnSave", True) self.assertTrue(check.ok) self.assertIn("matches", check.message) def test_check_ide_setting_reports_divergent_key(self) -> None: - definition = engine.IDE_DEFINITIONS["cursor"] + definition = ide.IDE_DEFINITIONS["cursor"] with tempfile.TemporaryDirectory() as home_dir: with mock.patch.dict(os.environ, {"HOME": home_dir, "XDG_CONFIG_HOME": ""}): - settings_file = engine.ide_settings_file(definition) + settings_file = ide.ide_settings_file(definition) settings_file.parent.mkdir(parents=True) settings_file.write_text(json.dumps({"editor.formatOnSave": False}), encoding="utf-8") - check = engine.check_ide_setting("demo", definition, "editor.formatOnSave", True) + check = ide.check_ide_setting("demo", definition, "editor.formatOnSave", True) self.assertFalse(check.ok) self.assertIn("Base will not overwrite user settings", check.message) @@ -1554,10 +1557,10 @@ def test_check_ide_settings_includes_manifest_settings(self) -> None: with tempfile.TemporaryDirectory() as home_dir: with mock.patch.dict(os.environ, {"HOME": home_dir, "XDG_CONFIG_HOME": ""}): - settings_file = engine.ide_settings_file(engine.IDE_DEFINITIONS["vscode"]) + settings_file = ide.ide_settings_file(ide.IDE_DEFINITIONS["vscode"]) settings_file.parent.mkdir(parents=True) settings_file.write_text(json.dumps({"editor.formatOnSave": True}), encoding="utf-8") - checks = engine.check_ide_settings(manifest) + checks = ide.check_ide_settings(manifest) self.assertEqual(len(checks), 1) self.assertTrue(checks[0].ok) @@ -1629,7 +1632,7 @@ def test_effective_ide_config_project_setting_wins_over_user_setting(self) -> No ), ) - effective = engine.effective_ide_config(project_ide, user_config) + effective = ide.effective_ide_config(project_ide, user_config) self.assertEqual( effective["vscode"].settings, @@ -1659,7 +1662,7 @@ def test_effective_ide_config_user_install_preference_overrides_project_install( ), ) - effective = engine.effective_ide_config(project_ide, user_config) + effective = ide.effective_ide_config(project_ide, user_config) self.assertFalse(effective["cursor"].install) self.assertEqual(effective["cursor"].extensions, ("github.copilot",)) @@ -1677,7 +1680,7 @@ def test_effective_ide_config_can_disable_all_ide_work(self) -> None: ide=UserIdeConfig(enabled=False, preferences={}), ) - self.assertEqual(engine.effective_ide_config(project_ide, user_config), {}) + self.assertEqual(ide.effective_ide_config(project_ide, user_config), {}) def test_effective_ide_config_can_disable_one_ide(self) -> None: project_ide = { @@ -1699,7 +1702,7 @@ def test_effective_ide_config_can_disable_one_ide(self) -> None: ), ) - effective = engine.effective_ide_config(project_ide, user_config) + effective = ide.effective_ide_config(project_ide, user_config) self.assertEqual(set(effective), {"vscode"}) @@ -1719,7 +1722,7 @@ def test_effective_ide_config_includes_user_only_ide_preferences(self) -> None: ), ) - effective = engine.effective_ide_config({}, user_config) + effective = ide.effective_ide_config({}, user_config) self.assertEqual(set(effective), {"vscode"}) self.assertFalse(effective["vscode"].install) @@ -1754,7 +1757,7 @@ def test_ide_preference_warning_checks_report_setting_conflicts(self) -> None: ), ) - checks = engine.ide_preference_warning_checks(manifest, user_config) + checks = ide.ide_preference_warning_checks(manifest, user_config) self.assertEqual(len(checks), 1) self.assertEqual(checks[0].status, "warn") @@ -1789,7 +1792,7 @@ def test_check_manifest_warns_but_succeeds_for_setting_conflict_only(self) -> No encoding="utf-8", ) with mock.patch.dict(os.environ, {"HOME": home_dir, "XDG_CONFIG_HOME": ""}): - settings_file = engine.ide_settings_file(engine.IDE_DEFINITIONS["vscode"]) + settings_file = ide.ide_settings_file(ide.IDE_DEFINITIONS["vscode"]) settings_file.parent.mkdir(parents=True) settings_file.write_text(json.dumps({"editor.formatOnSave": True}), encoding="utf-8") status = engine.check_manifest(fake_context(), default_manifest, manifest, output_format="text") @@ -1825,14 +1828,14 @@ def test_check_json_reports_ide_app_extension_and_settings(self) -> None: artifacts=(), ) manifest = read_manifest(manifest_path) - with mock.patch("base_setup.engine.command_exists", return_value=True), mock.patch( - "base_setup.engine.run_check", + with mock.patch("base_setup.process.command_exists", return_value=True), mock.patch( + "base_setup.process.run_check", return_value=True, ), mock.patch( - "base_setup.engine.list_ide_extensions", + "base_setup.ide.list_ide_extensions", return_value={"ms-python.python"}, ), mock.patch.dict(os.environ, {"HOME": tmpdir, "XDG_CONFIG_HOME": ""}): - settings_file = engine.ide_settings_file(engine.IDE_DEFINITIONS["vscode"]) + settings_file = ide.ide_settings_file(ide.IDE_DEFINITIONS["vscode"]) settings_file.parent.mkdir(parents=True) settings_file.write_text(json.dumps({"editor.formatOnSave": True}), encoding="utf-8") stdout_buffer = io.StringIO() @@ -1870,7 +1873,7 @@ def test_doctor_text_reports_ide_fix_guidance(self) -> None: with tempfile.TemporaryDirectory() as home_dir: with mock.patch.dict(os.environ, {"HOME": home_dir, "XDG_CONFIG_HOME": ""}), mock.patch( - "base_setup.engine.command_exists", + "base_setup.process.command_exists", return_value=False, ): stdout = io.StringIO() From 80c29038eac9c26ad641fd01da812456cfabcbff Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Sat, 30 May 2026 10:29:26 -0700 Subject: [PATCH 2/2] Fix base_setup test lint alias --- cli/python/base_setup/tests/test_engine.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/python/base_setup/tests/test_engine.py b/cli/python/base_setup/tests/test_engine.py index e1460a4..7ca9153 100644 --- a/cli/python/base_setup/tests/test_engine.py +++ b/cli/python/base_setup/tests/test_engine.py @@ -13,7 +13,7 @@ from unittest import mock from base_cli.config import UserConfig, UserIdeConfig, UserIdePreference -from base_setup import artifacts, checks, delegates, engine, ide, process +from base_setup import artifacts, checks as setup_checks, delegates, engine, ide, process from base_setup.artifacts import merge_artifacts from base_setup.errors import ArtifactError from base_setup.engine import main @@ -845,7 +845,7 @@ def test_doctor_manifest_supports_json_output(self) -> None: self.assertEqual(findings[0]["fix"], "basectl setup demo") def test_doctor_warning_status_does_not_fail(self) -> None: - check = checks.ArtifactCheck( + check = setup_checks.ArtifactCheck( name="optional-artifact", ok=False, message="Optional project artifact is not installed.", @@ -854,7 +854,7 @@ def test_doctor_warning_status_does_not_fail(self) -> None: ) self.assertEqual(engine.doctor_status(check), "warn") - self.assertEqual(checks.check_to_doctor_json(check)["status"], "warn") + self.assertEqual(setup_checks.check_to_doctor_json(check)["status"], "warn") default_manifest = BaseManifest( path=Path("default_manifest.yaml"),