Ask Spoolman which extra fields it has, once (issue #2983)

Bambuddy checked whether one of its four custom spool fields existed with
GET /field/spool/{name}. Spoolman has never served that. Its API declares
only POST and DELETE at that path -- confirmed against the live server's
own OpenAPI document -- so the probe answered 405 Method Not Allowed
every time and the check could not succeed on any version.

Every call therefore fell through to POST /field/spool/{name}, and that
endpoint is an upsert rather than a create. It answers 200 whether or not
the field is already there, so a field the user had renamed, retyped or
given a default to in Spoolman's own UI was reset to Bambuddy's version
of it, and an untrue "Created Spoolman extra field" was logged beside it.
The reporter's log carried 60 of those lines over three days -- once per
field per client init, which is every restart and every settings save.

Existence now comes from GET /field/spool, the listing endpoint, matched
on each row's `key`. Matching on `key` rather than the display `name` is
the part that fixes the overwrite: a renamed field is the same field, and
reading it as a missing one is what re-created it. A field that already
exists is now left completely alone.

The listing is read once per client and banked, so registering all four
fields costs one request instead of four, and a client that has already
looked makes none at all. Only a successful read is banked -- a client
that could not reach the listing asks again for the next field it has not
seen, so one transient failure does not leave it posting blind, and
overwriting, for the rest of its life.

An unreadable listing still falls back to attempting the POST. Registration
is best-effort by contract: it must not turn a write that might still
succeed into one that never happens, so an unexpected Spoolman build is no
worse off than before.

Measured against Spoolman 0.23.1: a field renamed to "Bambu RFID Tag"
survives a full registration pass that previously reset it, the pass makes
one GET and one POST for the single genuinely-missing field where it used
to make four POSTs, and a second pass on the same client makes no requests
at all.

The fake in test_spoolman_extra_field_registration_2903 modelled the
per-field path as a working probe, which is the assumption this bug was
built on; it now answers 405 as the real server does, and its assertions
follow the listing. 18 new tests cover the rest, 10 of which fail against
the old code.
This commit is contained in:
maziggy
2026-08-28 10:46:40 +02:00
parent e9daa2124e
commit 4d2c6debf7
4 changed files with 327 additions and 17 deletions
+1
View File
@@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file.
- **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
### Fixed
- **Spoolman reset your renamed extra fields on every restart (#2983, reported by @ngreatorex)** — Bambuddy checked whether one of its four custom spool fields existed by calling `GET /field/spool/{name}`. Spoolman has never served that: its API declares only `POST` and `DELETE` at that path, so the check answered **405 Method Not Allowed** every single time and could never succeed. Each call then fell through to `POST /field/spool/{name}` — and that endpoint is an *upsert*, not a create. It answers 200 whether or not the field is already there, so a field you had renamed, retyped or given a default to in Spoolman's own UI was silently reset to Bambuddy's version of it, and an untrue `Created Spoolman extra field` was logged alongside. The reporter's log carried 60 of those lines in three days. Existence now comes from the documented `GET /field/spool` listing, matched on the field's `key` rather than its display `name` — a rename is the same field, and treating it as a missing one is what caused the overwrite. An existing field is left completely alone. **You can now rename these fields in Spoolman and the name will stick.** Registering all four also costs one request instead of four, and none at all on a client that has already looked once. If the listing can't be read at all, Bambuddy falls back to attempting the write exactly as before, so an unexpected Spoolman build is no worse off than today.
- **The internal slicer picked PETG for a PLA plate, and an A1 process for a P1S (#2982, reported by @Igiegel)** — Both traced to one line of the slicer sidecar: its bundled-profile listing read `filament_type` off the leaf preset and never reported `compatible_printers` at all. Measured against the shipped trees, the leaf read finds nothing — zero of the 1156 filament presets in OrcaSlicer 2.4.2 carry a material, and zero of the 1792 in BambuStudio 02.08.02.61 — because it lives one to four hops up the `inherits` chain (`Bambu ABS @BBL A1` → `Bambu ABS @base` → `fdm_filament_abs`). With no material on any of them the pre-pick had only colour and source to go on, so a white PLA plate drew `Bambu PETG Basic` on an A1 mini and `Bambu PC` on a P1S. The sidecar now walks the chain, which recovers the material for 1124 of the 1156 (the other 32 name a parent Bambu's own bundle does not contain, and stay listed with no material rather than being dropped). **`compatible_printers` is the second half**, and it is the only truthful account of which printer a preset belongs to: the bundle ships no process preset named after a P1S, an X1, an X1E or an H2D Pro — all ten of the P1S's are named `@BBL X1C` and name the P1S only in that list. Reading the printer out of the preset *name* therefore made a P1S look like it had no compatible process at all: all 198 were hidden behind “Show all” and the auto-pick fell through to an alphabetically-first `0.06mm Fine @BBL A1 0.2 nozzle` the CLI then refused with “the selected printer is not compatible with the process preset”. A P1S now gets the `0.20mm Standard @BBL X1C` it should always have had, and 73 filaments instead of 4. **This half needs the updated sidecar image** — an older one simply reports nothing and Bambuddy stays on the name matcher, degraded exactly as before rather than broken. Three hardenings ride along so a stale sidecar fails more gracefully: a filament preset that states a *different* material than the plate asks for is now skipped outright rather than merely scored down (a preset stating no material stays eligible — unknown is not wrong), a slot is no longer held on printer-compatibility alone once its material turns out to disagree (a preset you chose yourself is exempt — printing PETG on a plate labelled PLA is a legitimate thing to do), and a dropdown the printer filter would empty now shows everything instead, since a visible preset for the wrong printer can be changed and an empty list cannot. `filament_colour` stays null throughout, which is correct: no BBL profile carries a colour at any depth, because colour is a spool attribute rather than a profile one.
- **Every slice that didn't name its own process quietly got the slowest one the slicer ships** — Found while tracing #2982. Within a tier the preset list is alphabetical, and Bambu's naming puts the finest layer height first, so the auto-pick landed on `0.08mm Extra Fine` for an X1 Carbon and `0.06mm Fine` for an A1 mini. Correct presets, but nobody's idea of a default. Among candidates that are equally valid for the selected printer, the one nearest 0.2mm now wins — `0.20mm Standard` where it exists, the closest thing to it otherwise, ties breaking toward the coarser and therefore faster height. A preset whose name carries no readable height is still pickable when it is the only candidate, and a process the 3MF named still overrides all of this.
- **An H2D Pro classified every bundled preset as another printer's** — Also found while tracing #2982, and the same shape as the A1 mini's `A1M` rename (#1649): the bundle names H2D Pro presets `@BBL H2DP` while the printer preset, and the model registry with it, spells the model `H2D Pro`. The alias table now carries the pair. It stays deliberately narrow — `H2DP` and a plain `H2D` are still different machines and must not collapse.
+66 -8
View File
@@ -113,6 +113,12 @@ class SpoolmanClient:
# grows with spool count; scoped to the instance so a client pointed at
# a different Spoolman starts over.
self._ensured_extra_fields: set[str] = set()
# Whether Spoolman's extra-field listing has been read once. Separate
# from the set above because the two answer different questions: the
# set is "which fields are known to exist", this is "have we asked".
# Without it a client registering three brand-new fields re-read the
# whole listing before each one.
self._extra_fields_listed = False
self._ensure_extra_lock = asyncio.Lock()
async def _get_client(self) -> httpx.AsyncClient:
@@ -953,6 +959,36 @@ class SpoolmanClient:
"""Register the 'tag' extra field in Spoolman if not present; returns True on success."""
return await self.ensure_extra_field("tag")
async def _load_existing_extra_field_keys(self) -> set[str] | None:
"""Keys of the spool extra fields Spoolman already has, or ``None`` when
the listing could not be read.
``None`` and ``set()`` mean different things and the caller acts on the
difference: an empty set is "Spoolman has no extra fields", which means
every field Bambuddy needs must be created; ``None`` is "we could not
find out", where the only safe move is to fall back to attempting the
write blind.
"""
try:
client = await self._get_client()
response = await client.get(f"{self.api_url}/field/spool")
if response.status_code != 200:
logger.debug(
"Spoolman extra-field listing returned %s; falling back to blind registration",
response.status_code,
)
return None
fields = response.json()
except Exception as e: # noqa: BLE001 — registration is best-effort, see _ensure_extra_fields
logger.debug("Could not read Spoolman extra-field listing: %s", e)
return None
if not isinstance(fields, list):
return None
# Match on `key`, not `name`: `key` is the identifier the extra dict is
# written under and the one Bambuddy cares about, while `name` is the
# free-text label a user is free to change in Spoolman's UI.
return {f["key"] for f in fields if isinstance(f, dict) and isinstance(f.get("key"), str)}
async def ensure_extra_field(self, name: str, field_type: str = "text") -> bool:
"""Register a custom extra field in Spoolman if not present.
@@ -960,18 +996,40 @@ class SpoolmanClient:
with HTTP 400 ('Unknown extra field <name>.'), so any custom field
Bambuddy persists alongside spools needs to be pre-registered.
Idempotent — returns True if the field already exists.
Existence is read from ``GET /field/spool``, the whole-listing endpoint.
This used to probe ``GET /field/spool/{name}`` for one field at a time,
which Spoolman has never served: it declares only POST and DELETE at
that path, so the probe answered 405 every time and the check could
never succeed (issue #2983, reported by @ngreatorex).
Falling through to the POST on every call was worse than a wasted
request, because that endpoint is an upsert rather than a create. It
answered 200 whether or not the field was already there, so a field a
user had renamed, retyped or given a default in Spoolman's own UI was
silently reset to Bambuddy's version of it on every restart. Reading
the listing first is what lets an existing field be left alone.
"""
try:
client = await self._get_client()
# Check if field already exists
response = await client.get(f"{self.api_url}/field/spool/{name}")
if response.status_code == 200:
logger.debug("Spoolman extra field %r already exists", name)
self._ensured_extra_fields.add(name)
if name in self._ensured_extra_fields:
return True
# Field doesn't exist - create it
if not self._extra_fields_listed:
existing = await self._load_existing_extra_field_keys()
if existing is not None:
# Bank the whole listing: the caller registers several
# fields in a row, and each one it already has is a request
# not sent and a user customisation not overwritten. Read
# once per client — every field created after this point is
# added to the set as it is created, so re-reading would
# only ever confirm what we already know.
self._ensured_extra_fields |= existing
self._extra_fields_listed = True
if name in existing:
logger.debug("Spoolman extra field %r already exists", name)
return True
client = await self._get_client()
field_data = {
"name": name,
"field_type": field_type,
@@ -49,12 +49,26 @@ class FakeSpoolman:
self.log.append(f"{request.method} {path}")
body = json.loads(request.content) if request.content else {}
if path == "/field/spool" and request.method == "GET":
if self.field_status != 200:
return httpx.Response(self.field_status)
return httpx.Response(
200,
json=[
{"key": name, "name": name, "field_type": "text", "entity_type": "spool"}
for name in sorted(self.registered)
],
)
if path.startswith("/field/spool/"):
name = path.rsplit("/", 1)[-1]
if request.method == "GET":
if self.field_status != 200:
return httpx.Response(self.field_status)
return httpx.Response(200) if name in self.registered else httpx.Response(404)
# Spoolman declares only POST and DELETE here -- its own OpenAPI
# document says so, and the live server answers 405. Modelling this
# as a working existence probe is what let the bug in issue #2983
# sit unnoticed: the check could never succeed, and the POST that
# followed silently overwrote whatever the user had customised.
if request.method != "POST":
return httpx.Response(405, json={"detail": "Method Not Allowed"})
if self.field_status != 200:
return httpx.Response(self.field_status)
self.registered.add(name)
@@ -93,7 +107,9 @@ class FakeSpoolman:
return httpx.Response(200, json=[])
def field_calls(self, name: str) -> list[str]:
return [entry for entry in self.log if entry.endswith(f"/field/spool/{name}")]
"""Every request this client made about ``name``: the listing read that
answers "does it exist", plus any creation of that specific field."""
return [entry for entry in self.log if entry == "GET /field/spool" or entry.endswith(f"/field/spool/{name}")]
def _client(fake: FakeSpoolman) -> SpoolmanClient:
@@ -167,7 +183,7 @@ class TestItAsksSpoolmanOnlyOnce:
await client.create_spool(filament_id=7, extra={"tag": json.dumps("A")})
await client.create_spool(filament_id=7, extra={"tag": json.dumps("B")})
assert fake.field_calls("tag") == ["GET /field/spool/tag", "POST /field/spool/tag"]
assert fake.field_calls("tag") == ["GET /field/spool", "POST /field/spool/tag"]
@pytest.mark.asyncio
async def test_an_already_registered_field_is_never_created(self):
@@ -176,7 +192,7 @@ class TestItAsksSpoolmanOnlyOnce:
await client.create_spool(filament_id=7, extra={"tag": json.dumps("A")})
assert fake.field_calls("tag") == ["GET /field/spool/tag"]
assert fake.field_calls("tag") == ["GET /field/spool"]
@pytest.mark.asyncio
async def test_a_write_carrying_no_extra_asks_nothing(self):
@@ -204,7 +220,7 @@ class TestItAsksSpoolmanOnlyOnce:
client.create_spool(filament_id=7, extra={"tag": json.dumps("B")}),
)
assert fake.field_calls("tag") == ["GET /field/spool/tag", "POST /field/spool/tag"]
assert fake.field_calls("tag") == ["GET /field/spool", "POST /field/spool/tag"]
@pytest.mark.asyncio
async def test_another_client_does_not_inherit_the_answer(self):
@@ -215,7 +231,7 @@ class TestItAsksSpoolmanOnlyOnce:
second_fake = FakeSpoolman()
await _client(second_fake).create_spool(filament_id=7, extra={"tag": json.dumps("B")})
assert "GET /field/spool/tag" in second_fake.log
assert "GET /field/spool" in second_fake.log
class TestEveryWritePathThatCarriesExtra:
@@ -0,0 +1,235 @@
"""Extra-field registration reads Spoolman's listing (#2983, reported by @ngreatorex).
``ensure_extra_field`` used to probe ``GET /field/spool/{name}`` per field.
Spoolman declares only POST and DELETE at that path -- verified against its own
OpenAPI document -- so the probe answered 405 and the existence check could
never succeed. Every call fell through to ``POST /field/spool/{name}``, which is
an *upsert*: it answered 200 whether or not the field existed, so a field a user
had renamed or retyped in Spoolman's UI was reset to Bambuddy's version of it
on every client init, and an untrue "Created Spoolman extra field" was logged
each time.
Existence now comes from ``GET /field/spool``, matched on ``key``.
These drive ``ensure_extra_field``'s own branches directly, with the HTTP layer
stubbed, so the awkward answers can be posed one at a time -- a listing that
isn't a list, a row with no key, a transport error mid-read.
``test_spoolman_extra_field_registration_2903`` covers the other half from the
outside, through a fake Spoolman that enforces the real API's rules; its fake
answers the per-field path 405, as the real server does.
"""
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from backend.app.services.spoolman import SpoolmanClient
BAMBU_FIELDS = ("tag", "bambu_slicer_filament", "bambu_slicer_filament_name", "bambu_color_name")
def _response(status: int, payload=None, text: str = "") -> MagicMock:
r = MagicMock()
r.status_code = status
r.text = text if payload is None else json.dumps(payload)
r.json = MagicMock(return_value=payload)
return r
def _field(key: str, name: str | None = None) -> dict:
"""A Spoolman field row. `name` is the user-editable label, `key` the
identifier the spool's extra dict is written under."""
return {
"key": key,
"name": name if name is not None else key,
"field_type": "text",
"entity_type": "spool",
"order": 0,
}
LISTING_URL = "http://spoolman.test/api/v1/field/spool"
def _client_with(listing, post_status: int = 200) -> tuple[SpoolmanClient, MagicMock]:
"""A SpoolmanClient whose HTTP calls are recorded.
``listing`` is either the parsed body of ``GET /field/spool`` or a
ready-made response for the failure cases.
GET is routed by URL rather than answering everything with the listing.
That matters: a mock that returns the listing for any GET also answers the
old per-field probe with it, which would let the pre-fix code pass these
tests. The real server answers ``GET /field/spool/{key}`` with **405**,
because it declares only POST and DELETE there -- so that is what any URL
other than the listing gets here.
"""
listing_response = listing if isinstance(listing, MagicMock) else _response(200, listing)
async def get(url, *args, **kwargs):
if url == LISTING_URL:
return listing_response
return _response(405, None, '{"detail":"Method Not Allowed"}')
http = MagicMock()
http.get = AsyncMock(side_effect=get)
http.post = AsyncMock(return_value=_response(post_status, {}))
client = SpoolmanClient("http://spoolman.test")
client._get_client = AsyncMock(return_value=http)
return client, http
class TestTheExistenceCheck:
@pytest.mark.asyncio
async def test_reads_the_listing_endpoint_not_the_per_field_one(self):
client, http = _client_with([_field("tag")])
await client.ensure_extra_field("tag")
(url,), _ = http.get.call_args
assert url == "http://spoolman.test/api/v1/field/spool"
@pytest.mark.asyncio
async def test_an_existing_field_is_never_posted(self):
"""The whole point: POST is an upsert, so posting an existing field is
what reset the user's customisation of it."""
client, http = _client_with([_field("tag", "Bambu RFID Tag")])
assert await client.ensure_extra_field("tag") is True
http.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_matches_on_key_not_on_the_user_facing_name(self):
"""A renamed field is still the same field. Matching on `name` would
make a rename look like a deletion and re-create it -- clobbering the
rename, which is exactly the reported behaviour."""
client, http = _client_with([_field("bambu_color_name", "Bambu Colour")])
assert await client.ensure_extra_field("bambu_color_name") is True
http.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_a_missing_field_is_created(self):
client, http = _client_with([_field("tag")])
assert await client.ensure_extra_field("bambu_color_name") is True
http.post.assert_awaited_once()
(url,), kwargs = http.post.call_args
assert url == "http://spoolman.test/api/v1/field/spool/bambu_color_name"
assert kwargs["json"] == {
"name": "bambu_color_name",
"field_type": "text",
"default_value": None,
}
@pytest.mark.asyncio
async def test_creates_every_field_when_spoolman_has_none(self):
"""An empty listing is a real answer -- "no extra fields yet" -- and
must be acted on, unlike an unreadable one."""
client, http = _client_with([])
for name in BAMBU_FIELDS:
assert await client.ensure_extra_field(name) is True
assert http.post.await_count == len(BAMBU_FIELDS)
@pytest.mark.asyncio
async def test_honours_a_non_default_field_type_when_creating(self):
client, http = _client_with([])
await client.ensure_extra_field("some_number", field_type="integer")
assert http.post.call_args.kwargs["json"]["field_type"] == "integer"
class TestRequestCount:
@pytest.mark.asyncio
async def test_registering_every_field_costs_one_listing_read(self):
client, http = _client_with([_field(k) for k in BAMBU_FIELDS])
await client._ensure_extra_fields(dict.fromkeys(BAMBU_FIELDS, "value"))
assert http.get.await_count == 1
http.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_creating_several_new_fields_still_reads_the_listing_once(self):
client, http = _client_with([])
await client._ensure_extra_fields(dict.fromkeys(BAMBU_FIELDS, "value"))
assert http.get.await_count == 1
assert http.post.await_count == len(BAMBU_FIELDS)
@pytest.mark.asyncio
async def test_a_second_pass_on_the_same_client_makes_no_requests(self):
client, http = _client_with([_field(k) for k in BAMBU_FIELDS])
await client._ensure_extra_fields(dict.fromkeys(BAMBU_FIELDS, "value"))
http.get.reset_mock()
await client._ensure_extra_fields(dict.fromkeys(BAMBU_FIELDS, "value"))
http.get.assert_not_awaited()
http.post.assert_not_awaited()
class TestDegradingWhenTheListingCannotBeRead:
"""An unreadable listing must not stop a write that might still succeed --
registration is best-effort, and falling back to the blind POST is exactly
what shipped before."""
@pytest.mark.asyncio
async def test_a_non_200_listing_falls_back_to_posting(self):
client, http = _client_with(_response(404, None, "Not Found"))
assert await client.ensure_extra_field("tag") is True
http.post.assert_awaited_once()
@pytest.mark.asyncio
async def test_a_transport_error_on_the_listing_falls_back_to_posting(self):
client, http = _client_with([])
http.get = AsyncMock(side_effect=OSError("connection reset"))
assert await client.ensure_extra_field("tag") is True
http.post.assert_awaited_once()
@pytest.mark.asyncio
async def test_a_listing_that_is_not_a_list_falls_back_to_posting(self):
client, http = _client_with(_response(200, {"detail": "Method Not Allowed"}))
assert await client.ensure_extra_field("tag") is True
http.post.assert_awaited_once()
@pytest.mark.asyncio
async def test_rows_without_a_usable_key_are_skipped_not_fatal(self):
client, http = _client_with([{"name": "orphan"}, "junk", _field("tag")])
assert await client.ensure_extra_field("tag") is True
http.post.assert_not_awaited()
@pytest.mark.asyncio
async def test_a_field_created_blind_is_not_looked_up_again(self):
"""The blind POST succeeded, so that field demonstrably exists now.
Re-reading the listing to confirm it would be a wasted request."""
client, http = _client_with([])
http.get = AsyncMock(side_effect=[_response(503), _response(200, [_field("tag")])])
assert await client.ensure_extra_field("tag") is True
assert await client.ensure_extra_field("tag") is True
assert http.get.await_count == 1
@pytest.mark.asyncio
async def test_a_later_field_still_gets_a_listing_read(self):
"""Only a *successful* read is banked. A client that could not reach the
listing once must not spend the rest of its life posting blind -- the
next field it has never seen has to ask again, or a transient blip
would clobber every remaining field's customisation."""
client, http = _client_with([])
http.get = AsyncMock(
side_effect=[_response(503), _response(200, [_field("bambu_color_name")])],
)
await client.ensure_extra_field("tag")
http.post.reset_mock()
assert await client.ensure_extra_field("bambu_color_name") is True
assert http.get.await_count == 2
http.post.assert_not_awaited()
class TestFailureReporting:
@pytest.mark.asyncio
async def test_a_rejected_creation_returns_false(self):
client, _ = _client_with([], post_status=400)
assert await client.ensure_extra_field("tag") is False
@pytest.mark.asyncio
async def test_a_rejected_creation_is_not_remembered_as_ensured(self):
client, _ = _client_with([], post_status=400)
await client.ensure_extra_field("tag")
assert "tag" not in client._ensured_extra_fields
@pytest.mark.asyncio
async def test_ensure_tag_extra_field_goes_through_the_same_path(self):
client, http = _client_with([_field("tag", "Bambu RFID Tag")])
assert await client.ensure_tag_extra_field() is True
http.post.assert_not_awaited()