From 40cc3340b1ccbc94a3aca9d7146641efd3cce021 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Fri, 4 Sep 2026 14:25:00 +0800 Subject: [PATCH] Content-address Bambu filament ids and check profiles against the catalog map Every vendor's filament_id declarations, Bambu's own bundle included, are now minted and checked the same way: the GF* catalog space is reserved but ownerless, format is validated unconditionally with no snapshot or BBL exemption, and both --remint and the default assign pass treat a non-OF declaration as one needing a fresh mint (so a future BambuStudio sync self-heals instead of needing a manual pass). A new check validates resources/printers/bambu_filament_ids.json against the tree it describes: that it parses, carries its header, keys only OF ids, maps each Bambu id once, and agrees with the tree on every product it shares. The redundant BBL/OFL carve-out in the profile checker's length check is dropped too, so it runs the same way for every vendor. The BBL bundle itself hasn't been touched yet and still declares its old GF ids, so TestRealTree.test_shipped_snapshot_matches_tree is expected to fail here (209 declarations flagged) until the next commit re-mints the bundle onto OF ids. --- scripts/assign_filament_ids.py | 193 ++++++++++++++++++++-------- scripts/orca_extra_profile_check.py | 17 +-- scripts/tests/test_filament_id.py | 179 +++++++++++++++++++++----- 3 files changed, 297 insertions(+), 92 deletions(-) diff --git a/scripts/assign_filament_ids.py b/scripts/assign_filament_ids.py index 46a138253c..cc3adeab4a 100755 --- a/scripts/assign_filament_ids.py +++ b/scripts/assign_filament_ids.py @@ -24,7 +24,10 @@ Policy (companion to assign_vendor_setting_ids.py; see docs/HLSD/filament_id.md) Identity changes (a family rename, a filament_vendor/filament_type fix) change the id BY DESIGN. * Reserved id spaces that are never minted into or altered: - - GF* Bambu AMS/RFID catalog (vendor BBL untouchable) + - GF* Bambu AMS/RFID catalog: frozen, no preset of any + vendor (including BBL) may declare one; the + generated resources/printers/bambu_filament_ids.json + carries the correspondence instead - QD_* Qidi device protocol: the box composes these ids at runtime, they are not preset ids, and no preset may declare one @@ -43,8 +46,9 @@ OFL it stays in OFL; a vendor chain that dead-ends id-less retries its direct parent in the OFL map. filament_vendor / filament_type resolve the same way. Run from anywhere: python3 scripts/assign_filament_ids.py - (default) mint + insert ids for id-less families; idempotent, never - rewrites a valid existing id; a no-op on a fully-idded tree + (default) mint + insert ids for id-less families, mint + replace + non-OF-format declarations; idempotent, never rewrites a + valid OF-format id; a no-op once every family has one --mint "Vendor/Type/Family" print the id that triple would mint; touches nothing --update-snapshot regenerate the snapshot from the tree @@ -75,6 +79,10 @@ FILAMENT_ID_LENGTH = 6 # base62 digits after the "OF" prefix -> 8 chars total SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) PROFILES_DIR = os.path.normpath(os.path.join(SCRIPTS_DIR, "..", "resources", "profiles")) SNAPSHOT_PATH = os.path.join(SCRIPTS_DIR, "filament_id_snapshot.json") +# Same path update_bambu_filament_ids.BAMBU_MAP_PATH computes; kept as a sibling +# constant (not imported) because that module imports FROM this one already. +BAMBU_MAP_PATH = os.path.normpath( + os.path.join(SCRIPTS_DIR, "..", "resources", "printers", "bambu_filament_ids.json")) OFL = "OrcaFilamentLibrary" @@ -89,6 +97,7 @@ BASE_NAME_RE = re.compile(r"\s?@.*$") MAX_CHECK_SALT = 8 UPDATE_HINT = 'run "python scripts/assign_filament_ids.py --update-snapshot" and commit the diff for maintainer review' +BAMBU_MAP_HINT = 'regenerate the map with "python scripts/update_bambu_filament_ids.py" and commit the diff for maintainer review' # Same output helpers/format as orca_extra_profile_check.py (not imported from @@ -304,12 +313,6 @@ def resolve_triple(name, filaments, ofl_filaments): base_name(name)) -def is_island_declaration(vendor, fid): - """Declarations frozen outside the OF mint domain: all of BBL (the QD_* - island was dissolved by plan v4 — Qidi declarations mint like any other).""" - return vendor == "BBL" - - def analyze_tree(profiles_dir): """Load every vendor bundle and derive the full filament_id state. @@ -351,8 +354,8 @@ def analyze_tree(profiles_dir): overrides = [] # (vendor, name, declared, inherited, file) missing_effective = [] # (vendor, name, file) instantiated presets resolving no id alias_candidates = [] # (vendor, rec, ofl_entry, own_key) presets riding an OFL family - triples = {} # fid -> set of triples of its non-island declarers - declarer_triples = [] # (vendor, rec, fid, triple) per non-island declarer + triples = {} # fid -> set of triples of its declarers + declarer_triples = [] # (vendor, rec, fid, triple) per declarer family_triples = {} # (vendor, family) -> {triple: [declarer names]} for vendor, filaments in vendors.items(): @@ -363,14 +366,13 @@ def analyze_tree(profiles_dir): occurring.add(fid) declared_ids.setdefault(vendor, set()).add(fid) ids.setdefault(fid, set()) - if not is_island_declaration(vendor, fid): - triple = resolve_triple(rec["name"], filaments, ofl_filaments) - rec["triple"] = triple - declarer_triples.append((vendor, rec, fid, triple)) - triples.setdefault(fid, set()).add(triple) - family_triples.setdefault( - (vendor, base_name(rec["name"])), {}).setdefault( - triple, []).append(rec["name"]) + triple = resolve_triple(rec["name"], filaments, ofl_filaments) + rec["triple"] = triple + declarer_triples.append((vendor, rec, fid, triple)) + triples.setdefault(fid, set()).add(triple) + family_triples.setdefault( + (vendor, base_name(rec["name"])), {}).setdefault( + triple, []).append(rec["name"]) if rec.get("inherits"): inherited, _src, skip_entry = resolve_filament_id( rec["name"], filaments, ofl_filaments, skip_own=True) @@ -495,7 +497,7 @@ def write_snapshot(path, obj): def reserved_space_owner(fid): """(is_reserved, owner_vendor or None) for the frozen id spaces.""" if fid.startswith("GF"): - return True, "BBL" + return True, None # Bambu AMS/RFID catalog: frozen, no vendor (not even BBL) may declare it if fid.startswith("QD_"): return True, None # dissolved Qidi device-protocol space: NO vendor may declare it if USER_CUSTOM_ID_RE.match(fid) or fid == "null": @@ -507,6 +509,8 @@ def reserved_space_desc(fid, owner): """Human description of a reserved space for error messages.""" if owner: return f"owned by {owner}" + if fid.startswith("GF"): + return "Bambu AMS/RFID catalog; frozen, no preset may declare it" if fid.startswith("QD_"): return "Qidi device protocol; composed by the device, never a preset id" return "reserved for user-custom presets" @@ -516,30 +520,35 @@ def reserved_space_desc(fid, owner): # Checks (imported and called tree-wide by orca_extra_profile_check.py) # --------------------------------------------------------------------------- -def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): +def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH, + map_path=BAMBU_MAP_PATH): """Validate filament_id state across every vendor. Returns the error count. - 1. Format: every id occurring in the tree (declared or effective) must be in - the snapshot, or match ^OF[0-9A-Za-z]{6}$, or belong to vendor BBL. + 1. Format: every id occurring in the tree (declared or effective) must + match ^OF[0-9A-Za-z]{6}$. No exceptions: not the snapshot, not BBL. 2. Snapshot equality, both directions: the tree-derived id->families multimap AND the id->triples map of the declarers must equal the snapshot exactly (the snapshot diff is the maintainer gate). - 3. Mint conformance: a non-island OF-format declaration must equal the mint - of the declarer's triple or a salted iteration, unless that exact - (id, triple) pair is grandfathered in the snapshot. + 3. Mint conformance: an OF-format declaration must equal the mint of the + declarer's triple or a salted iteration, unless that exact (id, triple) + pair is grandfathered in the snapshot. 4. Alias hygiene: a vendor preset riding an OFL family must keep the OFL base name, claim printers via non-empty compatible_printers, and declare no filament_id key of its own. - 5. Reserved namespaces (GF* for BBL; QD_*/P-hex/"null" for nobody) must not - be claimed by other vendors, except claims grandfathered in the snapshot. + 5. Reserved namespaces (GF*/QD_*/P-hex/"null", all ownerless) must not be + claimed by any vendor, except claims grandfathered in the snapshot. 6. Structure ratchet: (a) no NEW instantiated preset carries its own filament_id key; (b) no NEW declared-vs-inherited id drift; (c) every instantiated filament resolves an effective id (a hard load error in C++). - 7. Triple integrity: (a) every non-island declarer resolves non-empty - filament_vendor and filament_type (hard error, no grandfathering); - (b) declarers of one (bundle, family) resolve identical triples, unless - grandfathered in snapshot triple_exceptions; cross-bundle divergence on - the same family name is a warning only. + 7. Triple integrity: (a) every declarer resolves non-empty filament_vendor + and filament_type (hard error, no grandfathering); (b) declarers of one + (bundle, family) resolve identical triples, unless grandfathered in + snapshot triple_exceptions; cross-bundle divergence on the same family + name is a warning only. + 8. Bambu catalog map: resources/printers/bambu_filament_ids.json must parse, + carry source/bambustudio_commit/generated, key only OF-format ids, map + each Bambu id at most once, and for every row whose key the tree claims, + the tree's triple for that id must equal the row's (vendor, type, name). """ _utf8_console() errors = 0 @@ -558,14 +567,12 @@ def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): # -- 1. format ---------------------------------------------------------- for vendor in sorted(analysis["vendor_ids"]): for fid in sorted(analysis["vendor_ids"][vendor]): - if fid in snap_ids or OF_ID_RE.match(fid): - continue - if vendor == "BBL": + if OF_ID_RE.match(fid): continue print_error( - f'filament_id "{fid}" ({vendor}) is neither grandfathered in the ' - f'snapshot nor a minted "OF" id; new family ids must come from ' - f'"python scripts/assign_filament_ids.py" (see --mint)') + f'filament_id "{fid}" ({vendor}) is not a minted "OF" id; new ' + f'family ids must come from "python scripts/assign_filament_ids.py" ' + f'(see --mint)') errors += 1 # -- 2. snapshot equality (both directions) ----------------------------- @@ -719,6 +726,43 @@ def check_filament_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): f"({detail}); bundles of one product converge on one id only once " f"their triples agree") + # -- 8. Bambu catalog map -------------------------------------------------- + try: + bambu_map = load_json(map_path) + except (OSError, ValueError) as e: + print_error(f"Bambu catalog map {map_path} does not parse ({e}); {BAMBU_MAP_HINT}") + errors += 1 + else: + for key in ("source", "bambustudio_commit", "generated"): + if not bambu_map.get(key): + print_error(f'Bambu catalog map {map_path} is missing "{key}"; {BAMBU_MAP_HINT}') + errors += 1 + rows = bambu_map.get("filaments", {}) + bambu_id_owners = {} + for fid, row in sorted(rows.items()): + if not OF_ID_RE.match(fid): + print_error(f'Bambu catalog map key "{fid}" is not a minted "OF" id; ' + f"{BAMBU_MAP_HINT}") + errors += 1 + bambu_id = row.get("bambu_id") + if bambu_id in bambu_id_owners: + print_error( + f'Bambu catalog map: Bambu id "{bambu_id}" is mapped by both ' + f'"{bambu_id_owners[bambu_id]}" and "{fid}"; {BAMBU_MAP_HINT}') + errors += 1 + else: + bambu_id_owners[bambu_id] = fid + claimed = tree_triples.get(fid) + if not claimed: + continue # a product BambuStudio ships that the tree does not (yet) + row_triple = [row.get("vendor", ""), row.get("type", ""), row.get("name", "")] + if row_triple not in claimed: + print_error( + f'Bambu catalog map row "{fid}" claims triple "{"/".join(row_triple)}" ' + f'but the tree declares "{"; ".join("/".join(t) for t in claimed)}" for ' + f"that id; {BAMBU_MAP_HINT}") + errors += 1 + return errors @@ -897,13 +941,25 @@ def remove_filament_id(path, old_id): # --------------------------------------------------------------------------- def assign_missing_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): - """Mint + insert ids for id-less families. Never rewrites a valid existing id. + """Mint + insert ids for id-less families; mint + replace non-OF-format + declarations. Never rewrites a valid (OF-format) existing id. A family = (vendor, base name) group over instantiated filaments with no effective id. The minted id is a pure function of the family's triple — (filament_vendor, filament_type) resolved on the root(s), family name — and is inserted into the family's root(s): the presets its members inherit that carry no id, or the member itself when it has no vendor-side parent. + + A declaration whose value is not OF-format (e.g. a vendor bundle synced + from an upstream source that ships its own catalog ids, such as BBL's GF*) + is treated the same as a missing family: a fresh id is minted for the + declarer's triple and the value is replaced in place (declarers that share + one triple across several per-printer roots converge on the same id, same + as the multi-root families above). This is what makes a future BBL sync + self-healing: upstream files arrive with GF ids, this pass replaces them, + and the generated Bambu catalog map (keyed by the ids this mints) already + knows the resulting rows. + Returns (files_changed, errors). """ analysis = analyze_tree(profiles_dir) @@ -923,9 +979,17 @@ def assign_missing_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): continue families.setdefault((vendor, base_name(name)), []).append(rec) - if not families: - print_success("every instantiated filament already resolves a filament_id; " - "nothing to do (0 files changed)") + # Declarations whose value is not OF-format: treated as missing too (see + # docstring). Grouped by triple, not by (vendor, family), so declarers + # that legitimately share one triple across several files converge on one + # freshly minted id instead of each getting their own. + non_of_declarers = [ + (vendor, rec, fid, triple) for vendor, rec, fid, triple in analysis["declarer_triples"] + if not OF_ID_RE.match(fid)] + + if not families and not non_of_declarers: + print_success("every instantiated filament already resolves an OF-format " + "filament_id; nothing to do (0 files changed)") return 0, errors # Ids already spoken for: the whole tree (declared or effective) + snapshot. @@ -998,7 +1062,34 @@ def assign_missing_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): files_changed += 1 print_info(f'family "{vendor}/{family}": filament_id "{new_id}" -> {root["file"]}') + # Non-OF-format declarations: mint once per triple, rewrite every declarer + # that shares it (see docstring). + declarations_reminted = 0 + assigned = {} # triple -> id chosen this run + for vendor, rec, fid, triple in sorted(non_of_declarers, key=lambda x: (x[0], x[1]["file"])): + fvendor, ftype, family = triple + if not fvendor or not ftype: + missing = " and ".join( + k for k, v in (("filament_vendor", fvendor), + ("filament_type", ftype)) if not v) + print_error( + f'cannot re-mint "{rec["file"]}" (non-OF filament_id "{fid}"): resolves ' + f'empty {missing}; the mint key needs both (generic materials use ' + f'filament_vendor "Generic")') + errors += 1 + continue + if triple not in assigned: + assigned[triple] = mint_filament_id(fvendor, ftype, family, taken) + taken.add(assigned[triple]) + new_id = assigned[triple] + rewrite_filament_id(rec["path"], fid, new_id) + files_changed += 1 + declarations_reminted += 1 + print_info(f'family "{vendor}/{family}": non-OF filament_id "{fid}" -> "{new_id}" ' + f'({rec["file"]})') + print_info(f"families minted : {families_minted}") + print_info(f"non-OF reminted : {declarations_reminted}") print_info(f"files changed : {files_changed}") if files_changed: print_warning(f"now {UPDATE_HINT}") @@ -1010,9 +1101,12 @@ def assign_missing_ids(profiles_dir=PROFILES_DIR, snapshot_path=SNAPSHOT_PATH): # --------------------------------------------------------------------------- def remint_vendors(vendor_list, profiles_dir=PROFILES_DIR): - """Re-derive every non-island declared id in the given vendors from its - triple; rewrite mismatching declarations in place (byte-preserving). Never - touches the snapshot — run --update-snapshot afterwards and review the diff. + """Re-derive every declared id in the given vendors from its triple; + rewrite mismatching declarations in place (byte-preserving). Never touches + the snapshot — run --update-snapshot afterwards and review the diff. + Accepts BBL like any other vendor: the GF* catalog is reserved and + ownerless, so BBL's own declarations mint OF ids the same as everyone + else's. A declaration already equal to ANY salt iteration of its own triple is mint-conformant (check 3) and left alone — deliberate salt splits (distinct @@ -1028,9 +1122,6 @@ def remint_vendors(vendor_list, profiles_dir=PROFILES_DIR): for msg in analysis["read_errors"]: print_error(msg) errors += 1 - if "BBL" in vendor_list: - print_error("--remint BBL is forbidden (the GF* catalog is frozen)") - return 0, errors + 1 unknown = sorted(set(vendor_list) - set(analysis["vendors"])) if unknown: for v in unknown: @@ -1067,8 +1158,6 @@ def remint_vendors(vendor_list, profiles_dir=PROFILES_DIR): key=lambda r: r["file"]) for rec in recs: fid = rec["filament_id"] - if is_island_declaration(vendor, fid): - continue scanned += 1 # Any salt iteration of the declarer's own triple is already # mint-conformant (check 3) — leave it. This keeps deliberate salt @@ -1117,7 +1206,7 @@ def drop_redundant_ids(vendor, profiles_dir=PROFILES_DIR): dropped = 0 for rec in sorted(vendor_map.values(), key=lambda r: r["file"]): fid = rec.get("filament_id") - if not fid or is_island_declaration(vendor, fid) or not rec.get("inherits"): + if not fid or not rec.get("inherits"): continue resolved, _src, entry = resolve_filament_id( rec["name"], vendor_map, ofl_map, skip_own=True) diff --git a/scripts/orca_extra_profile_check.py b/scripts/orca_extra_profile_check.py index 0bca29a985..62bd8c2916 100644 --- a/scripts/orca_extra_profile_check.py +++ b/scripts/orca_extra_profile_check.py @@ -290,19 +290,14 @@ def check_name_consistency(profiles_dir, vendor_name): return error_count, 0 -def check_filament_id(vendor, vendor_folder): +def check_filament_id(vendor_folder): """ - Make sure filament_id is not longer than 8 characters, otherwise AMS won't work properly + Make sure filament_id is not longer than 8 characters, otherwise AMS won't work properly. - NOTE: superseded by check_filament_ids (assign_filament_ids.py) for non-BBL/OFL - vendors, which validates format/uniqueness/structure tree-wide against the - grandfather snapshot. This length check stays scoped to BBL/OFL because other - vendors ship grandfathered >8-char ids (e.g. Prusa's 36-char name-ids), so it - cannot simply go tree-wide. + Runs tree-wide, every vendor alike: check_filament_ids (assign_filament_ids.py) + already requires every id in the tree to match the fixed-length "OF" format, + so this is a redundant belt-and-suspenders check, not a substitute for it. """ - if vendor not in ('BBL', 'OrcaFilamentLibrary'): - return 0 - error = 0 vendor_path = Path(vendor_folder) if not vendor_path.exists(): @@ -602,7 +597,7 @@ def main(): errors_found += check_vector_type_keys(profiles_dir, vendor_name) - errors_found += check_filament_id(vendor_name, vendor_path / "filament") + errors_found += check_filament_id(vendor_path / "filament") checked_vendor_count += 1 if args.vendor: diff --git a/scripts/tests/test_filament_id.py b/scripts/tests/test_filament_id.py index 2ff0e42a7c..3d668d4fc3 100644 --- a/scripts/tests/test_filament_id.py +++ b/scripts/tests/test_filament_id.py @@ -118,10 +118,11 @@ class SyntheticTree: allow_shared_catalog=allow_shared_catalog) return rc, buf.getvalue() - def check(self): + def check(self, map_path=None): buf = io.StringIO() + kwargs = {} if map_path is None else {"map_path": map_path} with contextlib.redirect_stdout(buf): - errors = afi.check_filament_ids(self.profiles, self.snapshot) + errors = afi.check_filament_ids(self.profiles, self.snapshot, **kwargs) return errors, buf.getvalue() def assign(self): @@ -142,17 +143,24 @@ class SyntheticTree: dropped, errors = afi.drop_redundant_ids(vendor, self.profiles) return dropped, errors, buf.getvalue() -def make_clean_tree(): - """Baseline tree: OFL base+generic, a vendor family, a clean tuned generic.""" +def make_clean_tree(apla_id="AX01", generic_id="OGFL99"): + """Baseline tree: OFL base+generic, a vendor family, a clean tuned generic. + + apla_id/generic_id default to arbitrary non-OF placeholders (grandfathered + into the snapshot below) since most tests only need "already assigned, + don't touch". TestAssign passes real OF-format ids instead: assign_missing_ids + now treats a non-OF declaration as missing and remints it, so a non-OF + baseline would no longer be a no-op there. + """ t = SyntheticTree() t.add_vendor(OFL, [ - preset("fdm_pla", filament_id="OGFL99", instantiation=False, + preset("fdm_pla", filament_id=generic_id, instantiation=False, filament_vendor="Generic", filament_type="PLA"), preset("Generic PLA @System", inherits="fdm_pla", compatible_printers=[]), ]) t.add_vendor("VendorA", [ - preset("APLA @base", filament_id="AX01", instantiation=False, + preset("APLA @base", filament_id=apla_id, instantiation=False, filament_vendor="AVendor", filament_type="PLA"), preset("APLA @P1", inherits="APLA @base", compatible_printers=["P1 0.4 nozzle"]), @@ -171,6 +179,21 @@ class SyntheticTreeCase(unittest.TestCase): self.addCleanup(self.t.cleanup) +class OfCleanTreeCase(unittest.TestCase): + """Like SyntheticTreeCase, but the baseline family/generic already carry + real OF-format ids (check 1 now rejects "AX01"/"OGFL99" unconditionally, + with no snapshot exemption), so an otherwise-untouched tree still passes + check_filament_ids. Tests that specifically need a non-OF baseline to + remint or drop (TestRemint, TestDropRedundantIds, TestUpdateSnapshot) keep + using SyntheticTreeCase instead. + """ + def setUp(self): + self.t = make_clean_tree( + apla_id=afi.generate_filament_id("AVendor", "PLA", "APLA"), + generic_id=afi.generate_filament_id("Generic", "PLA", "fdm_pla")) + self.addCleanup(self.t.cleanup) + + # --------------------------------------------------------------------------- # mint # --------------------------------------------------------------------------- @@ -376,12 +399,13 @@ class TestTripleResolution(unittest.TestCase): # --------------------------------------------------------------------------- -# reserved namespaces / islands +# reserved namespaces # --------------------------------------------------------------------------- class TestReservedSpaces(unittest.TestCase): def test_owners(self): - self.assertEqual(afi.reserved_space_owner("GFL99"), (True, "BBL")) + # Bambu AMS/RFID catalog: reserved, but no vendor (not even BBL) may declare it + self.assertEqual(afi.reserved_space_owner("GFL99"), (True, None)) # Qidi device protocol: reserved, but no vendor may declare it self.assertEqual(afi.reserved_space_owner("QD_X4_PLA"), (True, None)) self.assertEqual(afi.reserved_space_owner("P1234abc"), (True, None)) @@ -390,20 +414,15 @@ class TestReservedSpaces(unittest.TestCase): self.assertEqual(afi.reserved_space_owner("OF5CgdDq"), (False, None)) self.assertEqual(afi.reserved_space_owner("P1234abcd"), (False, None)) # 8 hex chars: not the user space - def test_island_declarations(self): - self.assertTrue(afi.is_island_declaration("BBL", "GFL99")) - self.assertTrue(afi.is_island_declaration("BBL", "OF5CgdDq")) - # Qidi presets mint like any other vendor's: - self.assertFalse(afi.is_island_declaration("Qidi", "QD_X4_PLA")) - self.assertFalse(afi.is_island_declaration("Qidi", "OF5CgdDq")) - self.assertFalse(afi.is_island_declaration("VendorA", "GFL99")) + def test_gf_is_reserved_and_ownerless(self): + self.assertEqual(afi.reserved_space_owner("GFA00"), (True, None)) # --------------------------------------------------------------------------- # checks on synthetic trees # --------------------------------------------------------------------------- -class TestChecks(SyntheticTreeCase): +class TestChecks(OfCleanTreeCase): def test_clean_tree_is_silent(self): errors, out = self.t.check() self.assertEqual(errors, 0, out) @@ -418,7 +437,7 @@ class TestChecks(SyntheticTreeCase): compatible_printers=["P1"])) errors, out = self.t.check() self.assertGreater(errors, 0) - self.assertIn("neither grandfathered in the snapshot", out) + self.assertIn('is not a minted "OF" id', out) self.assertIn("BOGUS_9", out) def test_check2_new_claim_needs_snapshot_update(self): @@ -437,7 +456,8 @@ class TestChecks(SyntheticTreeCase): self.assertIn('"VendorA/APLA"', out) def test_check2_triple_change_needs_snapshot_update(self): - self.t.write_preset("VendorA", preset("APLA @base", filament_id="AX01", + apla_id = afi.generate_filament_id("AVendor", "PLA", "APLA") + self.t.write_preset("VendorA", preset("APLA @base", filament_id=apla_id, instantiation=False, filament_vendor="AVendor", filament_type="PETG"), @@ -542,7 +562,7 @@ class TestChecks(SyntheticTreeCase): self.assertEqual(errors, 0, out) def test_check5_reserved_namespace_claims(self): - for fid, marker in [("GFX99", "owned by BBL"), + for fid, marker in [("GFX99", "Bambu AMS/RFID catalog"), ("QD_X_PLA", "composed by the device"), ("P1a2b3c4", "user-custom"), ("null", "user-custom")]: @@ -560,7 +580,10 @@ class TestChecks(SyntheticTreeCase): self.assertIn(marker, out) def test_check6a_instantiated_preset_with_own_key(self): - self.t.write_preset("VendorA", preset("APLA @P1", filament_id="AX01", + # Same value the child would already resolve through inherits: this + # isolates check 6a (own key present) from check 6b (value drift). + apla_id = afi.generate_filament_id("AVendor", "PLA", "APLA") + self.t.write_preset("VendorA", preset("APLA @P1", filament_id=apla_id, inherits="APLA @base", compatible_printers=["P1 0.4 nozzle"]), register=False) @@ -569,13 +592,16 @@ class TestChecks(SyntheticTreeCase): self.assertIn("declares its own filament_id key", out) def test_check6b_declared_vs_inherited_drift(self): - self.t.write_preset("VendorA", preset("APLA @P1", filament_id="AX02", + apla_id = afi.generate_filament_id("AVendor", "PLA", "APLA") + drifted = afi.generate_filament_id("AVendor", "PLA", "APLA", salt=1) + self.t.write_preset("VendorA", preset("APLA @P1", filament_id=drifted, inherits="APLA @base", compatible_printers=["P1 0.4 nozzle"]), register=False) errors, out = self.t.check() self.assertGreater(errors, 0) - self.assertIn('declares filament_id "AX02" but its inherits chain resolves "AX01"', out) + self.assertIn(f'declares filament_id "{drifted}" but its inherits chain resolves ' + f'"{apla_id}"', out) def test_check6c_unresolvable_instantiated_filament(self): self.t.write_preset("VendorA", preset("DNEW @P1", compatible_printers=["P1"])) @@ -591,7 +617,7 @@ class TestChecks(SyntheticTreeCase): self.assertIn("snapshot not found", out) -class TestCheck7(SyntheticTreeCase): +class TestCheck7(OfCleanTreeCase): def test_7a_empty_vendor_is_hard_error(self): fid = afi.generate_filament_id("", "PLA", "NVPLA") self.t.write_preset("VendorA", preset("NVPLA @base", filament_id=fid, @@ -650,6 +676,43 @@ class TestCheck7(SyntheticTreeCase): self.assertIn('"APLA"', out) +class TestCheck8(OfCleanTreeCase): + def _write_map(self, rows): + path = os.path.join(self.t.dir, "bambu_filament_ids.json") + ubfi.write_map(path, rows, "testcommit", "2026-09-04") + return path + + def test_row_triple_must_match_tree(self): + fid = afi.generate_filament_id("V", "PLA", "Foo") + self.t.write_preset("VendorA", preset("Foo @base", filament_id=fid, + instantiation=False, + filament_vendor="V", filament_type="PLA")) + self.t.write_preset("VendorA", preset("Foo @P1", inherits="Foo @base", + compatible_printers=["P1"])) + map_path = self._write_map( + {fid: {"bambu_id": "GFZ00", "vendor": "V", "type": "PLA", "name": "Bar"}}) + errors, out = self.t.check(map_path) + self.assertGreater(errors, 0) + self.assertIn("regenerate the map", out) + + def test_duplicate_bambu_id_is_an_error(self): + map_path = self._write_map({ + "OFaaaaaa": {"bambu_id": "GFZ00", "vendor": "V", "type": "PLA", "name": "Foo"}, + "OFbbbbbb": {"bambu_id": "GFZ00", "vendor": "V", "type": "PETG", "name": "Bar"}, + }) + errors, out = self.t.check(map_path) + self.assertGreater(errors, 0) + self.assertIn("GFZ00", out) + + def test_row_for_unshipped_product_is_fine(self): + map_path = self._write_map({ + "OFcccccc": {"bambu_id": "GFZ99", "vendor": "Nobody", "type": "PLA", + "name": "Ships Nothing"}, + }) + errors, out = self.t.check(map_path) + self.assertEqual(errors, 0, out) + + # --------------------------------------------------------------------------- # --update-snapshot # --------------------------------------------------------------------------- @@ -698,7 +761,7 @@ class TestUpdateSnapshot(SyntheticTreeCase): # default run: mint + insert # --------------------------------------------------------------------------- -class TestAssign(SyntheticTreeCase): +class TestAssign(OfCleanTreeCase): def test_noop_on_fully_idded_tree(self): changed, errors, out = self.t.assign() self.assertEqual((changed, errors), (0, 0)) @@ -771,6 +834,56 @@ class TestAssign(SyntheticTreeCase): self.assertGreater(errors, 0) self.assertIn("shared with famil", out) + def test_non_of_declaration_is_treated_as_missing(self): + # A declaration that isn't OF-format (e.g. a vendor bundle synced from + # an upstream catalog, like BBL's GF ids) is reminted like a missing + # family, not left alone. + self.t.write_preset("VendorA", preset("Synced PLA @base", filament_id="GFZZ00", + instantiation=False, + filament_vendor="ZV", filament_type="PLA")) + self.t.write_preset("VendorA", preset("Synced PLA @P1", + inherits="Synced PLA @base", + compatible_printers=["P1"])) + changed, errors, out = self.t.assign() + self.assertEqual(errors, 0, out) + self.assertEqual(changed, 1) + path = self.t.preset_path("VendorA", "Synced PLA @base") + root = load_json_file(path) + want = afi.generate_filament_id("ZV", "PLA", "Synced PLA") + self.assertEqual(root["filament_id"], want) + # the value was replaced in place, not appended as a second key + raw = open(path, encoding="utf-8").read() + self.assertEqual(raw.count('"filament_id"'), 1) + # idempotent: the id is OF-format now, so a second run is a no-op + changed, errors, _out = self.t.assign() + self.assertEqual((changed, errors), (0, 0)) + + def test_non_of_multi_root_family_converges_on_one_id(self): + # Two per-printer roots of one product (same triple), both carrying + # the SAME non-OF id — the shape a synced vendor bundle ships (e.g. + # BambuStudio's own per-printer @base files). They must converge on + # one freshly minted id, not split into two. + self.t.write_preset("VendorA", preset("Synced ABS @P1base", filament_id="GFSYNC0", + instantiation=False, + filament_vendor="ZV", filament_type="ABS")) + self.t.write_preset("VendorA", preset("Synced ABS @P2base", filament_id="GFSYNC0", + instantiation=False, + filament_vendor="ZV", filament_type="ABS")) + self.t.write_preset("VendorA", preset("Synced ABS @P1", + inherits="Synced ABS @P1base", + compatible_printers=["P1"])) + self.t.write_preset("VendorA", preset("Synced ABS @P2", + inherits="Synced ABS @P2base", + compatible_printers=["P2"])) + changed, errors, out = self.t.assign() + self.assertEqual(errors, 0, out) + self.assertEqual(changed, 2) + want = afi.generate_filament_id("ZV", "ABS", "Synced ABS") + b1 = load_json_file(self.t.preset_path("VendorA", "Synced ABS @P1base")) + b2 = load_json_file(self.t.preset_path("VendorA", "Synced ABS @P2base")) + self.assertEqual(b1["filament_id"], want) + self.assertEqual(b2["filament_id"], want) + # --------------------------------------------------------------------------- # byte-preserving profile edits @@ -962,11 +1075,19 @@ class TestRemint(SyntheticTreeCase): self.assertEqual(root["filament_id"], afi.generate_filament_id(*self.TRIPLE, salt=1)) - def test_bbl_is_forbidden(self): + def test_bbl_is_reminted_like_any_vendor(self): + self.t.add_vendor("BBL", [ + preset("Bambu ABS @base", filament_id="GFB00", instantiation=False, + filament_vendor="Bambu Lab", filament_type="ABS"), + preset("Bambu ABS @P1", inherits="Bambu ABS @base", + compatible_printers=["P1"]), + ]) changed, errors, out = self.t.remint(["BBL"]) - self.assertEqual(changed, 0) - self.assertGreater(errors, 0) - self.assertIn("forbidden", out) + self.assertEqual(errors, 0, out) + self.assertEqual(changed, 1) + root = load_json_file(self.t.preset_path("BBL", "Bambu ABS @base")) + self.assertEqual(root["filament_id"], + afi.generate_filament_id("Bambu Lab", "ABS", "Bambu ABS")) # --------------------------------------------------------------------------- @@ -1044,7 +1165,7 @@ class TestRealTree(unittest.TestCase): # review-fix regressions # --------------------------------------------------------------------------- -class TestReviewFixes(SyntheticTreeCase): +class TestReviewFixes(OfCleanTreeCase): def test_check3_skips_of_id_inherited_from_other_vendor(self): # An OFL family carries its own minted OF id and a vendor tunes it # correctly (same base name, non-empty printers). The new claim must