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

@@ -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)