refactor: Run python services in place via uv run

A python-runner service ran from a tool venv (uv tool install --editable),
separate from the project venv that uv sync manages. The editable link kept
source live, but dependencies were resolved independently in each venv, so a
new dependency landed in the project venv and never reached the running
service — leaving no clean rollout for it.

Deploy now emits ExecStart=uv run --project <source> --no-dev <program>,
running the service in place from its own project venv. uv run syncs the env
to the lockfile before launching, so a single restart picks up both code and
dependency changes; the ExecStart is deterministic from source and never goes
stale. The tool-venv install is gated to the command runner only; uv tool
install is reserved for tool-behavior programs, where PATH is the point. A
python service with no resolvable source falls back to a PATH lookup.
This commit is contained in:
2026-06-17 11:03:11 -07:00
parent cd0a3bc9e2
commit 26a00c4073
3 changed files with 112 additions and 15 deletions

View File

@@ -200,11 +200,15 @@ def _build_deployed_service(
env = dict(svc.defaults.env) if (svc.defaults and svc.defaults.env) else {} env = dict(svc.defaults.env) if (svc.defaults and svc.defaults.env) else {}
env = resolve_env_vars(env, _env_context(name, config_key, port)) env = resolve_env_vars(env, _env_context(name, config_key, port))
# Ensure python tool is installed before resolving binary # `command`-runner services resolve a tool on PATH → ensure it's installed.
# `python`-runner services run in place via `uv run` (below) — no tool venv.
if run.runner == "command":
_ensure_python_tool(config, svc.program, messages) _ensure_python_tool(config, svc.program, messages)
# Build run_cmd # Build run_cmd
run_cmd = _build_run_cmd(name, run, env, messages) run_cmd = _build_run_cmd(
name, run, env, messages, _program_source_dir(config, svc.program)
)
# Proxy: a path prefix (handle_path on the gateway) and/or a hostname (a # Proxy: a path prefix (handle_path on the gateway) and/or a hostname (a
# dedicated host site block, so a root-based app serves unchanged). # dedicated host site block, so a root-based app serves unchanged).
@@ -253,8 +257,11 @@ def _build_deployed_job(
config_key = job.program or name config_key = job.program or name
env = dict(job.defaults.env) if (job.defaults and job.defaults.env) else {} env = dict(job.defaults.env) if (job.defaults and job.defaults.env) else {}
env = resolve_env_vars(env, _env_context(name, config_key, None)) env = resolve_env_vars(env, _env_context(name, config_key, None))
if run.runner == "command":
_ensure_python_tool(config, job.program, messages) _ensure_python_tool(config, job.program, messages)
run_cmd = _build_run_cmd(name, run, env, messages) run_cmd = _build_run_cmd(
name, run, env, messages, _program_source_dir(config, job.program)
)
stack = None stack = None
if job.program and job.program in config.programs: if job.program and job.program in config.programs:
@@ -293,10 +300,25 @@ def _python_tool_needs_install(program: str) -> bool:
return False return False
def _program_source_dir(config: CastleConfig, program: str | None) -> Path | None:
"""The absolute source dir of a referenced program, if any.
`load_config` has already resolved `source` to an absolute path (repo: and
relative forms included), so this is a plain lookup."""
if program and program in config.programs:
src = config.programs[program].source
if src:
return Path(src)
return None
def _ensure_python_tool( def _ensure_python_tool(
config: CastleConfig, program: str | None, messages: list[str] config: CastleConfig, program: str | None, messages: list[str]
) -> None: ) -> None:
"""Ensure a Python program's editable install is current.""" """Ensure a Python program's editable install is current.
Only the `command` runner needs this — it resolves a tool on PATH. The
`python` runner runs in place via `uv run` and never touches a tool venv."""
if not program or program not in config.programs: if not program or program not in config.programs:
return return
comp = config.programs[program] comp = config.programs[program]
@@ -323,15 +345,31 @@ def _ensure_python_tool(
messages.append(f"Installed {program}") messages.append(f"Installed {program}")
def _build_run_cmd(name: str, run: object, env: dict[str, str], messages: list[str]) -> list[str]: def _build_run_cmd(
name: str,
run: object,
env: dict[str, str],
messages: list[str],
source_dir: Path | None = None,
) -> list[str]:
"""Build a run command list from a RunSpec.""" """Build a run command list from a RunSpec."""
match run.runner: # type: ignore[union-attr] match run.runner: # type: ignore[union-attr]
case "python": case "python":
# Run the program in place from its own project venv via `uv run`,
# which syncs the env to the lockfile before launching. One venv per
# program (no separate tool venv); restart picks up both code and
# dependency changes. Falls back to a PATH lookup only when there's no
# resolvable source (a service that declares run.program without a
# catalog program).
if source_dir and source_dir.is_dir():
uv = shutil.which("uv") or "uv"
cmd = [uv, "run", "--project", str(source_dir), "--no-dev", run.program] # type: ignore[union-attr]
else:
resolved = shutil.which(run.program) # type: ignore[union-attr] resolved = shutil.which(run.program) # type: ignore[union-attr]
if not resolved: if not resolved:
messages.append( messages.append(
f"Warning: '{run.program}' not on PATH. " # type: ignore[union-attr] f"Warning: '{run.program}' has no source dir and is not on " # type: ignore[union-attr]
f"Install with: uv tool install --editable <source>" f"PATH. Declare a program source, or install it."
) )
cmd = [resolved or run.program] # type: ignore[union-attr] cmd = [resolved or run.program] # type: ignore[union-attr]
if run.args: # type: ignore[union-attr] if run.args: # type: ignore[union-attr]

View File

@@ -0,0 +1,50 @@
"""Tests for `_build_run_cmd` — the python runner runs in place via `uv run`."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from castle_core.deploy import _build_run_cmd
from castle_core.manifest import RunPython
def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
"""A python service with a real source dir launches via `uv run --project`."""
run = RunPython(runner="python", program="my-svc")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
assert cmd == [
"/usr/bin/uv",
"run",
"--project",
str(tmp_path),
"--no-dev",
"my-svc",
]
def test_python_runner_appends_args(tmp_path: Path) -> None:
run = RunPython(runner="python", program="my-svc", args=["--flag", "x"])
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
assert cmd[-2:] == ["--flag", "x"]
assert cmd[:5] == ["/usr/bin/uv", "run", "--project", str(tmp_path), "--no-dev"]
def test_python_runner_falls_back_to_path_without_source() -> None:
"""No resolvable source → PATH lookup of the script (no uv run)."""
run = RunPython(runner="python", program="my-svc")
with patch("castle_core.deploy.shutil.which", return_value="/home/u/.local/bin/my-svc"):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=None)
assert cmd == ["/home/u/.local/bin/my-svc"]
def test_python_runner_warns_when_unresolvable() -> None:
"""No source and not on PATH → a warning, bare program name as last resort."""
run = RunPython(runner="python", program="my-svc")
messages: list[str] = []
with patch("castle_core.deploy.shutil.which", return_value=None):
cmd = _build_run_cmd("my-svc", run, {}, messages, source_dir=None)
assert cmd == ["my-svc"]
assert any("my-svc" in m for m in messages)

View File

@@ -199,12 +199,21 @@ Discriminated union on `runner`:
| Runner | Sync | Deploy | Key fields | | Runner | Sync | Deploy | Key fields |
|--------|------|--------|------------| |--------|------|--------|------------|
| `python` | `uv sync` | `which(program)` → installed binary | `program`, `args` | | `python` | *(none — `uv run` self-syncs)* | `uv run --project <source> --no-dev <program>` | `program`, `args` |
| `command` | *(none)* | `which(argv[0])` → resolved path | `argv` | | `command` | *(none)* | `which(argv[0])` → resolved path | `argv` |
| `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` | | `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
| `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` | | `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
| `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` | | `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` |
A `python` service runs **in place from its own project venv** via `uv run`, which
syncs the env to the project's lockfile before launching. There is no separate
tool venv and no `uv tool install` step: **a restart picks up both code and
dependency changes** (the deploy-time `ExecStart` is deterministic from `source`,
so it never goes stale). `uv tool install` is reserved for `tool`-behavior
programs, where being on a human's PATH is the point. If a `python` service
declares a `program` with no resolvable `source`, deploy falls back to a PATH
lookup of the script.
```yaml ```yaml
run: run:
runner: python runner: python