This commit is contained in:
2026-04-27 20:19:16 -07:00
parent 01a09d6a61
commit a28d40c49e

View File

@@ -32,6 +32,7 @@ from castle_core.registry import (
load_registry, load_registry,
save_registry, save_registry,
) )
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user" SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
@@ -105,9 +106,7 @@ def _env_prefix(name: str) -> str:
return name.replace("-", "_").upper() return name.replace("-", "_").upper()
def _resolve_description( def _resolve_description(config: CastleConfig, spec: ServiceSpec | JobSpec) -> str | None:
config: CastleConfig, spec: ServiceSpec | JobSpec
) -> str | None:
"""Get description, falling through to program if referenced.""" """Get description, falling through to program if referenced."""
if spec.description: if spec.description:
return spec.description return spec.description
@@ -116,9 +115,7 @@ def _resolve_description(
return None return None
def _build_deployed_service( def _build_deployed_service(config: CastleConfig, name: str, svc: ServiceSpec) -> DeployedComponent:
config: CastleConfig, name: str, svc: ServiceSpec
) -> DeployedComponent:
"""Build a DeployedComponent from a ServiceSpec.""" """Build a DeployedComponent from a ServiceSpec."""
run = svc.run run = svc.run
prefix = _env_prefix(name) prefix = _env_prefix(name)
@@ -146,6 +143,9 @@ def _build_deployed_service(
# Resolve secrets # Resolve secrets
env = resolve_env_vars(env) env = resolve_env_vars(env)
# Ensure python tool is installed before resolving binary
_ensure_python_tool(config, svc.component)
# Build run_cmd # Build run_cmd
run_cmd = _build_run_cmd(run, env) run_cmd = _build_run_cmd(run, env)
@@ -173,9 +173,7 @@ def _build_deployed_service(
) )
def _build_deployed_job( def _build_deployed_job(config: CastleConfig, name: str, job: JobSpec) -> DeployedComponent:
config: CastleConfig, name: str, job: JobSpec
) -> DeployedComponent:
"""Build a DeployedComponent from a JobSpec.""" """Build a DeployedComponent from a JobSpec."""
run = job.run run = job.run
prefix = _env_prefix(name) prefix = _env_prefix(name)
@@ -191,6 +189,9 @@ def _build_deployed_job(
# Resolve secrets # Resolve secrets
env = resolve_env_vars(env) env = resolve_env_vars(env)
# Ensure python tool is installed before resolving binary
_ensure_python_tool(config, job.component)
# Build run_cmd # Build run_cmd
run_cmd = _build_run_cmd(run, env) run_cmd = _build_run_cmd(run, env)
@@ -211,6 +212,71 @@ def _build_deployed_job(
) )
def _python_tool_needs_install(program: str) -> bool:
"""Check if a Python tool's editable install is broken.
Returns True if the binary is missing or the editable install's
.pth file points to a directory that no longer exists.
"""
if not shutil.which(program):
return True
tool_dir = Path.home() / ".local" / "share" / "uv" / "tools" / program
if not tool_dir.exists():
return True
for pth_file in tool_dir.glob("lib/python*/site-packages/*.pth"):
if pth_file.name == "_virtualenv.pth":
continue
try:
target = pth_file.read_text().strip()
except OSError:
continue
if not target or target.startswith("import "):
continue
if not Path(target).exists():
return True
return False
def _ensure_python_tool(config: CastleConfig, component: str | None) -> None:
"""Ensure a Python program's editable install is current.
Checks if the uv tool's .pth file points to a directory that still
exists. If the binary is missing or the editable path is stale,
reinstalls from the source directory defined in the program spec.
"""
if not component or component not in config.programs:
return
comp = config.programs[component]
if not comp.source or not comp.stack or not comp.stack.startswith("python"):
return
source_dir = Path(comp.source)
if not source_dir.is_dir():
print(f" Warning: source not found: {source_dir}")
return
if not _python_tool_needs_install(component):
return
pkg_spec = str(source_dir)
if comp.install_extras:
pkg_spec += "[" + ",".join(comp.install_extras) + "]"
print(f" Installing {component} from {source_dir}...")
result = subprocess.run(
["uv", "tool", "install", "--editable", pkg_spec, "--force"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f" Error: {component} install failed:\n{result.stdout}{result.stderr}")
else:
print(f" Installed {component}")
def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]: def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
"""Build a run command list from a RunSpec.""" """Build a run command list from a RunSpec."""
match run.runner: match run.runner: