diff --git a/cli/bash/commands/basectl/subcommands/setup_common.sh b/cli/bash/commands/basectl/subcommands/setup_common.sh index 513a3046..8dc46e85 100644 --- a/cli/bash/commands/basectl/subcommands/setup_common.sh +++ b/cli/bash/commands/basectl/subcommands/setup_common.sh @@ -187,6 +187,18 @@ setup_recreate_venv_enabled() { [[ "${BASE_SETUP_RECREATE_VENV:-false}" == true ]] } +setup_base_recreate_venv_enabled() { + setup_recreate_venv_enabled || return 1 + [[ -z "${BASE_SETUP_PROJECT_NAME:-}" || "${BASE_SETUP_PROJECT_NAME:-}" == base ]] +} + +setup_project_recreate_venv_enabled() { + local project="$1" + + setup_recreate_venv_enabled || return 1 + [[ -n "$project" && "$project" != base ]] +} + setup_notifications_enabled() { [[ "${BASE_SETUP_NOTIFY:-true}" == true ]] } @@ -648,7 +660,7 @@ setup_create_virtualenv() { setup_ensure_cached_paths venv_dir="$_BASE_SETUP_VENV_DIR_CACHE" - if setup_virtualenv_exists && ! setup_recreate_venv_enabled; then + if setup_virtualenv_exists && ! setup_base_recreate_venv_enabled; then log_info "Virtual environment already exists at '$venv_dir'." return 0 fi @@ -683,7 +695,7 @@ setup_base_python_package_installed() { local package="$1" local venv_dir python_bin - if setup_is_dry_run && setup_recreate_venv_enabled; then + if setup_is_dry_run && setup_base_recreate_venv_enabled; then return 1 fi @@ -1157,7 +1169,20 @@ setup_run_project_bootstrap_layer() { -u BASE_PROJECT_VENV_DIR ) fi - env "${project_env_args[@]}" BASE_HOME="$BASE_HOME" BASE_PROJECT="$project" PYTHONPATH="$_BASE_SETUP_PYTHONPATH_CACHE" "$python_bin" -m base_setup "${args[@]}" + if setup_project_recreate_venv_enabled "$project"; then + env "${project_env_args[@]}" \ + BASE_HOME="$BASE_HOME" \ + BASE_PROJECT="$project" \ + BASE_SETUP_RECREATE_PROJECT_VENV=true \ + PYTHONPATH="$_BASE_SETUP_PYTHONPATH_CACHE" \ + "$python_bin" -m base_setup "${args[@]}" + else + env "${project_env_args[@]}" \ + BASE_HOME="$BASE_HOME" \ + BASE_PROJECT="$project" \ + PYTHONPATH="$_BASE_SETUP_PYTHONPATH_CACHE" \ + "$python_bin" -m base_setup "${args[@]}" + fi } setup_run_project_artifact_layer() { diff --git a/cli/bash/commands/basectl/tests/setup.bats b/cli/bash/commands/basectl/tests/setup.bats index 37e9c600..ad7b1de1 100644 --- a/cli/bash/commands/basectl/tests/setup.bats +++ b/cli/bash/commands/basectl/tests/setup.bats @@ -429,6 +429,31 @@ EOF [ "$(cat "$TEST_STATE_DIR/project-setup-args")" = "$(printf '%s\n' --dry-run --manifest "$manifest_path" --action setup demo)" ] } +@test "basectl setup project --recreate-venv targets the project virtualenv" { + local base_venv_dir="$TEST_HOME/.base.d/base/.venv" + local demo_venv_dir="$TEST_HOME/.base.d/demo/.venv" + local manifest_path="$TEST_TMPDIR/demo_manifest.yaml" + + create_brew_stub + create_xcode_stubs + touch "$TEST_STATE_DIR/xcode-installed" + mkdir -p "$TEST_TMPDIR/CommandLineTools" + touch "$TEST_STATE_DIR/python-installed" + touch "$TEST_STATE_DIR/pyyaml-installed" + touch "$TEST_STATE_DIR/click-installed" + create_project_setup_venv_stub "$base_venv_dir" + create_project_setup_venv_stub "$demo_venv_dir" + printf 'base marker\n' > "$base_venv_dir/old.txt" + printf 'project:\n name: demo\nartifacts: []\n' > "$manifest_path" + + run_base_command setup --dry-run --manifest "$manifest_path" --recreate-venv demo + + [ "$status" -eq 0 ] + [[ "$output" != *"Would move existing virtual environment '$base_venv_dir'"* ]] + [ -f "$base_venv_dir/old.txt" ] + [ "$(cat "$TEST_STATE_DIR/project-bootstrap-recreate-venv")" = "true" ] +} + @test "basectl setup infers project name from explicit manifest" { local base_venv_dir="$TEST_HOME/.base.d/base/.venv" local demo_venv_dir="$TEST_HOME/.base.d/demo/.venv" diff --git a/cli/bash/commands/basectl/tests/setup_helpers.bash b/cli/bash/commands/basectl/tests/setup_helpers.bash index 7bec1fc6..71ed50e1 100644 --- a/cli/bash/commands/basectl/tests/setup_helpers.bash +++ b/cli/bash/commands/basectl/tests/setup_helpers.bash @@ -556,6 +556,9 @@ if [[ "${1:-}" == "-m" && "${2:-}" == "base_setup" ]]; then esac shift || true done + if [[ "$action" == "bootstrap" ]]; then + printf '%s\n' "${BASE_SETUP_RECREATE_PROJECT_VENV:-}" > "$BASE_SETUP_TEST_STATE_DIR/project-bootstrap-recreate-venv" + fi if [[ "$action" == "precheck" && "$output_format" == "json" ]]; then if [[ "$remote_network" == true ]]; then printf '[{"id":"BASE-P080","status":"ok","name":"git_repository","message":"Project is inside a Git repository.","fix":""},{"id":"BASE-P083","status":"ok","name":"git_origin_reachability","message":"Project Git origin remote is reachable.","fix":""}]\n' diff --git a/cli/python/base_setup/artifacts.py b/cli/python/base_setup/artifacts.py index 6e18d6fd..e30467d6 100644 --- a/cli/python/base_setup/artifacts.py +++ b/cli/python/base_setup/artifacts.py @@ -2,6 +2,7 @@ import os import subprocess +import time import venv from collections.abc import Iterable from pathlib import Path @@ -293,10 +294,14 @@ def reconcile_python_artifacts( ) -> None: venv_dir = project_venv_dir(project) python_bin = venv_dir / "bin" / "python" + recreate_venv = project_venv_recreate_enabled() missing = [] + if recreate_venv: + backup_existing_project_venv(ctx, venv_dir, dry_run=dry_run) + for definition, version in artifact_definitions: - if python_artifact_installed(python_bin, definition.package, version): + if not recreate_venv and python_artifact_installed(python_bin, definition.package, version): ctx.log.info( "Python artifact '%s' is already installed in the project virtual environment.", definition.name, @@ -310,12 +315,12 @@ def reconcile_python_artifacts( requirements = [requirement for _definition, _version, requirement in missing] if dry_run: - if not python_bin.exists(): + if recreate_venv or not python_bin.exists(): ctx.log.info("[DRY-RUN] Would create project virtual environment at '%s'.", venv_dir) process.dry_run_command(ctx, pip_install_command(python_bin, requirements)) return - if not python_bin.exists(): + if recreate_venv or not python_bin.exists(): ctx.log.info("Creating project virtual environment at '%s'.", venv_dir) venv.create(venv_dir, with_pip=True) @@ -332,6 +337,28 @@ def reconcile_python_artifacts( reconcile_python_artifacts_sequential(ctx, python_bin, missing) +def project_venv_recreate_enabled() -> bool: + return os.environ.get("BASE_SETUP_RECREATE_PROJECT_VENV") == "true" + + +def backup_existing_project_venv(ctx: base_cli.Context, venv_dir: Path, dry_run: bool) -> None: + if not venv_dir.exists(): + return + timestamp = time.strftime("%Y%m%dT%H%M%S") + backup_path = venv_dir.with_name(f"{venv_dir.name}.backup.{timestamp}") + if backup_path.exists(): + raise ArtifactError(f"Project virtual environment backup path already exists at '{backup_path}'.") + if dry_run: + ctx.log.info( + "[DRY-RUN] Would move existing project virtual environment '%s' to '%s'.", + venv_dir, + backup_path, + ) + return + ctx.log.info("Moving existing project virtual environment '%s' to '%s'.", venv_dir, backup_path) + venv_dir.rename(backup_path) + + def reconcile_python_artifacts_sequential( ctx: base_cli.Context, python_bin: Path, diff --git a/cli/python/base_setup/tests/test_artifacts.py b/cli/python/base_setup/tests/test_artifacts.py index 0f0fa5e6..3b91650f 100644 --- a/cli/python/base_setup/tests/test_artifacts.py +++ b/cli/python/base_setup/tests/test_artifacts.py @@ -413,6 +413,49 @@ def test_python_artifact_uses_manifest_project_not_environment(self) -> None: project_venv_dir.assert_called_once_with("demo") + def test_recreate_project_venv_backs_up_stale_venv_before_install(self) -> None: + definition = get_artifact_definition("python-package", "requests") + self.assertIsNotNone(definition) + ctx = fake_context() + + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + marker = root / "stale-python-ran" + venv_dir = root / "demo" / ".venv" + python_bin = venv_dir / "bin" / "python" + python_bin.parent.mkdir(parents=True) + (venv_dir / "pyvenv.cfg").write_text("home = /missing/python\n", encoding="utf-8") + (venv_dir / "old.txt").write_text("old venv\n", encoding="utf-8") + python_bin.write_text(f"#!/bin/sh\ntouch '{marker}'\nexit 1\n", encoding="utf-8") + python_bin.chmod(0o755) + + with ( + mock.patch.dict(os.environ, {"BASE_SETUP_RECREATE_PROJECT_VENV": "true"}), + mock.patch("base_setup.artifacts.project_venv_dir", return_value=venv_dir), + mock.patch("base_setup.artifacts.venv.create") as create_venv, + mock.patch("base_setup.process.run_command") as run_command, + ): + artifacts.reconcile_python_artifact(ctx, definition, "latest", "demo", dry_run=False) + + backups = list((root / "demo").glob(".venv.backup.*")) + + self.assertEqual(len(backups), 1) + self.assertTrue((backups[0] / "old.txt").is_file()) + self.assertFalse((venv_dir / "old.txt").exists()) + self.assertFalse(marker.exists()) + create_venv.assert_called_once_with(venv_dir, with_pip=True) + run_command.assert_called_once_with( + ctx, + [ + str(venv_dir / "bin" / "python"), + "-m", + "pip", + "install", + "--disable-pip-version-check", + "requests", + ], + ) + def test_reconcile_artifacts_batches_python_installs(self) -> None: click = get_artifact_definition("python-package", "click") requests = get_artifact_definition("python-package", "requests")