mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
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:
@@ -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()
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user