feat(slicer): add /slicer/bundles routes for .bbscfg import + bundle slicing

Wires Bambuddy to the orca-slicer-api fork's bundle endpoints (shipped
  in bambuddy/bundle-import). Users will eventually upload a BambuStudio
  "Printer Preset Bundle" (.bbscfg) once per printer; subsequent slices
  pick from the bundle by preset name instead of re-uploading the JSON
  triplet every time.

  Service layer:
  - BundleSummary / BundleNotFoundError types
  - import_bundle / list_bundles / get_bundle / delete_bundle methods
  - slice_with_bundle: POST /slice with bundle id + per-category names
    instead of attached profile JSONs

  Routes (LIBRARY_UPLOAD perm gate):
  - POST   /api/v1/slicer/bundles
  - GET    /api/v1/slicer/bundles
  - GET    /api/v1/slicer/bundles/:id
  - DELETE /api/v1/slicer/bundles/:id

  All routes proxy via _resolve_slicer_api_url so they follow the user's
  preferred_slicer setting (bambu_studio vs orcaslicer). Status-code
  mapping treats sidecar 4xx as 400, BundleNotFoundError as 404,
  unreachable as 503, and sidecar 5xx as 502.
This commit is contained in:
maziggy
2026-05-06 08:45:53 +02:00
parent 3407afc0c7
commit 060ba509da
4 changed files with 821 additions and 8 deletions
+148 -8
View File
@@ -16,7 +16,7 @@ import json
import logging
import time
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -37,7 +37,14 @@ from backend.app.services.bambu_cloud import (
BambuCloudError,
BambuCloudService,
)
from backend.app.services.slicer_api import SlicerApiError, SlicerApiService
from backend.app.services.slicer_api import (
BundleNotFoundError,
BundleSummary,
SlicerApiError,
SlicerApiService,
SlicerApiUnavailableError,
SlicerInputError,
)
logger = logging.getLogger(__name__)
@@ -368,6 +375,145 @@ async def list_unified_presets(
)
def _bundle_summary_to_dict(b: BundleSummary) -> dict:
"""Serialize a BundleSummary for the JSON response. The frontend uses
these arrays to populate the preset dropdowns when a user picks the
bundle as the slice source.
"""
return {
"id": b.id,
"printer_preset_name": b.printer_preset_name,
"printer": b.printer,
"process": b.process,
"filament": b.filament,
"version": b.version,
}
@router.post("/bundles", status_code=201)
async def import_slicer_bundle(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
):
"""Forward a BambuStudio Printer Preset Bundle (.bbscfg) to the sidecar.
The user exports their printer's preset bundle from BambuStudio (File
-> Export -> Export Preset Bundle, "Printer preset bundle" option).
Uploading it here unpacks the bundle on the sidecar and exposes its
inner printer / process / filament presets to subsequent slice
requests via the bundle-id selector.
Idempotent: re-uploading the same file yields the same id (sidecar
hashes the zip content), so duplicate uploads collapse rather than
accumulate.
"""
api_url = await _resolve_slicer_api_url(db)
if not api_url:
raise HTTPException(status_code=503, detail="No slicer sidecar configured")
# Multer on the sidecar caps bundle uploads at 50MB. We don't enforce
# that here — let the sidecar's filter own the limit so it stays in
# one place — but we do reject empty / huge files at the FastAPI
# layer to avoid pointlessly streaming them to the sidecar first.
contents = await file.read()
if not contents:
raise HTTPException(status_code=400, detail="Bundle file is empty")
filename = file.filename or "bundle.bbscfg"
try:
async with SlicerApiService(base_url=api_url) as svc:
summary = await svc.import_bundle(contents, filename=filename)
except SlicerInputError as e:
# Sidecar's 4xx — most likely a non-.bbscfg upload, a corrupt zip,
# or a path-traversal entry that the manifest validator caught.
# Surface verbatim so the user sees the actual reason in the toast.
raise HTTPException(status_code=400, detail=str(e)) from e
except SlicerApiUnavailableError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
except SlicerApiError as e:
# 5xx from the sidecar's import path is rare — usually a disk
# write failure inside DATA_PATH/bundles. 502 (bad gateway) is
# closer to the truth than 500 here, since we're proxying.
raise HTTPException(status_code=502, detail=str(e)) from e
return _bundle_summary_to_dict(summary)
@router.get("/bundles")
async def list_slicer_bundles(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
):
"""List every Printer Preset Bundle currently stored on the sidecar.
Drives the SliceModal's "Bundle" tier and a Settings panel where
users can review / delete imported bundles. Returns ``[]`` when the
sidecar has no bundles imported yet.
"""
api_url = await _resolve_slicer_api_url(db)
if not api_url:
# No sidecar configured: empty list rather than 503 so the modal
# renders cleanly. Same shape as the bundled-presets fallback.
return []
try:
async with SlicerApiService(base_url=api_url) as svc:
bundles = await svc.list_bundles()
except SlicerApiUnavailableError as e:
# Sidecar offline: surface as 503 so the frontend can show a
# banner. Differs from the bundled-tier behaviour because that
# path also has cloud + local fallbacks; bundles is the only
# source for its tier.
raise HTTPException(status_code=503, detail=str(e)) from e
except SlicerApiError as e:
raise HTTPException(status_code=502, detail=str(e)) from e
return [_bundle_summary_to_dict(b) for b in bundles]
@router.get("/bundles/{bundle_id}")
async def get_slicer_bundle(
bundle_id: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
):
"""Return one bundle by id. 404 if it doesn't exist on the sidecar."""
api_url = await _resolve_slicer_api_url(db)
if not api_url:
raise HTTPException(status_code=503, detail="No slicer sidecar configured")
try:
async with SlicerApiService(base_url=api_url) as svc:
summary = await svc.get_bundle(bundle_id)
except BundleNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except SlicerApiUnavailableError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
except SlicerApiError as e:
raise HTTPException(status_code=502, detail=str(e)) from e
return _bundle_summary_to_dict(summary)
@router.delete("/bundles/{bundle_id}", status_code=204)
async def delete_slicer_bundle(
bundle_id: str,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
):
"""Remove a stored bundle from the sidecar. Future slice requests
referencing this id will fail with 404 from the sidecar.
"""
api_url = await _resolve_slicer_api_url(db)
if not api_url:
raise HTTPException(status_code=503, detail="No slicer sidecar configured")
try:
async with SlicerApiService(base_url=api_url) as svc:
await svc.delete_bundle(bundle_id)
except BundleNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except SlicerApiUnavailableError as e:
raise HTTPException(status_code=503, detail=str(e)) from e
except SlicerApiError as e:
raise HTTPException(status_code=502, detail=str(e)) from e
@router.get("/preview-progress/{request_id}")
async def get_preview_slice_progress(
request_id: str,
@@ -392,8 +538,6 @@ async def get_preview_slice_progress(
api_url = await _resolve_slicer_api_url(db)
if not api_url:
from fastapi import HTTPException
raise HTTPException(status_code=503, detail="No slicer sidecar configured")
url = f"{api_url}/slice/progress/{request_id}"
try:
@@ -402,11 +546,7 @@ async def get_preview_slice_progress(
except httpx.RequestError:
# Sidecar unreachable: surface as 503 instead of 500 so the
# frontend's poller can keep trying without flagging a hard error.
from fastapi import HTTPException
raise HTTPException(status_code=503, detail="Slicer sidecar unreachable") from None
if response.status_code == 404:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Progress unavailable")
return response.json()
+223
View File
@@ -47,6 +47,42 @@ class SliceResult(NamedTuple):
filament_used_mm: float
class BundleSummary(NamedTuple):
"""Sidecar's view of a stored Printer Preset Bundle (.bbscfg).
Mirrors the JSON shape returned by `/profiles/bundle(s)` on the
sidecar — `printer`, `process`, `filament` are each a list of preset
names available within the bundle (without the `.json` extension and
without the BambuStudio "# " user-clone prefix; the sidecar accepts
both forms when looking them up at slice time).
"""
id: str
printer_preset_name: str
printer: list[str]
process: list[str]
filament: list[str]
version: str | None
class BundleNotFoundError(SlicerApiError):
"""Sidecar returned 404 for the bundle id (deleted, never imported)."""
def _parse_bundle_summary(payload: dict) -> BundleSummary:
"""Build a BundleSummary from the sidecar's JSON. Tolerant of missing
optional fields so a sidecar that adds keys later doesn't break parsing.
"""
return BundleSummary(
id=str(payload.get("id") or ""),
printer_preset_name=str(payload.get("printer_preset_name") or ""),
printer=list(payload.get("printer") or []),
process=list(payload.get("process") or []),
filament=list(payload.get("filament") or []),
version=payload.get("version"),
)
_shared_http_client: httpx.AsyncClient | None = None
@@ -155,6 +191,102 @@ class SlicerApiService:
raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
return response.json()
async def import_bundle(
self,
zip_bytes: bytes,
*,
filename: str = "bundle.bbscfg",
) -> BundleSummary:
"""POST /profiles/bundle — upload a BambuStudio Printer Preset Bundle.
Idempotent on the sidecar side: re-uploading the same file yields the
same id (deterministic SHA-256 prefix of the zip content) and the
sidecar reuses its existing extracted directory, so re-importing is
always safe.
Raises:
SlicerInputError: 4xx — bundle isn't a valid .bbscfg, or fails the
sidecar's path-traversal / manifest validation.
SlicerApiUnavailableError: connection error or 5xx.
"""
files = {"file": (filename, zip_bytes, "application/zip")}
try:
response = await self._client.post(
f"{self.base_url}/profiles/bundle",
files=files,
timeout=60.0,
)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
if response.status_code >= 500:
raise SlicerApiServerError(
f"Slicer sidecar /profiles/bundle failed ({response.status_code}): {_format_sidecar_error(response)}",
)
if response.status_code >= 400:
raise SlicerInputError(
f"Slicer sidecar rejected bundle ({response.status_code}): {_format_sidecar_error(response)}",
)
return _parse_bundle_summary(response.json())
async def list_bundles(self) -> list[BundleSummary]:
"""GET /profiles/bundles — list every imported bundle and its presets.
Returns an empty list when the sidecar's bundle store is empty (the
sidecar returns ``[]`` rather than 404 in that case). Network errors
and 5xx surface as ``SlicerApiUnavailableError`` so callers can
decide whether to render an empty UI or a "sidecar offline" banner.
"""
try:
response = await self._client.get(f"{self.base_url}/profiles/bundles", timeout=10.0)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
if response.status_code >= 400:
raise SlicerApiUnavailableError(
f"Slicer sidecar /profiles/bundles returned {response.status_code}",
)
payload = response.json()
if not isinstance(payload, list):
raise SlicerApiServerError("Slicer sidecar returned non-array bundle list")
return [_parse_bundle_summary(b) for b in payload if isinstance(b, dict)]
async def get_bundle(self, bundle_id: str) -> BundleSummary:
"""GET /profiles/bundles/<id> — single bundle summary.
Raises:
BundleNotFoundError: 404 — id does not exist on the sidecar.
SlicerApiUnavailableError: connection error or 5xx.
"""
try:
response = await self._client.get(
f"{self.base_url}/profiles/bundles/{bundle_id}",
timeout=10.0,
)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
if response.status_code == 404:
raise BundleNotFoundError(f"Bundle {bundle_id!r} not found on sidecar")
if response.status_code >= 400:
raise SlicerApiUnavailableError(
f"Slicer sidecar /profiles/bundles/{bundle_id} returned {response.status_code}",
)
return _parse_bundle_summary(response.json())
async def delete_bundle(self, bundle_id: str) -> None:
"""DELETE /profiles/bundles/<id> — remove a stored bundle."""
try:
response = await self._client.delete(
f"{self.base_url}/profiles/bundles/{bundle_id}",
timeout=10.0,
)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
if response.status_code == 404:
raise BundleNotFoundError(f"Bundle {bundle_id!r} not found on sidecar")
if response.status_code >= 400:
raise SlicerApiUnavailableError(
f"Slicer sidecar DELETE /profiles/bundles/{bundle_id} returned {response.status_code}",
)
async def _poll_progress(
self,
request_id: str,
@@ -292,6 +424,97 @@ class SlicerApiService:
filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
)
async def slice_with_bundle(
self,
*,
model_bytes: bytes,
model_filename: str,
bundle_id: str,
printer_name: str,
process_name: str,
filament_names: list[str],
plate: int | None = None,
export_3mf: bool = False,
request_id: str | None = None,
on_progress: Callable[[dict], None] | None = None,
) -> SliceResult:
"""POST /slice with bundle id + per-category preset names.
Asks the sidecar to materialize the printer / process / filament
JSONs from a previously-imported `.bbscfg`, instead of accepting
them as multipart attachments. Equivalent to
``slice_with_profiles`` from the user's perspective — same return
shape, same 4xx/5xx semantics, same progress-poll wiring — but
the sidecar saves the round-trip of re-uploading the JSONs every
time a user kicks off a slice with the same bundle.
``filament_names`` is plate-slot-ordered: index 0 is slot 1, etc.
Single-color callers pass a one-element list. The sidecar joins
them as semicolon-separated `--load-filaments` for the CLI.
Raises:
SlicerInputError: 4xx — bundle / preset name not found, etc.
SlicerApiServerError: sidecar 5xx (CLI failure on resolved
triplet — same conditions that fail slice_with_profiles).
SlicerApiUnavailableError: connection error.
"""
files = {
"file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
}
data: dict[str, str | list[str]] = {
"bundle": bundle_id,
"printerName": printer_name,
"processName": process_name,
}
# The sidecar's SlicingSettings supports both `filamentName` (single
# legacy field, kept for clients that pre-date multi-color) and
# `filamentNames` (semicolon/comma-separated, matches multi-color
# uploads). Always send the array form so a single-slot case still
# ends up in the same code path on the sidecar.
data["filamentNames"] = ";".join(filament_names)
if plate is not None:
data["plate"] = str(plate)
if export_3mf:
data["exportType"] = "3mf"
if request_id is not None:
data["requestId"] = request_id
progress_task: asyncio.Task | None = None
if request_id is not None and on_progress is not None:
progress_task = asyncio.create_task(
self._poll_progress(request_id, on_progress),
name=f"slicer-progress-{request_id}",
)
try:
response = await self._client.post(
f"{self.base_url}/slice",
files=files,
data=data,
timeout=self.timeout_seconds,
)
except httpx.RequestError as exc:
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
finally:
if progress_task is not None:
progress_task.cancel()
try:
await progress_task
except (asyncio.CancelledError, Exception):
pass
if response.status_code >= 500:
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
if response.status_code >= 400:
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
return SliceResult(
content=response.content,
print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
)
async def slice_without_profiles(
self,
*,
@@ -8,6 +8,8 @@ import httpx
import pytest
from backend.app.services.slicer_api import (
BundleNotFoundError,
BundleSummary,
SlicerApiServerError,
SlicerApiService,
SlicerApiUnavailableError,
@@ -479,3 +481,246 @@ class TestSliceWithProfilesProgress:
assert result is not None
# Sustained 404 → no snapshots ever forwarded.
assert snapshots == []
# ── BundleSummary parsing + bundle CRUD client methods ─────────────────────
class TestBundleClientMethods:
"""Coverage for import_bundle / list_bundles / get_bundle / delete_bundle.
Mirrors the existing SlicerApiService tests' mock-transport pattern. The
bundle endpoints are simple JSON CRUD on the sidecar, but the response
parsing has to remain forgiving (newer sidecars may add fields, older
ones may omit some) and the failure modes have to map cleanly to our
typed exceptions so route handlers can pick the right HTTP status.
"""
SAMPLE_SUMMARY = {
"id": "2bd8722dd20a837e",
"printer_preset_name": "# Bambu Lab H2D 0.4 nozzle",
"printer": ["# Bambu Lab H2D 0.4 nozzle"],
"process": ["# 0.20mm Standard @BBL H2D"],
"filament": ["# Bambu PLA Basic @BBL H2D"],
"version": "02.06.00.50",
}
@pytest.mark.asyncio
async def test_import_bundle_happy_path(self):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["method"] = request.method
captured["content_type"] = request.headers.get("content-type", "")
return httpx.Response(status_code=201, json=self.SAMPLE_SUMMARY)
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
summary = await service.import_bundle(b"PK\x03\x04zip-bytes", filename="H2D.bbscfg")
assert isinstance(summary, BundleSummary)
assert summary.id == "2bd8722dd20a837e"
assert summary.printer == ["# Bambu Lab H2D 0.4 nozzle"]
assert summary.process == ["# 0.20mm Standard @BBL H2D"]
assert summary.filament == ["# Bambu PLA Basic @BBL H2D"]
assert captured["method"] == "POST"
assert captured["url"].endswith("/profiles/bundle")
assert captured["content_type"].startswith("multipart/form-data")
@pytest.mark.asyncio
async def test_import_bundle_400_raises_input_error(self):
# Non-.bbscfg uploads, corrupt zips, malicious entry names — all
# rejected by the sidecar with 4xx so the user can fix and retry.
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
status_code=400,
json={"message": "Bundle is missing bundle_structure.json"},
)
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
with pytest.raises(SlicerInputError) as exc_info:
await service.import_bundle(b"not a zip")
assert "missing bundle_structure" in str(exc_info.value)
@pytest.mark.asyncio
async def test_import_bundle_5xx_raises_server_error(self):
# Disk-write failure on DATA_PATH — rare but observable when /data
# is a tmpfs that filled up.
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code=500, json={"message": "ENOSPC"})
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
with pytest.raises(SlicerApiServerError):
await service.import_bundle(b"x")
@pytest.mark.asyncio
async def test_import_bundle_connection_error(self):
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused")
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
with pytest.raises(SlicerApiUnavailableError):
await service.import_bundle(b"x")
@pytest.mark.asyncio
async def test_list_bundles_returns_summaries(self):
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/profiles/bundles"
return httpx.Response(status_code=200, json=[self.SAMPLE_SUMMARY])
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
bundles = await service.list_bundles()
assert len(bundles) == 1
assert bundles[0].id == self.SAMPLE_SUMMARY["id"]
@pytest.mark.asyncio
async def test_list_bundles_empty_array(self):
# Sidecar returns [] when no bundles imported yet — must not raise.
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code=200, json=[])
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
assert await service.list_bundles() == []
@pytest.mark.asyncio
async def test_list_bundles_non_array_raises(self):
# Older / mis-configured sidecar returning {} instead of []. Surface
# the bug with a clear server error rather than silently treating
# malformed payload as empty.
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code=200, json={"unexpected": "shape"})
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
with pytest.raises(SlicerApiServerError):
await service.list_bundles()
@pytest.mark.asyncio
async def test_get_bundle_404_raises_not_found(self):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code=404, json={"message": "not found"})
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
with pytest.raises(BundleNotFoundError):
await service.get_bundle("deadbeef00000000")
@pytest.mark.asyncio
async def test_get_bundle_happy_path(self):
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/profiles/bundles/2bd8722dd20a837e"
return httpx.Response(status_code=200, json=self.SAMPLE_SUMMARY)
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
summary = await service.get_bundle("2bd8722dd20a837e")
assert summary.id == "2bd8722dd20a837e"
@pytest.mark.asyncio
async def test_delete_bundle_204_succeeds_silently(self):
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "DELETE"
return httpx.Response(status_code=204)
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
# Should not raise.
await service.delete_bundle("2bd8722dd20a837e")
@pytest.mark.asyncio
async def test_delete_bundle_404_raises_not_found(self):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code=404, json={"message": "not found"})
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
with pytest.raises(BundleNotFoundError):
await service.delete_bundle("missing")
class TestSliceWithBundle:
"""The bundle slice path takes the same model upload but replaces the
profile-attachment fields with bundle-id + preset-name form fields.
Coverage for the form shape, the multi-filament join, and the same
4xx/5xx mapping as slice_with_profiles."""
@pytest.mark.asyncio
async def test_form_fields_and_filament_join(self):
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["body"] = request.content
captured["content_type"] = request.headers.get("content-type", "")
return httpx.Response(
status_code=200,
content=b"; G-CODE",
headers={
"x-print-time-seconds": "60",
"x-filament-used-g": "1.0",
"x-filament-used-mm": "100.0",
},
)
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
result = await service.slice_with_bundle(
model_bytes=b"solid Cube\n",
model_filename="Cube.stl",
bundle_id="2bd8722dd20a837e",
printer_name="# Bambu Lab H2D 0.4 nozzle",
process_name="# 0.20mm Standard @BBL H2D",
filament_names=["# Bambu PLA Basic @BBL H2D", "# Bambu PETG HF @BBL H2D"],
)
assert isinstance(result, SliceResult)
assert result.print_time_seconds == 60
assert captured["url"].endswith("/slice")
assert captured["content_type"].startswith("multipart/form-data")
# Multi-filament joined with ';' — the sidecar's parser splits on
# both ';' and ',' so the wire format is the more-explicit ';'.
body = captured["body"]
assert b"# Bambu PLA Basic @BBL H2D;# Bambu PETG HF @BBL H2D" in body
# Each form field appears in the multipart body.
assert b'name="bundle"' in body
assert b'name="printerName"' in body
assert b'name="processName"' in body
assert b'name="filamentNames"' in body
# Bundle id round-trips on the wire.
assert b"2bd8722dd20a837e" in body
@pytest.mark.asyncio
async def test_404_unknown_preset_maps_to_input_error(self):
# Sidecar returns 404 when bundle exists but preset name doesn't.
# The slice route classifies this as user-correctable input error,
# not server failure.
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
status_code=404,
json={"message": 'process preset "Imaginary" not found in bundle "abc"'},
)
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
with pytest.raises(SlicerInputError):
await service.slice_with_bundle(
model_bytes=b"x",
model_filename="Cube.stl",
bundle_id="abc",
printer_name="p",
process_name="Imaginary",
filament_names=["f"],
)
@pytest.mark.asyncio
async def test_5xx_maps_to_server_error(self):
# CLI segfault on the resolved triplet — same handling as slice_with_profiles.
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
status_code=500,
json={"message": "Slicer process failed (signal SIGSEGV)"},
)
service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
with pytest.raises(SlicerApiServerError):
await service.slice_with_bundle(
model_bytes=b"x",
model_filename="Cube.3mf",
bundle_id="abc",
printer_name="p",
process_name="pr",
filament_names=["f"],
)
+205
View File
@@ -431,3 +431,208 @@ class TestResolveSlicerApiUrl:
):
url = await sp._resolve_slicer_api_url(MagicMock())
assert url is None
class TestBundleRoutes:
"""Route-level coverage for the bundle proxy endpoints. Each route
resolves the sidecar URL via _resolve_slicer_api_url, then proxies the
operation through SlicerApiService. We mock both pieces so we can pin
the HTTP-status mapping (sidecar input error → 400, BundleNotFoundError
→ 404, unreachable → 503) without spinning up a sidecar.
"""
SAMPLE_SUMMARY = sp.BundleSummary(
id="abc123def456abcd",
printer_preset_name="# Bambu Lab H2D 0.4 nozzle",
printer=["# Bambu Lab H2D 0.4 nozzle"],
process=["# 0.20mm Standard @BBL H2D"],
filament=["# Bambu PLA Basic @BBL H2D"],
version="02.06.00.50",
)
def _patched_service(self, **methods) -> MagicMock:
"""Build a SlicerApiService mock that supports `async with` and
exposes the bundle methods via AsyncMock per the override dict."""
svc = MagicMock()
svc.__aenter__ = AsyncMock(return_value=svc)
svc.__aexit__ = AsyncMock(return_value=False)
for name, mock in methods.items():
setattr(svc, name, mock)
return svc
@pytest.mark.asyncio
async def test_import_bundle_happy_path(self):
from io import BytesIO
from fastapi import UploadFile
svc = self._patched_service(
import_bundle=AsyncMock(return_value=self.SAMPLE_SUMMARY),
)
with (
patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
patch.object(sp, "SlicerApiService", return_value=svc),
):
file = UploadFile(filename="H2D.bbscfg", file=BytesIO(b"PK\x03\x04"))
result = await sp.import_slicer_bundle(file=file, db=MagicMock(), _=None)
assert result["id"] == "abc123def456abcd"
assert result["printer"] == ["# Bambu Lab H2D 0.4 nozzle"]
svc.import_bundle.assert_awaited_once()
kwargs = svc.import_bundle.await_args.kwargs
assert kwargs["filename"] == "H2D.bbscfg"
@pytest.mark.asyncio
async def test_import_bundle_no_sidecar_returns_503(self):
from io import BytesIO
from fastapi import HTTPException, UploadFile
with (
patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)),
pytest.raises(HTTPException) as exc,
):
await sp.import_slicer_bundle(
file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")),
db=MagicMock(),
_=None,
)
assert exc.value.status_code == 503
@pytest.mark.asyncio
async def test_import_bundle_empty_file_returns_400(self):
from io import BytesIO
from fastapi import HTTPException, UploadFile
with (
patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
pytest.raises(HTTPException) as exc,
):
await sp.import_slicer_bundle(
file=UploadFile(filename="x.bbscfg", file=BytesIO(b"")),
db=MagicMock(),
_=None,
)
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_import_bundle_sidecar_400_passes_through(self):
from io import BytesIO
from fastapi import HTTPException, UploadFile
svc = self._patched_service(
import_bundle=AsyncMock(side_effect=sp.SlicerInputError("bad zip")),
)
with (
patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
patch.object(sp, "SlicerApiService", return_value=svc),
pytest.raises(HTTPException) as exc,
):
await sp.import_slicer_bundle(
file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")),
db=MagicMock(),
_=None,
)
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_import_bundle_sidecar_unreachable_returns_503(self):
from io import BytesIO
from fastapi import HTTPException, UploadFile
svc = self._patched_service(
import_bundle=AsyncMock(side_effect=sp.SlicerApiUnavailableError("offline")),
)
with (
patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
patch.object(sp, "SlicerApiService", return_value=svc),
pytest.raises(HTTPException) as exc,
):
await sp.import_slicer_bundle(
file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")),
db=MagicMock(),
_=None,
)
assert exc.value.status_code == 503
@pytest.mark.asyncio
async def test_list_bundles_happy_path(self):
svc = self._patched_service(
list_bundles=AsyncMock(return_value=[self.SAMPLE_SUMMARY]),
)
with (
patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
patch.object(sp, "SlicerApiService", return_value=svc),
):
result = await sp.list_slicer_bundles(db=MagicMock(), _=None)
assert len(result) == 1
assert result[0]["id"] == "abc123def456abcd"
@pytest.mark.asyncio
async def test_list_bundles_no_sidecar_returns_empty(self):
# Differs from import: list returns [] instead of 503 so the
# SliceModal still renders cleanly when no sidecar is configured
# (matches bundled-tier behaviour above).
with patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)):
result = await sp.list_slicer_bundles(db=MagicMock(), _=None)
assert result == []
@pytest.mark.asyncio
async def test_list_bundles_sidecar_unreachable_returns_503(self):
from fastapi import HTTPException
svc = self._patched_service(
list_bundles=AsyncMock(side_effect=sp.SlicerApiUnavailableError("offline")),
)
with (
patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
patch.object(sp, "SlicerApiService", return_value=svc),
pytest.raises(HTTPException) as exc,
):
await sp.list_slicer_bundles(db=MagicMock(), _=None)
assert exc.value.status_code == 503
@pytest.mark.asyncio
async def test_get_bundle_404(self):
from fastapi import HTTPException
svc = self._patched_service(
get_bundle=AsyncMock(side_effect=sp.BundleNotFoundError("not found")),
)
with (
patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
patch.object(sp, "SlicerApiService", return_value=svc),
pytest.raises(HTTPException) as exc,
):
await sp.get_slicer_bundle("missing", db=MagicMock(), _=None)
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_delete_bundle_204(self):
# delete returns None on success; FastAPI sends 204 because the route
# declares status_code=204.
svc = self._patched_service(delete_bundle=AsyncMock(return_value=None))
with (
patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
patch.object(sp, "SlicerApiService", return_value=svc),
):
result = await sp.delete_slicer_bundle("abc", db=MagicMock(), _=None)
assert result is None
svc.delete_bundle.assert_awaited_once_with("abc")
@pytest.mark.asyncio
async def test_delete_bundle_404(self):
from fastapi import HTTPException
svc = self._patched_service(
delete_bundle=AsyncMock(side_effect=sp.BundleNotFoundError("not found")),
)
with (
patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
patch.object(sp, "SlicerApiService", return_value=svc),
pytest.raises(HTTPException) as exc,
):
await sp.delete_slicer_bundle("missing", db=MagicMock(), _=None)
assert exc.value.status_code == 404