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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version

## [Unreleased]

### Fixed

- `plate job/send <url> --dry-run` now reports `would_slice` consistently with
the real run. The dry-run predictor previously returned `would_slice: false`
for sources whose extension it could not read from the URL path (Printables
model pages, extension-less direct links), even though the real run downloads
a model file (falling back to `.stl`) and slices it. The prediction and the
real run now share one slicing predicate, so a dry-run pre-check no longer
disagrees with what actually happens.

## [0.4.0] - 2026-07-29

### Added
Expand Down
2 changes: 2 additions & 0 deletions bambu_cli/job/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
generate_print_payload,
)
from bambu_cli.job.predict import ( # noqa: F401
_ext_would_slice,
_predicted_download_slice_extension,
_predicted_sliced_remote_name,
_predicted_url_download_extension,
_predicted_url_remote_name,
Expand Down
18 changes: 13 additions & 5 deletions bambu_cli/job/orchestrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@
from bambu_cli.errors import BambuError
from bambu_cli.job.payload import _parse_print_options, _print_next_command
from bambu_cli.job.predict import (
_ext_would_slice,
_predicted_download_slice_extension,
_predicted_sliced_remote_name,
_predicted_url_download_extension,
_predicted_url_remote_name,
_slice_args_for_job,
)
Expand Down Expand Up @@ -175,10 +176,17 @@ def _run_job(ctx, args, steps=None):
logger.info(" Re-run without --dry-run to download and prepare the model.")
summary["status"] = "dry_run_url_skipped"
summary["would_download"] = True
source_ext = _predicted_url_download_extension(source, args)
# Predict the extension the download will actually hand to the
# pipeline, then decide via the SAME predicate the real run uses
# below (`_ext_would_slice`). `_predicted_download_slice_extension`
# mirrors the downloader's own fallback to ".stl" for sources whose
# extension is unknowable offline (Printables pages, ?id= links), so
# would_slice here matches what the real run does instead of
# defaulting to False.
source_ext = _predicted_download_slice_extension(source, args)
if source_ext in ARCHIVE_DOWNLOAD_EXTENSIONS:
summary["would_extract"] = True
elif source_ext in SLICEABLE_EXTENSIONS:
elif _ext_would_slice(source_ext):
summary["would_slice"] = True
summary["remote_name"] = predicted_remote_name
summary["would_upload"] = True
Expand Down Expand Up @@ -296,7 +304,7 @@ def _run_job(ctx, args, steps=None):
)
summary["status"] = "dry_run_local_skipped"
summary["archive_entry"] = member_filename
summary["would_slice"] = member_ext in SLICEABLE_EXTENSIONS
summary["would_slice"] = _ext_would_slice(member_ext)
summary["remote_name"] = predicted_remote_name
summary["would_upload"] = True
summary["would_print"] = bool(getattr(args, "confirm", False)) and not bool(
Expand All @@ -320,7 +328,7 @@ def _run_job(ctx, args, steps=None):
summary["extracted_path"] = extracted_path
summary["archive_entry"] = archive_entry

if ext in SLICEABLE_EXTENSIONS:
if _ext_would_slice(ext):
summary["would_slice"] = True
predicted_remote_name = _predicted_sliced_remote_name(source_path, getattr(args, "copies", 1))
if _safe_remote_name(predicted_remote_name) is None:
Expand Down
36 changes: 36 additions & 0 deletions bambu_cli/job/predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
SLICEABLE_EXTENSIONS,
)
from bambu_cli.download import (
_download_source_extension,
_download_target_filename,
_file_extension,
_is_printables_model_url,
Expand All @@ -22,6 +23,41 @@
from bambu_cli.slicer import _sliced_output_path


def _ext_would_slice(ext):
"""The one slicing predicate shared by the dry-run predictor and the real run.

Given the extension of the file that will actually be handed to the
upload/print pipeline — the downloaded file, the selected ZIP member, or the
local file — return whether job/send will slice it. The ``--dry-run``
prediction and the real run both route through this, so ``would_slice`` in a
dry-run payload cannot disagree with what the real run does. ``ext`` must be a
single-suffix, lowercased extension from ``_file_extension`` so that, e.g.,
``foo.gcode.3mf`` is treated as print-ready ``.3mf`` on both sides.
"""
return ext in SLICEABLE_EXTENSIONS


def _predicted_download_slice_extension(url, args):
"""Predict the extension a URL download hands to the slicer, matching the doer.

The real run decides ``would_slice`` from the *downloaded* file's extension
(orchestrate.py). Offline we cannot resolve Printables pages, redirects, or
Content-Disposition, so we reuse the downloader's own inference,
``_download_source_extension``: it takes an extension from the URL path when
one is present and otherwise falls back to ``.stl`` — exactly what the real
download lands for a model source with no determinable extension (Printables
model pages, ``?id=`` links). Predicting that same ``.stl`` fallback is what
makes ``would_slice`` agree with the real run instead of defaulting to False.

``--name`` is deliberately ignored here: the real download overrides the
``--name`` extension with the URL/resolved-name extension
(``_download_filename_with_extension``), so honoring it in the predictor would
reintroduce a divergence. Archive (``.zip``) URLs keep their real extension so
callers can report ``would_extract`` instead.
"""
return _download_source_extension(url)


def _slice_args_for_job(filepath, args, output_dir):
"""Build a slice command namespace from job-level arguments."""
return argparse.Namespace(
Expand Down
5 changes: 5 additions & 0 deletions tests/agent_cli_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,11 @@ def smoke_url_job_dry_run_json(root):
assert False, f"URL dry-run payload did not report planned download/upload: {payload}"
if payload.get("would_print") is not True:
assert False, f"URL dry-run payload did not report planned print: {payload}"
# A Printables model page cannot be resolved offline, but the real run
# downloads a model file (STL/STEP preferred) and slices it, so the dry-run
# prediction must report would_slice=True rather than defaulting to False.
if payload.get("would_slice") is not True or payload.get("would_extract") is not False:
assert False, f"Printables model URL dry-run did not predict slicing: {payload}"
if payload.get("downloaded_path") or payload.get("uploaded") or payload.get("printed"):
assert False, f"URL dry-run payload reported side effects: {payload}"
print("url-job-dry-run-json smoke ok")
Expand Down
129 changes: 122 additions & 7 deletions tests/test_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,15 +347,21 @@ def test_printed_success(tmp_path, capsys):


@pytest.mark.parametrize(
"ext,would_slice,would_extract",
"filename,would_slice,would_extract",
[
(".stl", True, False),
(".zip", False, True),
(".3mf", False, False),
("model.stl", True, False),
("model.step", True, False),
("model.obj", True, False),
("model.zip", False, True),
("model.3mf", False, False),
# ".gcode.3mf" is this project's primary printer-ready format; the
# last-suffix extension is ".3mf", so it must NOT be predicted as
# sliceable — the real run treats it as print-ready and skips slicing.
("plate.gcode.3mf", False, False),
],
)
def test_dry_run_direct_url(ext, would_slice, would_extract, capsys):
url = f"https://example.com/model{ext}"
def test_dry_run_direct_url(filename, would_slice, would_extract, capsys):
url = f"https://example.com/{filename}"
args = _parse(["job", url, "--dry-run", "--json"])
_run_job(_ctx(), args, JobSteps())
payload = _read_json(capsys)
Expand All @@ -365,10 +371,79 @@ def test_dry_run_direct_url(ext, would_slice, would_extract, capsys):
assert payload["would_extract"] is would_extract
assert payload["would_upload"] is True
assert payload["would_print"] is False
if ext != ".zip":
if not would_extract:
assert payload["remote_name"] is not None


def test_dry_run_printables_model_url_predicts_slice(capsys):
"""Regression: a Printables model page dry-run must report would_slice=True.

The predictor cannot resolve the page offline, but the real run downloads a
model file (STL/STEP preferred) and slices it. The shared predicate now
falls back to ".stl" — the same fallback the downloader lands — so the
dry-run prediction matches the real run instead of defaulting to False.
"""
url = "https://printables.com/model/12345-agent-smoke"
args = _parse(["job", url, "--dry-run", "--json"])
_run_job(_ctx(), args, JobSteps())
payload = _read_json(capsys)
assert payload["status"] == "dry_run_url_skipped"
assert payload["would_download"] is True
assert payload["would_slice"] is True
assert payload["would_extract"] is False
assert payload["would_upload"] is True
# Remote name stays unpredictable offline for a Printables page; would_slice
# being True must not fabricate one.
assert payload["remote_name"] is None


def test_predictor_and_doer_share_slice_predicate():
"""The dry-run URL prediction routes through the same predicate as the real run.

For any URL, the predicted-download extension fed to _ext_would_slice must
equal the decision the doer would make for that same extension. Guards
against the two paths drifting apart again (the original bug: predictor said
would_slice=False for sources the doer sliced).
"""
from bambu_cli.download import _file_extension
from bambu_cli.job import _ext_would_slice, _predicted_download_slice_extension

cases = {
"https://printables.com/model/12345-foo": True,
"https://example.com/model.stl": True,
"https://example.com/model.step": True,
"https://example.com/model.obj": True,
"https://example.com/model.3mf": False,
"https://example.com/plate.gcode.3mf": False,
"https://example.com/bundle.zip": False,
"https://example.com/download?id=1": True,
}
for url, expected in cases.items():
args = _parse(["job", url, "--dry-run", "--json"])
predicted_ext = _predicted_download_slice_extension(url, args)
# Predictor's decision.
assert _ext_would_slice(predicted_ext) is expected, url
# Doer applies the identical predicate to the file it actually handles;
# simulate by feeding the predicted extension back through it.
assert _ext_would_slice(_file_extension(f"x{predicted_ext}")) is expected, url


def test_dry_run_extensionless_url_predicts_slice(capsys):
"""An extension-less direct link predicts slicing, matching the doer's fallback.

The real download resolves the filename via Content-Disposition/content and
falls back to ".stl" when nothing determines it, then slices. The dry-run
prediction must agree.
"""
url = "https://example.com/download?id=1"
args = _parse(["job", url, "--dry-run", "--json"])
_run_job(_ctx(), args, JobSteps())
payload = _read_json(capsys)
assert payload["status"] == "dry_run_url_skipped"
assert payload["would_slice"] is True
assert payload["would_extract"] is False


def test_dry_run_local_model_file(tmp_path, capsys):
stl = tmp_path / "model.stl"
stl.write_bytes(b"solid x")
Expand Down Expand Up @@ -407,6 +482,46 @@ def test_dry_run_local_printer_ready_file(tmp_path, capsys):
assert payload["printable_path"] == _display_path(str(ready))


def test_dry_run_local_gcode_3mf_is_print_ready_not_sliced(tmp_path, capsys):
"""A local .gcode.3mf is print-ready: dry-run must report would_slice=False."""
ready = tmp_path / "plate.gcode.3mf"
ready.write_bytes(b"x" * 10)
args = _parse(["job", str(ready), "--dry-run", "--json"])
_run_job(_ctx(), args, JobSteps())
payload = _read_json(capsys)
assert payload["status"] == "dry_run_local_skipped"
assert payload["would_slice"] is False
assert payload["would_upload"] is True
assert payload["remote_name"] == "plate.gcode.3mf"


@pytest.mark.parametrize(
"member,would_slice",
[
("model.stl", True),
("model.3mf", False),
# Last-suffix extension of a ".gcode.3mf" member is ".3mf": print-ready.
("plate.gcode.3mf", False),
],
)
def test_dry_run_local_zip_member_slice_matches_predicate(tmp_path, member, would_slice, capsys):
"""ZIP dry-run reports would_slice per the selected member's extension.

Uses the same _ext_would_slice predicate as the real run, so a .3mf or
.gcode.3mf member is reported print-ready while an .stl member is sliceable.
"""
zpath = tmp_path / "bundle.zip"
with zipfile.ZipFile(zpath, "w") as zf:
zf.writestr(member, b"content")
args = _parse(["job", str(zpath), "--dry-run", "--json"])
_run_job(_ctx(), args, JobSteps())
payload = _read_json(capsys)
assert payload["status"] == "dry_run_local_skipped"
assert payload["archive_entry"] == member
assert payload["would_slice"] is would_slice
assert payload["would_upload"] is True


def test_dry_run_local_printer_ready_empty_file_fails(tmp_path):
ready = tmp_path / "model.3mf"
ready.write_bytes(b"")
Expand Down