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:
@@ -15,6 +15,7 @@ dependencies — env is generated *from* requirements, not the reverse.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -134,10 +135,25 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
|
|||||||
return out
|
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:
|
def _check(config: CastleConfig, req: Requirement) -> bool:
|
||||||
"""Is a single requirement satisfied? (The check is fixed by its kind.)"""
|
"""Is a single requirement satisfied? (The check is fixed by its kind.)"""
|
||||||
if req.kind == "system":
|
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":
|
if req.kind == "deployment":
|
||||||
return req.ref in config.deployments
|
return req.ref in config.deployments
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -91,13 +91,27 @@ def test_functional_predicate_reports_unmet(monkeypatch: pytest.MonkeyPatch) ->
|
|||||||
{"web": prog, "api": ProgramSpec(id="api")},
|
{"web": prog, "api": ProgramSpec(id="api")},
|
||||||
{"web": _dep("web"), "api": _dep("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)
|
m = R.build_model(cfg, check=True)
|
||||||
web = next(n for n in m.nodes if n.name == "web")
|
web = next(n for n in m.nodes if n.name == "web")
|
||||||
assert web.unmet == ["system:pandoc"] # deployment:api exists → satisfied
|
assert web.unmet == ["system:pandoc"] # deployment:api exists → satisfied
|
||||||
assert web.functional is False
|
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:
|
def test_missing_deployment_requirement_is_unmet() -> None:
|
||||||
prog = ProgramSpec(id="web", requires=[Requirement(kind="deployment", ref="ghost")])
|
prog = ProgramSpec(id="web", requires=[Requirement(kind="deployment", ref="ghost")])
|
||||||
cfg = _cfg({"web": prog}, {"web": _dep("web")})
|
cfg = _cfg({"web": prog}, {"web": _dep("web")})
|
||||||
|
|||||||
Reference in New Issue
Block a user