Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions cli/bash/commands/basectl/subcommands/setup_common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]]
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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() {
Expand Down
25 changes: 25 additions & 0 deletions cli/bash/commands/basectl/tests/setup.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions cli/bash/commands/basectl/tests/setup_helpers.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
33 changes: 30 additions & 3 deletions cli/python/base_setup/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import subprocess
import time
import venv
from collections.abc import Iterable
from pathlib import Path
Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand All @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions cli/python/base_setup/tests/test_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading