Merge branch 'main' into feat/printer-agent-infra

This commit is contained in:
Ian Chua
2026-09-28 20:48:37 +08:00
2933 changed files with 209931 additions and 266930 deletions
+51 -37
View File
@@ -138,7 +138,13 @@ PROFILE_SUBDIRS = ("filament", "process", "machine")
PROFILE_TYPES = ("machine_model", "process", "filament", "machine")
# Data files that sit under a vendor bundle but are not presets: no name, no type.
NON_PROFILE_FILES = {"filaments_color_codes.json", "cli_config.json"}
NON_PROFILE_FILES = {
"filaments_color_codes.json",
"cli_config.json",
"filament_id_map.json",
"filament_name_map.json",
"support_recommended_params.json",
}
# Mirror PrintConfigDef::handle_legacy's ignore set in PrintConfig.cpp; a test
# checks parity. Used by normalize and check. Active options and
@@ -431,6 +437,7 @@ def resolve_filament_field(name, field, filaments, ofl_filaments, seen=None, in_
the same hop semantics as resolve_filament_id: own value, else walk
`inherits` in the vendor map with OFL base-bundle fallback. Values are list
options — the first element counts; "" when the chain never defines one.
Templates pulled in by `include` are not consulted: none states either field.
"""
if seen is None:
seen = set()
@@ -881,8 +888,8 @@ def _vendor_json_files(vendor_path):
def check_preset_name_uniqueness(profiles_dir, vendor):
"""No two profiles in a bundle may share a type and a name, indexed or not.
The loader resolves "inherits" through a per-type map of the bundle's profiles
(PresetBundle.cpp load_subfiles), and std::map::emplace keeps the first
The loader resolves "inherits" and "include" through per-type maps of the bundle's
profiles (PresetBundle.cpp load_subfiles), and std::map::emplace keeps the first
insertion: a second file claiming the name is silently dropped, and which one
wins is nothing but index order. An unindexed twin counts too - it is one
sub_path edit away from deciding that silently.
@@ -1281,11 +1288,11 @@ def check_normalized(profiles_dir, vendor):
Those two commands define a profile file's canonical shape - identifying keys
first, keys the slicer no longer reads gone, filament options that are vectors
written as vectors - and a <vendor>.json's canonical lists, ordered parents-first
so the loader resolves every "inherits" in one pass. Running them over a
contributed bundle has to be a no-op; where it would not be, the file that was
reviewed is not the file that ships, and the next maintainer to run normalize
carries an unrelated diff into their own change.
written as vectors - and a <vendor>.json's canonical lists, ordered
dependencies-first so the loader resolves every "inherits" and "include" in one
pass. Running them over a contributed bundle has to be a no-op; where it would
not be, the file that was reviewed is not the file that ships, and the next
maintainer to run normalize carries an unrelated diff into their own change.
It asks the normalize and update-index sections below rather than restating what
they do, because a second definition of normal is free to drift from the one that
@@ -2028,9 +2035,9 @@ def trim_profiles(profiles_dir=PROFILES_DIR, vendors=None, profile_types=None,
cli_config.json carry no "type" - all of them stay. A file that cannot be parsed
is reported and kept: never delete what could not be read.
An unindexed file that some surviving profile names in "inherits" is kept too,
and reported, UNLESS an indexed profile already carries that name: "inherits" is
resolved by preset name, so the indexed one is the parent every child actually
An unindexed file that some surviving profile names in "inherits" or "include" is
kept too, and reported, UNLESS an indexed profile already carries that name: both
are resolved by preset name, so the indexed one is the parent every child actually
gets, and the unindexed file is a stale copy the loader never reaches. Where no
indexed profile provides the name the inheriting preset really is broken, and
deleting the file would destroy the only record of the settings it was written
@@ -2063,7 +2070,7 @@ def trim_profiles(profiles_dir=PROFILES_DIR, vendors=None, profile_types=None,
listed.add(posixpath.normpath(sub_path.replace("\\", "/")))
candidates = {} # path -> profile, for every unindexed preset
inherited = set() # every name the files that stay claim as a parent
inherited = set() # every name the files that stay claim as a parent or include
provided = {} # name -> sub_path, for the profiles the loader can see
for sub in subs:
for path in _walk_json(os.path.join(vendor_dir, sub)):
@@ -2078,8 +2085,7 @@ def trim_profiles(profiles_dir=PROFILES_DIR, vendors=None, profile_types=None,
if not isinstance(profile, dict):
continue
if sub_path in listed or profile.get("type") not in PROFILE_TYPES:
if profile.get("inherits"):
inherited.add(profile["inherits"])
inherited.update(profile_dependencies(profile))
if sub_path in listed and profile.get("name"):
provided[profile["name"]] = sub_path
continue
@@ -2098,8 +2104,7 @@ def trim_profiles(profiles_dir=PROFILES_DIR, vendors=None, profile_types=None,
for path, profile in rescued.items():
del candidates[path]
kept[path] = profile
if profile.get("inherits"):
inherited.add(profile["inherits"])
inherited.update(profile_dependencies(profile))
for path in sorted(kept):
print_warning(f"{_rel(path, profiles_dir)}: not indexed by {vendor}.json but "
@@ -2136,40 +2141,48 @@ def trim_profiles(profiles_dir=PROFILES_DIR, vendors=None, profile_types=None,
# update-index
# ---------------------------------------------------------------------------
def topological_sort(profiles):
"""Order index entries parents-first, so the loader resolves inherits in one pass.
def profile_dependencies(profile):
"""The names a profile needs loaded before it: its parent and every include."""
include = profile.get("include") or []
if isinstance(include, str):
include = [include]
return [name for name in [profile.get("inherits"), *include] if name]
Entries whose parent is not in the same section keep their own (sorted) order at
the end; the loader finds those parents through the base bundle instead.
def topological_sort(profiles):
"""Order index entries dependencies-first, so the loader resolves every
"inherits" and "include" in one pass.
Entries that neither depend on nor are depended on by another in the same section
go at the end in name order; the loader finds their parents, if any, through the
base bundle instead. Every entry on a dependency cycle, which no order can satisfy,
goes there too.
"""
graph = defaultdict(list)
in_degree = defaultdict(int)
by_name = {p["name"]: p for p in profiles}
all_names = set(by_name)
placed = set()
for profile in profiles:
parent = profile.get("inherits")
child = profile["name"]
if parent in all_names:
graph[parent].append(child)
in_degree[child] += 1
in_degree.setdefault(parent, 0)
placed.add(child)
placed.add(parent)
for parent in profile_dependencies(profile):
if parent in all_names:
graph[parent].append(child)
in_degree[child] += 1
in_degree.setdefault(parent, 0)
queue = sorted(name for name, degree in in_degree.items() if degree == 0)
result = []
while queue:
current = queue.pop(0)
result.append(by_name[current])
placed.add(current)
for child in sorted(graph[current]):
in_degree[child] -= 1
if in_degree[child] == 0:
queue.append(child)
result.extend(by_name[name] for name in sorted(all_names - placed))
ordered = {p["name"] for p in result}
result.extend(by_name[name] for name in sorted(all_names - ordered))
return result
@@ -2219,15 +2232,15 @@ def build_index_sections(profiles_dir, vendor, profile_types=None):
"name": name,
"sub_path": os.path.relpath(path, vendor_dir).replace(os.sep, "/"),
}
if profile.get("inherits"):
entry["inherits"] = profile["inherits"]
for key in ("inherits", "include"):
if profile.get(key):
entry[key] = profile[key]
by_name[name].append(entry["sub_path"])
entries.append(entry)
sorted_entries = topological_sort(entries)
for entry in sorted_entries:
entry.pop("inherits", None) # ordering input only, not part of the index
sections[profile_type + "_list"] = sorted_entries
# inherits/include were ordering input only; the index holds name and sub_path
sections[profile_type + "_list"] = [{"name": e["name"], "sub_path": e["sub_path"]}
for e in topological_sort(entries)]
for rel, found in sorted(unplaceable.items()):
problems.append(f'{rel}: type {found!r} is not one of {list(PROFILE_TYPES)}, so it '
@@ -2412,7 +2425,8 @@ def build_parser():
add("update-index", [vendor_opt, type_opt, dry_run_opt, profiles_opt],
"regenerate the *_list sections of <vendor>.json",
"Rebuild the *_list sections of each <vendor>.json from the files on disk,\n"
"ordered parents-first so the loader resolves inherits in one pass.\n"
"ordered dependencies-first so the loader resolves inherits and include\n"
"in one pass.\n"
"\n"
"A profile is indexed under the section its own \"type\" names, so run\n"
"normalize first: it is what writes a missing type. Two files claiming one\n"
+45
View File
@@ -405,6 +405,20 @@ class TestTripleResolution(unittest.TestCase):
self.assertEqual(afi.resolve_triple("MyPLA @P1", fmap, {}),
("MyVendor", "PLA", "MyPLA"))
def test_split_vendor_and_type_bases_resolve(self):
# A partial base is normal, not an error: vendor and type may live on
# different ancestors, with an intermediate supplying neither (the
# Snapmaker shape). The pair is complete at the instantiated preset.
recs = [
self.rec("APLA @P1", inherits="mid"),
self.rec("mid", inherits="typebase"),
self.rec("typebase", filament_type=["PLA"], inherits="vendorbase"),
self.rec("vendorbase", filament_vendor=["AV"]),
]
fmap = {r["name"]: r for r in recs}
self.assertEqual(afi.resolve_triple("APLA @P1", fmap, {}),
("AV", "PLA", "APLA"))
# ---------------------------------------------------------------------------
# checks on synthetic trees
@@ -417,6 +431,24 @@ class TestChecks(OfCleanTreeCase):
self.assertNotIn("[ERROR]", out)
self.assertNotIn("[WARNING]", out)
def test_instantiated_preset_over_partial_bases_is_silent(self):
# Vendor and type split across two non-instantiated bases, an
# intermediate base with neither: base profiles are allowed to be
# partial. Only the instantiated preset must resolve both.
self.t.write_preset("VendorA", preset("XPLA vendorbase", instantiation=False,
filament_vendor="XV"))
self.t.write_preset("VendorA", preset("XPLA typebase", instantiation=False,
filament_type="PLA",
inherits="XPLA vendorbase"))
self.t.write_preset("VendorA", preset("XPLA mid", instantiation=False,
inherits="XPLA typebase"))
self.t.write_preset("VendorA", preset(
"XPLA @P1", inherits="XPLA mid",
filament_id=afi.generate_filament_id("XV", "PLA", "XPLA"),
compatible_printers=["P1"]))
errors, out = self.t.check()
self.assertEqual(errors, 0, out)
def test_check1_unknown_non_of_id(self):
self.t.write_preset("VendorA", preset("BPLA @base", filament_id="BOGUS_9",
instantiation=False,
@@ -1523,6 +1555,19 @@ class TestRealTree(unittest.TestCase):
self.assertEqual(analysis["missing_effective"], [])
self.assertEqual(analysis["read_errors"], [])
def test_every_instantiated_filament_resolves_vendor_and_type(self):
# The property the web guide resolves at load: a partial base is fine as
# long as the instantiated preset ends up with both fields. Guards the
# split-base bundles (Snapmaker, Anker, SeeMeCNC).
analysis = afi.analyze_tree(REAL_PROFILES)
unresolved = [
(vendor, rec["name"], rec["triple"][0], rec["triple"][1])
for vendor, filaments in analysis["vendors"].items()
for rec in filaments.values()
if rec["instantiation"] and not (rec["triple"][0] and rec["triple"][1])
]
self.assertEqual(unresolved, [])
# ---------------------------------------------------------------------------
# review-fix regressions
# ---------------------------------------------------------------------------
+20
View File
@@ -411,6 +411,26 @@ class TestUpdateIndex(TreeCase):
for entry in self.t.read_index("V")["filament_list"]:
self.assertEqual(sorted(entry), ["name", "sub_path"])
def test_include_targets_are_listed_before_their_users(self):
# The loader resolves include like inherits: in one pass over the list, so
# a template must be listed before every preset that includes it - even
# though a template has no parent of its own to order it by.
self.t.write("V", "machine/P.json", {"type": "machine", "name": "P",
"include": ["T start", "T end"]})
self.t.write("V", "machine/T start.json", {"type": "machine", "name": "T start"})
self.t.write("V", "machine/T end.json", {"type": "machine", "name": "T end"})
self.t.write("V", "filament/F.json", {"type": "filament", "name": "F",
"inherits": "B", "include": "S"})
self.t.write("V", "filament/B.json", {"type": "filament", "name": "B"})
self.t.write("V", "filament/S.json", {"type": "filament", "name": "S"})
rc, out = self.run_command("update-index")
self.assertEqual(rc, 0, out)
machines = [e["name"] for e in self.t.read_index("V")["machine_list"]]
self.assertEqual(machines, ["T end", "T start", "P"])
filaments = [e["name"] for e in self.t.read_index("V")["filament_list"]]
self.assertLess(filaments.index("B"), filaments.index("F"))
self.assertLess(filaments.index("S"), filaments.index("F"))
def test_a_profile_with_no_usable_type_is_reported_not_dropped(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
self.t.write("V", "filament/B.json", {"name": "B"})