diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 28e5fd0..b513ce9 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -200,11 +200,15 @@ def _build_deployed_service( env = dict(svc.defaults.env) if (svc.defaults and svc.defaults.env) else {} env = resolve_env_vars(env, _env_context(name, config_key, port)) - # Ensure python tool is installed before resolving binary - _ensure_python_tool(config, svc.program, messages) + # `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) # 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 # 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 env = dict(job.defaults.env) if (job.defaults and job.defaults.env) else {} env = resolve_env_vars(env, _env_context(name, config_key, None)) - _ensure_python_tool(config, job.program, messages) - run_cmd = _build_run_cmd(name, run, env, messages) + if run.runner == "command": + _ensure_python_tool(config, job.program, messages) + run_cmd = _build_run_cmd( + name, run, env, messages, _program_source_dir(config, job.program) + ) stack = None if job.program and job.program in config.programs: @@ -293,10 +300,25 @@ def _python_tool_needs_install(program: str) -> bool: 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( config: CastleConfig, program: str | None, messages: list[str] ) -> 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: return comp = config.programs[program] @@ -323,17 +345,33 @@ def _ensure_python_tool( 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.""" match run.runner: # type: ignore[union-attr] case "python": - resolved = shutil.which(run.program) # type: ignore[union-attr] - if not resolved: - messages.append( - f"Warning: '{run.program}' not on PATH. " # type: ignore[union-attr] - f"Install with: uv tool install --editable " - ) - cmd = [resolved or run.program] # type: ignore[union-attr] + # 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] + if not resolved: + messages.append( + f"Warning: '{run.program}' has no source dir and is not on " # type: ignore[union-attr] + f"PATH. Declare a program source, or install it." + ) + cmd = [resolved or run.program] # type: ignore[union-attr] if run.args: # type: ignore[union-attr] cmd.extend(run.args) # type: ignore[union-attr] return cmd diff --git a/core/tests/test_deploy_run_cmd.py b/core/tests/test_deploy_run_cmd.py new file mode 100644 index 0000000..d1306a6 --- /dev/null +++ b/core/tests/test_deploy_run_cmd.py @@ -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) diff --git a/docs/registry.md b/docs/registry.md index 1e2253c..2af3d2c 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -199,12 +199,21 @@ Discriminated union on `runner`: | Runner | Sync | Deploy | Key fields | |--------|------|--------|------------| -| `python` | `uv sync` | `which(program)` → installed binary | `program`, `args` | +| `python` | *(none — `uv run` self-syncs)* | `uv run --project --no-dev ` | `program`, `args` | | `command` | *(none)* | `which(argv[0])` → resolved path | `argv` | | `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` | | `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` | | `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 run: runner: python