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
2 changes: 2 additions & 0 deletions .ai-context/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ Base orchestrates mature tools instead of replacing them:
- uv owns Python dependency resolution, lockfiles, and project-local `.venv`
environments when a manifest declares `python.manager: uv`; individual
commands can opt into `uv run` with `runner: uv`.
- Base owns the supported Python runtime window for `python.requires_python`
and uses that declaration when creating Base-managed project virtualenvs.
- IDEs own editor behavior; Base can install apps/extensions/settings
additively.
- Docker, `just`, Taskfile, Devbox, Nix, and similar tools can be project-level
Expand Down
6 changes: 4 additions & 2 deletions .ai-context/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The current command surface covers:
- mise integration
- bundled declarative artifact registry for Base-managed built-in artifacts
- explicit uv-managed Python project setup through `python.manager: uv`
- project Python runtime requirements through `python.requires_python`
- cleanup, logs, and local command history
- local config inspection
- onboarding
Expand All @@ -34,8 +35,8 @@ The current command surface covers:

The `v1.1.0` milestone is complete. Future work is tracked in GitHub Issues,
with Linux runtime support, Docker/service artifacts, project-specific PR
policy, and broader setup/Python version policy work remaining outside the
1.1 release contract.
policy, and broader setup policy work remaining outside the 1.1 release
contract.

The Homebrew bottle and consumer upgrade contract has passed the #526 rehearsal.
Supported macOS installs should continue to use bottled Homebrew packages, with
Expand All @@ -44,6 +45,7 @@ source builds treated as fallback validation rather than the normal user path.
Recent released work includes:

- local command-history index with future report surfaces still deferred
- project Python version requirements for Base-managed virtualenv creation
- workspace manifest status/check/doctor reporting plus explicit clone and pull
support
- guarded `basectl release publish`
Expand Down
126 changes: 113 additions & 13 deletions cli/python/base_setup/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import time
import venv
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path

import base_cli
Expand All @@ -13,11 +14,22 @@
from .checks import ArtifactCheck
from .errors import ArtifactError
from .manifest import ArtifactRequest
from .python_policy import evaluate_python_requirement
from .python_policy import inspect_python_interpreter
from .python_policy import PythonInterpreter
from .python_policy import resolve_python_interpreter
from .python_policy import version_label
from .registry import ArtifactDefinition, get_artifact_definition

PIP_INSTALL_COMMAND_PREFIX = ("-m", "pip", "install", "--disable-pip-version-check")


@dataclass(frozen=True)
class ProjectRuntimeConfig:
name: str
python_requirement: str | None = None


def homebrew_no_auto_update_env() -> dict[str, str]:
env = os.environ.copy()
env["HOMEBREW_NO_AUTO_UPDATE"] = "1"
Expand Down Expand Up @@ -203,14 +215,15 @@ def reconcile_artifact(
ctx: base_cli.Context,
definition: ArtifactDefinition,
version: str,
project: str,
project: str | ProjectRuntimeConfig,
dry_run: bool,
) -> None:
runtime_config = project_runtime_config(project)
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)
reconcile_python_artifact(ctx, definition, version, runtime_config, dry_run=dry_run)
return
raise ArtifactError(f"Artifact manager '{definition.manager}' is not implemented.")

Expand All @@ -219,24 +232,36 @@ def reconcile_artifacts(
ctx: base_cli.Context,
artifacts: tuple[ArtifactRequest, ...],
definitions: tuple[ArtifactDefinition, ...],
project: str,
project: str | ProjectRuntimeConfig,
dry_run: bool,
) -> None:
runtime_config = project_runtime_config(project)
pending_python_artifacts: list[tuple[ArtifactDefinition, str]] = []

def flush_python_artifacts() -> None:
nonlocal pending_python_artifacts
if not pending_python_artifacts:
return
reconcile_python_artifacts(ctx, tuple(pending_python_artifacts), project, dry_run=dry_run)
reconcile_python_artifacts(
ctx,
tuple(pending_python_artifacts),
runtime_config,
dry_run=dry_run,
)
pending_python_artifacts = []

for artifact, definition in zip(artifacts, definitions, strict=True):
if definition.manager == "pip":
pending_python_artifacts.append((definition, artifact.version))
continue
flush_python_artifacts()
reconcile_artifact(ctx, definition, artifact.version, project, dry_run=dry_run)
reconcile_artifact(
ctx,
definition,
artifact.version,
runtime_config,
dry_run=dry_run,
)
flush_python_artifacts()


Expand Down Expand Up @@ -301,7 +326,7 @@ def reconcile_python_artifact(
ctx: base_cli.Context,
definition: ArtifactDefinition,
version: str,
project: str,
project: str | ProjectRuntimeConfig,
dry_run: bool,
) -> None:
reconcile_python_artifacts(ctx, ((definition, version),), project, dry_run=dry_run)
Expand All @@ -310,16 +335,20 @@ def reconcile_python_artifact(
def reconcile_python_artifacts(
ctx: base_cli.Context,
artifact_definitions: tuple[tuple[ArtifactDefinition, str], ...],
project: str,
project: str | ProjectRuntimeConfig,
dry_run: bool,
) -> None:
venv_dir = project_venv_dir(project)
runtime_config = project_runtime_config(project)
python_requirement = runtime_config.python_requirement
venv_dir = project_venv_dir(runtime_config.name)
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)
elif python_requirement is not None and python_bin.exists():
ensure_existing_project_venv_matches_requirement(python_bin, runtime_config.name, python_requirement)

for definition, version in artifact_definitions:
if not recreate_venv and python_artifact_installed(python_bin, definition.package, version):
Expand All @@ -328,7 +357,7 @@ def reconcile_python_artifacts(
definition.name,
)
continue
missing.append((definition, version, python_requirement(definition, version)))
missing.append((definition, version, python_package_requirement(definition, version)))

if not missing:
return
Expand All @@ -337,13 +366,21 @@ def reconcile_python_artifacts(

if dry_run:
if recreate_venv or not python_bin.exists():
ctx.log.info("[DRY-RUN] Would create project virtual environment at '%s'.", venv_dir)
if python_requirement is None:
ctx.log.info("[DRY-RUN] Would create project virtual environment at '%s'.", venv_dir)
else:
interpreter = project_python_interpreter(python_requirement)
ctx.log.info(
"[DRY-RUN] Would create project virtual environment at '%s' with Python %s from '%s'.",
venv_dir,
version_label(interpreter.version),
interpreter.path,
)
process.dry_run_command(ctx, pip_install_command(python_bin, requirements))
return

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)
create_project_virtualenv(ctx, venv_dir, python_requirement)

names = ", ".join(definition.name for definition, _version, _requirement in missing)
ctx.log.info("Installing Python artifacts into project virtual environment: %s.", names)
Expand All @@ -362,6 +399,69 @@ def project_venv_recreate_enabled() -> bool:
return os.environ.get("BASE_SETUP_RECREATE_PROJECT_VENV") == "true"


def project_runtime_config(project: str | ProjectRuntimeConfig) -> ProjectRuntimeConfig:
if isinstance(project, ProjectRuntimeConfig):
return project
return ProjectRuntimeConfig(name=project)


def create_project_virtualenv(ctx: base_cli.Context, venv_dir: Path, python_requirement: str | None) -> None:
if python_requirement is None:
ctx.log.info("Creating project virtual environment at '%s'.", venv_dir)
venv.create(venv_dir, with_pip=True)
return

interpreter = project_python_interpreter(python_requirement)
ctx.log.info(
"Creating project virtual environment at '%s' with Python %s.",
venv_dir,
version_label(interpreter.version),
)
process.run_command(ctx, [str(interpreter.path), "-m", "venv", str(venv_dir)])


def project_python_interpreter(python_requirement: str) -> PythonInterpreter:
policy = evaluate_python_requirement(python_requirement)
if not policy.ok or policy.selected_version is None:
raise ArtifactError(
f"python.requires_python '{python_requirement}' {policy.error}. "
"Choose a Python version supported by Base."
)
interpreter = resolve_python_interpreter(policy.selected_version)
if interpreter is None:
selected = version_label(policy.selected_version)
raise ArtifactError(
f"Python {selected} is not available for python.requires_python '{python_requirement}'. "
f"Install Python {selected} or update base_manifest.yaml."
)
return interpreter


def ensure_existing_project_venv_matches_requirement(
python_bin: Path,
project: str,
python_requirement: str,
) -> None:
policy = evaluate_python_requirement(python_requirement)
if not policy.ok or policy.selected_version is None:
raise ArtifactError(
f"python.requires_python '{python_requirement}' {policy.error}. "
"Choose a Python version supported by Base."
)

interpreter = inspect_python_interpreter(python_bin)
if interpreter is None or interpreter.version == policy.selected_version:
return

expected = version_label(policy.selected_version)
actual = version_label(interpreter.version)
raise ArtifactError(
f"Project virtual environment '{python_bin.parent.parent}' uses Python {actual}, "
f"but python.requires_python '{python_requirement}' selects Python {expected}. "
f"Run 'basectl setup {project} --recreate-venv' to recreate the project virtual environment."
)


def backup_existing_project_venv(ctx: base_cli.Context, venv_dir: Path, dry_run: bool) -> None:
if not venv_dir.exists():
return
Expand Down Expand Up @@ -396,7 +496,7 @@ def reconcile_python_artifacts_sequential(
process.run_command(ctx, pip_install_command(python_bin, (requirement,)))


def python_requirement(definition: ArtifactDefinition, version: str) -> str:
def python_package_requirement(definition: ArtifactDefinition, version: str) -> str:
return f"{definition.package}=={version}" if version != "latest" else definition.package


Expand Down
34 changes: 30 additions & 4 deletions cli/python/base_setup/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from .artifacts import check_artifact
from .artifacts import merge_artifacts
from .artifacts import ProjectRuntimeConfig
from .artifacts import reconcile_artifacts
from .artifacts import resolve_artifact_definitions
from .build import check_build
Expand Down Expand Up @@ -39,6 +40,7 @@
from .ide import reconcile_ide_settings
from .manifest import BaseManifest, ManifestError, read_manifest
from .pyproject import check_pyproject
from .python_policy import python_requirement_checks
from .uv import check_uv
from .uv import manifest_uses_uv_project_manager
from .uv import reconcile_uv_project
Expand Down Expand Up @@ -210,7 +212,13 @@ def reconcile_manifest(
reconcile_uv_project(ctx, effective_manifest, dry_run=dry_run)

if artifacts:
reconcile_artifacts(ctx, artifacts, definitions, effective_manifest.project_name, dry_run=dry_run)
reconcile_artifacts(
ctx,
artifacts,
definitions,
project_runtime_argument(effective_manifest),
dry_run=dry_run,
)

ctx.log.info("Project '%s' setup is complete.", effective_manifest.project_name)

Expand All @@ -229,7 +237,22 @@ def reconcile_bootstrap_artifacts(
ctx.log.info("Base default manifest declares no bootstrap artifacts.")
return

reconcile_artifacts(ctx, artifacts, definitions, manifest.project_name, dry_run=dry_run)
reconcile_artifacts(
ctx,
artifacts,
definitions,
project_runtime_argument(manifest),
dry_run=dry_run,
)


def project_runtime_argument(manifest: BaseManifest) -> str | ProjectRuntimeConfig:
if manifest.python.requires_python is None:
return manifest.project_name
return ProjectRuntimeConfig(
name=manifest.project_name,
python_requirement=manifest.python.requires_python,
)


def check_manifest(
Expand Down Expand Up @@ -329,7 +352,10 @@ def doctor_pre_venv_manifest(


def pre_venv_manifest_checks(manifest: BaseManifest, remote_network: bool = False) -> tuple[ArtifactCheck, ...]:
return check_git_remote(manifest, check_network=remote_network)
checks: list[ArtifactCheck] = []
checks.extend(python_requirement_checks(manifest))
checks.extend(check_git_remote(manifest, check_network=remote_network))
return tuple(checks)


def manifest_checks(
Expand Down Expand Up @@ -366,7 +392,7 @@ def manifest_checks(
for artifact, definition in zip(artifacts, definitions, strict=True):
checks.append(check_artifact(effective_manifest.project_name, artifact, definition))

if not checks:
if not pre_venv_checks and not checks:
checks.append(
ArtifactCheck(
name="manifest",
Expand Down
27 changes: 17 additions & 10 deletions cli/python/base_setup/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ class ActivateConfig:
@dataclass(frozen=True)
class PythonConfig:
manager: str | None = None
requires_python: str | None = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -690,21 +691,27 @@ def _read_python(path: Path, python_data: Any) -> PythonConfig:
if not isinstance(python_data, dict):
raise ManifestError(f"{path}: python must be a mapping when provided.")

allowed_keys = {"manager"}
allowed_keys = {"manager", "requires_python"}
unknown_keys = sorted(set(python_data) - allowed_keys)
if unknown_keys:
raise ManifestError(f"{path}: python has unsupported keys: {', '.join(unknown_keys)}.")

manager = python_data.get("manager")
if manager is None:
return PythonConfig()
if not isinstance(manager, str) or not manager.strip():
raise ManifestError(f"{path}: python.manager must be a non-empty string when provided.")
manager = manager.strip()
if manager not in SUPPORTED_PYTHON_MANAGERS:
supported = ", ".join(sorted(SUPPORTED_PYTHON_MANAGERS))
raise ManifestError(f"{path}: python.manager must be one of: {supported}.")
return PythonConfig(manager=manager)
if manager is not None:
if not isinstance(manager, str) or not manager.strip():
raise ManifestError(f"{path}: python.manager must be a non-empty string when provided.")
manager = manager.strip()
if manager not in SUPPORTED_PYTHON_MANAGERS:
supported = ", ".join(sorted(SUPPORTED_PYTHON_MANAGERS))
raise ManifestError(f"{path}: python.manager must be one of: {supported}.")

requires_python = python_data.get("requires_python")
if requires_python is not None:
if not isinstance(requires_python, str) or not requires_python.strip():
raise ManifestError(f"{path}: python.requires_python must be a non-empty string when provided.")
requires_python = requires_python.strip()

return PythonConfig(manager=manager, requires_python=requires_python)


def _read_optional_runner(path: Path, field_name: str, runner_data: Any) -> str | None:
Expand Down
Loading
Loading