unify id generation scripts

This commit is contained in:
SoftFever
2026-09-07 10:46:59 +08:00
parent 06e665fca7
commit 3500a1e588
12 changed files with 2230 additions and 763 deletions

View File

@@ -20,7 +20,7 @@
> Please describe the tests that you have conducted to verify the changes made in this PR. > Please describe the tests that you have conducted to verify the changes made in this PR.
--> -->
- [ ] New filament/material profiles: I wrote **no** `filament_id` key by hand — I ran `python scripts/assign_filament_ids.py` and `--update-snapshot` and committed both diffs (see `doc/developer-reference/filament_id.md`) - [ ] New filament/material profiles: I wrote **no** `filament_id` key by hand — I ran `python scripts/orca_id_tool.py --generate` and `--update-snapshot` and committed both diffs (see [docs/HLSD/filament_id.md](docs/HLSD/filament_id.md))
<!-- <!--
> A guide for users on how to download the artifacts from this PR. > A guide for users on how to download the artifacts from this PR.

View File

@@ -9,7 +9,7 @@ OrcaFilamentLibrary (OFL), Qidi, or Snapmaker bundle. The granularity is the nam
not the brand behind it: `AAA PLA Lite` and `AAA PLA Pro` are two filaments with two ids, not not the brand behind it: `AAA PLA Lite` and `AAA PLA Pro` are two filaments with two ids, not
variants of one. variants of one.
**How it is generated:** an id is computed, never invented. `scripts/assign_filament_ids.py` **How it is generated:** an id is computed, never invented. `scripts/orca_id_tool.py`
mints it as a deterministic hash of the product's identity — the triple mints it as a deterministic hash of the product's identity — the triple
`(filament_vendor, filament_type, filament name)`, where the filament name is the preset name `(filament_vendor, filament_type, filament name)`, where the filament name is the preset name
with its `@...` variant suffix stripped — producing an 8-character `OF*` code that is the with its `@...` variant suffix stripped — producing an 8-character `OF*` code that is the
@@ -34,7 +34,7 @@ This page is the rule for authoring `filament_id` in system profiles
> [!IMPORTANT] > [!IMPORTANT]
> **Never write a `filament_id` value by hand.** A new filament gets its id from > **Never write a `filament_id` value by hand.** A new filament gets its id from
> `python scripts/assign_filament_ids.py`; one already in the tree has one — inherit it. > `python scripts/orca_id_tool.py --generate`; one already in the tree has one — inherit it.
## The design, in two pieces ## The design, in two pieces
@@ -158,7 +158,8 @@ key needed). Tuning a generic material → **join the OrcaFilamentLibrary filame
different product by rule 5, so it then needs its own id. different product by rule 5, so it then needs its own id.
5. **Ids follow the product identity.** The id is a pure function of the product triple 5. **Ids follow the product identity.** The id is a pure function of the product triple
`(filament_vendor, filament_type, filament name)`, so correcting any of them re-mints the id `(filament_vendor, filament_type, filament name)`, so correcting any of them re-mints the id
**by design** (via `--remint <Vendor>`; the exact sequence is in the FAQ). Nothing forwards **by design**, applied by `--generate` (preview with `--dry-run`, confine with `--vendor`) and
gated by the `--update-snapshot` diff; the exact sequence is in the FAQ. Nothing forwards
the old value, so anything outside the tree that stored it — a device tray, a calibration the old value, so anything outside the tree that stored it — a device tray, a calibration
record, a saved project — falls back to matching by filament type until the user re-selects record, a saved project — falls back to matching by filament type until the user re-selects
the filament. Re-mint deliberately, and only to fix a genuinely wrong identity. the filament. Re-mint deliberately, and only to fix a genuinely wrong identity.
@@ -167,7 +168,7 @@ key needed). Tuning a generic material → **join the OrcaFilamentLibrary filame
## Minting — nobody invents ids ## Minting — nobody invents ids
New ids are deterministic, computed exactly like the `setting_id` precedent New ids are deterministic, computed exactly like the `setting_id` precedent
(`scripts/assign_vendor_setting_ids.py`): (the `setting_id` half of `scripts/orca_id_tool.py`):
```text ```text
FILAMENT_ID_NAMESPACE = uuid5(setting-id NAMESPACE, "filament_id") FILAMENT_ID_NAMESPACE = uuid5(setting-id NAMESPACE, "filament_id")
@@ -204,36 +205,49 @@ Workflow for a new filament:
```bash ```bash
# 1. Author the filament with NO filament_id key anywhere. # 1. Author the filament with NO filament_id key anywhere.
python scripts/assign_filament_ids.py # 2. mint + insert ids into the root preset(s) python scripts/orca_id_tool.py --dry-run # 2. preview the ids — writes nothing
python scripts/assign_filament_ids.py --update-snapshot # 3. record the new claims in the snapshot python scripts/orca_id_tool.py --generate # 3. apply them to the profile file(s)
python scripts/assign_filament_ids.py --check # 4. verify — the same checks CI runs python scripts/orca_id_tool.py --update-snapshot # 4. record the new claims in the snapshot
# 5. Commit the profile edits together with scripts/filament_id_snapshot.json. python scripts/orca_id_tool.py --check # 5. validate the filament_id state
python scripts/orca_extra_profile_check.py # 6. ...and everything else CI checks
# 7. Commit the profile edits together with scripts/filament_id_snapshot.json, for review.
``` ```
The default run mints ids for id-less filaments and replaces any declaration that is not in `--generate` makes every filament's id equal the mint of its own
`OF` format; it never rewrites a valid `OF` id, so it is idempotent and a no-op once every `(filament_vendor, filament_type, filament name)` triple: it inserts one where an instantiated
filament has one. It edits profile files byte-preservingly (indentation, BOM, and line endings filament resolves none, and re-derives one that does not match. A preset that *inherits* a
intact) and re-parses them to fail loudly. mismatching id is the one case left to the author — check 3b names it, and the fix is to inherit
`--mint "filament_vendor/filament_type/filament_name"` prints the id a **new** mint of that a preset of the same filament or to give the preset its own key. It converges on an id the same
triple would get, without touching anything — note that for a triple whose id already exists product already holds and salts only past ids *other* products hold, so an id already equal to a
it prints the next *free* salt iteration, not the live id (asking for salt iteration of its own triple is left alone and deliberate salt splits survive. The same run
`Polymaker/PLA/PolyLite PLA` today prints the salt-1 id, because `OF5CgdDq` is taken). assigns `generate_preset_setting_id(vendor, type, name)` to every instantiated filament, process
and machine preset of every vendor except BBL, which keeps its authoritative `G*` ids, strips
`setting_id` from base profiles, and fixes the misspelled `settings_id` key — dropped, or, for
BBL, whose ids have no formula to fall back on, restored under the correct name. It is idempotent and
byte-preserving (indentation, BOM, and line endings intact, every edited file re-parsed to fail
loudly), and a no-op on a tree that already passes `scripts/orca_extra_profile_check.py` — the
check CI runs over both id kinds, of which `--check` is the `filament_id` half.
Maintenance modes (`--remint` is also the step for identity fixes — see the FAQ; the rest are - `--filament-id` limits the run to `filament_id`.
normally only used by id migrations): - `--setting-id` limits the run to `setting_id`. The two exclude each other; pass neither to
write both.
- `--remint VENDOR` re-derives a vendor's declared ids from their triples — any vendor, BBL - `--vendor VENDOR` confines the run to that bundle; repeatable. The ids are still derived
included, since no bundle is exempt from the mint rule. A declaration already equal to a tree-wide, so a narrowed run writes exactly what a full one would — and reports any duplicate
salt iteration of its own triple is conformant and left alone, so deliberate salt splits it was not allowed to clear, since only a run covering both bundles can.
survive; convergence onto an id another bundle already uses for the *same* triple is legal - `--dry-run` reports what `--generate` would do and writes nothing; with no mode of its own it
by design — that is the point. implies `--generate`, so `--dry-run --vendor <Vendor>` previews just that bundle.
- `--drop-redundant-ids VENDOR` deletes declarations that merely re-declare an inherited
OrcaFilamentLibrary id.
- `--profiles DIR` points the tooling at a different profile tree (default - `--profiles DIR` points the tooling at a different profile tree (default
`resources/profiles`). `resources/profiles`). `--check` and `--update-snapshot` read and write the sanctioned state of
the tree they are given, so pointing them elsewhere needs `--snapshot PATH` for that tree too —
`scripts/filament_id_snapshot.json` describes `resources/profiles` and no other tree.
**Identity fixes need no separate mode.** `--generate` re-derives an id that no longer matches its
triple exactly the way it fills in a missing one, so a rename or a `filament_vendor` /
`filament_type` correction is just: fix the config, run `--generate` (confine it with `--vendor`,
preview it with `--dry-run`), then `--update-snapshot` and review the diff.
If you skip the tooling, CI fails and prints the remedy: the expected id for your filament and If you skip the tooling, CI fails and prints the remedy: the expected id for your filament and
the instruction to run `python scripts/assign_filament_ids.py`; once the id is minted, the the instruction to run `python scripts/orca_id_tool.py --generate`; once the id is minted, the
snapshot checks likewise point at `--update-snapshot` and tell you to commit the resulting snapshot checks likewise point at `--update-snapshot` and tell you to commit the resulting
diff. diff.
@@ -466,18 +480,20 @@ ambiguity check behind structure rule 3.
`Generic PLA` base name, set `compatible_printers`; no id key needed. `Generic PLA` base name, set `compatible_printers`; no id key needed.
- **A branded filament that borrows a generic's settings?** Fine — inherit `Generic X @System` - **A branded filament that borrows a generic's settings?** Fine — inherit `Generic X @System`
(or any real filament) for the settings and declare the id of your own filament; run (or any real filament) for the settings and declare the id of your own filament; run
`python scripts/assign_filament_ids.py` to mint it. Inheritance never changes the id. `python scripts/orca_id_tool.py --generate` to mint it. Inheritance never changes the id.
- **I need to fix a filament's `filament_vendor` or `filament_type`.** Fix the config, run - **I need to fix a filament's `filament_vendor` or `filament_type`.** Fix the config, run
`--remint <Vendor>` then `--update-snapshot`, and commit the profile and snapshot diffs `--generate --vendor <Vendor>` (preview with `--dry-run`), then `--update-snapshot`, and commit
together. The id re-derives from the corrected identity, and nothing forwards the old the profile and snapshot diffs together. The id re-derives from the corrected identity, and
value, so a tray or record still holding it falls back to matching by filament type. nothing forwards the old value, so a tray or record still holding it falls back to matching by
filament type.
- **I need to rename a filament.** Rename the presets (adding `renamed_from`, which keeps the - **I need to rename a filament.** Rename the presets (adding `renamed_from`, which keeps the
preset *name* resolving), then `--remint <Vendor>`, then `--update-snapshot`. The id follows preset *name* resolving), then `--generate --vendor <Vendor>` (preview with `--dry-run`), then
the new filament name; as with any identity fix, the old id is not forwarded. `--update-snapshot`. The id follows the new filament name; as with any identity fix, the old id
is not forwarded.
- **Can I reuse a `QD_*` id for a Qidi profile?** No — nobody can. It is the device protocol's - **Can I reuse a `QD_*` id for a Qidi profile?** No — nobody can. It is the device protocol's
own id space: the box composes those values at runtime and no preset carries one. Author own id space: the box composes those values at runtime and no preset carries one. Author
Qidi filaments like any other vendor's. Qidi filaments like any other vendor's.
- **CI says my filament needs an id.** Run `python scripts/assign_filament_ids.py`, then - **CI says my filament needs an id.** Run `python scripts/orca_id_tool.py --generate`, then
`--update-snapshot`, and commit both diffs. Do not type an id by hand. `--update-snapshot`, and commit both diffs. Do not type an id by hand.
For general profile authoring, see the profile development guide on the For general profile authoring, see the profile development guide on the

View File

@@ -1,258 +0,0 @@
#!/usr/bin/env python3
"""
Assign deterministic, globally-unique setting_id to OrcaSlicer system profiles.
Policy (see AGENTS.md "Critical Constraints"):
* A preset's setting_id is a pure function of its identity:
setting_id = base62_16( uuid5(NAMESPACE, "<vendor>/<type>/<name>") )
The same value is recomputed on the fly by the C++ app
(Slic3r::generate_preset_setting_id); the two MUST stay byte-identical. The rule
(generate_preset_setting_id, below) is also imported by the validator
(orca_extra_profile_check.py). Uniqueness is therefore automatic: two presets
collide only if they share vendor + type + name, which the validator flags.
* Bambu (BBL) owns the authoritative "G*" id space and is the only reserved vendor:
its ids are never rewritten (preserves backward-compat with Bambu-synced presets).
Every other vendor - including OrcaFilamentLibrary and Custom - follows the
deterministic rule.
* Only instantiated presets (instantiation == "true") carry a setting_id; base /
template profiles do not.
Only setting_id is rewritten. filament_id is deliberately left untouched: it is a
per-material id, shared across a filament's nozzle variants and inherited from base
templates, so it must not be made per-file unique.
Run from anywhere: python3 scripts/assign_vendor_setting_ids.py
The script is idempotent: a second run over an unchanged tree produces no diff.
"""
import json
import os
import re
import sys
import uuid
# Deterministic preset setting_id rule. Imported by the validator
# (orca_extra_profile_check.py) and kept byte-identical to the C++
# Slic3r::generate_preset_setting_id. Dedicated namespace, distinct from the cloud
# namespace (f47ac10b-...) so the two id spaces never coincide; this constant is baked
# into both languages - never change it.
NAMESPACE = uuid.UUID("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f")
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
ID_LENGTH = 16
def generate_preset_setting_id(vendor, type_name, name):
"""Deterministic 16-char base62 setting_id for a preset.
input = f"{vendor}/{type_name}/{name}"; u = uuid5(NAMESPACE, input);
id = the low ID_LENGTH base62 digits of int(u.bytes, "big"), most-significant first.
"""
u = uuid.uuid5(NAMESPACE, f"{vendor}/{type_name}/{name}")
n = int.from_bytes(u.bytes, "big")
digits = []
for _ in range(ID_LENGTH):
digits.append(ALPHABET[n % 62])
n //= 62
return "".join(reversed(digits))
PROFILES_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "resources", "profiles"))
# Bambu (BBL) is the only reserved vendor: it keeps its authoritative "G*" cloud ids.
RESERVED_VENDORS = {"BBL"}
PROFILE_SUBDIRS = ("filament", "process", "machine")
def iter_profile_files(vendor_dir):
"""Yield (json path, type) under a vendor, in a deterministic order.
type is the subdir name ("filament"/"process"/"machine"), which matches
Preset::get_type_string() on the C++ side.
"""
for sub in PROFILE_SUBDIRS:
base = os.path.join(vendor_dir, sub)
if not os.path.isdir(base):
continue
for root, dirs, files in os.walk(base):
dirs.sort() # deterministic traversal across filesystems
for name in sorted(files):
if name.endswith(".json"):
yield os.path.join(root, name), sub
def read_profile(path):
"""Return (setting_id, instantiation, name) as present (or None)."""
try:
with open(path, "rb") as f:
data = json.loads(f.read())
except (ValueError, OSError):
return None, None, None
if not isinstance(data, dict):
return None, None, None
return data.get("setting_id"), data.get("instantiation"), data.get("name")
def list_vendors():
return sorted(
d for d in os.listdir(PROFILES_DIR)
if os.path.isdir(os.path.join(PROFILES_DIR, d))
)
_JSON_STR = r'"(?:[^"\\]|\\.)*"'
def remove_key_line(text, key):
"""Remove a top-level `"key": "..."` member, preserving formatting.
Handles both the common case (member has a trailing comma) and the member
being the LAST in its object (consume the preceding comma instead, so no
dangling comma is left). Returns (new_text, count).
"""
# Member followed by a comma (not the last in the object).
trailing = re.compile(
r'[ \t]*"' + re.escape(key) + r'"[ \t]*:[ \t]*' + _JSON_STR + r'[ \t]*,[ \t]*\r?\n'
)
new, n = trailing.subn("", text, count=1)
if n:
return new, n
# Member is the last one: drop the preceding comma and the member itself.
leading = re.compile(
r',[ \t]*\r?\n[ \t]*"' + re.escape(key) + r'"[ \t]*:[ \t]*' + _JSON_STR
)
return leading.subn("", text, count=1)
def _remove_key_in_tree(key, should_remove):
"""Remove `key` from files where should_remove(sid, inst, text) is True."""
removed = 0
for vendor in list_vendors():
for path, _type in iter_profile_files(os.path.join(PROFILES_DIR, vendor)):
with open(path, "rb") as f:
text = f.read().decode("utf-8")
sid, inst, _name = read_profile(path)
if not should_remove(sid, inst, text):
continue
new_text, n = remove_key_line(text, key)
if n == 0:
raise RuntimeError(f"Could not locate {key} line to remove: {path}")
json.loads(new_text) # fail loudly if removal broke the JSON
with open(path, "wb") as f:
f.write(new_text.encode("utf-8"))
removed += 1
return removed
def remove_misspelled_settings_id():
"""Delete the misspelled "settings_id" key (extra "s") wherever it appears.
The app never reads that key, so those presets effectively had no setting_id
and get a correct one assigned by the normal pass; here we drop the junk key.
"""
return _remove_key_in_tree(
"settings_id", lambda sid, inst, text: '"settings_id"' in text
)
def strip_base_setting_ids():
"""Remove setting_id from every base profile (instantiation != "true").
Convention: only instantiated, user-selectable presets carry a setting_id;
base/template profiles do not. Applied across all vendors.
"""
return _remove_key_in_tree(
"setting_id", lambda sid, inst, text: bool(sid) and inst != "true"
)
def replace_id_value(text, key, new_value):
"""Replace the first top-level `"key": "..."` value, preserving all formatting."""
pattern = re.compile(r'("' + re.escape(key) + r'"\s*:\s*)"(?:[^"\\]|\\.)*"')
repl = lambda m: m.group(1) + json.dumps(new_value, ensure_ascii=False)
new_text, n = pattern.subn(repl, text, count=1)
return new_text, n
def insert_setting_id(text, new_id):
"""Insert a `"setting_id"` line into a preset that lacks one.
Placed just before `filament_id` (or, failing that, `instantiation`) so it
matches the canonical key order, reusing that anchor line's indentation and
line ending. Only setting_id is added; filament_id is left untouched.
"""
for key in ("filament_id", "instantiation"):
m = re.search(r'^([ \t]*)"' + key + r'"[ \t]*:.*?(\r?\n)', text, re.MULTILINE)
if m:
line = f'{m.group(1)}"setting_id": {json.dumps(new_id, ensure_ascii=False)},{m.group(2)}'
return text[:m.start()] + line + text[m.start():], 1
return text, 0
def rewrite_file(path, new_id, has_setting_id):
"""Set the preset's setting_id to new_id (replacing or inserting as needed).
filament_id is intentionally left untouched. Uses binary IO so the file's
original line endings (LF or CRLF) and exact formatting are preserved
byte-for-byte apart from the changed/added line. The result is re-parsed to
guarantee it is still valid JSON.
"""
with open(path, "rb") as f:
text = f.read().decode("utf-8")
if has_setting_id:
text, n = replace_id_value(text, "setting_id", new_id)
else:
text, n = insert_setting_id(text, new_id)
if n == 0:
raise RuntimeError(f"Could not set setting_id on {path}")
json.loads(text) # fail loudly if the edit broke the JSON
with open(path, "wb") as f:
f.write(text.encode("utf-8"))
return True
def main():
# 0. Drop the misspelled "settings_id" key wherever it appears.
typos = remove_misspelled_settings_id()
# 1. Strip setting_id from base profiles everywhere (only instantiated presets keep one).
stripped = strip_base_setting_ids()
# 2. Assign the deterministic setting_id to every instantiated preset of every
# non-reserved vendor.
changed = added = 0
vendors_touched = []
for vendor in list_vendors():
if vendor in RESERVED_VENDORS:
continue
vendor_changed = 0
for path, type_name in iter_profile_files(os.path.join(PROFILES_DIR, vendor)):
sid, inst, name = read_profile(path)
if inst != "true":
continue
if not name:
raise RuntimeError(f"instantiated preset has no \"name\": {path}")
new_id = generate_preset_setting_id(vendor, type_name, name)
if sid == new_id:
continue # already correct - idempotent
rewrite_file(path, new_id, has_setting_id=sid is not None)
changed += 1
vendor_changed += 1
if sid is None:
added += 1
if vendor_changed:
vendors_touched.append((vendor, vendor_changed))
print(f"Misspelled settings_id removed : {typos}")
print(f"Base setting_ids stripped : {stripped}")
print(f"Reserved vendors : {sorted(RESERVED_VENDORS)}")
print(f"Vendors updated : {len(vendors_touched)}")
for v, n in vendors_touched:
print(f" {v} ({n} files)")
print(f"Files rewritten : {changed} (of which newly assigned: {added})")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -3,8 +3,7 @@ import json
import argparse import argparse
from pathlib import Path from pathlib import Path
from assign_vendor_setting_ids import generate_preset_setting_id from orca_id_tool import generate_preset_setting_id, check_filament_ids
from assign_filament_ids import check_filament_ids
OBSOLETE_KEYS = { OBSOLETE_KEYS = {
"acceleration", "scale", "rotate", "duplicate", "duplicate_grid", "acceleration", "scale", "rotate", "duplicate", "duplicate_grid",
@@ -488,10 +487,11 @@ PROFILE_SUBDIRS = ("filament", "process", "machine")
def check_setting_id_uniqueness(profiles_dir): def check_setting_id_uniqueness(profiles_dir):
""" """
Validate setting_id across every vendor (see scripts/assign_vendor_setting_ids.py): Validate setting_id across every vendor (see scripts/orca_id_tool.py):
1. Every instantiated preset must HAVE a setting_id. (all vendors) 1. Every instantiated preset must HAVE a setting_id. (all vendors)
2. A stored setting_id must equal generate_preset_setting_id(vendor, type, name); a stale 2. A stored setting_id must equal generate_preset_setting_id(vendor, type, name); a stale
value means the JSON was edited without rerunning assign_vendor_setting_ids.py. value means the JSON was edited without rerunning
"python scripts/orca_id_tool.py --generate --setting-id".
(all vendors EXCEPT the formula-exempt ones, e.g. BBL) (all vendors EXCEPT the formula-exempt ones, e.g. BBL)
3. Base profiles (instantiation != "true") must not carry a setting_id. (all vendors) 3. Base profiles (instantiation != "true") must not carry a setting_id. (all vendors)
4. setting_id must be globally unique - no two files may share one. (all vendors) 4. setting_id must be globally unique - no two files may share one. (all vendors)
@@ -523,7 +523,8 @@ def check_setting_id_uniqueness(profiles_dir):
errors += 1 errors += 1
print_error( print_error(
f'profile {rel} uses the misspelled key "settings_id" ' f'profile {rel} uses the misspelled key "settings_id" '
f'(should be "setting_id"); run assign_vendor_setting_ids.py' f'(should be "setting_id"); run '
f'"python scripts/orca_id_tool.py --generate --setting-id"'
) )
sid = data.get("setting_id") sid = data.get("setting_id")
instantiated = data.get("instantiation") == "true" instantiated = data.get("instantiation") == "true"
@@ -533,7 +534,8 @@ def check_setting_id_uniqueness(profiles_dir):
errors += 1 errors += 1
print_error( print_error(
f'base profile {rel} (instantiation != "true") must not have a ' f'base profile {rel} (instantiation != "true") must not have a '
f'setting_id ("{sid}"); run assign_vendor_setting_ids.py' f'setting_id ("{sid}"); run '
f'"python scripts/orca_id_tool.py --generate --setting-id"'
) )
continue continue
# Rule 1: every instantiated preset must have a setting_id. # Rule 1: every instantiated preset must have a setting_id.
@@ -541,7 +543,7 @@ def check_setting_id_uniqueness(profiles_dir):
errors += 1 errors += 1
print_error( print_error(
f"instantiated preset {rel} is missing a setting_id; " f"instantiated preset {rel} is missing a setting_id; "
f"run assign_vendor_setting_ids.py" f'run "python scripts/orca_id_tool.py --generate --setting-id"'
) )
continue continue
# Rule 2: the stored id must match the deterministic rule. BBL keeps its # Rule 2: the stored id must match the deterministic rule. BBL keeps its
@@ -553,7 +555,7 @@ def check_setting_id_uniqueness(profiles_dir):
print_error( print_error(
f'setting_id "{sid}" in {rel} does not match the expected ' f'setting_id "{sid}" in {rel} does not match the expected '
f'"{expected}" for {vendor}/{sub}/{data.get("name", "")}; ' f'"{expected}" for {vendor}/{sub}/{data.get("name", "")}; '
f"run assign_vendor_setting_ids.py" f'run "python scripts/orca_id_tool.py --generate --setting-id"'
) )
continue continue
# Rule 4: collect for the global-uniqueness check below. # Rule 4: collect for the global-uniqueness check below.
@@ -631,7 +633,7 @@ def main():
# Runs once over the whole tree regardless of the --vendor filter. # Runs once over the whole tree regardless of the --vendor filter.
errors_found += check_setting_id_uniqueness(profiles_dir) errors_found += check_setting_id_uniqueness(profiles_dir)
# Global filament_id check (see scripts/assign_filament_ids.py): effective ids # Global filament_id check (see scripts/orca_id_tool.py): effective ids
# are resolved loader-faithfully and validated against the sanctioned snapshot # are resolved loader-faithfully and validated against the sanctioned snapshot
# (scripts/filament_id_snapshot.json). Runs once over the whole tree. # (scripts/filament_id_snapshot.json). Runs once over the whole tree.
errors_found += check_filament_ids(profiles_dir) errors_found += check_filament_ids(profiles_dir)

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Tests for scripts/assign_filament_ids.py (stdlib unittest, no external deps). """Tests for scripts/orca_id_tool.py (stdlib unittest, no external deps).
Run from the repo root: python -m unittest discover -s scripts/tests -v Run from the repo root: python -m unittest discover -s scripts/tests -v
""" """
@@ -17,7 +17,7 @@ import uuid
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import assign_filament_ids as afi # noqa: E402 import orca_id_tool as afi # noqa: E402
import update_bambu_filament_ids as ubfi # noqa: E402 import update_bambu_filament_ids as ubfi # noqa: E402
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
@@ -99,6 +99,17 @@ class SyntheticTree:
if register: if register:
self.add_to_index(vendor, data["name"]) self.add_to_index(vendor, data["name"])
def set_sub_path(self, vendor, name, sub_path):
"""Rewrite one index entry's sub_path (the file itself does not move)."""
idx_path = os.path.join(self.profiles, vendor + ".json")
with open(idx_path, encoding="utf-8") as f:
index = json.load(f)
for entry in index["filament_list"]:
if entry["name"] == name:
entry["sub_path"] = sub_path
with open(idx_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=4, ensure_ascii=False)
def remove_preset(self, vendor, name): def remove_preset(self, vendor, name):
os.remove(self.preset_path(vendor, name)) os.remove(self.preset_path(vendor, name))
idx_path = os.path.join(self.profiles, vendor + ".json") idx_path = os.path.join(self.profiles, vendor + ".json")
@@ -109,12 +120,25 @@ class SyntheticTree:
with open(idx_path, "w", encoding="utf-8") as f: with open(idx_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=4, ensure_ascii=False) json.dump(index, f, indent=4, ensure_ascii=False)
def bytes_map(self):
"""{relative path -> file bytes} over every .json in the tree."""
raw = {}
for root, dirs, files in os.walk(self.profiles):
dirs.sort()
for name in sorted(files):
if not name.endswith(".json"):
continue
path = os.path.join(root, name)
with open(path, "rb") as f:
raw[os.path.relpath(path, self.profiles)] = f.read()
return raw
# -- pipeline wrappers --------------------------------------------------- # -- pipeline wrappers ---------------------------------------------------
def update_snapshot(self): def update_snapshot(self, dry_run=False):
buf = io.StringIO() buf = io.StringIO()
with contextlib.redirect_stdout(buf): with contextlib.redirect_stdout(buf):
rc = afi.update_snapshot(self.profiles, self.snapshot) rc = afi.update_snapshot(self.profiles, self.snapshot, dry_run)
return rc, buf.getvalue() return rc, buf.getvalue()
def check(self, map_path=None): def check(self, map_path=None):
@@ -124,23 +148,32 @@ class SyntheticTree:
errors = afi.check_filament_ids(self.profiles, self.snapshot, **kwargs) errors = afi.check_filament_ids(self.profiles, self.snapshot, **kwargs)
return errors, buf.getvalue() return errors, buf.getvalue()
def assign(self): # assign() and remint() are the same one pass over the tree — every filament
# ends up with the id its own triple mints, whether that means inserting a
# missing key or rewriting a non-conformant one. Both names are kept because
# they read differently at the call sites.
def assign(self, vendors=None, dry_run=False):
buf = io.StringIO() buf = io.StringIO()
with contextlib.redirect_stdout(buf): with contextlib.redirect_stdout(buf):
changed, errors = afi.assign_missing_ids(self.profiles, self.snapshot) changed, errors = afi.generate_filament_ids(
self.profiles, self.snapshot, vendors, dry_run)
return changed, errors, buf.getvalue() return changed, errors, buf.getvalue()
def remint(self, vendors): def remint(self, vendors, dry_run=False):
buf = io.StringIO() buf = io.StringIO()
with contextlib.redirect_stdout(buf): with contextlib.redirect_stdout(buf):
changed, errors = afi.remint_vendors(vendors, self.profiles) changed, errors = afi.generate_filament_ids(
self.profiles, self.snapshot, vendors, dry_run)
return changed, errors, buf.getvalue() return changed, errors, buf.getvalue()
def drop_redundant(self, vendor): def cli(self, *flags):
"""Run main() against this tree, capturing stdout."""
buf = io.StringIO() buf = io.StringIO()
with contextlib.redirect_stdout(buf): with contextlib.redirect_stdout(buf):
dropped, errors = afi.drop_redundant_ids(vendor, self.profiles) rc = afi.main([*flags, "--profiles", self.profiles,
return dropped, errors, buf.getvalue() "--snapshot", self.snapshot])
return rc, buf.getvalue()
def make_clean_tree(apla_id="AX01", generic_id="OGFL99"): def make_clean_tree(apla_id="AX01", generic_id="OGFL99"):
"""Baseline tree: OFL base+generic, a vendor filament, a clean tuned generic. """Baseline tree: OFL base+generic, a vendor filament, a clean tuned generic.
@@ -182,8 +215,8 @@ class OfCleanTreeCase(unittest.TestCase):
real OF-format ids (check 1 now rejects "AX01"/"OGFL99" unconditionally, real OF-format ids (check 1 now rejects "AX01"/"OGFL99" unconditionally,
with no snapshot exemption), so an otherwise-untouched tree still passes with no snapshot exemption), so an otherwise-untouched tree still passes
check_filament_ids. Tests that specifically need a non-OF baseline to check_filament_ids. Tests that specifically need a non-OF baseline to
remint or drop (TestRemint, TestDropRedundantIds, TestUpdateSnapshot) keep remint (TestRemint, TestUpdateSnapshot) keep using SyntheticTreeCase
using SyntheticTreeCase instead. instead.
""" """
def setUp(self): def setUp(self):
self.t = make_clean_tree( self.t = make_clean_tree(
@@ -243,6 +276,81 @@ class TestMint(unittest.TestCase):
self.assertEqual(afi.mint_filament_id(*triple, set()), self.assertEqual(afi.mint_filament_id(*triple, set()),
afi.generate_filament_id(*triple)) afi.generate_filament_id(*triple))
def test_mint_never_returns_a_salt_the_identity_check_rejects(self):
# mint_filament_id stops at MAX_CHECK_SALT, the last iteration check 3
# accepts, so the tool can never write an id its own --check rejects.
triple = ("Polymaker", "PLA", "PolyLite PLA")
all_taken = afi.mint_iterations(triple)
self.assertEqual(len(all_taken), afi.MAX_CHECK_SALT + 1)
last = afi.mint_filament_id(*triple, all_taken - {afi.generate_filament_id(
*triple, salt=afi.MAX_CHECK_SALT)})
self.assertIn(last, afi.mint_iterations(triple))
with self.assertRaises(RuntimeError):
afi.mint_filament_id(*triple, all_taken)
def test_want_id_policy(self):
# The whole id policy: a triple's id is its first mint iteration that no
# OTHER product holds, in the tree or in the snapshot.
triple = ("Polymaker", "PLA", "PolyLite PLA")
other = ("Elegoo", "PETG", "Rapid PETG")
salt0 = afi.generate_filament_id(*triple)
salt1 = afi.generate_filament_id(*triple, salt=1)
def snapshot(**entries):
return {"ids": {fid: {"filaments": [], "name": t[2],
"filament_type": t[1], "filament_vendor": t[0]}
for fid, t in entries.items()}}
# nobody holds it: salt 0
self.assertEqual(afi.make_want_id({"triple_sets": {}})(triple), salt0)
# the tree holds it under this very triple: reuse is convergence
self.assertEqual(
afi.make_want_id({"triple_sets": {salt0: {triple}}})(triple), salt0)
# Another triple "holds" it, but salt0 is not a mint iteration of THAT
# triple, so the holder is a wrong declaration this very run rewrites.
# A transient squatter must not push a product off its own id.
self.assertEqual(
afi.make_want_id({"triple_sets": {salt0: {other}}})(triple), salt0)
self.assertEqual(
afi.make_want_id({"triple_sets": {salt0: {triple, other}}})(triple), salt0)
# The snapshot is read the same way: only a conformant record holds.
self.assertEqual(
afi.make_want_id({"triple_sets": {}}, snapshot(**{salt0: other}))(triple),
salt0)
# ... and the snapshot's own triple reuses it
self.assertEqual(
afi.make_want_id({"triple_sets": {}}, snapshot(**{salt0: triple}))(triple),
salt0)
# Unused here, but salting must still be reachable: see
# test_want_id_salts_past_a_genuine_collision.
self.assertNotEqual(salt0, salt1)
# memoized: one triple keeps one id for the whole run
want_id = afi.make_want_id({"triple_sets": {}})
self.assertEqual(want_id(triple), want_id(triple))
# ... and a second triple never gets the id this run already handed out
self.assertNotEqual(want_id(triple), want_id(other))
def test_want_id_salts_past_a_genuine_collision(self):
# Salting exists for one case only: two products whose triples mint the
# same id. That needs a base62 collision, so force one — salt 0 of every
# triple collapses to a single value while the salted iterations stay
# distinct. `other` then CONFORMANTLY holds the shared salt-0 id, and
# `triple` must step past it instead of stealing it.
triple = ("V", "PLA", "X")
other = ("W", "ABS", "Y")
real = afi.generate_filament_id
def colliding(vendor, ftype, name, salt=0):
return "OFcolid" if salt == 0 else real(vendor, ftype, name, salt)
afi.generate_filament_id = colliding
try:
self.assertIn("OFcolid", afi.mint_iterations(other)) # holder conforms
got = afi.make_want_id({"triple_sets": {"OFcolid": {other}}})(triple)
finally:
afi.generate_filament_id = real
self.assertEqual(got, real(*triple, salt=1))
class TestBaseName(unittest.TestCase): class TestBaseName(unittest.TestCase):
def test_filament_name_derivation(self): def test_filament_name_derivation(self):
@@ -258,6 +366,27 @@ class TestBaseName(unittest.TestCase):
self.assertEqual(afi.base_name(name), filament_name, msg=name) self.assertEqual(afi.base_name(name), filament_name, msg=name)
# ---------------------------------------------------------------------------
# vendor discovery
# ---------------------------------------------------------------------------
class TestVendorDiscovery(unittest.TestCase):
def test_a_bundle_is_a_subdir_with_a_matching_index(self):
# Neither half alone makes a bundle: resources/profiles tracks a "user"
# directory with no user.json, and an index without its directory is a
# leftover. Both are skipped, tree-wide.
t = SyntheticTree()
self.addCleanup(t.cleanup)
t.add_vendor("VendorA", [])
os.makedirs(os.path.join(t.profiles, "user", "filament"))
with open(os.path.join(t.profiles, "Orphan.json"), "w",
encoding="utf-8") as f:
json.dump({"name": "Orphan", "filament_list": []}, f)
self.assertEqual(afi.list_vendor_names(t.profiles), ["VendorA"])
self.assertEqual(sorted(afi.analyze_tree(t.profiles)["vendors"]),
["VendorA"])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# resolver (loader-faithful semantics) # resolver (loader-faithful semantics)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -323,12 +452,6 @@ class TestResolver(unittest.TestCase):
fid, src, _e = self.resolve("A", [self.rec("A")], []) fid, src, _e = self.resolve("A", [self.rec("A")], [])
self.assertEqual((fid, src), (None, "missing")) self.assertEqual((fid, src), (None, "missing"))
def test_skip_own_resolves_inherited(self):
fid, _src, _e = self.resolve(
"A", [self.rec("A", "OWN", inherits="B"), self.rec("B", "PARENT")], [],
skip_own=True)
self.assertEqual(fid, "PARENT")
class TestTripleResolution(unittest.TestCase): class TestTripleResolution(unittest.TestCase):
@staticmethod @staticmethod
@@ -692,6 +815,16 @@ class TestCheck6(OfCleanTreeCase):
self.assertGreater(errors, 0) self.assertGreater(errors, 0)
self.assertIn("regenerate the map", out) self.assertIn("regenerate the map", out)
def test_non_of_map_key_is_an_error(self):
# The map is keyed by OUR ids; a Bambu id in the key column means the
# map was generated or hand-edited the wrong way round.
map_path = self._write_map({
"GFB00": {"bambu_id": "GFB00", "vendor": "V", "type": "PLA",
"name": "Foo"}})
errors, out = self.t.check(map_path)
self.assertEqual(errors, 1, out)
self.assertIn('"GFB00" is not a minted "OF" id', out)
def test_duplicate_bambu_id_is_an_error(self): def test_duplicate_bambu_id_is_an_error(self):
map_path = self._write_map({ map_path = self._write_map({
"OFaaaaaa": {"bambu_id": "GFZ00", "vendor": "V", "type": "PLA", "name": "Foo"}, "OFaaaaaa": {"bambu_id": "GFZ00", "vendor": "V", "type": "PLA", "name": "Foo"},
@@ -786,6 +919,21 @@ class TestUpdateSnapshot(SyntheticTreeCase):
self.assertEqual(list(snap["ids"]["AX01"]), self.assertEqual(list(snap["ids"]["AX01"]),
["filaments", "name", "filament_type", "filament_vendor"]) ["filaments", "name", "filament_type", "filament_vendor"])
def test_refuses_a_tree_it_could_not_read(self):
# A bundle that does not parse contributes no ids, so sanctioning the
# rest would record the loss as a deliberate removal.
with open(self.t.snapshot, "rb") as f:
before = f.read()
with open(os.path.join(self.t.profiles, "VendorA",
"filament", "APLA @base.json"), "w",
encoding="utf-8") as f:
f.write("{ not json")
rc, out = self.t.update_snapshot()
self.assertEqual(rc, 1, out)
self.assertIn("unreadable filament profile", out)
with open(self.t.snapshot, "rb") as f:
self.assertEqual(f.read(), before)
def test_refuses_an_id_declared_under_two_triples(self): def test_refuses_an_id_declared_under_two_triples(self):
# VendorB re-declares APLA's id for a different product: one id, two # VendorB re-declares APLA's id for a different product: one id, two
# triples. No single entry can describe it, and check 3 rejects it anyway. # triples. No single entry can describe it, and check 3 rejects it anyway.
@@ -818,16 +966,36 @@ class TestUpdateSnapshot(SyntheticTreeCase):
with open(self.t.snapshot, "rb") as f: with open(self.t.snapshot, "rb") as f:
self.assertEqual(f.read(), before) # nothing written on refusal self.assertEqual(f.read(), before) # nothing written on refusal
def test_dry_run_reports_without_writing(self):
self.t.write_preset("VendorA", preset("ANEW @P2", inherits="APLA @base",
compatible_printers=["P2"]))
with open(self.t.snapshot, "rb") as f:
before = f.read()
rc, out = self.t.update_snapshot(dry_run=True)
self.assertEqual(rc, 0)
self.assertIn("would be rewritten", out)
self.assertIn("claims added : 1", out)
with open(self.t.snapshot, "rb") as f:
self.assertEqual(f.read(), before)
# the real run writes exactly what the dry run reported
rc, out = self.t.update_snapshot()
self.assertEqual(rc, 0)
self.assertIn("snapshot written", out)
snap = load_json_file(self.t.snapshot)
self.assertEqual(snap["ids"]["AX01"]["filaments"],
["VendorA/ANEW", "VendorA/APLA"])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# default run: mint + insert # --generate: one rule for inserts and rewrites alike
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAssign(OfCleanTreeCase): class TestAssign(OfCleanTreeCase):
def test_noop_on_fully_idded_tree(self): def test_noop_on_fully_idded_tree(self):
changed, errors, out = self.t.assign() changed, errors, out = self.t.assign()
self.assertEqual((changed, errors), (0, 0)) self.assertEqual((changed, errors), (0, 0))
self.assertIn("nothing to do (0 files changed)", out) self.assertIn("filament_ids inserted : 0", out)
self.assertIn("filament_ids re-minted : 0", out)
def test_mints_into_filament_root_and_rootless_member(self): def test_mints_into_filament_root_and_rootless_member(self):
self.t.write_preset("VendorA", preset("FNEW @base", instantiation=False, self.t.write_preset("VendorA", preset("FNEW @base", instantiation=False,
@@ -855,7 +1023,7 @@ class TestAssign(OfCleanTreeCase):
# idempotent: second run is a no-op # idempotent: second run is a no-op
changed, errors, out = self.t.assign() changed, errors, out = self.t.assign()
self.assertEqual((changed, errors), (0, 0)) self.assertEqual((changed, errors), (0, 0))
self.assertIn("nothing to do", out) self.assertIn("filament_ids inserted : 0", out)
def test_refuses_filament_with_incomplete_triple(self): def test_refuses_filament_with_incomplete_triple(self):
self.t.write_preset("VendorA", preset("ENEW @base", instantiation=False)) self.t.write_preset("VendorA", preset("ENEW @base", instantiation=False))
@@ -867,6 +1035,21 @@ class TestAssign(OfCleanTreeCase):
self.assertIn("resolves empty filament_vendor and filament_type", out) self.assertIn("resolves empty filament_vendor and filament_type", out)
self.assertIn("mint key needs both", out) self.assertIn("mint key needs both", out)
def test_refuses_to_re_mint_a_declaration_with_an_incomplete_triple(self):
# The rewrite half of the same guard: a declared id that is not its
# triple's mint still cannot be re-derived without a filament_vendor.
self.t.write_preset("VendorA", preset("QNEW @base", filament_id="OFZZZZZZ",
instantiation=False,
filament_type="PLA"))
self.t.write_preset("VendorA", preset("QNEW @P1", inherits="QNEW @base",
compatible_printers=["P1"]))
before = self.t.bytes_map()
changed, errors, out = self.t.assign()
self.assertEqual((changed, errors), (0, 1), out)
self.assertIn("resolves empty filament_vendor", out)
self.assertIn("mint key needs both", out)
self.assertEqual(self.t.bytes_map(), before)
def test_refuses_filament_with_divergent_root_fields(self): def test_refuses_filament_with_divergent_root_fields(self):
self.t.write_preset("VendorA", preset("HNEW @base1", instantiation=False, self.t.write_preset("VendorA", preset("HNEW @base1", instantiation=False,
filament_vendor="HV", filament_vendor="HV",
@@ -909,10 +1092,10 @@ class TestAssign(OfCleanTreeCase):
errors, out = self.t.check() errors, out = self.t.check()
self.assertEqual(errors, 0, out) self.assertEqual(errors, 0, out)
def test_non_of_declaration_is_treated_as_missing(self): def test_non_of_declaration_is_re_minted(self):
# A declaration that isn't OF-format (e.g. a vendor bundle synced from # 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 # an upstream catalog, like BBL's GF ids) is not the mint of its own
# filament, not left alone. # triple, so the same pass rewrites it in place.
self.t.write_preset("VendorA", preset("Synced PLA @base", filament_id="GFZZ00", self.t.write_preset("VendorA", preset("Synced PLA @base", filament_id="GFZZ00",
instantiation=False, instantiation=False,
filament_vendor="ZV", filament_type="PLA")) filament_vendor="ZV", filament_type="PLA"))
@@ -927,9 +1110,10 @@ class TestAssign(OfCleanTreeCase):
want = afi.generate_filament_id("ZV", "PLA", "Synced PLA") want = afi.generate_filament_id("ZV", "PLA", "Synced PLA")
self.assertEqual(root["filament_id"], want) self.assertEqual(root["filament_id"], want)
# the value was replaced in place, not appended as a second key # the value was replaced in place, not appended as a second key
raw = open(path, encoding="utf-8").read() with open(path, encoding="utf-8") as f:
raw = f.read()
self.assertEqual(raw.count('"filament_id"'), 1) self.assertEqual(raw.count('"filament_id"'), 1)
# idempotent: the id is OF-format now, so a second run is a no-op # idempotent: the id is its triple's mint now, so a second run is a no-op
changed, errors, _out = self.t.assign() changed, errors, _out = self.t.assign()
self.assertEqual((changed, errors), (0, 0)) self.assertEqual((changed, errors), (0, 0))
@@ -959,6 +1143,148 @@ class TestAssign(OfCleanTreeCase):
self.assertEqual(b1["filament_id"], want) self.assertEqual(b1["filament_id"], want)
self.assertEqual(b2["filament_id"], want) self.assertEqual(b2["filament_id"], want)
def test_converges_on_an_existing_tree_id_for_the_same_triple(self):
# BEHAVIOUR CHANGE: an id-less filament whose product is already shipped
# (with its conforming id) in another bundle converges on that id
# instead of salting past it. One product, one id, in every bundle.
want = afi.generate_filament_id("CV", "PLA", "CPLA")
self.t.add_vendor("VendorB", [
preset("CPLA @base", filament_id=want, instantiation=False,
filament_vendor="CV", filament_type="PLA"),
preset("CPLA @PB", inherits="CPLA @base",
compatible_printers=["PB 0.4 nozzle"]),
])
self.t.write_preset("VendorA", preset("CPLA @base", instantiation=False,
filament_vendor="CV",
filament_type="PLA"))
self.t.write_preset("VendorA", preset("CPLA @P1", inherits="CPLA @base",
compatible_printers=["P1"]))
changed, errors, out = self.t.assign()
self.assertEqual((changed, errors), (1, 0), out)
root = load_json_file(self.t.preset_path("VendorA", "CPLA @base"))
self.assertEqual(root["filament_id"], want)
def test_a_squatted_id_is_still_minted_for_its_own_product(self):
# VendorB's "Other" declares the id that belongs to VendorA's "DPLA" —
# a copy-paste, not a real claim, since it is not the mint of Other's
# own triple. That declaration is transient (--generate rewrites it), so
# it must NOT push DPLA onto a salted id: DPLA gets its canonical one.
want0 = afi.generate_filament_id("DV", "PLA", "DPLA")
self.t.add_vendor("VendorB", [
preset("Other @base", filament_id=want0, instantiation=False,
filament_vendor="OV", filament_type="ABS"),
])
self.t.write_preset("VendorA", preset("DPLA @base", instantiation=False,
filament_vendor="DV",
filament_type="PLA"))
self.t.write_preset("VendorA", preset("DPLA @P1", inherits="DPLA @base",
compatible_printers=["P1"]))
changed, errors, out = self.t.assign(["VendorA"])
# VendorB is outside the write set, so the id DPLA just took is still
# declared there: the run says so rather than leaving a silent duplicate.
self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("declared by VendorB/filament/Other @base.json", out)
# ...and keeps saying so until a run that covers VendorB clears it.
self.assertEqual(self.t.assign(["VendorA"])[:2], (0, 1))
root = load_json_file(self.t.preset_path("VendorA", "DPLA @base"))
self.assertEqual(root["filament_id"], want0)
# A full run re-mints Other to its own triple and the duplicate is gone.
other = load_json_file(self.t.preset_path("VendorB", "Other @base"))
self.assertEqual(other["filament_id"], want0)
changed, errors, out = self.t.assign()
self.assertEqual((changed, errors), (1, 0), out)
other = load_json_file(self.t.preset_path("VendorB", "Other @base"))
self.assertEqual(other["filament_id"],
afi.generate_filament_id("OV", "ABS", "Other"))
def test_mismatching_of_declaration_is_re_derived(self):
# BEHAVIOUR CHANGE: the default run used to leave an OF-format id alone
# and needed a separate mode to fix it. One rule now: an id that is not
# a mint iteration of its own triple is rewritten.
self.t.write_preset("VendorA", preset("KNEW @base", filament_id="OFZZZZZZ",
instantiation=False,
filament_vendor="KV",
filament_type="PLA"))
self.t.write_preset("VendorA", preset("KNEW @P1", inherits="KNEW @base",
compatible_printers=["P1"]))
want = afi.generate_filament_id("KV", "PLA", "KNEW")
changed, errors, out = self.t.assign()
self.assertEqual((changed, errors), (1, 0), out)
self.assertIn('"OFZZZZZZ" -> "%s"' % want, out)
root = load_json_file(self.t.preset_path("VendorA", "KNEW @base"))
self.assertEqual(root["filament_id"], want)
changed, errors, _out = self.t.assign()
self.assertEqual((changed, errors), (0, 0))
def test_vendor_filter_limits_rewrites_and_inserts_alike(self):
# A mismatching declarer in VendorA and an id-less filament in VendorB;
# only VendorA is written, and VendorB's bytes are untouched.
self.t.write_preset("VendorA", preset("LNEW @base", filament_id="OFZZZZZZ",
instantiation=False,
filament_vendor="LV",
filament_type="PLA"))
self.t.write_preset("VendorA", preset("LNEW @P1", inherits="LNEW @base",
compatible_printers=["P1"]))
self.t.add_vendor("VendorB", [
preset("MNEW @base", instantiation=False,
filament_vendor="MV", filament_type="PLA"),
preset("MNEW @PB", inherits="MNEW @base",
compatible_printers=["PB 0.4 nozzle"]),
])
before = self.t.bytes_map()
changed, errors, out = self.t.assign(["VendorA"])
self.assertEqual((changed, errors), (1, 0), out)
root = load_json_file(self.t.preset_path("VendorA", "LNEW @base"))
self.assertEqual(root["filament_id"],
afi.generate_filament_id("LV", "PLA", "LNEW"))
after = self.t.bytes_map()
for rel, raw in before.items():
if rel.split(os.sep)[0].startswith("VendorB"):
self.assertEqual(after[rel], raw, rel)
# ... and the deferred half is exactly what a VendorB run then writes
changed, errors, out = self.t.assign(["VendorB"])
self.assertEqual((changed, errors), (1, 0), out)
b_root = load_json_file(self.t.preset_path("VendorB", "MNEW @base"))
self.assertEqual(b_root["filament_id"],
afi.generate_filament_id("MV", "PLA", "MNEW"))
def test_unknown_vendor_reports_and_writes_nothing(self):
# A real insert is pending, so "wrote nothing" means the unknown vendor
# aborted the run before any write — not that the tree was already done.
self.t.write_preset("VendorA", preset("PNEW @base", instantiation=False,
filament_vendor="PV",
filament_type="PLA"))
self.t.write_preset("VendorA", preset("PNEW @P1", inherits="PNEW @base",
compatible_printers=["P1"]))
before = self.t.bytes_map()
changed, errors, out = self.t.assign(["Nope"])
self.assertEqual((changed, errors), (0, 1))
self.assertIn("unknown vendor", out)
self.assertEqual(self.t.bytes_map(), before)
# ... and that pending insert is real: a known-vendor run makes it.
changed, errors, out = self.t.assign(["VendorA"])
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(
load_json_file(self.t.preset_path("VendorA", "PNEW @base"))["filament_id"],
afi.generate_filament_id("PV", "PLA", "PNEW"))
def test_dry_run_reports_the_real_run_and_writes_nothing(self):
self.t.write_preset("VendorA", preset("NNEW @base", instantiation=False,
filament_vendor="NV",
filament_type="PLA"))
self.t.write_preset("VendorA", preset("NNEW @P1", inherits="NNEW @base",
compatible_printers=["P1"]))
before = self.t.bytes_map()
dry_changed, errors, out = self.t.assign(dry_run=True)
self.assertEqual((dry_changed, errors), (1, 0), out)
self.assertIn("would insert", out)
self.assertEqual(self.t.bytes_map(), before)
changed, errors, out = self.t.assign()
self.assertEqual((changed, errors), (dry_changed, 0), out)
root = load_json_file(self.t.preset_path("VendorA", "NNEW @base"))
self.assertEqual(root["filament_id"],
afi.generate_filament_id("NV", "PLA", "NNEW"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# byte-preserving profile edits # byte-preserving profile edits
@@ -1056,7 +1382,7 @@ class TestInsertEditing(unittest.TestCase):
afi.rewrite_filament_id(path, "OFold123", "OFxxx999") # stale old id afi.rewrite_filament_id(path, "OFold123", "OFxxx999") # stale old id
def test_delete_line_with_trailing_comma(self): def test_delete_line_with_trailing_comma(self):
text, n = afi.delete_filament_id_line(self.CRLF_WITH_ID, "OFold123") text, n = afi.delete_key_line(self.CRLF_WITH_ID, "filament_id", "OFold123")
self.assertEqual(n, 1) self.assertEqual(n, 1)
self.assertNotIn("filament_id", json.loads(text)) self.assertNotIn("filament_id", json.loads(text))
self.assertEqual(text, self.CRLF_WITH_ID.replace( self.assertEqual(text, self.CRLF_WITH_ID.replace(
@@ -1065,15 +1391,40 @@ class TestInsertEditing(unittest.TestCase):
def test_delete_last_property_line(self): def test_delete_last_property_line(self):
lf_text = ('{\n "name": "K @base",\n "instantiation": "false",\n' lf_text = ('{\n "name": "K @base",\n "instantiation": "false",\n'
' "filament_id": "OFold123"\n}\n') ' "filament_id": "OFold123"\n}\n')
text, n = afi.delete_filament_id_line(lf_text, "OFold123") text, n = afi.delete_key_line(lf_text, "filament_id", "OFold123")
self.assertEqual(n, 1) self.assertEqual(n, 1)
data = json.loads(text) data = json.loads(text)
self.assertNotIn("filament_id", data) self.assertNotIn("filament_id", data)
self.assertEqual(data["instantiation"], "false") self.assertEqual(data["instantiation"], "false")
# the preceding comma goes with it, and nothing else moves
self.assertEqual(text, '{\n "name": "K @base",\n'
' "instantiation": "false"\n}\n')
def test_dry_run_edits_verify_but_write_nothing(self):
t = SyntheticTree()
self.addCleanup(t.cleanup)
t.add_vendor("VendorA", [])
fresh = t.preset_path("VendorA", "JNEW @base")
with open(fresh, "wb") as f:
f.write(self.CRLF_TEXT.encode("utf-8"))
idded = t.preset_path("VendorA", "JOLD @base")
with open(idded, "wb") as f:
f.write(self.CRLF_WITH_ID.encode("utf-8"))
afi.write_filament_id(fresh, "OFabc123", dry_run=True)
afi.rewrite_filament_id(idded, "OFold123", "OFnew456", dry_run=True)
with open(fresh, "rb") as f:
self.assertEqual(f.read(), self.CRLF_TEXT.encode("utf-8"))
with open(idded, "rb") as f:
self.assertEqual(f.read(), self.CRLF_WITH_ID.encode("utf-8"))
# the edit is still applied and verified in memory: only the write is
# skipped, so a stale old id fails just as loudly
with self.assertRaises(RuntimeError):
afi.rewrite_filament_id(idded, "OFstale1", "OFnew456", dry_run=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# --remint # --generate: re-minting non-conformant declarations
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestRemint(SyntheticTreeCase): class TestRemint(SyntheticTreeCase):
@@ -1138,17 +1489,23 @@ class TestRemint(SyntheticTreeCase):
a = load_json_file(self.t.preset_path("VendorA", "APLA @base")) a = load_json_file(self.t.preset_path("VendorA", "APLA @base"))
self.assertEqual(a["filament_id"], want) self.assertEqual(a["filament_id"], want)
def test_blocked_by_other_triple_occurrence(self): def test_not_blocked_by_a_non_conformant_occurrence(self):
# VendorB carries APLA's id under a PETG triple of its own, so that
# declaration is wrong and this run's job is to rewrite it. It must not
# cost VendorA's APLA the id its triple actually mints.
want0 = afi.generate_filament_id(*self.TRIPLE) want0 = afi.generate_filament_id(*self.TRIPLE)
self.t.add_vendor("VendorB", [ self.t.add_vendor("VendorB", [
preset("BPLA @base", filament_id=want0, instantiation=False, preset("BPLA @base", filament_id=want0, instantiation=False,
filament_vendor="BV", filament_type="PETG"), filament_vendor="BV", filament_type="PETG"),
]) ])
changed, errors, _out = self.t.remint(["VendorA"]) changed, errors, out = self.t.remint(["VendorA"])
self.assertEqual((changed, errors), (1, 0)) self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("declared by VendorB/filament/BPLA @base.json", out)
root = load_json_file(self.t.preset_path("VendorA", "APLA @base")) root = load_json_file(self.t.preset_path("VendorA", "APLA @base"))
self.assertEqual(root["filament_id"], self.assertEqual(root["filament_id"], want0)
afi.generate_filament_id(*self.TRIPLE, salt=1)) # Widening the run to both bundles clears it.
changed, errors, out = self.t.remint(["VendorA", "VendorB"])
self.assertEqual((changed, errors), (1, 0), out)
def test_bbl_is_reminted_like_any_vendor(self): def test_bbl_is_reminted_like_any_vendor(self):
self.t.add_vendor("BBL", [ self.t.add_vendor("BBL", [
@@ -1164,59 +1521,195 @@ class TestRemint(SyntheticTreeCase):
self.assertEqual(root["filament_id"], self.assertEqual(root["filament_id"],
afi.generate_filament_id("Bambu Lab", "ABS", "Bambu ABS")) afi.generate_filament_id("Bambu Lab", "ABS", "Bambu ABS"))
def test_dry_run_leaves_every_file_untouched(self):
# --------------------------------------------------------------------------- before = self.t.bytes_map()
# --drop-redundant-ids changed, errors, out = self.t.remint(["VendorA"], dry_run=True)
# --------------------------------------------------------------------------- self.assertEqual((changed, errors), (1, 0), out)
self.assertIn("would rewrite", out)
class TestDropRedundantIds(SyntheticTreeCase): self.assertEqual(self.t.bytes_map(), before)
def test_drops_only_ofl_riding_same_base_name_declarations(self): # the real run then makes exactly that one change
# Redundant: same base name, rides the OFL generic, re-declares its id. changed, errors, _out = self.t.remint(["VendorA"])
self.t.write_preset("VendorA", preset("Generic PLA @P2", self.assertEqual((changed, errors), (1, 0))
inherits="Generic PLA @System",
compatible_printers=["P2"],
filament_id="OGFL99"))
# Renamed rider: not dropped (a repoint worksheet decision, not a drop).
self.t.write_preset("VendorA", preset("Tuned PLA @P3",
inherits="Generic PLA @System",
compatible_printers=["P3"],
filament_id="OGFL99"))
dropped, errors, out = self.t.drop_redundant("VendorA")
self.assertEqual(errors, 0, out)
self.assertEqual(dropped, 1)
self.assertIn('dropped filament_id "OGFL99"', out)
self.assertIn('rides OFL "Generic PLA @System"', out)
gone = load_json_file(self.t.preset_path("VendorA", "Generic PLA @P2"))
self.assertNotIn("filament_id", gone)
kept = load_json_file(self.t.preset_path("VendorA", "Tuned PLA @P3"))
self.assertEqual(kept["filament_id"], "OGFL99")
# vendor-rooted filaments are untouched
root = load_json_file(self.t.preset_path("VendorA", "APLA @base")) root = load_json_file(self.t.preset_path("VendorA", "APLA @base"))
self.assertEqual(root["filament_id"], "AX01") self.assertEqual(root["filament_id"],
afi.generate_filament_id(*self.TRIPLE))
def test_bbl_is_forbidden(self):
dropped, errors, out = self.t.drop_redundant("BBL")
self.assertEqual(dropped, 0)
self.assertGreater(errors, 0)
self.assertIn("forbidden", out)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# --mint CLI # CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestMintCli(unittest.TestCase): class TestCli(unittest.TestCase):
def test_prints_an_of_id(self): """main(argv) over a synthetic tree. The clean tree's baseline ids are
deliberately non-conformant ("AX01"/"OGFL99"), so a --generate run always
has both a filament_id rewrite and setting_id inserts to do."""
def setUp(self):
self.t = make_clean_tree()
self.addCleanup(self.t.cleanup)
def test_bare_invocation_prints_help(self):
buf = io.StringIO() buf = io.StringIO()
with contextlib.redirect_stdout(buf): with contextlib.redirect_stdout(buf):
rc = afi.main(["--mint", "Polymaker/PLA/PolyLite PLA"]) rc = afi.main([])
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
self.assertRegex(buf.getvalue().strip(), r"^OF[0-9A-Za-z]{6}$") self.assertIn("usage:", buf.getvalue())
self.assertIn("--generate", buf.getvalue())
# ... and so does any invocation naming no mode: help, and no work
before = self.t.bytes_map()
rc, out = self.t.cli()
self.assertEqual(rc, 0)
self.assertIn("usage:", out)
self.assertEqual(self.t.bytes_map(), before)
def test_requires_exactly_three_parts(self): def test_another_tree_needs_its_own_snapshot(self):
with self.assertRaises(SystemExit), \ # --profiles retargets the tree, but the sanctioned state of that tree
contextlib.redirect_stderr(io.StringIO()): # is not the repo snapshot: checking against it is meaningless and
afi.main(["--mint", "Vendor/Filament"]) # re-recording into it would overwrite the tracked file.
with open(afi.SNAPSHOT_PATH, "rb") as f:
repo_snapshot = f.read()
for mode in ("--check", "--update-snapshot"):
with self.assertRaises(SystemExit) as caught:
with contextlib.redirect_stderr(io.StringIO()):
afi.main([mode, "--profiles", self.t.profiles])
self.assertEqual(caught.exception.code, 2, mode)
with open(afi.SNAPSHOT_PATH, "rb") as f:
self.assertEqual(f.read(), repo_snapshot)
# Named explicitly, both modes run against that tree.
rc, out = self.t.cli("--update-snapshot")
self.assertEqual(rc, 0, out)
# --generate only reads the snapshot as a list of ids not to reuse, so
# it keeps working without one.
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = afi.main(["--dry-run", "--profiles", self.t.profiles])
self.assertEqual(rc, 0, buf.getvalue())
def test_filament_id_and_setting_id_together_are_rejected(self):
# Each flag's help promises it skips the other kind, so the pair cannot
# quietly mean "both".
with self.assertRaises(SystemExit) as caught:
with contextlib.redirect_stderr(io.StringIO()):
afi.main(["--generate", "--filament-id", "--setting-id",
"--profiles", self.t.profiles])
self.assertEqual(caught.exception.code, 2)
def test_a_run_with_errors_does_not_report_success(self):
# An unreadable profile must not be buried under a green summary line.
path = self.t.preset_path("VendorA", "APLA @base")
with open(path, "w", encoding="utf-8") as f:
f.write("{ not json")
rc, out = self.t.cli("--generate")
self.assertEqual(rc, 1, out)
self.assertIn("error(s)", out)
self.assertNotIn("SUCCESS", out)
def test_dry_run_alone_previews_generate(self):
before = self.t.bytes_map()
rc, out = self.t.cli("--dry-run")
self.assertEqual(rc, 0, out)
self.assertIn("would ", out)
self.assertIn("nothing written", out)
self.assertEqual(self.t.bytes_map(), before)
def test_the_reported_count_is_files_not_edits(self):
# A file both passes touch is still one file. "SoloPLA @P1" needs a
# filament_id rewrite AND a setting_id insert, so an edit-counting
# summary would over-report the tree.
self.t.write_preset("VendorA", preset(
"SoloPLA @P1", filament_id="AX01", filament_vendor="AVendor",
filament_type="PLA", compatible_printers=["P1"]))
before = self.t.bytes_map()
rc, out = self.t.cli("--generate")
self.assertEqual(rc, 0, out)
after = self.t.bytes_map()
touched = {p for p in after if after[p] != before.get(p)}
self.assertIn("VendorA/filament/SoloPLA @P1.json".replace("/", os.sep), touched)
self.assertIn("rewrite VendorA/filament/SoloPLA @P1.json", out)
self.assertIn("update VendorA/filament/SoloPLA @P1.json", out)
summary = [l for l in out.splitlines() if "file(s) changed" in l]
self.assertEqual(len(summary), 1, out)
self.assertIn(f"{len(touched)} file(s) changed", summary[0])
def test_dryrun_is_the_same_flag(self):
before = self.t.bytes_map()
rc, out = self.t.cli("--dryrun")
self.assertEqual(rc, 0, out)
self.assertIn("would ", out) # the same preview, not a silent no-op
self.assertIn("nothing written", out)
self.assertEqual(self.t.bytes_map(), before)
def test_generate_vendor_writes_only_in_that_bundle(self):
before = self.t.bytes_map()
rc, out = self.t.cli("--generate", "--vendor", "VendorA")
self.assertEqual(rc, 0, out)
after = self.t.bytes_map()
changed = sorted(rel for rel in before if after[rel] != before[rel])
self.assertTrue(changed, out)
for rel in changed:
self.assertTrue(rel.startswith("VendorA" + os.sep), rel)
# The bundles it spared were not simply already conformant: the
# un-narrowed run goes on to write in them too.
rc, out = self.t.cli("--generate")
self.assertEqual(rc, 0, out)
final = self.t.bytes_map()
self.assertTrue(any(final[rel] != after[rel] for rel in after
if not rel.startswith("VendorA" + os.sep)), out)
def test_generate_unknown_vendor_returns_1(self):
before = self.t.bytes_map()
rc, out = self.t.cli("--generate", "--vendor", "Nope")
self.assertEqual(rc, 1)
self.assertIn("unknown vendor", out)
self.assertEqual(self.t.bytes_map(), before)
def test_setting_id_only_leaves_filament_ids_alone(self):
rc, out = self.t.cli("--generate", "--setting-id")
self.assertEqual(rc, 0, out)
root = load_json_file(self.t.preset_path("VendorA", "APLA @base"))
self.assertEqual(root["filament_id"], "AX01") # not re-minted
member = load_json_file(self.t.preset_path("VendorA", "APLA @P1"))
self.assertNotIn("filament_id", member)
self.assertEqual(member["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament",
"APLA @P1"))
def test_filament_id_only_inserts_no_setting_id(self):
rc, out = self.t.cli("--generate", "--filament-id")
self.assertEqual(rc, 0, out)
root = load_json_file(self.t.preset_path("VendorA", "APLA @base"))
self.assertEqual(root["filament_id"],
afi.generate_filament_id("AVendor", "PLA", "APLA"))
for name in ("APLA @base", "APLA @P1"):
self.assertNotIn(
"setting_id", load_json_file(self.t.preset_path("VendorA", name)))
def test_check_mode_returns_1_on_errors(self):
# What CI keys off: --check exits nonzero when the tree does not match
# the snapshot it is validated against.
before = self.t.bytes_map()
rc, out = self.t.cli("--check")
self.assertEqual(rc, 1)
self.assertIn("error(s)", out)
self.assertEqual(self.t.bytes_map(), before) # --check never writes
def test_removed_and_conflicting_flags_are_rejected(self):
for argv in (["--remint", "VendorA"], # removed mode
["--mint", "A/B/C"], # removed mode
["--drop-redundant-ids", "VendorA"], # removed mode
["--assign"], # removed mode
["--generate", "--check"], # two modes
["--vendor", "VendorA"], # narrowing without a mode
["--filament-id"], # narrowing without a mode
["--check", "--vendor", "VendorA"]): # narrowing on --check
with self.subTest(argv=argv):
with self.assertRaises(SystemExit) as cm, \
contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
afi.main([*argv, "--profiles", self.t.profiles])
self.assertEqual(cm.exception.code, 2)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1231,6 +1724,13 @@ class TestRealTree(unittest.TestCase):
errors = afi.check_filament_ids(REAL_PROFILES) errors = afi.check_filament_ids(REAL_PROFILES)
self.assertEqual(errors, 0, buf.getvalue()) self.assertEqual(errors, 0, buf.getvalue())
def test_check_cli_returns_0(self):
# The exact CI invocation, return code included.
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = afi.main(["--check"])
self.assertEqual(rc, 0, buf.getvalue())
def test_every_instantiated_filament_resolves_an_id(self): def test_every_instantiated_filament_resolves_an_id(self):
analysis = afi.analyze_tree(REAL_PROFILES) analysis = afi.analyze_tree(REAL_PROFILES)
self.assertEqual(analysis["missing_effective"], []) self.assertEqual(analysis["missing_effective"], [])
@@ -1241,6 +1741,46 @@ class TestRealTree(unittest.TestCase):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestReviewFixes(OfCleanTreeCase): class TestReviewFixes(OfCleanTreeCase):
def test_one_file_reached_by_two_spellings_counts_once(self):
# The filament pass reaches a file through its index sub_path, the
# setting_id pass through os.walk. Those two spellings differ whenever
# the sub_path is not already normalized - always, on Windows, where
# every sub_path keeps the "/" the index stores. One file is one file.
# One preset per write path: SoloPLA has no id (inserted), WrongPLA
# declares one that is not its own mint (rewritten). Both are
# instantiated, so the setting_id pass reaches them too.
self.t.write_preset("VendorA", preset("SoloPLA @P1",
compatible_printers=["P1 0.4 nozzle"],
filament_vendor="SV",
filament_type="PLA"))
self.t.write_preset("VendorA", preset(
"WrongPLA @P1", filament_id=afi.generate_filament_id("WV", "PLA", "Nope"),
compatible_printers=["P1 0.4 nozzle"],
filament_vendor="WV", filament_type="PLA"))
for name in ("SoloPLA @P1", "WrongPLA @P1"):
self.t.set_sub_path("VendorA", name, f"filament/./{name}.json")
touched = set()
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
afi.generate_filament_ids(self.t.profiles, self.t.snapshot,
None, False, touched)
afi.generate_setting_ids(self.t.profiles, None, False, touched)
for name in ("SoloPLA @P1", "WrongPLA @P1"):
self.assertEqual([t for t in touched if t.endswith(name + ".json")],
[self.t.preset_path("VendorA", name)], sorted(touched))
self.assertEqual(sorted(touched),
sorted(os.path.normpath(t) for t in touched))
def test_a_broken_edit_names_the_file(self):
# An edit that produces invalid JSON is refused, and the message has to
# say which of ~12,000 files it was: a bare JSONDecodeError does not.
path = self.t.preset_path("VendorA", "APLA @base")
with self.assertRaises(RuntimeError) as caught:
afi._edit_profile(path, lambda text: (text.replace("{", "{,", 1), 1),
what="test edit")
self.assertIn(path, str(caught.exception))
self.assertIn("test edit", str(caught.exception))
def test_check3_skips_of_id_inherited_from_other_vendor(self): def test_check3_skips_of_id_inherited_from_other_vendor(self):
# An OFL filament carries its own minted OF id and a vendor tunes it # An OFL filament carries its own minted OF id and a vendor tunes it
# correctly (same base name, non-empty printers). The new claim must # correctly (same base name, non-empty printers). The new claim must

View File

@@ -0,0 +1,902 @@
#!/usr/bin/env python3
"""Tests for the setting_id half of scripts/orca_id_tool.py (stdlib unittest, no
external deps).
Run from the repo root: python -m unittest discover -s scripts/tests -v
"""
import contextlib
import io
import json
import os
import shutil
import sys
import tempfile
import unittest
import uuid
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import orca_id_tool as afi # noqa: E402
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
REAL_PROFILES = os.path.join(REPO_ROOT, "resources", "profiles")
# ---------------------------------------------------------------------------
# helpers: synthetic profile trees
# ---------------------------------------------------------------------------
def preset(name, instantiation=True, setting_id=None, settings_id=None,
filament_id=None, type_name="filament", **extra):
"""A preset in the canonical key order the shipped profiles use."""
data = {"type": type_name, "name": name, "from": "system"}
if setting_id is not None:
data["setting_id"] = setting_id
if settings_id is not None:
data["settings_id"] = settings_id
if filament_id is not None:
data["filament_id"] = filament_id
data["instantiation"] = "true" if instantiation else "false"
data.update(extra)
return data
class SettingTree:
"""A throwaway resources/profiles-shaped directory of setting_id-bearing bundles.
A bundle is a `<vendor>/` directory plus the sibling `<vendor>.json` index
that makes list_vendor_names() see it; presets live under filament/,
process/ and machine/, the three subdirs generate_setting_ids walks.
write() also registers filament presets in the index's filament_list, the
way a shipped bundle does. generate_setting_ids never reads that list, but
generate_filament_ids does: without it the filament half of the tool is a
no-op on this tree, and the tests that assert --setting-id leaves
filament_ids alone would hold for the wrong reason.
"""
def __init__(self):
self.dir = tempfile.mkdtemp(prefix="setting_id_test_")
self.profiles = os.path.join(self.dir, "profiles")
os.makedirs(self.profiles)
def cleanup(self):
shutil.rmtree(self.dir, ignore_errors=True)
def index_path(self, vendor):
return os.path.join(self.profiles, vendor + ".json")
def add_vendor(self, vendor):
"""Create the bundle dir and its index; idempotent, keeps the list."""
for sub in afi.PROFILE_SUBDIRS:
os.makedirs(os.path.join(self.profiles, vendor, sub), exist_ok=True)
if not os.path.exists(self.index_path(vendor)):
self._write_index(vendor, {"name": vendor, "version": "01.00.00.00",
"filament_list": []})
def _write_index(self, vendor, index):
with open(self.index_path(vendor), "w", encoding="utf-8",
newline="\n") as f:
json.dump(index, f, indent=4, ensure_ascii=False)
def register(self, vendor, subdir, name):
"""Add a filament preset to the bundle index's filament_list."""
with open(self.index_path(vendor), encoding="utf-8") as f:
index = json.load(f)
sub_path = os.path.join(subdir, name + ".json").replace(os.sep, "/")
index["filament_list"].append({"name": name, "sub_path": sub_path})
self._write_index(vendor, index)
def path(self, vendor, subdir, name):
return os.path.join(self.profiles, vendor, subdir, name + ".json")
def write(self, vendor, subdir, data, name=None):
"""Write a preset as indented LF JSON; returns its path."""
self.add_vendor(vendor)
file_name = name if name is not None else data["name"]
path = self.path(vendor, subdir, file_name)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
f.write("\n")
if subdir.split(os.sep)[0] == "filament" and data.get("name"):
self.register(vendor, subdir, file_name)
return path
def write_raw(self, vendor, subdir, name, raw):
"""Write exact bytes (BOM, CRLF, tabs, broken JSON); returns its path."""
self.add_vendor(vendor)
path = self.path(vendor, subdir, name)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(raw)
return path
def read(self, vendor, subdir, name):
with open(self.path(vendor, subdir, name), encoding="utf-8-sig") as f:
return json.load(f)
def raw(self, vendor, subdir, name):
with open(self.path(vendor, subdir, name), "rb") as f:
return f.read()
def bytes_map(self):
"""relative path -> bytes, for every file in the tree."""
out = {}
for root, dirs, files in os.walk(self.profiles):
dirs.sort()
for name in sorted(files):
path = os.path.join(root, name)
with open(path, "rb") as f:
out[os.path.relpath(path, self.profiles)] = f.read()
return out
# -- pipeline wrappers ---------------------------------------------------
def run(self, vendors=None, dry_run=False):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
changed, errors = afi.generate_setting_ids(self.profiles, vendors, dry_run)
return changed, errors, buf.getvalue()
def run_filament_ids(self, vendors=None, dry_run=False):
"""The OTHER half of --generate, against this tree's own (absent)
snapshot, so the id policy never consults the repo's real one."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
changed, errors = afi.generate_filament_ids(
self.profiles, os.path.join(self.dir, "filament_id_snapshot.json"),
vendors, dry_run)
return changed, errors, buf.getvalue()
class SettingTreeCase(unittest.TestCase):
def setUp(self):
self.t = SettingTree()
self.addCleanup(self.t.cleanup)
# ---------------------------------------------------------------------------
# mint: the C++/Python byte-identity contract
# ---------------------------------------------------------------------------
class TestSettingIdMint(unittest.TestCase):
# Copied verbatim from tests/libslic3r/test_preset_setting_id.cpp: the C++
# generate_preset_setting_id() recomputes these ids on the fly, so the two
# implementations must stay byte-identical.
GOLDEN = [
("Afinia", "filament", "Afinia ABS @Afinia H400", "TL34qSVkppBvMvgH"),
("Afinia", "process", "0.20mm Standard @Afinia H400", "FzmtNsy7XQvpd7w0"),
("Afinia", "machine", "Afinia H400 0.4 nozzle", "r4FZagW0S8uoaJPd"),
("Anycubic", "filament", "Generic PLA @Anycubic Kobra 2", "YIWGGLQ8Oepd30Fv"),
("Creality", "process", "0.16mm Optimal @Creality Ender-3 V3", "2Nrbq8PxssUPBLza"),
("Elegoo", "machine", "Elegoo Neptune 4 0.4 nozzle", "69QdWuRQwAZk9rFu"),
]
def test_golden_vectors(self):
for vendor, type_name, name, expected in self.GOLDEN:
with self.subTest(name=name):
self.assertEqual(
afi.generate_preset_setting_id(vendor, type_name, name), expected)
def test_namespace_and_length_are_frozen(self):
# Baked into the C++ side and into every shipped profile; never change it.
self.assertEqual(afi.NAMESPACE,
uuid.UUID("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f"))
self.assertEqual(afi.SETTING_ID_LENGTH, 16)
self.assertEqual(
afi.ALPHABET,
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
self.assertEqual(len(afi.ALPHABET), 62)
def test_format_is_sixteen_alphabet_chars(self):
for vendor, type_name, name in [("Creality", "filament", "CR PLA @Ender"),
("Elegoo", "machine", "Elegoo Neptune 4"),
("拓竹", "filament", "拓竹 PLA @P1")]:
with self.subTest(name=name):
sid = afi.generate_preset_setting_id(vendor, type_name, name)
self.assertEqual(len(sid), afi.SETTING_ID_LENGTH)
self.assertTrue(set(sid) <= set(afi.ALPHABET), sid)
def test_is_the_low_base62_digits_of_the_uuid5(self):
# Independent re-implementation of the whole rule, key layout included.
for vendor, type_name, name, _expected in self.GOLDEN:
u = uuid.uuid5(afi.NAMESPACE, f"{vendor}/{type_name}/{name}")
n = int.from_bytes(u.bytes, "big")
digits = ""
for _ in range(afi.SETTING_ID_LENGTH):
digits = afi.ALPHABET[n % 62] + digits
n //= 62
self.assertEqual(afi.generate_preset_setting_id(vendor, type_name, name),
digits)
def test_deterministic(self):
a = afi.generate_preset_setting_id("VendorX", "filament", "My PLA")
self.assertEqual(a, afi.generate_preset_setting_id("VendorX", "filament", "My PLA"))
def test_every_identity_component_changes_the_id(self):
base = afi.generate_preset_setting_id("VendorX", "filament", "My PLA")
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorY", "filament", "My PLA"))
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorX", "process", "My PLA"))
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorX", "filament", "My PETG"))
def test_key_is_a_flat_slash_join(self):
# The mint key is "<vendor>/<type>/<name>" with no escaping, so a "/" in
# a component shifts the split — harmless in practice (vendor is a
# directory name and type is one of PROFILE_SUBDIRS), but it is what the
# C++ side does too and the two must agree byte for byte.
self.assertEqual(afi.generate_preset_setting_id("A/B", "filament", "C"),
afi.generate_preset_setting_id("A", "B/filament", "C"))
class TestBase62Tail(unittest.TestCase):
def test_hand_computed_digits(self):
self.assertEqual(afi._base62_tail(0, 4), "0000")
self.assertEqual(afi._base62_tail(61, 1), "z") # last alphabet char
self.assertEqual(afi._base62_tail(62, 2), "10") # 1*62 + 0
self.assertEqual(afi._base62_tail(3843, 2), "zz") # 61*62 + 61
self.assertEqual(afi._base62_tail(3907, 3), "111") # 62^2 + 62 + 1
def test_keeps_only_the_low_digits(self):
self.assertEqual(afi._base62_tail(62, 1), "0") # high digit dropped
self.assertEqual(afi._base62_tail(3907, 2), "11")
# ---------------------------------------------------------------------------
# assignment
# ---------------------------------------------------------------------------
class TestAssignment(SettingTreeCase):
def test_instantiation_is_read_exactly_as_the_validator_reads_it(self):
# orca_extra_profile_check.py tests `instantiation == "true"` strictly.
# Anything looser here would hand an id to a preset the validator calls
# a base profile, and the two would fight over it on every run.
for name, value in [("Boolean", True), ("Capitalised", "True"),
("Numeric", 1), ("Absent", None)]:
with self.subTest(instantiation=value):
data = {"type": "filament", "name": name, "from": "system"}
if value is not None:
data["instantiation"] = value
self.t.write("VendorA", "filament", data)
before = self.t.bytes_map()
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (0, 0), out)
self.assertEqual(self.t.bytes_map(), before)
def test_a_bundle_without_an_index_is_still_assigned(self):
# setting_id is a per-file property, and the validator walks every
# directory. A bundle whose index has not landed yet must be fixable,
# or the validator flags files this tool refuses to touch.
path = os.path.join(self.t.profiles, "Noindex", "process", "Q.json")
os.makedirs(os.path.dirname(path))
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(preset("Q", type_name="process"), f, indent=4)
self.assertFalse(os.path.exists(self.t.index_path("Noindex")))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
with open(path, encoding="utf-8") as f:
self.assertEqual(json.load(f)["setting_id"],
afi.generate_preset_setting_id("Noindex", "process", "Q"))
def test_assigns_across_every_profile_subdir(self):
self.t.write("VendorA", "filament", preset("A PLA @P1"))
self.t.write("VendorA", "process",
preset("0.20mm Standard @P1", type_name="process"))
self.t.write("VendorA", "machine",
preset("P1 0.4 nozzle", type_name="machine"))
# os.walk recursion: a preset in a nested directory is walked too.
self.t.write("VendorA", os.path.join("filament", "nested"),
preset("A PETG @P1"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (4, 0), out)
for subdir, type_name, name in [
("filament", "filament", "A PLA @P1"),
("process", "process", "0.20mm Standard @P1"),
("machine", "machine", "P1 0.4 nozzle"),
(os.path.join("filament", "nested"), "filament", "A PETG @P1")]:
self.assertEqual(
self.t.read("VendorA", subdir, name)["setting_id"],
afi.generate_preset_setting_id("VendorA", type_name, name),
msg=name)
def test_type_comes_from_the_subdirectory_not_the_type_field(self):
# The subdir name is the type name (Preset::get_type_string()); a stale
# "type" field inside the file does not enter the id.
self.t.write("VendorA", "process", preset("Odd @P1", type_name="filament"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(self.t.read("VendorA", "process", "Odd @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "process", "Odd @P1"))
def test_idempotent(self):
self.t.write("VendorA", "filament", preset("A PLA @P1"))
self.t.write("VendorA", "process",
preset("0.20mm Standard @P1", type_name="process"))
self.t.write("VendorA", "filament", preset("A PLA @base", instantiation=False,
setting_id="LEFTOVER00000000"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (3, 0), out)
after_first = self.t.bytes_map()
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (0, 0), out)
self.assertEqual(self.t.bytes_map(), after_first)
def test_stale_value_is_replaced_in_place(self):
path = self.t.write("VendorA", "filament",
preset("A PLA @P1", setting_id="0000000000000000",
filament_id="OFabc123"))
before = self.t.raw("VendorA", "filament", "A PLA @P1")
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
want = afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1")
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
want)
raw = self.t.raw("VendorA", "filament", "A PLA @P1")
self.assertEqual(raw.count(b'"setting_id"'), 1) # replaced, not appended
self.assertEqual(raw, before.replace(b'"0000000000000000"',
b'"%s"' % want.encode()))
self.assertTrue(os.path.isfile(path))
def test_missing_value_is_inserted_before_filament_id(self):
self.t.write("VendorA", "filament",
preset("A PLA @P1", filament_id="OFabc123"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
text = self.t.raw("VendorA", "filament", "A PLA @P1").decode("utf-8")
want = afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1")
self.assertIn(f'"setting_id": "{want}"', text)
self.assertLess(text.index('"setting_id"'), text.index('"filament_id"'))
def test_base_profiles_are_stripped(self):
self.t.write("VendorA", "filament",
preset("A PLA @base", instantiation=False,
setting_id="0000000000000000", filament_id="OFabc123"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
data = self.t.read("VendorA", "filament", "A PLA @base")
self.assertNotIn("setting_id", data)
self.assertEqual(data["filament_id"], "OFabc123") # nothing else touched
self.assertEqual(data["instantiation"], "false")
def test_base_profile_without_instantiation_key_is_stripped(self):
# No "instantiation" key at all == not instantiated (str(None) != "true").
data = {"type": "filament", "name": "A PLA @root", "from": "system",
"setting_id": "0000000000000000"}
self.t.write("VendorA", "filament", data)
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
self.assertNotIn("setting_id", self.t.read("VendorA", "filament", "A PLA @root"))
def test_misspelled_settings_id_is_dropped(self):
self.t.write("VendorA", "filament",
preset("A PLA @base", instantiation=False,
settings_id="0000000000000000"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
data = self.t.read("VendorA", "filament", "A PLA @base")
self.assertNotIn("settings_id", data)
self.assertNotIn("setting_id", data)
def test_typo_drop_and_assignment_are_one_file_change(self):
self.t.write("VendorA", "filament",
preset("A PLA @P1", settings_id="0000000000000000",
filament_id="OFabc123"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out) # ONE counted file change
data = self.t.read("VendorA", "filament", "A PLA @P1")
self.assertNotIn("settings_id", data)
self.assertEqual(data["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
def test_reserved_vendor_keeps_instantiated_ids_and_loses_base_ones(self):
# BBL owns the authoritative "G*" cloud id space: its instantiated
# presets are never rewritten, its base declarations still are stripped.
self.t.write("BBL", "filament",
preset("Bambu ABS @BBL A1", setting_id="GFSB00_07"))
self.t.write("BBL", "filament", preset("Bambu ABS @P1 no id"))
self.t.write("BBL", "filament",
preset("Bambu ABS @base", instantiation=False,
setting_id="GFSB00_00"))
kept = self.t.raw("BBL", "filament", "Bambu ABS @BBL A1")
kept_idless = self.t.raw("BBL", "filament", "Bambu ABS @P1 no id")
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out) # only the base profile
self.assertEqual(self.t.raw("BBL", "filament", "Bambu ABS @BBL A1"), kept)
self.assertEqual(self.t.raw("BBL", "filament", "Bambu ABS @P1 no id"),
kept_idless)
self.assertNotIn("setting_id",
self.t.read("BBL", "filament", "Bambu ABS @base"))
def test_reserved_vendors_misspelled_key_is_corrected_not_dropped(self):
# A reserved vendor's id is authoritative, so there is no formula to
# fall back on. Dropping the typo and stopping there would leave the
# preset with no setting_id at all and no way for the tool to give it
# one - a validator error nothing can clear. Fix the key, keep the value.
self.t.write("BBL", "filament",
preset("Bambu PLA @P1", settings_id="GFSA00_01"))
# A base profile still just loses the key; it may not carry an id.
self.t.write("BBL", "filament",
preset("Bambu PLA @base", instantiation=False,
settings_id="GFSA00_00"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (2, 0), out)
fixed = self.t.read("BBL", "filament", "Bambu PLA @P1")
self.assertNotIn("settings_id", fixed)
self.assertEqual(fixed["setting_id"], "GFSA00_01")
base = self.t.read("BBL", "filament", "Bambu PLA @base")
self.assertNotIn("settings_id", base)
self.assertNotIn("setting_id", base)
# Idempotent: the corrected file is what the next run expects to see.
self.assertEqual(self.t.run()[:2], (0, 0))
def test_a_managed_vendors_misspelled_key_is_still_replaced_by_the_mint(self):
self.t.write("VendorA", "filament",
preset("A PLA @P1", settings_id="whatever"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
data = self.t.read("VendorA", "filament", "A PLA @P1")
self.assertNotIn("settings_id", data)
self.assertEqual(data["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
def test_reserved_vendors_constant(self):
self.assertEqual(afi.RESERVED_VENDORS, {"BBL"})
self.assertEqual(afi.PROFILE_SUBDIRS, ("filament", "process", "machine"))
# ---------------------------------------------------------------------------
# --vendor narrowing
# ---------------------------------------------------------------------------
class TestVendorNarrowing(SettingTreeCase):
def setUp(self):
super().setUp()
self.t.write("VendorA", "filament", preset("A PLA @P1"))
self.t.write("VendorB", "filament", preset("B PLA @P1"))
def test_restricts_writes_to_the_named_vendor(self):
before = self.t.bytes_map()
changed, errors, out = self.t.run(vendors=["VendorA"])
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
self.assertEqual(self.t.raw("VendorB", "filament", "B PLA @P1"),
before[os.path.join("VendorB", "filament", "B PLA @P1.json")])
# ... and the vendor left out is written by a later run, unchanged in kind.
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(self.t.read("VendorB", "filament", "B PLA @P1")["setting_id"],
afi.generate_preset_setting_id("VendorB", "filament", "B PLA @P1"))
def test_unknown_vendor_reports_and_writes_nothing(self):
before = self.t.bytes_map()
changed, errors, out = self.t.run(vendors=["Nope"])
self.assertEqual((changed, errors), (0, 1))
self.assertIn("Nope", out)
self.assertEqual(self.t.bytes_map(), before)
def test_unknown_vendor_blocks_the_known_ones_too(self):
before = self.t.bytes_map()
changed, errors, _out = self.t.run(vendors=["VendorA", "Nope"])
self.assertEqual((changed, errors), (0, 1))
self.assertEqual(self.t.bytes_map(), before)
# ---------------------------------------------------------------------------
# --dry-run
# ---------------------------------------------------------------------------
class TestDryRun(SettingTreeCase):
def test_writes_nothing_and_previews_the_real_run(self):
self.t.write("VendorA", "filament", preset("A PLA @P1"))
self.t.write("VendorA", "process",
preset("0.20mm Standard @P1", type_name="process"))
self.t.write("VendorA", "filament",
preset("A PLA @base", instantiation=False,
setting_id="0000000000000000"))
before = self.t.bytes_map()
dry_changed, dry_errors, out = self.t.run(dry_run=True)
self.assertEqual((dry_changed, dry_errors), (3, 0), out)
self.assertIn("would", out)
self.assertEqual(self.t.bytes_map(), before) # nothing written
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (dry_changed, dry_errors), out)
self.assertNotEqual(self.t.bytes_map(), before)
# ---------------------------------------------------------------------------
# byte preservation
# ---------------------------------------------------------------------------
class TestBytePreservation(SettingTreeCase):
CRLF_TEXT = (
'{\r\n'
'\t"type": "filament",\r\n'
'\t"name": "CRLF PLA @P1",\r\n'
'\t"from": "system",\r\n'
'\t"filament_id": "OFabc123",\r\n'
'\t"instantiation": "true",\r\n'
'\t"filament_type": [\r\n'
'\t\t"PLA"\r\n'
'\t]\r\n'
'}\r\n'
)
def test_crlf_and_tab_indentation_survive(self):
self.t.write_raw("VendorA", "filament", "CRLF PLA @P1",
self.CRLF_TEXT.encode("utf-8"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
raw = self.t.raw("VendorA", "filament", "CRLF PLA @P1")
self.assertEqual(raw.count(b"\n"), raw.count(b"\r\n")) # still CRLF-only
want = afi.generate_preset_setting_id("VendorA", "filament", "CRLF PLA @P1")
inserted = ('\t"setting_id": "%s",\r\n' % want).encode("utf-8")
# Every original byte survives: dropping the inserted line restores the file.
self.assertEqual(raw.replace(inserted, b"", 1),
self.CRLF_TEXT.encode("utf-8"))
def test_bom_survives(self):
raw_in = b"\xef\xbb\xbf" + json.dumps(
preset("BOM PLA @P1", filament_id="OFabc123"),
indent=4, ensure_ascii=False).encode("utf-8") + b"\n"
self.t.write_raw("VendorA", "filament", "BOM PLA @P1", raw_in)
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
raw = self.t.raw("VendorA", "filament", "BOM PLA @P1")
self.assertTrue(raw.startswith(b"\xef\xbb\xbf"))
self.assertEqual(raw.count(b"\xef\xbb\xbf"), 1)
want = afi.generate_preset_setting_id("VendorA", "filament", "BOM PLA @P1")
self.assertEqual(self.t.read("VendorA", "filament", "BOM PLA @P1")["setting_id"],
want)
self.assertEqual(
raw.replace((' "setting_id": "%s",\n' % want).encode("utf-8"), b"", 1),
raw_in)
def test_non_ascii_name_round_trips(self):
name = "拓竹 PLA @P1 0.4 nozzle"
raw_in = (json.dumps(preset(name, filament_id="OFabc123"), indent=4,
ensure_ascii=False).encode("utf-8") + b"\n")
self.t.write_raw("VendorA", "filament", name, raw_in)
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
raw = self.t.raw("VendorA", "filament", name)
self.assertIn(name.encode("utf-8"), raw) # not escaped to \uXXXX
data = self.t.read("VendorA", "filament", name)
self.assertEqual(data["name"], name)
self.assertEqual(data["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", name))
def test_surrounding_formatting_is_untouched_on_a_strip(self):
text = ('{\r\n'
'\t"type": "filament",\r\n'
'\t"name": "Odd @base",\r\n'
'\t"setting_id": "0000000000000000",\r\n'
'\t"instantiation": "false",\r\n'
'\t"compatible_printers": []\r\n'
'}\r\n')
self.t.write_raw("VendorA", "filament", "Odd @base", text.encode("utf-8"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(
self.t.raw("VendorA", "filament", "Odd @base").decode("utf-8"),
text.replace('\t"setting_id": "0000000000000000",\r\n', "", 1))
# ---------------------------------------------------------------------------
# byte-preserving key edits
# ---------------------------------------------------------------------------
class TestInsertAnchor(unittest.TestCase):
WITH_BOTH = ('{\n "name": "K @P1",\n "filament_id": "OFabc123",\n'
' "instantiation": "true"\n}\n')
WITH_INSTANTIATION = ('{\n "name": "K @P1",\n'
' "instantiation": "true"\n}\n')
def test_inserts_before_filament_id(self):
text, n = afi.insert_setting_id(self.WITH_BOTH, "0123456789abcdef")
self.assertEqual(n, 1)
json.loads(text)
self.assertEqual(text, self.WITH_BOTH.replace(
' "filament_id"',
' "setting_id": "0123456789abcdef",\n "filament_id"', 1))
def test_falls_back_to_instantiation(self):
text, n = afi.insert_setting_id(self.WITH_INSTANTIATION, "0123456789abcdef")
self.assertEqual(n, 1)
json.loads(text)
self.assertIn('"setting_id": "0123456789abcdef",\n "instantiation"', text)
def test_falls_back_to_name(self):
# Last-resort anchor: every preset has a name, so the insert does not
# depend on filament_id having been written first — a dry run, which
# writes none, must reach the same verdict as the real run.
text, n = afi.insert_setting_id('{\n "name": "K",\n "x": 1\n}\n',
"0123456789abcdef")
self.assertEqual(n, 1)
json.loads(text)
self.assertIn('"name": "K",\n "setting_id": "0123456789abcdef",', text)
def test_no_anchor_returns_zero(self):
src = '{\n "type": "filament"\n}\n'
text, n = afi.insert_setting_id(src, "0123456789abcdef")
self.assertEqual(n, 0)
self.assertEqual(text, src)
class TestKeyLineHelpers(unittest.TestCase):
def test_delete_trailing_comma_form(self):
text = ('{\n "name": "K",\n "setting_id": "OLD0000000000000",\n'
' "instantiation": "false"\n}\n')
out, n = afi.delete_key_line(text, "setting_id")
self.assertEqual(n, 1)
self.assertEqual(json.loads(out), {"name": "K", "instantiation": "false"})
self.assertEqual(out, text.replace(
' "setting_id": "OLD0000000000000",\n', "", 1))
def test_delete_last_property_form_consumes_the_preceding_comma(self):
text = ('{\n "name": "K",\n "instantiation": "false",\n'
' "setting_id": "OLD0000000000000"\n}\n')
out, n = afi.delete_key_line(text, "setting_id")
self.assertEqual(n, 1)
self.assertEqual(json.loads(out), {"name": "K", "instantiation": "false"})
self.assertEqual(out, '{\n "name": "K",\n "instantiation": "false"\n}\n')
def test_delete_requires_the_exact_old_value(self):
text = ('{\n "name": "K",\n "setting_id": "OLD0000000000000",\n'
' "instantiation": "false"\n}\n')
out, n = afi.delete_key_line(text, "setting_id", old_value="OTHER")
self.assertEqual((out, n), (text, 0))
_out, n = afi.delete_key_line(text, "setting_id",
old_value="OLD0000000000000")
self.assertEqual(n, 1)
def test_delete_missing_key_returns_zero(self):
text = '{\n "name": "K"\n}\n'
self.assertEqual(afi.delete_key_line(text, "setting_id"), (text, 0))
def test_delete_does_not_confuse_the_two_spellings(self):
text = ('{\n "name": "K",\n "settings_id": "TYPO000000000000",\n'
' "setting_id": "REAL000000000000",\n'
' "instantiation": "true"\n}\n')
out, n = afi.delete_key_line(text, "settings_id")
self.assertEqual(n, 1)
self.assertEqual(json.loads(out)["setting_id"], "REAL000000000000")
def test_replace_refuses_a_stale_old_value(self):
text = '{\n "setting_id": "OLD0000000000000"\n}\n'
out, n = afi.replace_key_value(text, "setting_id", "NEW0000000000000",
old_value="NOTTHIS000000000")
self.assertEqual((out, n), (text, 0))
out, n = afi.replace_key_value(text, "setting_id", "NEW0000000000000",
old_value="OLD0000000000000")
self.assertEqual(n, 1)
self.assertEqual(json.loads(out)["setting_id"], "NEW0000000000000")
def test_insert_key_line_prefers_before_over_after(self):
text = ('{\n "name": "K",\n "filament_id": "OFabc123",\n'
' "instantiation": "true"\n}\n')
out, n = afi.insert_key_line(text, "setting_id", "V",
before=("filament_id", "instantiation"),
after=("name",))
self.assertEqual(n, 1)
self.assertLess(out.index('"setting_id"'), out.index('"filament_id"'))
self.assertGreater(out.index('"setting_id"'), out.index('"name"'))
# The `before` tuple's own order decides, not the order in the file.
out, _n = afi.insert_key_line(text, "setting_id", "V",
before=("instantiation", "filament_id"))
self.assertGreater(out.index('"setting_id"'), out.index('"filament_id"'))
def test_insert_key_line_falls_back_to_after(self):
text = '{\n "name": "K",\n "from": "system"\n}\n'
out, n = afi.insert_key_line(text, "setting_id", "V",
before=("filament_id",), after=("name",))
self.assertEqual(n, 1)
self.assertEqual(out, '{\n "name": "K",\n "setting_id": "V",\n'
' "from": "system"\n}\n')
def test_insert_key_line_without_any_anchor(self):
text = '{\n "from": "system"\n}\n'
self.assertEqual(
afi.insert_key_line(text, "setting_id", "V", before=("filament_id",),
after=("name",)),
(text, 0))
# ---------------------------------------------------------------------------
# error paths: reported and counted, never raised
# ---------------------------------------------------------------------------
class TestErrorPaths(SettingTreeCase):
def test_unparsable_profile_is_reported_and_the_run_continues(self):
broken = b'{\n "name": "Broken @P1",\n oops\n}\n'
self.t.write_raw("VendorA", "filament", "Broken @P1", broken)
self.t.write("VendorA", "filament", preset("Good @P1"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("Broken @P1", out)
self.assertEqual(self.t.raw("VendorA", "filament", "Broken @P1"), broken)
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
def test_non_object_top_level_is_reported(self):
raw = b'[\n {"name": "K"}\n]\n'
self.t.write_raw("VendorA", "filament", "List @P1", raw)
self.t.write("VendorA", "filament", preset("Good @P1"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("List @P1", out)
self.assertEqual(self.t.raw("VendorA", "filament", "List @P1"), raw)
def test_nameless_instantiated_preset_is_reported(self):
nameless = {"type": "filament", "from": "system", "instantiation": "true"}
self.t.write("VendorA", "filament", nameless, name="Nameless")
self.t.write("VendorA", "filament", preset("Good @P1"))
before = self.t.raw("VendorA", "filament", "Nameless")
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("Nameless", out)
self.assertEqual(self.t.raw("VendorA", "filament", "Nameless"), before)
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
def test_a_nameless_preset_still_gets_its_misspelled_key_dropped(self):
# The nameless-preset error must not abandon an edit already queued for
# the same file: leaving "settings_id" behind would keep the validator
# red with no way for this tool to clear it.
nameless = {"type": "filament", "from": "system", "settings_id": "JUNK123",
"instantiation": "true"}
self.t.write("VendorA", "filament", nameless, name="Nameless")
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 1), out)
self.assertNotIn("settings_id", self.t.read("VendorA", "filament", "Nameless"))
self.assertNotIn("setting_id", self.t.read("VendorA", "filament", "Nameless"))
self.assertIn('misspelled "settings_id" dropped : 1', out)
def test_an_unanchorable_file_is_reported_not_raised(self):
# One oddly formatted profile must not abort the pass over all the
# others, and it must fail the same way in a dry run as in a real one.
raw = b'{ "type":"filament", "name":"Flat @P1", "instantiation":"true" }\n'
self.t.write_raw("VendorA", "filament", "Flat @P1", raw)
self.t.write("VendorA", "filament", preset("Good @P1"))
dry_changed, dry_errors, dry_out = self.t.run(dry_run=True)
changed, errors, out = self.t.run()
self.assertEqual((dry_changed, dry_errors), (changed, errors), dry_out)
self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("Flat @P1", out)
self.assertEqual(self.t.raw("VendorA", "filament", "Flat @P1"), raw)
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
def test_nameless_base_preset_is_fine(self):
# Only instantiated presets need an identity; a nameless base profile is
# simply left alone.
nameless = {"type": "filament", "from": "system", "instantiation": "false"}
self.t.write("VendorA", "filament", nameless, name="Nameless base")
before = self.t.bytes_map()
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (0, 0), out)
self.assertEqual(self.t.bytes_map(), before)
# ---------------------------------------------------------------------------
# the real tree
# ---------------------------------------------------------------------------
@unittest.skipUnless(os.path.isdir(REAL_PROFILES), "resources/profiles not present")
class TestRealTree(unittest.TestCase):
def test_shipped_tree_needs_no_setting_id_change(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
changed, errors = afi.generate_setting_ids(REAL_PROFILES, dry_run=True)
self.assertEqual((changed, errors), (0, 0), buf.getvalue())
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
class TestCli(SettingTreeCase):
def main(self, argv):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = afi.main(argv)
return rc, buf.getvalue()
def test_setting_id_only_run_touches_no_filament_id(self):
# "OFZZZZZZ" is not the mint of the preset's own triple, so the filament
# half of --generate has real work waiting on this tree (proven at the
# end): leaving the id alone is the narrowing's doing, not an idle tree.
self.t.write("VendorA", "filament",
preset("A PLA @P1", filament_id="OFZZZZZZ",
filament_vendor=["AV"], filament_type=["PLA"]))
self.t.write("VendorA", "machine",
preset("P1 0.4 nozzle", type_name="machine"))
rc, out = self.main(["--generate", "--setting-id",
"--profiles", self.t.profiles])
self.assertEqual(rc, 0, out)
filament = self.t.read("VendorA", "filament", "A PLA @P1")
self.assertEqual(filament["filament_id"], "OFZZZZZZ")
self.assertEqual(filament["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
machine = self.t.read("VendorA", "machine", "P1 0.4 nozzle")
self.assertNotIn("filament_id", machine)
self.assertEqual(machine["setting_id"],
afi.generate_preset_setting_id("VendorA", "machine", "P1 0.4 nozzle"))
# ... and the skipped half does re-mint that id when it is allowed to run.
changed, errors, out = self.t.run_filament_ids()
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(
self.t.read("VendorA", "filament", "A PLA @P1")["filament_id"],
afi.generate_filament_id("AV", "PLA", "A PLA"))
def test_dry_run_setting_id_writes_nothing(self):
self.t.write("VendorA", "filament", preset("A PLA @P1"))
before = self.t.bytes_map()
rc, out = self.main(["--generate", "--setting-id", "--dry-run",
"--profiles", self.t.profiles])
self.assertEqual(rc, 0, out)
self.assertIn("1 file(s) would change", out) # there WAS one to write
self.assertEqual(self.t.bytes_map(), before)
# the real run then writes exactly it
rc, out = self.main(["--generate", "--setting-id",
"--profiles", self.t.profiles])
self.assertEqual(rc, 0, out)
self.assertIn("1 file(s) changed", out)
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament",
"A PLA @P1"))
def test_setting_id_without_generate_is_a_usage_error(self):
with contextlib.redirect_stderr(io.StringIO()), \
self.assertRaises(SystemExit) as cm:
afi.main(["--setting-id", "--profiles", self.t.profiles])
self.assertEqual(cm.exception.code, 2)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Generate resources/printers/bambu_filament_ids.json: the map from Orca's Generate resources/printers/bambu_filament_ids.json: the map from Orca's
content-addressed filament_id ("OF" + 6 base62 chars, see assign_filament_ids.py) content-addressed filament_id ("OF" + 6 base62 chars, see orca_id_tool.py)
to Bambu Lab's own AMS/RFID catalog id ("GF..." etc.) for the subset of filament to Bambu Lab's own AMS/RFID catalog id ("GF..." etc.) for the subset of filament
products Bambu ships. products Bambu ships.
@@ -54,7 +54,7 @@ import tempfile
import datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from assign_filament_ids import ( # noqa: E402 from orca_id_tool import ( # noqa: E402
BAMBU_MAP_PATH, BAMBU_MAP_PATH,
OFL, OFL,
OF_ID_RE, OF_ID_RE,

View File

@@ -545,7 +545,7 @@ std::string generate_preset_setting_id(const std::string& vendor, const std::str
return ""; return "";
// Dedicated namespace for preset setting_ids, distinct from the cloud per-user // Dedicated namespace for preset setting_ids, distinct from the cloud per-user
// namespace (OrcaCloudServiceAgent). Keep in sync with scripts/assign_vendor_setting_ids.py; // namespace (OrcaCloudServiceAgent). Keep in sync with scripts/orca_id_tool.py;
// never change this constant. // never change this constant.
static const boost::uuids::uuid vendor_namespace = static const boost::uuids::uuid vendor_namespace =
boost::uuids::string_generator()("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f"); boost::uuids::string_generator()("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f");

View File

@@ -93,8 +93,8 @@ class PresetBundle;
// Deterministic preset setting_id: uuid5(vendor/type/name) -> 16 base62 chars. // Deterministic preset setting_id: uuid5(vendor/type/name) -> 16 base62 chars.
// Pure function of a system preset's identity, so the value can be assigned by // Pure function of a system preset's identity, so the value can be assigned by
// scripts/assign_vendor_setting_ids.py and recomputed here when a profile ships // scripts/orca_id_tool.py and recomputed here when a profile ships without it.
// without it. MUST stay byte-identical to scripts/assign_vendor_setting_ids.py. // MUST stay byte-identical to scripts/orca_id_tool.py.
// This is NOT the per-user cloud-sync setting_id // This is NOT the per-user cloud-sync setting_id
// (OrcaCloudServiceAgent::generate_uuid_for_setting_id) - do not conflate them. // (OrcaCloudServiceAgent::generate_uuid_for_setting_id) - do not conflate them.
std::string generate_preset_setting_id(const std::string& vendor, std::string generate_preset_setting_id(const std::string& vendor,

View File

@@ -5293,8 +5293,8 @@ std::string PresetBundle::load_vendor_preset(
loaded.description = entry.description; loaded.description = entry.description;
loaded.setting_id = entry.setting_id; loaded.setting_id = entry.setting_id;
// Derive the preset setting_id on the fly when a profile ships without one, // Derive the preset setting_id on the fly when a profile ships without one,
// matching scripts/assign_vendor_setting_ids.py. Only instantiated presets // matching scripts/orca_id_tool.py. Only instantiated presets carry an id;
// carry an id; non-instantiated base profiles return earlier above. This never // non-instantiated base profiles return earlier above. This never
// touches the per-user cloud-sync setting_id written into user .info files. // touches the per-user cloud-sync setting_id written into user .info files.
if (loaded.setting_id.empty() && entry.instantiation == "true") if (loaded.setting_id.empty() && entry.instantiation == "true")
loaded.setting_id = generate_preset_setting_id( loaded.setting_id = generate_preset_setting_id(

View File

@@ -5,10 +5,10 @@
using namespace Slic3r; using namespace Slic3r;
// Golden vectors from the Python reference generate_preset_setting_id (defined in // Golden vectors from the Python reference generate_preset_setting_id (defined in
// scripts/assign_vendor_setting_ids.py). The C++ generate_preset_setting_id() MUST stay // scripts/orca_id_tool.py). The C++ generate_preset_setting_id() MUST stay byte-identical
// byte-identical to it, otherwise app-side on-the-fly ids would diverge from the // to it, otherwise app-side on-the-fly ids would diverge from the
// script-assigned ones in the profiles. Regenerate a vector with: // script-assigned ones in the profiles. Regenerate a vector with:
// python3 -c "from assign_vendor_setting_ids import generate_preset_setting_id as g; print(g('Afinia','filament','Afinia ABS @Afinia H400'))" // python3 -c "import sys; sys.path.insert(0, 'scripts'); from orca_id_tool import generate_preset_setting_id as g; print(g('Afinia','filament','Afinia ABS @Afinia H400'))"
TEST_CASE("preset setting_id matches the Python reference", "[Preset][setting_id]") { TEST_CASE("preset setting_id matches the Python reference", "[Preset][setting_id]") {
struct Vec { const char* vendor; const char* type; const char* name; const char* expected; }; struct Vec { const char* vendor; const char* type; const char* name; const char* expected; };
const Vec vectors[] = { const Vec vectors[] = {