fix: system requirement check verifies the package, not a PATH command

`system_dependencies` holds apt PACKAGE names; checking `which <name>` only
coincides with "installed" when the name equals its command (pandoc, rsync). Names
like poppler-utils / texlive-latex-base / docker-compose-plugin have no binary of
that name, so the functional? predicate wrongly reported them unmet. Fall back to
`dpkg -s` (the real "installed" check) after the PATH fast-path.
This commit is contained in:
2026-07-05 15:17:03 -07:00
parent add356dcf2
commit b9d38be707
2 changed files with 32 additions and 2 deletions

View File

@@ -15,6 +15,7 @@ dependencies — env is generated *from* requirements, not the reverse.
from __future__ import annotations
import shutil
import subprocess
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
@@ -134,10 +135,25 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
return out
def _dpkg_installed(pkg: str) -> bool:
"""Whether a dpkg package is installed (the authoritative 'installed' check on
Debian/Ubuntu). Returns False where dpkg isn't available."""
dpkg = shutil.which("dpkg")
if not dpkg:
return False
r = subprocess.run([dpkg, "-s", pkg], capture_output=True, text=True)
return r.returncode == 0 and "install ok installed" in r.stdout
def _check(config: CastleConfig, req: Requirement) -> bool:
"""Is a single requirement satisfied? (The check is fixed by its kind.)"""
if req.kind == "system":
return shutil.which(req.ref) is not None
# `system_dependencies` holds PACKAGE names, not executables. A PATH lookup
# only coincides with 'installed' when the package name equals its command
# (pandoc, rsync) — for names like poppler-utils / texlive-latex-base /
# docker-compose-plugin it never matches. Fast-path `which`, then ask the
# package manager (the real meaning of 'installed').
return shutil.which(req.ref) is not None or _dpkg_installed(req.ref)
if req.kind == "deployment":
return req.ref in config.deployments
return True

View File

@@ -91,13 +91,27 @@ def test_functional_predicate_reports_unmet(monkeypatch: pytest.MonkeyPatch) ->
{"web": prog, "api": ProgramSpec(id="api")},
{"web": _dep("web"), "api": _dep("api")},
)
monkeypatch.setattr(R.shutil, "which", lambda _: None) # nothing installed
monkeypatch.setattr(R.shutil, "which", lambda _: None) # not on PATH
monkeypatch.setattr(R, "_dpkg_installed", lambda _: False) # nor as a package
m = R.build_model(cfg, check=True)
web = next(n for n in m.nodes if n.name == "web")
assert web.unmet == ["system:pandoc"] # deployment:api exists → satisfied
assert web.functional is False
def test_system_requirement_satisfied_by_package_not_on_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A package name that isn't a command (poppler-utils) is satisfied when the
package is installed — a PATH lookup alone would wrongly report it unmet."""
prog = ProgramSpec(id="t", system_dependencies=["poppler-utils"])
cfg = _cfg({"t": prog}, {"t": _dep("t")})
monkeypatch.setattr(R.shutil, "which", lambda _: None) # no `poppler-utils` binary
monkeypatch.setattr(R, "_dpkg_installed", lambda pkg: pkg == "poppler-utils")
t = next(n for n in R.build_model(cfg, check=True).nodes if n.name == "t")
assert t.functional is True and t.unmet == []
def test_missing_deployment_requirement_is_unmet() -> None:
prog = ProgramSpec(id="web", requires=[Requirement(kind="deployment", ref="ghost")])
cfg = _cfg({"web": prog}, {"web": _dep("web")})