diff --git a/cli/bash/commands/basectl/subcommands/setup_common.sh b/cli/bash/commands/basectl/subcommands/setup_common.sh index 8a98a41d..8943465b 100644 --- a/cli/bash/commands/basectl/subcommands/setup_common.sh +++ b/cli/bash/commands/basectl/subcommands/setup_common.sh @@ -823,14 +823,39 @@ setup_resolve_project_manifest() { setup_project_venv_dir() { local project="$1" + local project_root="${2:-}" + local manifest_path="${3:-}" if [[ "$project" != base && -n "${BASE_PROJECT_VENV_DIR:-}" ]]; then printf '%s\n' "$BASE_PROJECT_VENV_DIR" return 0 fi + if [[ "$project" != base && -n "$project_root" ]] && setup_project_uses_uv_manager "$manifest_path"; then + printf '%s\n' "$project_root/.venv" + return 0 + fi printf '%s\n' "$HOME/.base.d/$project/.venv" } +setup_project_root_from_manifest() { + local manifest_path="$1" + + (cd -- "$(dirname -- "$manifest_path")" && pwd -P) +} + +setup_project_uses_uv_manager() { + local manifest_path="$1" + + [[ -n "$manifest_path" && -f "$manifest_path" ]] || return 1 + awk ' + /^[[:space:]]*#/ { next } + /^[^[:space:]][^:]*:/ { in_python = 0 } + /^[[:space:]]*python:[[:space:]]*$/ { in_python = 1; next } + in_python && /^[[:space:]]+manager:[[:space:]]*['\''"]?uv['\''"]?[[:space:]]*(#.*)?$/ { found = 1 } + END { exit found ? 0 : 1 } + ' "$manifest_path" +} + setup_project_check_record_path() { local project="$1" @@ -1171,6 +1196,7 @@ setup_run_project_artifact_layer() { project=base fi manifest_path="$resolve_output" + resolved_root="$(setup_project_root_from_manifest "$manifest_path")" || return 1 fi if [[ "$project" == base ]]; then project_env_args=( @@ -1204,7 +1230,7 @@ setup_run_project_artifact_layer() { fi fi - if [[ "$action" == setup ]]; then + if [[ "$action" == setup ]] && ! setup_project_uses_uv_manager "$manifest_path"; then setup_run_project_bootstrap_layer "$manifest_path" "$project" "$output_format" exit_code=$? if ((exit_code)); then @@ -1214,8 +1240,8 @@ setup_run_project_artifact_layer() { fi fi - project_venv_dir="$(setup_project_venv_dir "$project")" - if ! setup_virtualenv_healthy_path "$project_venv_dir"; then + project_venv_dir="$(setup_project_venv_dir "$project" "$resolved_root" "$manifest_path")" + if ! setup_project_uses_uv_manager "$manifest_path" && ! setup_virtualenv_healthy_path "$project_venv_dir"; then if setup_is_dry_run && [[ "$action" == setup ]]; then log_info "[DRY-RUN] Would run Python project setup layer through base-wrapper for project '$project'." return 0 @@ -1258,7 +1284,18 @@ setup_run_project_artifact_layer() { return 1 fi - env "${project_env_args[@]}" "$BASE_HOME/bin/base-wrapper" --project "$project" base_setup "${args[@]}" + if setup_project_uses_uv_manager "$manifest_path"; then + env "${project_env_args[@]}" \ + BASE_HOME="$BASE_HOME" \ + BASE_PROJECT="$project" \ + BASE_PROJECT_ROOT="$resolved_root" \ + BASE_PROJECT_MANIFEST="$manifest_path" \ + BASE_PROJECT_VENV_DIR="$project_venv_dir" \ + PYTHONPATH="$_BASE_SETUP_PYTHONPATH_CACHE" \ + "$python_bin" -m base_setup "${args[@]}" + else + env "${project_env_args[@]}" "$BASE_HOME/bin/base-wrapper" --project "$project" base_setup "${args[@]}" + fi exit_code=$? if ((exit_code)) && [[ "$action" == setup ]]; then diff --git a/cli/bash/commands/basectl/tests/check.bats b/cli/bash/commands/basectl/tests/check.bats index 6b70f169..9cc646e3 100644 --- a/cli/bash/commands/basectl/tests/check.bats +++ b/cli/bash/commands/basectl/tests/check.bats @@ -282,6 +282,30 @@ load ./setup_helpers.bash [[ "$output" == *"Virtual environment is missing at '$TEST_HOME/.base.d/demo/.venv'."* ]] } +@test "basectl check uv-managed project does not require historical Base project venv" { + local base_venv_dir="$TEST_HOME/.base.d/base/.venv" + local project_root="$TEST_TMPDIR/demo" + local manifest_path="$project_root/base_manifest.yaml" + + create_brew_stub + create_xcode_stubs + touch "$TEST_STATE_DIR/xcode-installed" + mkdir -p "$TEST_TMPDIR/CommandLineTools" "$project_root" + 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" + printf 'project:\n name: demo\npython:\n manager: uv\nartifacts: []\n' > "$manifest_path" + + run_base_command check demo --manifest "$manifest_path" --format json + + [ "$status" -eq 0 ] + [[ "$output" != *"BASE-P050"* ]] + [[ "$output" != *"$TEST_HOME/.base.d/demo/.venv"* ]] + [ "$(cat "$TEST_STATE_DIR/project-setup-args")" = "$(printf '%s\n' --manifest "$manifest_path" --action check --format json demo)" ] + [ "$(cat "$TEST_STATE_DIR/project-setup-project")" = "demo" ] +} + @test "basectl check project passes opt-in remote network diagnostics flag" { local venv_dir="$TEST_HOME/.base.d/base/.venv" local workspace="$TEST_TMPDIR/workspace" diff --git a/cli/bash/commands/basectl/tests/setup.bats b/cli/bash/commands/basectl/tests/setup.bats index 97a475ba..f7ca66db 100644 --- a/cli/bash/commands/basectl/tests/setup.bats +++ b/cli/bash/commands/basectl/tests/setup.bats @@ -422,6 +422,31 @@ EOF [ "$(cat "$TEST_STATE_DIR/project-setup-args")" = "$(printf '%s\n' --dry-run --manifest "$manifest_path" --action setup demo)" ] } +@test "basectl setup uv-managed project does not bootstrap historical Base project venv" { + local base_venv_dir="$TEST_HOME/.base.d/base/.venv" + local project_root="$TEST_TMPDIR/demo" + local manifest_path="$project_root/base_manifest.yaml" + + create_brew_stub + create_xcode_stubs + touch "$TEST_STATE_DIR/xcode-installed" + mkdir -p "$TEST_TMPDIR/CommandLineTools" "$project_root" + 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" + printf 'project:\n name: demo\npython:\n manager: uv\nartifacts: []\n' > "$manifest_path" + + run_base_command setup --dry-run --manifest "$manifest_path" + + [ "$status" -eq 0 ] + [[ "$output" != *"Would create project virtual environment at '$TEST_HOME/.base.d/demo/.venv'"* ]] + [[ "$output" != *"Would run Python project setup layer through base-wrapper"* ]] + [ ! -e "$TEST_HOME/.base.d/demo/.venv" ] + [ "$(cat "$TEST_STATE_DIR/project-setup-args")" = "$(printf '%s\n' --dry-run --manifest "$manifest_path" --action setup demo)" ] + [ "$(cat "$TEST_STATE_DIR/project-setup-project")" = "demo" ] +} + @test "project setup resolves named project manifests from the workspace" { local base_venv_dir="$TEST_HOME/.base.d/base/.venv" local workspace="$TEST_TMPDIR/workspace" diff --git a/cli/python/base_setup/tests/test_uv.py b/cli/python/base_setup/tests/test_uv.py index e61eceb5..d5008dc0 100644 --- a/cli/python/base_setup/tests/test_uv.py +++ b/cli/python/base_setup/tests/test_uv.py @@ -139,7 +139,38 @@ def test_check_uv_reports_project_files_and_stale_base_venv(self) -> None: self.assertEqual(findings["BASE-P151"].status, "") self.assertEqual(findings["BASE-P152"].status, "warn") self.assertEqual(findings["BASE-P153"].status, "warn") + self.assertEqual(findings["BASE-P154"].status, "warn") self.assertIn(str(stale_venv), findings["BASE-P153"].message) + self.assertIn("uv sync", findings["BASE-P154"].fix) + + def test_check_uv_reports_project_venv_when_present(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "project" + manifest = write_manifest( + root, + "\n".join( + [ + "project:", + " name: demo", + "python:", + " manager: uv", + "artifacts: []", + ] + ), + ) + (root / "pyproject.toml").write_text("[project]\nname = 'demo'\n", encoding="utf-8") + (root / "uv.lock").write_text("version = 1\n", encoding="utf-8") + python_bin = root / ".venv" / "bin" / "python" + python_bin.parent.mkdir(parents=True) + python_bin.write_text("#!/usr/bin/env python\n", encoding="utf-8") + python_bin.chmod(0o755) + + with mock.patch("base_setup.uv.process.command_exists", return_value=True): + checks = check_uv(manifest) + + findings = {check.finding_id: check for check in checks} + self.assertEqual(findings["BASE-P154"].status, "") + self.assertIn(str(root / ".venv"), findings["BASE-P154"].message) def test_uv_project_setup_skips_python_package_artifacts(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: diff --git a/cli/python/base_setup/uv.py b/cli/python/base_setup/uv.py index 833bc235..3fb76b8a 100644 --- a/cli/python/base_setup/uv.py +++ b/cli/python/base_setup/uv.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from pathlib import Path import base_cli @@ -55,6 +56,7 @@ def check_uv(manifest: BaseManifest) -> tuple[ArtifactCheck, ...]: if uses_uv_manager: checks.append(pyproject_check(manifest.path.parent / "pyproject.toml")) checks.append(uv_lock_check(manifest.path.parent / "uv.lock")) + checks.append(uv_project_venv_check(manifest.path.parent / ".venv")) stale_check = stale_base_venv_check(manifest) if stale_check is not None: checks.append(stale_check) @@ -123,6 +125,26 @@ def uv_lock_check(lock_path: Path) -> ArtifactCheck: ) +def uv_project_venv_check(venv_path: Path) -> ArtifactCheck: + python_path = venv_path / "bin" / "python" + if python_path.is_file() and os.access(python_path, os.X_OK): + return ArtifactCheck( + name="uv project virtualenv", + ok=True, + message=f"uv project virtualenv exists at '{venv_path}'.", + fix="", + finding_id="BASE-P154", + ) + return ArtifactCheck( + name="uv project virtualenv", + ok=False, + message=f"uv project manager is declared, but '{python_path}' does not exist or is not executable.", + fix="Run 'uv sync' from the project root.", + finding_id="BASE-P154", + status="warn", + ) + + def stale_base_venv_check(manifest: BaseManifest) -> ArtifactCheck | None: stale_venv = Path.home() / ".base.d" / manifest.project_name / ".venv" if not stale_venv.exists(): diff --git a/docs/doctor-findings.md b/docs/doctor-findings.md index d4d74b1e..95270fa7 100644 --- a/docs/doctor-findings.md +++ b/docs/doctor-findings.md @@ -135,6 +135,7 @@ Doctor commands use the same diagnostic item fields. The top-level | `BASE-P151` | uv-managed project `pyproject.toml` presence | | `BASE-P152` | uv-managed project `uv.lock` presence | | `BASE-P153` | Stale Base-managed project virtual environment ignored by a uv-managed project | +| `BASE-P154` | uv-managed project virtual environment readiness | | `BASE-P160` | Manifest command executable availability | | `BASE-P161` | Manifest command project script path readiness | @@ -152,7 +153,7 @@ configuration source and do not cause Base to install Python dependencies. Warnings in this range should guide users toward a valid Python project file without failing the Base manifest check by themselves. -`BASE-P150` through `BASE-P153` are uv support diagnostics. They are warnings +`BASE-P150` through `BASE-P154` are uv support diagnostics. They are warnings when uv tooling or expected uv project files are missing, because check/doctor should explain readiness without performing dependency resolution. Command invocation still fails hard when a command declares `runner: uv` and the `uv` diff --git a/docs/python-manifest.md b/docs/python-manifest.md index c001d57a..1117bae1 100644 --- a/docs/python-manifest.md +++ b/docs/python-manifest.md @@ -134,6 +134,7 @@ uv support adds these project diagnostics: - `BASE-P152`: uv-managed project `uv.lock` presence - `BASE-P153`: stale Base-managed project virtual environment ignored by a uv-managed project +- `BASE-P154`: uv-managed project virtual environment readiness - `BASE-P160`: manifest command starts with an executable that is not available on PATH or in the project virtual environment - `BASE-P161`: manifest command references a project script path that is