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
45 changes: 41 additions & 4 deletions cli/bash/commands/basectl/subcommands/setup_common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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=(
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions cli/bash/commands/basectl/tests/check.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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 @@ -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"
Expand Down
31 changes: 31 additions & 0 deletions cli/python/base_setup/tests/test_uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions cli/python/base_setup/uv.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
from pathlib import Path

import base_cli
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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():
Expand Down
3 changes: 2 additions & 1 deletion docs/doctor-findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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`
Expand Down
1 change: 1 addition & 0 deletions docs/python-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading