Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **`measure` and `render` no longer answer off a part the engine built without
a file it asked for** (#355). `check` has refused this since #354, but the fix
was scoped to `runner.py`, so the same part was still measured and still drawn:
`measure` printed the volume of a bare plate at exit `0` and `render` wrote
four PNGs of it. An `import()` of an absent target renders as nothing and emits
no stderr marker, so the mesh is well-formed and the existing guards — which key
on the engine's stderr — had nothing to see. All three verbs now refuse on one
shared answer (`runner.absent_build_inputs`) rather than each deriving its own.

The shared answer includes the narrowing, which is the half that matters: a file
named only from a `%` subtree reaches `engine_inputs.missing` exactly as a real
dependency does, so refusing on that field alone would exit `4` on a correct
part. `render` refuses before any view moves, and `measure`'s `--out FILE` form
before its rename, so neither disturbs what a previous run left.

## [0.7.8] - 2026-09-03

### Added
Expand Down
16 changes: 11 additions & 5 deletions docs/SPEC-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,11 +494,17 @@ given.

**A sibling payload that refuses for one of these reasons attributes it the same way.**
`measure` and `render` produce no verdict, so they carry the refusal as their own
`error`/`hint` and exit `4`. This holds today for the first two arrivals — a name that did
not resolve and a value that was defaulted, which share one stderr signal. The third is
`check`-only so far: `measure` and `render` do not yet read `engine_inputs.missing`, and
until they do a reader MUST NOT infer one verb's answer from another's on that arrival
(#355).
`error`/`hint` and exit `4`. This holds for **all three** arrivals since #355: the first
two share one stderr signal, and the third is read off `engine_inputs.missing`, which all
three verbs now consult through one shared answer rather than each deriving its own. A
reader may therefore rely on the three verbs agreeing about whether the engine built the
part the source describes.

That sharing is a requirement, not an implementation note. The narrowing the paragraph
above demands — a `%`-ed subtree's absent file is not a build input — MUST be applied
identically by every verb. A verb that refused on the unnarrowed `missing` would exit `4`
on a part `check` passes, which is the same fault as the one being fixed with the sign
reversed: a correct part reported as unmeasurable.

`render` additionally publishes an `origin`, and on both arrivals it refuses for that
field is `null` — a defaulted `"model"` would assert the very attribution the report
Expand Down
63 changes: 63 additions & 0 deletions src/partspec/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,31 @@ def _hollowed_measurements(first_line: str) -> BuildError:
)


def _absent_input_refusal(absent: list[str]) -> BuildError:
"""`measure` or `render` refusing a part built without a file it asked for.

The cause and the hint come from `runner`, which `check` reads too: one
engine fact must not be described differently depending on the verb (#308's
rule, #355). What is local here is the middle clause -- there is no report on
these paths to be wrong, only numbers and pictures that would be taken off
the wrong part.

`origin=None` deliberately. Whether the path is a typo or the file simply has
not been generated yet is not something partspec can tell, so neither "model"
nor "environment" may be asserted (SPEC-report §6.1) -- the same answer
`check` gives by leaving `build_origin` null.
"""
from .runner import _ABSENT_INPUT_CAUSE, _ABSENT_INPUT_HINT

detail = (
f"{_ABSENT_INPUT_CAUSE}, so these would be measurements of something "
f"other than what this source describes: {absent[0]}"
)
if len(absent) > 1:
detail += f" (and {len(absent) - 1} more)"
return BuildError(detail, hint=_ABSENT_INPUT_HINT, origin=None)


def _build_to_file(
backend: Any,
source: Any,
Expand Down Expand Up @@ -678,6 +703,26 @@ def _build_to_file(
# engines; this one still fires whenever the destination is
# fine, which is every other case.
return _hollowed_measurements(unresolved_out[0])
if deps_out:
# #286's failure through the other channel (#309, #355). The build
# SUCCEEDED and the depfile is complete, so `unresolved_out` above
# is empty by construction -- the engine had nothing to complain
# about. What it could not open is visible only in the depfile, and
# an `import()` of an absent target renders as nothing, so the mesh
# is well-formed and it is not the part.
#
# Here rather than in the caller, and before the rename, for the
# same reason as the two guards above: a refusal must leave `dest`
# exactly as the caller left it. `check` refuses this at exit 4
# while `measure` returned a number an agent would write into a
# contract that then passes forever.
from .runner import absent_build_inputs

absent = absent_build_inputs(
source, deps_out[-1], Path(scratch), timeout_s, source.path
)
if absent:
return _absent_input_refusal(absent)
(Path(scratch) / f"{source.path.stem}{ARTIFACT_SUFFIX}").replace(dest)
return built
except OSError as exc:
Expand Down Expand Up @@ -1488,8 +1533,10 @@ def _measure_resolved(
_measure_failure(part, target, backend, dest_refusal[0].message, dest_refusal[0].hint)
return EXIT_USAGE
written_to = dest
build_dir = dest.parent
else:
out = _out_dir(args.target, Path(args.out) if args.out is not None else None)
build_dir = out
artifact = backend.build(
source,
out,
Expand Down Expand Up @@ -1517,6 +1564,22 @@ def _measure_resolved(
# HONESTLY produce, and these do not qualify (#286). The `--out FILE`
# form has already refused inside `_build_to_file`, before its rename.
artifact = _hollowed_measurements(engine_unresolved[0])
elif engine_deps and not isinstance(artifact, BuildError):
# The same hazard through the other channel (#309, #355). A file the
# engine asked for and could not open leaves no stderr marker at all --
# `import()` of an absent target renders as nothing -- so `unresolved`
# above is empty and the mesh is well-formed. `check` refuses this at
# exit 4; `measure` reported a number off a part that is missing a piece,
# and an agent writes that number into a contract that passes forever.
#
# Reached only by the directory form: the `--out FILE` form has already
# refused inside `_build_to_file`, before its rename, because a refusal
# there must leave the caller's file untouched.
from .runner import absent_build_inputs

absent = absent_build_inputs(source, engine_deps[-1], build_dir, timeout_s, source.path)
if absent:
artifact = _absent_input_refusal(absent)
if isinstance(artifact, BuildError):
_measure_failure(part, target, backend, artifact.message, artifact.hint)
return exit_code(Verdict.ERROR)
Expand Down
37 changes: 37 additions & 0 deletions src/partspec/engines/openscad.py
Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,27 @@ def _display_failure(returncode: int, stderr: str) -> bool:
return returncode in (139, -11)


def _absent_input_views(absent: list[str]) -> BuildError:
"""`render` refusing a part the engine built without a file it asked for.

Cause and hint come from `runner`, which `check` and `measure` read too: one
engine fact, one diagnosis, whichever verb met it (#308's rule, #355). What is
local here is that the output is pictures -- `render`'s payload records an
`origin`, and `None` is the honest one, since whether the path is a typo or
the file has not been generated yet is not something partspec can tell
(SPEC-report §6.1).
"""
from ..runner import _ABSENT_INPUT_CAUSE, _ABSENT_INPUT_HINT

detail = (
f"{_ABSENT_INPUT_CAUSE}, so these would be pictures of something other "
f"than what this source describes: {absent[0]}"
)
if len(absent) > 1:
detail += f" (and {len(absent) - 1} more)"
return BuildError(detail, hint=_ABSENT_INPUT_HINT, origin=None)


def _hollowed_views(first_line: str) -> BuildError:
"""`render` refusing to draw a part the engine built out of something it lost.

Expand Down Expand Up @@ -1520,6 +1541,22 @@ def render_views(
# A picture is the one output a reader trusts without checking, which
# is why `render` could not keep the exemption #286 gave it.
return _hollowed_views(unresolved[0])
if stl_deps:
# The same refusal through the other channel (#309, #355). A file the
# engine asked for and could not open emits no stderr marker -- an
# absent `import()` target renders as nothing -- so `unresolved` above is
# empty and the STL is well-formed. `check` refuses this at exit 4 while
# `render` wrote four PNGs of a part missing a piece.
#
# Here for the reason the guard above is here, in as many words: below
# this point the views are rendered and moved as a batch, so a refusal
# asked later leaves four pictures of the wrong part on disk. A picture
# is the one output a reader trusts without checking.
from ..runner import absent_build_inputs

absent = absent_build_inputs(source, stl_deps[-1], out_dir, timeout_s, source.path)
if absent:
return _absent_input_views(absent)
closure = include_closure(source.path)
executable = find_executable()
assert executable is not None # render() just used it
Expand Down
39 changes: 32 additions & 7 deletions src/partspec/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,14 +367,9 @@ def _evaluate(
# it actually opened, which is the one thing no static reader can know.
report.source_closure = _closure(part.source, engine_deps[0])

unexported = _only_in_dropped_subtrees(
_engine_source(part), engine_deps[0].missing, out_dir, timeout_s
absent = absent_build_inputs(
_engine_source(part), engine_deps[0], out_dir, timeout_s, part.source.path
)
absent = [
_relative(f, part.source.path) or f.name
for f in engine_deps[0].missing
if f not in unexported
]
if absent:
# The build SUCCEEDED, the depfile is `complete` -- a success-path
# read is never anything else -- and the complete answer is that a
Expand Down Expand Up @@ -1784,6 +1779,36 @@ def _resolved(literals: set[str]) -> set[Path]:
return {f for f in missing if f in dropped_paths and f not in kept_paths}


def absent_build_inputs(
engine_source: Any,
deps: Any,
out_dir: Path,
timeout_s: float | None,
relative_to: Path,
) -> list[str]:
"""Build inputs the engine asked for, could not open, and did not exclude.

Shared by `check`, `measure` and `render` so one engine fact is not diagnosed
three ways depending on the verb (#308's rule, #355).

**The filter is the load-bearing half, not a refinement.** OpenSCAD evaluates
a `%`-ed subtree, so an absent file inside one reaches the depfile exactly as
a real dependency does -- `.missing` alone cannot tell them apart. Refusing on
it would refuse a correct part, which is what `_only_in_dropped_subtrees`
exists to prevent (#354 review, B1). Measured: `cube(...); %import("gone.stl");`
and `cube(...); import("gone.stl");` produce identical `missing` entries, and
only the second is a fault.

Costs nothing on the ordinary path: `_only_in_dropped_subtrees` returns
immediately when there is nothing missing, so the extra engine pass is paid
only where a build input is already known to be absent.
"""
if not deps.missing:
return []
unexported = _only_in_dropped_subtrees(engine_source, deps.missing, out_dir, timeout_s)
return sorted(_relative(f, relative_to) or f.name for f in deps.missing if f not in unexported)


_ABSENT_INPUT_CAUSE = (
"the engine asked for a build input that is not on disk and rendered without it"
)
Expand Down
94 changes: 94 additions & 0 deletions tests/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -1893,3 +1893,97 @@ def test_a_method_scratch_that_failed_refuses_rather_than_exporting_the_bare_fil
assert _only_in_dropped_subtrees(wrapped, missing, out, None) == set(), (
"no usable entry is no evidence; falling back to source.path fails OPEN"
)


# -- one engine fact, one diagnosis, whichever verb meets it (#355) ---------------------


def _verb_target(tmp_path: Path, body: str, name: str) -> str:
(tmp_path / f"{name}.scad").write_text(body)
spec = tmp_path / f"spec_{name}.py"
spec.write_text(
"from partspec import Part, openscad\n\n\ndef make():\n"
f" return Part({name!r}, openscad({name + '.scad'!r}))\n"
)
return f"{spec}:make"


@needs_scad_tier
@pytest.mark.parametrize(
("name", "body", "refuses"), _MODIFIER_SHAPES, ids=[s[0] for s in _MODIFIER_SHAPES]
)
def test_measure_refuses_exactly_what_check_refuses(
tmp_path: Path, name: str, body: str, refuses: bool, capsys
):
"""`measure` was the hazard, not `check`.

A number taken off a part that is missing a piece gets written into a
contract, and that contract then passes forever (#286, #309). Before #355
`check` exited 4 here while `measure` exited 0 and printed the volume of a
bare plate.

The `%`/`*` rows are the half a naive fix gets wrong: those files reach
`engine_inputs.missing` exactly as a real dependency does, so refusing on
that field alone refuses a correct part.
"""
from partspec.cli import main

code = main(["measure", _verb_target(tmp_path, body, f"m_{name}")])
err = capsys.readouterr().err
if refuses:
assert code == 4, "measure must not answer off a part missing a build input"
assert "build input that is not on disk" in err, (
"the exit code alone would pass for any refusal; this asserts the reason"
)
else:
assert code == 0, "the export does not depend on this file; refusing it is a false red"


@needs_scad_tier
@pytest.mark.parametrize(
("name", "body", "refuses"), _MODIFIER_SHAPES, ids=[s[0] for s in _MODIFIER_SHAPES]
)
def test_render_refuses_exactly_what_check_refuses(
tmp_path: Path, name: str, body: str, refuses: bool, capsys
):
"""A picture is the one output a reader trusts without checking (#307).

Asserts the REASON, not just the code. On an engine with no offscreen path --
apt 2021.01 without a display, which is what CI's mesh-only job has -- every
row exits 4 for a reason that has nothing to do with this issue. Reading only
the code there would have passed the refusing rows for the wrong reason while
failing the others, which is how the first draft of this test behaved.
"""
from partspec.cli import main

out = tmp_path / f"out_{name}"
code = main(["render", _verb_target(tmp_path, body, f"r_{name}"), "--out", str(out)])
err = capsys.readouterr().err
if "without a display" in err:
pytest.skip("this OpenSCAD cannot render PNG here; the question is unanswerable")

pngs = list(out.rglob("*.png"))
if refuses:
assert code == 4, "render must not draw a part missing a build input"
assert "build input that is not on disk" in err
assert not pngs, "the refusal must land before any view moves"
else:
assert code == 0
assert pngs, "a dropped subtree is not a missing input; this part is renderable"


@needs_scad_tier
def test_the_three_verbs_give_one_diagnosis_for_one_engine_fact(tmp_path: Path):
"""#308's rule. The cause and the hint are shared, so a reader who met this
through one verb recognises it through another."""
from partspec.cli import _absent_input_refusal
from partspec.engines.openscad import _absent_input_views
from partspec.runner import _ABSENT_INPUT_CAUSE, _ABSENT_INPUT_HINT

for refusal in (_absent_input_refusal(["gone.stl"]), _absent_input_views(["gone.stl"])):
assert refusal.message.startswith(_ABSENT_INPUT_CAUSE)
assert refusal.hint == _ABSENT_INPUT_HINT
assert refusal.origin is None, (
"a typo and a not-yet-generated file are indistinguishable here, "
"so neither 'model' nor 'environment' may be claimed"
)