From 00a2c5a0871a107c5f910d6bfd241808477d3660 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 28 Sep 2026 17:17:49 +0800 Subject: [PATCH] update profile checks --- .github/workflows/check_profiles.yml | 3 + scripts/orca_profile_tool.py | 804 ++++++++++++++++++++++++++- scripts/tests/test_profile_tool.py | 334 ++++++++++- 3 files changed, 1131 insertions(+), 10 deletions(-) diff --git a/.github/workflows/check_profiles.yml b/.github/workflows/check_profiles.yml index e668482bfd..e0b081deb2 100644 --- a/.github/workflows/check_profiles.yml +++ b/.github/workflows/check_profiles.yml @@ -14,6 +14,9 @@ on: # this workflow. - 'resources/printers/**' - 'scripts/**' + # orca_profile_tool.py reads the variant key sets from PrintConfig.cpp, and its + # tests the obsolete keys, so a PR changing either must be checked against the profiles. + - 'src/libslic3r/PrintConfig.cpp' - ".github/workflows/check_profiles.yml" workflow_dispatch: diff --git a/scripts/orca_profile_tool.py b/scripts/orca_profile_tool.py index 29863f8d1a..f7f622c8c0 100755 --- a/scripts/orca_profile_tool.py +++ b/scripts/orca_profile_tool.py @@ -6,6 +6,7 @@ usage: python scripts/orca_profile_tool.py [options] commands: check validate the whole tree -- what CI runs + fix-variant resize the variant arrays check rejects generate-id write the filament_id / setting_id each profile's identity implies normalize rewrite profile files into their canonical shape trim delete profile files no .json list references @@ -89,6 +90,7 @@ parent in the OFL map. filament_vendor / filament_type resolve the same way. """ import argparse +import functools import json import os import posixpath @@ -179,6 +181,99 @@ VECTOR_KEYS = { "filament_type", } +PRINT_CONFIG_CPP = os.path.normpath( + os.path.join(SCRIPTS_DIR, "..", "src", "libslic3r", "PrintConfig.cpp")) + + +@functools.lru_cache(maxsize=None) +def _variant_scheme(): + """Per config preset type: the key listing the preset's own variants, and the + stride of every key sized by it. + + Read from the four variant key sets in PrintConfig.cpp, the std::set + initializers DynamicPrintConfig::get_parameter_size sizes by, so membership is + the engine's own and not guessable from names. Each holds one value per variant; + printer_options_with_variant_2, the machine_max_* limits, holds a (normal, + silent) pair per variant, stride 2. + """ + with open(PRINT_CONFIG_CPP, encoding="utf-8") as f: + source = f.read() + + def members(name): + match = re.search(r"std::set\s+" + name + r"\s*=\s*\{(.*?)\};", + source, re.DOTALL) + if match is None: + raise RuntimeError(f"{PRINT_CONFIG_CPP} no longer defines {name}") + # An initializer can carry a commented-out entry (filament_extruder_id). + body = re.sub(r"//[^\n]*|/\*.*?\*/", "", match.group(1), flags=re.DOTALL) + return sorted(set(re.findall(r'"([^"\n]+)"', body))) + + return { + "machine": ("printer_extruder_variant", { + **dict.fromkeys(members("printer_options_with_variant_1"), 1), + **dict.fromkeys(members("printer_options_with_variant_2"), 2)}), + "process": ("print_extruder_variant", + dict.fromkeys(members("print_options_with_variant"), 1)), + "filament": ("filament_extruder_variant", + dict.fromkeys(members("filament_options_with_variant"), 1)), + } + + +@functools.lru_cache(maxsize=None) +def _variant_names(): + """The parts a variant string is built from, read from PrintConfig.cpp. + + A variant is selected by exact string compare of " ", so what is legal is the two enum maps the engine builds that string from, + not the list of strings some profile happens to ship. Returns (extruder types, + writable volume types, variant-name rewrites, extruder_type rewrites): the volume + types minus RUNTIME_VOLUME_TYPES, and the spellings PrintConfigDef::handle_legacy + rewrites in the six variant keys. The loader still accepts such a spelling, but a + profile must write the enum name, so the rewrite only names that name in the error; + it is read rather than restated because it belongs to the engine too. + """ + with open(PRINT_CONFIG_CPP, encoding="utf-8") as f: + source = f.read() + + def enum_map(name): + match = re.search(r"t_config_enum_values\s+" + name + r"\s*=\s*\{(.*?)\};", + source, re.DOTALL) + if match is None: + raise RuntimeError(f"{PRINT_CONFIG_CPP} no longer defines {name}") + body = re.sub(r"//[^\n]*|/\*.*?\*/", "", match.group(1), flags=re.DOTALL) + return set(re.findall(r'"([^"\n]+)"', body)) + + def rewrites(anchor): + """The (old, new) ReplaceString pairs of handle_legacy's branch for one key. + + An absent branch means the loader rewrites nothing, which is a defined answer + rather than a broken parse: every legacy name is then reported as unknown. + """ + match = re.search(r'opt_key == "' + anchor + r'"\s*\)\s*\{(.*?)\}', + source, re.DOTALL) + return ({} if match is None else + dict(re.findall(r'ReplaceString\(value,\s*"([^"]+)",\s*"([^"]+)"\)', + match.group(1)))) + + return (enum_map("s_keys_map_ExtruderType"), + enum_map("s_keys_map_NozzleVolumeType") - RUNTIME_VOLUME_TYPES, + rewrites("extruder_variant_list"), + rewrites("extruder_type")) + + +# What a preset that writes no variant layout takes: the default extruder_type and +# nozzle volume type (PrintConfig.cpp defaults for extruder_type and +# default_nozzle_volume_type), one such variant per extruder on a machine +# (extend_extruder_variant) and a single one on a process or filament. +DEFAULT_EXTRUDER_TYPE = "Direct Drive" +DEFAULT_NOZZLE_VOLUME_TYPE = "Standard" +DEFAULT_VARIANT = f"{DEFAULT_EXTRUDER_TYPE} {DEFAULT_NOZZLE_VOLUME_TYPE}" + +# A nozzle volume type the engine computes for a hybrid extruder at runtime, and no +# profile ever writes: s_keys_map_NozzleVolumeType carries it for the several +# sub-nozzles of one extruder, which no single variant string can name. +RUNTIME_VOLUME_TYPES = {"Hybrid"} + OF_ID_RE = re.compile(r"^OF[0-9A-Za-z]{6}$") # Filament name = preset base name: strip the first "@..." suffix. The space before # "@" is optional because names like "Afinia PLA@HS" exist. @@ -1248,6 +1343,482 @@ def check_vector_type_keys(profiles_dir, vendor): return error_count +@functools.lru_cache(maxsize=None) +def load_vendor_configs(profiles_dir, vendor): + """name -> (tree-relative file, data) per config preset type, from the bundle's + index lists - the per-kind maps the loader resolves inherits and include in. + + Cached because every vendor's filaments may reach OrcaFilamentLibrary. Unreadable + files are check_name_consistency's to report; when two indexed files claim one + name the first wins, as std::map::emplace keeps the first insertion. + """ + configs = {sub: {} for sub in PROFILE_SUBDIRS} + try: + index = load_json(os.path.join(str(profiles_dir), vendor + ".json")) + except (OSError, ValueError): + return configs + for section in PROFILE_TYPES: + for entry in index.get(section + "_list", []): + sub_path = entry.get("sub_path", "") + try: + data = load_json(os.path.join(str(profiles_dir), vendor, sub_path)) + except (OSError, ValueError): + continue + if isinstance(data, dict) and data.get("type") in configs and data.get("name"): + configs[data["type"]].setdefault(data["name"], (f"{vendor}/{sub_path}", data)) + return configs + + +def compose_config(name, presets, ofl_presets, cache, in_ofl=False, seen=frozenset()): + """A preset's config as the loader composes it: the parent's composed config, + then each `include` in the order listed, then its own keys - later layers win. + + Each key maps to (value, path): the value as the winning file wrote it, width + included, and the tree-relative files it passes through on the way to this + preset, from that file down to this one. `inherits` resolves in the bundle + first, then in OrcaFilamentLibrary (filaments only) and stays there once it + enters; `include` never leaves the includer's bundle. A name that resolves to + nothing, or a cycle, contributes nothing - the loader's own error paths leave + the preset without it too. + """ + if (in_ofl, name) in cache: + return cache[(in_ofl, name)] + bundle = ofl_presets if in_ofl else presets + if name not in bundle and not in_ofl and name in ofl_presets: + bundle, in_ofl = ofl_presets, True + if name not in bundle or name in seen: + return {} + seen = seen | {name} + rel, data = bundle[name] + config = {} + if data.get("inherits"): + config.update( + compose_config(data["inherits"], presets, ofl_presets, cache, in_ofl, seen)) + include = data.get("include") or [] + for included in ([include] if isinstance(include, str) else include): + if included in bundle: + config.update(compose_config(included, presets, ofl_presets, cache, in_ofl, seen)) + config = {key: (value, path + (rel,)) for key, (value, path) in config.items()} + config.update((key, (value, (rel,))) for key, value in data.items()) + cache[(in_ofl, name)] = config + return config + + +def _as_list(value): + return value if isinstance(value, list) else [value] + + +def _per_extruder(values, i): + """Entry i of a per-extruder vector, padded the way the loader pads: by its first value.""" + return values[i] if i < len(values) else values[0] + + +def _check_printer_layout(rel, config): + """A machine's extruder_variant_list, printer_extruder_variant, printer_extruder_id, + extruder_type and default_nozzle_volume_type agree with each other, the loader's + defaults standing in for whatever the composed config leaves out. + + A machine that writes none of the three list keys takes one default variant + "Direct Drive Standard" per extruder, so there is nothing to check. Otherwise + printer_extruder_variant is extruder_variant_list flattened extruder-major and + printer_extruder_id gives each of its entries its 1-based extruder (extruder 1 + everywhere when absent); every variant starts with its extruder's extruder_type + ("Direct Drive" when absent); the default_nozzle_volume_type ("Standard" when + absent) is one the extruder lists. A list without the pair leaves the loader on + the default single variant; a pair without the list gives the sidebar no variant + switch, and with single_extruder_multi_material off the loader replaces it with + one default variant per extruder. Returns (errors, warnings). + """ + menu = config.get("extruder_variant_list") + variant = config.get("printer_extruder_variant") + if menu is None and variant is None: + return 0, 0 + errors = warnings = 0 + extruders = len(_as_list(config.get("nozzle_diameter", [""]))) + types = _as_list(config.get("extruder_type", [DEFAULT_EXTRUDER_TYPE])) + volume_types = _as_list(config.get("default_nozzle_volume_type", [DEFAULT_NOZZLE_VOLUME_TYPE])) + semm_off = str(_as_list(config.get("single_extruder_multi_material", ["1"]))[0]) \ + .lower() in ("0", "false") + flat, ids = [], [] + if menu is not None: + menu = _as_list(menu) + if len(menu) != extruders: + print_error(f"{rel}: extruder_variant_list has {len(menu)} entries for {extruders} " + f"extruder(s) (nozzle_diameter); it holds one entry per extruder") + errors += 1 + for i, entry in enumerate(menu): + variants = entry.split(",") + flat += variants + ids += [str(i + 1)] * len(variants) + extruder_type = _per_extruder(types, i) + if not all(v.startswith(extruder_type + " ") for v in variants): + print_error(f'{rel}: extruder_variant_list entry {i + 1} "{entry}" holds a ' + f"variant that does not start with extruder {i + 1}'s " + f'extruder_type "{extruder_type}"') + errors += 1 + if f"{extruder_type} {_per_extruder(volume_types, i)}" not in variants: + print_error(f'{rel}: default_nozzle_volume_type "{_per_extruder(volume_types, i)}" is ' + f'not a nozzle volume type extruder {i + 1} lists ("{entry}")') + errors += 1 + if variant is not None: + variant = _as_list(variant) + written_ids = config.get("printer_extruder_id") + pair_ids = ["1"] * len(variant) if written_ids is None else _as_list(written_ids) + if len(pair_ids) != len(variant): + print_error(f"{rel}: printer_extruder_id has {len(pair_ids)} entries for the " + f"{len(variant)} entries of printer_extruder_variant") + errors += 1 + if menu is not None and variant is not None: + if variant != flat: + print_error(f"{rel}: printer_extruder_variant {json.dumps(variant)} is not " + f"extruder_variant_list flattened extruder-major {json.dumps(flat)}") + errors += 1 + elif pair_ids != ids: + print_error(f"{rel}: printer_extruder_id {json.dumps(pair_ids)}" + f"{' (absent, so extruder 1 everywhere)' if written_ids is None else ''} " + f"does not give each entry of printer_extruder_variant its 1-based " + f"extruder {json.dumps(ids)}") + errors += 1 + elif menu is not None: + if len(flat) > 1: + print_error(f"{rel}: extruder_variant_list offers {len(flat)} variants but the " + f"preset writes no printer_extruder_variant/printer_extruder_id, so the " + f'loader sizes every variant array to the single default variant ' + f'"{DEFAULT_VARIANT}" and no other variant has a value; write the pair') + errors += 1 + else: + crowded = [i for i in dict.fromkeys(pair_ids) if pair_ids.count(i) > 1] + if crowded: + print_error(f"{rel}: printer_extruder_variant lists several variants for extruder " + f"{crowded[0]} but the preset writes no extruder_variant_list, so the " + f"sidebar offers no variant switch and the extra variants are unreachable") + errors += 1 + elif semm_off and variant != [DEFAULT_VARIANT] * extruders: + print_warning(f"{rel}: with single_extruder_multi_material off the loader replaces " + f"printer_extruder_variant {json.dumps(variant)} by one " + f'"{DEFAULT_VARIANT}" per extruder, because extruder_variant_list is ' + f"absent; write the list or drop the pair") + warnings += 1 + return errors, warnings + + +def _variant_length(ptype, config): + """(variant length, reason) of a config as compose_config gives it, after + inherits and include. + + The variant length is the length of the preset's *_extruder_variant list. A + machine without one has the variants its extruder_variant_list offers, and + without that list one default "Direct Drive Standard" variant per extruder + (len(nozzle_diameter)) - the list's default in extend_extruder_variant. A process + or filament without its list has the single default variant. The reason says + which of these gave the length, and is empty when the list is written. + """ + list_key = _variant_scheme()[ptype][0] + if list_key in config: + return len(_as_list(config[list_key][0])), "" + if ptype != "machine": + return 1, f"no {list_key}, so the single default variant" + if "extruder_variant_list" in config: + menu = _as_list(config["extruder_variant_list"][0]) + return (sum(len(entry.split(",")) for entry in menu), + f"no {list_key}, so the variants extruder_variant_list offers") + extruders = len(_as_list(config["nozzle_diameter"][0])) if "nozzle_diameter" in config else 1 + return extruders, f"no {list_key}, so one default variant per extruder" + + +def _variant_presets(profiles_dir, vendor): + """Yield (ptype, rel, data, config, variant length, reason) for every machine, + process and filament preset of the bundle, bases included: data as its file + holds it, config as compose_config gives it, and the variant length and reason + as _variant_length does. + """ + presets = load_vendor_configs(profiles_dir, vendor) + library = {} if vendor == OFL else load_vendor_configs(profiles_dir, OFL)["filament"] + for ptype in _variant_scheme(): + ofl_presets = library if ptype == "filament" else {} + cache = {} + for name, (rel, data) in sorted(presets[ptype].items(), key=lambda item: item[1][0]): + config = compose_config(name, presets[ptype], ofl_presets, cache) + yield (ptype, rel, data, config, *_variant_length(ptype, config)) + + +def indexed_sub_paths(profiles_dir, vendor): + """The sub_paths a bundle's index lists, normalized, or None with no index. + + The loader reads what .json references and nothing else, so a file no list + names never loads and its contents are not worth judging; "filament/./X.json" + names the same file as "filament/X.json". A bundle with no readable index is + check_name_consistency's to report. Returns a set of sub_paths, or None. + """ + try: + library = load_json(os.path.join(profiles_dir, vendor + ".json")) + except (ValueError, OSError): + return None + listed = set() + for section in PROFILE_TYPES: + for entry in library.get(section + "_list", []): + if entry.get("sub_path"): + listed.add(posixpath.normpath(entry["sub_path"].replace("\\", "/"))) + return listed + + +def _variant_entries(key, value): + """(where, variant string) of every entry of a variant list key, as the loader + reads it: extruder_variant_list holds one ","-joined menu per extruder, the other + three one variant per array entry.""" + if key == "extruder_variant_list": + return [(f"extruder {i}", variant) + for i, entry in enumerate(_as_list(value), 1) + for variant in str(entry).split(",")] + return [(f"entry {i}", str(variant)) + for i, variant in enumerate(_as_list(value), 1)] + + +def _check_variant_string(rel, key, where, variant): + """Report one variant string that is not " " + in the engine's enum names. Returns the error count, 0 or 1. + + Legality is the two enum maps _variant_names reads, in every bundle: a variant + outside them is dead - nothing selects it, nothing rejects it, and it still counts + toward the variant length every array is sized by, so the values tuned for it + silently never reach the G-code. A legacy name the loader rewrites in these keys + is an error too, naming the enum name to write instead. + """ + extruder_types, volume_types, legacy, _extruder_legacy = _variant_names() + + if not variant: + print_error(f"{rel}: {key} {where} is empty; every entry names one variant, " + f'" "') + return 1 + # Longest first, so an extruder type that is a prefix of another cannot win. + extruder = next((t for t in sorted(extruder_types, key=len, reverse=True) + if variant.startswith(t + " ")), None) + if extruder is None: + # "DirectDrive" is rewritten in extruder_type alone, so a variant string + # carrying it is dead. + print_error(f'{rel}: {key} {where} holds "{variant}", which no extruder can ' + f"select: it does not start with an extruder type the enum has " + f'({", ".join(sorted(extruder_types))}); a variant is ' + f'" "') + return 1 + volume = variant[len(extruder) + 1:] + if volume in volume_types: + return 0 + if volume in legacy: + print_error(f'{rel}: {key} {where} holds the legacy variant "{variant}"; the ' + f'loader still rewrites "{volume}" to "{legacy[volume]}", but a ' + f'profile writes the enum name: "{extruder} {legacy[volume]}"') + elif volume in RUNTIME_VOLUME_TYPES: + print_error(f'{rel}: {key} {where} holds "{variant}", which no extruder can ' + f"select: Hybrid names the sub-nozzles of one hybrid extruder at " + f"runtime, never a nozzle volume type a profile writes") + else: + print_error(f'{rel}: {key} {where} holds "{variant}", which no extruder can ' + f'select: "{volume}" is not a nozzle volume type the enum has ' + f'({", ".join(sorted(volume_types))}), so nothing selects the variant ' + f"and the values tuned for it never reach the G-code") + return 1 + + +def _variant_repeats(data): + """One message per variant a preset's own lists name twice. + + The lookup returns the first equal string, so a repeat is a variant nothing + selects, and the value written beside it sits at an index no extruder reads. A + process's variant is the (extruder id, variant) pair, so one string on two + extruders is two pairs rather than a repeat; with print_extruder_id absent or + short every pair reads as extruder 1, which check_variant_arrays warns about once + per preset instead. + """ + messages = [] + if "filament_extruder_variant" in data: + variants = [str(v) for v in _as_list(data["filament_extruder_variant"])] + messages += [ + f'filament_extruder_variant lists "{v}" {variants.count(v)} times; a ' + f"filament's variants are matched by exact string, so the repeat is a " + f"variant nothing selects and its value sits at an index no extruder reads" + for v in sorted({v for v in variants if variants.count(v) > 1})] + if "extruder_variant_list" in data: + for i, entry in enumerate(_as_list(data["extruder_variant_list"]), 1): + variants = str(entry).split(",") + messages += [ + f'extruder_variant_list extruder {i} lists "{v}" ' + f"{variants.count(v)} times; the sidebar offers it once, so the repeat " + f"is a variant nothing selects and the arrays hold a value no extruder " + f"reads" + for v in sorted({v for v in variants if variants.count(v) > 1})] + if "print_extruder_variant" in data and "print_extruder_id" in data: + ids = _as_list(data["print_extruder_id"]) + variants = _as_list(data["print_extruder_variant"]) + if len(ids) == len(variants): + pairs = [(str(i), str(v)) for i, v in zip(ids, variants)] + messages += [ + f'print_extruder_variant lists the pair (extruder {i}, "{v}") ' + f"{pairs.count((i, v))} times; a variant is found by that pair, so the " + f"repeat is unreachable" + for i, v in sorted({p for p in pairs if pairs.count(p) > 1})] + return messages + + +def _check_variant_seed(rel, option, value, allowed, legacy, runtime=frozenset()): + """Report the values of an enum option a variant string is composed from. + + extruder_type and the nozzle volume type (nozzle_volume_type, seeded by + default_nozzle_volume_type) are the two halves of every variant string, and all + three are enum options: an unknown value fails the validator's load of the whole + bundle, while the app silently loads the option's default instead, which is worth + naming precisely here. A legacy name the loader rewrites, and a name in `runtime` + the engine computes for itself, are errors too. Returns the error count. + """ + errors = 0 + for name in ([] if value is None else _as_list(value)): + name = str(name) + if name in allowed: + continue + if name in legacy: + print_error(f'{rel}: {option} spells the legacy name "{name}"; the loader ' + f'still rewrites it to "{legacy[name]}", but a profile writes the ' + f"enum name") + elif name in runtime: + print_error(f'{rel}: {option} names "{name}", which the engine computes ' + f"for a hybrid extruder at runtime; no profile writes it") + else: + print_error(f'{rel}: {option} "{name}" is not one of ' + f'({", ".join(sorted(allowed))}); the option is an enum, so an ' + f"unknown value fails loading the whole bundle in the validator " + f"and silently becomes the default in the app") + errors += 1 + return errors + + +def check_variant_names(profiles_dir, vendor): + """Every variant string and extruder or nozzle volume type a preset writes is an + enum name the engine has. + + check_variant_arrays judges how many values a variant key holds; this judges what + the variants are called, the other half of the same scheme. It reads the four list + keys, extruder_type, nozzle_volume_type and default_nozzle_volume_type of every + preset the bundle's index references - bases included, since a base's list is what + sizes its children's arrays - and reports as errors, in every bundle alike: + + * a variant string the two enums cannot build (dead variant), a legacy spelling + the loader still rewrites in these keys included (_check_variant_string); + * a variant list naming one variant twice: the lookup returns the first equal + string, so the repeat is unreachable. A filament list takes variant strings, a + menu takes them per extruder, and a process takes (extruder id, variant) pairs, + where one string on two extruders is two pairs, not a repeat; + * an extruder_type, nozzle_volume_type or default_nozzle_volume_type that is not + an enum name a profile writes (_check_variant_seed). + + Key/value types elsewhere are check_vector_type_keys' and the validator's; a file + no list references is skipped, check_index_coverage having reported it. Returns + the error count. + """ + extruder_types, volume_types, legacy, extruder_legacy = _variant_names() + errors = 0 + listed = indexed_sub_paths(profiles_dir, vendor) + if listed is None: + return 0 + vendor_dir = os.path.join(profiles_dir, vendor) + + for path, _sub in iter_profile_files(vendor_dir): + if os.path.basename(path) in NON_PROFILE_FILES: + continue + sub_path = posixpath.normpath( + os.path.relpath(path, vendor_dir).replace(os.sep, "/")) + if sub_path not in listed: + continue + try: + data = load_json(path) + except (ValueError, OSError): + # Parse failures are reported by the checks that walk the same files. + continue + if not isinstance(data, dict): + continue + rel = f"{vendor}/{sub_path}" + + for key in ("extruder_variant_list", "printer_extruder_variant", + "print_extruder_variant", "filament_extruder_variant"): + if key in data: + for where, variant in _variant_entries(key, data[key]): + errors += _check_variant_string(rel, key, where, variant) + + # A list naming one variant twice, and the two enum values a variant string is + # composed from. + for message in _variant_repeats(data): + print_error(f"{rel}: {message}") + errors += 1 + errors += _check_variant_seed(rel, "extruder_type", data.get("extruder_type"), + extruder_types, extruder_legacy) + for option in ("nozzle_volume_type", "default_nozzle_volume_type"): + errors += _check_variant_seed(rel, option, data.get(option), volume_types, + legacy, RUNTIME_VOLUME_TYPES) + return errors + + +def check_variant_arrays(profiles_dir, vendor, strict=False): + """Every variant array an instantiated preset writes is exactly its + variant length x stride wide, and a printer's variant layout keys agree. + + Only instantiated presets are judged, each at the variant length of its composed + config (_variant_length's); a base is not, since what it writes counts only where + it reaches a preset that does not override it. A full-width array holds one + value per variant; any other width is an error, one value included and whatever + the values. A key the preset does not write takes what reaches it - the default, + or an array it inherits or includes - which the loader sizes to the preset, and + is not checked. With strict, every instantiated preset also holds each key that + reaches it at its own width, so a preset with other variants than the file its + array comes from restates the array. The machine_max_* limits hold + a (normal, silent) pair per variant. A process that lists variants pairs each + with its extruder id (extruder 1 everywhere when absent); a machine's layout keys + are held to _check_printer_layout, which reports a printer_extruder_id that does + not fit a written printer_extruder_variant, as the process check below does for + print_extruder_id. Returns (errors, warnings). + """ + errors = warnings = 0 + for ptype, rel, data, config, variant_length, reason in _variant_presets(profiles_dir, + vendor): + if data.get("instantiation") != "true": + continue + list_key, strides = _variant_scheme()[ptype] + listed = list_key in config + for key, stride in sorted(strides.items()): + if key not in config or (listed and key in ("printer_extruder_id", + "print_extruder_id")): + continue + if key not in data and not strict: + continue + value, path = config[key] + width, need = len(_as_list(value)), variant_length * stride + if width == need: + continue + print_error(f'{rel}: "{key}" has {width} values for variant length {variant_length}' + f"{' (' + reason + ')' if reason else ''} at stride {stride}, " + f"which takes {need}" + f"{'' if key in data else ' (it comes from ' + path[0] + ')'}") + errors += 1 + config = {key: value for key, (value, _path) in config.items()} + if ptype == "machine": + new_errors, new_warnings = _check_printer_layout(rel, config) + errors += new_errors + warnings += new_warnings + elif ptype == "process" and listed: + variants = _as_list(config[list_key]) + if "print_extruder_id" in config: + ids = _as_list(config["print_extruder_id"]) + if len(ids) != variant_length: + print_error(f"{rel}: print_extruder_id has {len(ids)} entries for the " + f"{variant_length} entries of print_extruder_variant; the two " + f"list the (extruder id, variant) pairs, one per entry") + errors += 1 + elif len(set(variants)) < len(variants): + print_warning(f"{rel}: print_extruder_variant repeats a variant but " + f"print_extruder_id is absent, so every entry defaults to " + f"extruder 1 and the repeated variant is unreachable") + warnings += 1 + return errors, warnings + + def check_conflict_keys(profiles_dir, vendor): """A renamed option and its old spelling must not co-exist in one profile. @@ -1348,9 +1919,12 @@ def check_normalized(profiles_dir, vendor): # check # --------------------------------------------------------------------------- -def check_profiles(profiles_dir=PROFILES_DIR, vendors=None): +def check_profiles(profiles_dir=PROFILES_DIR, vendors=None, strict=False): """Validate the whole profile tree. Returns the error count. + strict holds every preset's inherited and included variant arrays to its + own width too (check_variant_arrays). + The per-vendor checks honour `vendors`; the setting_id and filament_id checks are cross-vendor properties a narrowed run cannot answer, so they always cover the whole tree. With no `vendors`, every bundle is checked except the `user` directory @@ -1389,6 +1963,10 @@ def check_profiles(profiles_dir=PROFILES_DIR, vendors=None): warnings_found += new_warnings errors_found += check_vector_type_keys(profiles_dir, vendor) + errors_found += check_variant_names(profiles_dir, vendor) + new_errors, new_warnings = check_variant_arrays(profiles_dir, vendor, strict) + errors_found += new_errors + warnings_found += new_warnings errors_found += check_filament_id_length(profiles_dir, vendor) new_errors, gaps = check_index_coverage(profiles_dir, vendor) @@ -1493,6 +2071,49 @@ def delete_key_line(text, key, old_value=None): return text, 0 +_JSON_WS = re.compile(r"[ \t\r\n]*") + + +def _json_members(text): + """(key, key start, value start, value end) of each member of the top-level + JSON object in text, in file order.""" + decoder = json.JSONDecoder() + members = [] + i = _JSON_WS.match(text, text.index("{") + 1).end() + while text[i] != "}": + key_start = i + key, i = decoder.raw_decode(text, i) + i = _JSON_WS.match(text, _JSON_WS.match(text, i).end() + 1).end() # past the ':' + _value, end = decoder.raw_decode(text, i) + members.append((key, key_start, i, end)) + i = _JSON_WS.match(text, end).end() + i = _JSON_WS.match(text, i + (text[i] == ",")).end() + return members + + +def set_array_value(text, key, values): + """Write values as the "key" array, byte-preserving the rest. + + An existing array keeps its one-line or one-value-per-line layout; a preset + without the key gets it as its last member, one value per line, in the file's + own indentation and line ending. Returns (text, 1). + """ + items = [json.dumps(v, ensure_ascii=False) for v in values] + members = _json_members(text) + for name, _key_start, start, end in reversed(members): # json.load keeps the last + if name == key: + m = re.fullmatch(r"\[(\r?\n)([ \t]*).*(\r?\n)([ \t]*)\]", text[start:end], re.DOTALL) + array = ("[" + ", ".join(items) + "]" if m is None else + "[" + m[1] + ("," + m[1]).join(m[2] + item for item in items) + + m[3] + m[4] + "]") + return text[:start] + array + text[end:], 1 + _name, key_start, _start, end = members[-1] + indent = text[text.rfind("\n", 0, key_start) + 1:key_start] + nl = "\r\n" if "\r\n" in text else "\n" + array = "[" + nl + ("," + nl).join(indent * 2 + item for item in items) + nl + indent + "]" + return text[:end] + "," + nl + indent + json.dumps(key) + ": " + array + text[end:], 1 + + def insert_filament_id(text, new_id): """Insert a `"filament_id"` line before `instantiation`, else after `name`.""" return insert_key_line(text, "filament_id", new_id, @@ -1908,6 +2529,21 @@ def create_ordered_profile(profile, priority_fields): # The keys that identify a preset, hoisted to the top of every rewritten file. NORMALIZE_FIELD_ORDER = ("type", "name", "renamed_from", "inherits", "from", "setting_id", "filament_id", "instantiation") + + +def identity_block_misplaced(keys): + """The identifying keys are not a correctly ordered prefix of `keys`. + + create_ordered_profile hoists them, so this is the one part of the canonical + shape a rewrite is guaranteed to change. Without asking, normalize would only + ever apply the hoist as a side effect of some other rule firing, which leaves + a file that breaks this rule reported as already normalized. + """ + present = [k for k in keys if k in NORMALIZE_FIELD_ORDER] + return (keys[:len(present)] != present + or present != [k for k in NORMALIZE_FIELD_ORDER if k in keys]) + + # Settings a filament profile must not pin: they belong to the process. FILAMENT_DROP_FIELDS = ("initial_layer_print_speed", "outer_wall_speed", "inner_wall_speed", "infill_speed", "top_surface_speed", @@ -1967,6 +2603,11 @@ def _normalize_profile(data, sub): del data[field] changes.append(f"remove {field}") + # Last, because it describes the file as written rather than its contents, and + # create_ordered_profile does the hoisting when the file is written. + if identity_block_misplaced(list(data)): + changes.append("put the identifying keys first") + return changes @@ -2304,6 +2945,128 @@ def update_profile_indexes(profiles_dir=PROFILES_DIR, vendors=None, profile_type return changed, errors +# --------------------------------------------------------------------------- +# fix-variant +# --------------------------------------------------------------------------- + +# The members of the variant sets that name the variants rather than hold values. +VARIANT_LAYOUT_KEYS = {"printer_extruder_variant", "printer_extruder_id", "print_extruder_variant", + "print_extruder_id", "filament_extruder_variant"} + + +def _fit_width(values, need, stride): + """values cut to need, or padded by repeating the last value - the last + (normal, silent) pair at stride 2, after dropping a trailing half pair; a lone + value fills every entry, normal and silent alike.""" + if len(values) > need: + return values[:need] + if len(values) == 1: + return values * need + values = values[:len(values) - len(values) % stride] + return values + values[-stride:] * ((need - len(values)) // stride) + + +def fix_variant_arrays(profiles_dir=PROFILES_DIR, vendors=None, dry_run=False, strict=False): + """Resize every variant array check_variant_arrays rejects to its + width: extra values are dropped, missing ones repeat the last. The variant lists + and extruder ids are layout, not values, and are left to check. + + An array an instantiated preset writes is resized in place, to the preset's + width; bases are left alone and no key is added. With strict, a key that then + reaches an instantiated preset at another width is written, resized, into the + most general file on the way down to the preset whose own width it fits and whose + instantiated presets taking it all need that width - a base only the presets with + a variant list inherit, say - else into the preset's own file. Presets of every bundle count towards that agreement; only + files of `vendors` are touched. Returns (files changed, errors). + """ + profiles_dir = str(profiles_dir) + names = list_vendor_names(profiles_dir) + scope = set(vendors or names) + files = {rel: data for vendor in names + for configs in load_vendor_configs(profiles_dir, vendor).values() + for rel, data in configs.values()} + verb = "would " if dry_run else "" + changed, errors = set(), 0 + + def fit(rel, key, values, need, stride): + fitted = _fit_width(values, need, stride) + if not fitted: + print_error(f'{rel}: "{key}" cannot be resized from {len(values)} to {need} values') + return fitted + + # Each preset's own arrays first, then (strict) what reaches the presets; a write + # into a preset hands its children a new source, which the next pass judges. The + # in-memory configs take each write so a dry run sees it too. + for _ in range(8): + own = {} # file -> its own variant length + users = defaultdict(list) # (file, key) -> [(value, path, need, stride)] + writes = defaultdict(dict) # file -> {key: values} + for vendor in names: + for ptype, rel, data, config, variant_length, _reason in _variant_presets( + profiles_dir, vendor): + own[rel] = variant_length + instantiated = data.get("instantiation") == "true" + for key, stride in _variant_scheme()[ptype][1].items(): + if key not in config or key in VARIANT_LAYOUT_KEYS: + continue + value, path = config[key] + values, need = _as_list(value), variant_length * stride + if key in data: + if len(values) != need and instantiated and vendor in scope: + fitted = fit(rel, key, values, need, stride) + if fitted: + writes[rel][key] = fitted + else: + errors += 1 + elif strict and instantiated: + users[(path[0], key)].append((values, path, need, stride)) + if not writes and strict: + for (_source, key), group in users.items(): + for values, path, need, stride in group: + if len(values) == need or path[-1].split("/")[0] not in scope: + continue + target = next((f for f in path if f.split("/")[0] in scope + and own[f] * stride == need + and all(n == need for _v, p, n, _s in group if f in p)), + path[-1]) + fitted = fit(path[-1], key, values, need, stride) + if fitted: + writes[target][key] = fitted + else: + errors += 1 + if not writes or errors: + break + for rel, arrays in sorted(writes.items()): + def apply(text, _arrays=arrays): + for key, values in _arrays.items(): + text, _n = set_array_value(text, key, values) + return text, len(_arrays) + + try: + data = _edit_profile(os.path.join(profiles_dir, *rel.split("/")), apply, dry_run, + "variant array resize") + except (OSError, RuntimeError, ValueError) as e: + print_error(str(e)) + errors += 1 + continue + print_info(f"{verb}resize {rel}: " + ", ".join( + f"{key} {len(_as_list(files[rel][key])) if key in files[rel] else 'added'}" + f" -> {len(values)}" for key, values in arrays.items())) + files[rel].update(arrays) + changed.add(rel) + if any(data.get(key) != values for key, values in arrays.items()): + print_error(f"{rel}: the variant array resize did not take effect") + errors += 1 + if errors: + break + else: + print_error("variant array resize did not settle; run check") + errors += 1 + load_vendor_configs.cache_clear() # its configs took the writes above + print_success(f"{len(changed)} profile(s) {'would be ' if dry_run else ''}resized") + return len(changed), errors + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -2321,6 +3084,10 @@ examples: preview exactly that; writes nothing orca_profile_tool.py generate-id --setting-id --vendor Elegoo setting_id only, and only in that bundle + orca_profile_tool.py fix-variant --vendor Snapmaker --dry-run + preview resizing the variant arrays check rejects in that bundle + orca_profile_tool.py check --strict --vendor BBL + also hold every preset's inherited variant arrays to its own width after adding, renaming or deleting profile files, run in this order: normalize -> update-index -> generate-id -> check @@ -2369,19 +3136,39 @@ def build_parser(): name, parents=parents, help=help_text, description=description, allow_abbrev=False, formatter_class=argparse.RawDescriptionHelpFormatter) + strict_opt = argparse.ArgumentParser(add_help=False) + strict_opt.add_argument("--strict", action="store_true", + help="also hold every preset's inherited and included variant " + "arrays to its own width") + add( - "check", [vendor_opt, profiles_opt], + "check", [vendor_opt, strict_opt, profiles_opt], "validate the whole profile tree -- what CI runs", "Validate the whole profile tree: preset name uniqueness, index coverage\n" "both ways, compatible_printers, default-material references, obsolete,\n" - "conflicting and vector-typed keys, filament_id length, that normalize and\n" - "update-index would leave every bundle alone, and the tree-wide setting_id\n" - "and filament_id state. Exits nonzero on errors.\n" + "conflicting and vector-typed keys, variant names and variant array widths,\n" + "filament_id length, that normalize and update-index would leave every\n" + "bundle alone, and the tree-wide setting_id and filament_id state. Exits\n" + "nonzero on errors.\n" "\n" "--vendor narrows the per-vendor checks only: setting_id uniqueness and the\n" "filament_id state are cross-vendor properties a narrowed run cannot answer,\n" "so they always cover the whole tree.") + add("fix-variant", [vendor_opt, strict_opt, dry_run_opt, profiles_opt], + "resize the variant arrays check rejects", + "Resize every variant array that check reports as the wrong width\n" + "in the selectable preset that writes it: extra values are dropped, missing\n" + "ones repeat the last value (the last normal/silent pair for machine_max_*).\n" + "Bases are left alone and no key is added. The variant lists and extruder\n" + "ids are left to check. Byte-preserving apart from the arrays it writes.\n" + "\n" + "--strict then also writes each key that reaches a preset at another width:\n" + "into the most general file on the way down to the preset that has the\n" + "preset's width and whose presets all agree, else into the preset itself.\n" + "Presets of every bundle count towards that agreement; --vendor limits the\n" + "files written.") + generate_cmd = add( "generate-id", [vendor_opt, dry_run_opt, profiles_opt], "write the id each profile's identity implies", @@ -2461,14 +3248,17 @@ def main(argv=None): profile_types = tuple(getattr(args, "profile_type", []) or ()) or None if args.command == "check": - errors = check_profiles(profiles_dir, vendors) + errors = check_profiles(profiles_dir, vendors, strict=args.strict) return 1 if errors else 0 if args.command == "generate-id": return run_generate_id(profiles_dir, vendors, args.filament_id, args.setting_id, args.dry_run) - if args.command == "normalize": + if args.command == "fix-variant": + _changed, errors = fix_variant_arrays(profiles_dir, vendors, dry_run=args.dry_run, + strict=args.strict) + elif args.command == "normalize": _changed, errors = normalize_profiles(profiles_dir, vendors, profile_types, force=args.force, dry_run=args.dry_run) elif args.command == "trim": diff --git a/scripts/tests/test_profile_tool.py b/scripts/tests/test_profile_tool.py index 5359b1158b..bff86b4915 100644 --- a/scripts/tests/test_profile_tool.py +++ b/scripts/tests/test_profile_tool.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Tests for the tree-maintenance half of scripts/orca_profile_tool.py: the -normalize, trim, update-index and check commands, and the subcommand dispatch that -reaches them (stdlib unittest, no external deps). +normalize, trim, update-index, fix-variant and check commands, and the subcommand +dispatch that reaches them (stdlib unittest, no external deps). The id halves are covered by test_filament_id.py and test_setting_id.py. @@ -140,6 +140,28 @@ class TestObsoleteKeys(unittest.TestCase): self.assertEqual(apt.OBSOLETE_KEYS, set(re.findall(r'"([^"\n]+)"', keys))) +class TestVariantScheme(unittest.TestCase): + def test_the_key_sets_are_read_from_the_engine(self): + scheme = apt._variant_scheme() + self.assertEqual(scheme["machine"][1]["retraction_length"], 1) + self.assertEqual(scheme["machine"][1]["machine_max_speed_x"], 2) + self.assertIn("outer_wall_speed", scheme["process"][1]) + self.assertIn("nozzle_temperature", scheme["filament"][1]) + # Commented out of its initializer, so not a member. + self.assertNotIn("filament_extruder_id", scheme["filament"][1]) + for list_key, strides in scheme.values(): + self.assertEqual(strides[list_key], 1) + + def test_the_variant_names_are_read_from_the_engine(self): + extruder_types, volume_types, legacy, extruder_legacy = apt._variant_names() + self.assertEqual(extruder_types, {"Direct Drive", "Bowden"}) + self.assertEqual(volume_types, {"Standard", "High Flow", "TPU High Flow", "E3D High Flow"}) + # Hybrid is an enum value no variant string may name. + self.assertNotIn("Hybrid", volume_types) + self.assertEqual(legacy, {"Normal": "Standard", "Big Traffic": "High Flow"}) + self.assertEqual(extruder_legacy, {"DirectDrive": "Direct Drive"}) + + class TestNormalize(TreeCase): def test_a_missing_type_is_filled_in_from_the_directory(self): self.t.write("V", "filament/A.json", {"name": "A"}) @@ -917,10 +939,315 @@ class TestNormalized(TreeCase): # CLI dispatch # --------------------------------------------------------------------------- +class TestFixVariant(TreeCase): + def preset(self, rel, **data): + name = os.path.splitext(os.path.basename(rel))[0] + self.t.write("V", rel, {"type": rel.split("/")[0], "name": name, **data}) + self.t.index("V", rel.split("/")[0], name, rel) + + def processes(self, common): + """outer_wall_speed written once in a list-less base, reached by list-less + presets through one base and by two-variant presets through another.""" + dual = ["Direct Drive Standard", "Direct Drive High Flow"] + self.preset("process/common.json", instantiation="false", outer_wall_speed=common) + self.preset("process/single.json", inherits="common", instantiation="false") + self.preset("process/dual.json", inherits="common", instantiation="false", + print_extruder_variant=dual, print_extruder_id=["1", "1"]) + for name, base in (("S1", "single"), ("S2", "single"), ("D1", "dual")): + self.preset(f"process/{name}.json", inherits=base, instantiation="true") + + def width_errors(self, *strict): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + errors, _warnings = apt.check_variant_arrays(self.t.profiles, "V", bool(strict)) + return errors, buf.getvalue() + + def test_a_machine_without_a_variant_list_has_one_variant_per_extruder(self): + # extruder_variant_list defaults to one "Direct Drive Standard" per extruder. + self.preset("machine/M.json", instantiation="true", nozzle_diameter=["0.4", "0.4"], + retraction_length=["0.8", "1.2"], z_hop=["0.4"], + machine_max_speed_x=["500", "200"]) + errors, out = self.width_errors() + self.assertEqual(errors, 2, out) + self.assertIn('M.json: "z_hop" has 1 values for variant length 2 (no ' + 'printer_extruder_variant, so one default variant per extruder) at stride 1, ' + 'which takes 2', out) + self.assertIn('M.json: "machine_max_speed_x" has 2 values for variant length 2', out) + self.assertNotIn("retraction_length", out) + + def test_one_value_is_an_error_where_the_list_has_more_variants(self): + self.preset("process/P.json", instantiation="true", outer_wall_speed=["30"], + print_extruder_id=["1", "1"], + print_extruder_variant=["Direct Drive Standard", "Direct Drive High Flow"]) + errors, out = self.width_errors() + self.assertEqual(errors, 1, out) + self.assertIn('P.json: "outer_wall_speed" has 1 values for variant length 2 at stride 1, ' + 'which takes 2', out) + + def test_one_value_is_padded_to_every_variant(self): + self.preset("machine/M.json", instantiation="true", nozzle_diameter=["0.4"] * 3, + retraction_length=["0.8"], machine_max_speed_x=["500", "200"]) + rc, out = self.run_command("fix-variant") + self.assertEqual(rc, 0, out) + machine = self.t.read("V", "machine/M.json") + self.assertEqual(machine["retraction_length"], ["0.8"] * 3) + self.assertEqual(machine["machine_max_speed_x"], ["500", "200"] * 3) + self.assertEqual(self.width_errors()[0], 0) + + def test_extruder_ids_are_left_to_check(self): + self.preset("process/P.json", instantiation="true", print_extruder_id=["1"], + print_extruder_variant=["Direct Drive Standard", "Direct Drive High Flow"]) + self.run_command("fix-variant") + self.assertEqual(self.t.read("V", "process/P.json")["print_extruder_id"], ["1"]) + errors, out = self.width_errors() + self.assertEqual(errors, 1, out) + self.assertIn("print_extruder_id has 1 entries for the 2 entries", out) + + def test_a_base_is_judged_only_where_its_array_reaches_a_preset(self): + # A leaf overriding the base's one-value limit passes in both modes. + self.preset("machine/common.json", instantiation="false", machine_max_speed_x=["500"]) + self.preset("machine/M.json", inherits="common", instantiation="true", + machine_max_speed_x=["500", "200"]) + self.assertEqual(self.width_errors()[0], 0) + self.assertEqual(self.width_errors("strict")[0], 0) + + def test_a_shared_array_is_fixed_in_the_base_whose_presets_agree(self): + self.processes(["30", "40"]) + self.assertEqual(self.width_errors()[0], 0) + errors, out = self.width_errors("strict") + self.assertEqual(errors, 2, out) + self.assertIn('process/S1.json: "outer_wall_speed" has 2 values for variant length 1', out) + rc, out = self.run_command("fix-variant", "--strict") + self.assertEqual(rc, 0, out) + self.assertEqual(self.t.read("V", "process/single.json")["outer_wall_speed"], ["30"]) + self.assertEqual(self.t.read("V", "process/common.json")["outer_wall_speed"], + ["30", "40"]) + self.assertNotIn("outer_wall_speed", self.t.read("V", "process/S1.json")) + self.assertEqual(self.width_errors("strict")[0], 0) + + def test_an_inherited_array_is_checked_and_restated_only_when_strict(self): + self.processes(["30"]) + self.assertEqual(self.width_errors()[0], 0) + errors, out = self.width_errors("strict") + self.assertEqual(errors, 1, out) + self.assertIn('process/D1.json: "outer_wall_speed" has 1 values for variant length 2 at ' + 'stride 1, which takes 2 (it comes from V/process/common.json)', out) + before = self.t.bytes_map() + self.run_command("fix-variant") + self.assertEqual(self.t.bytes_map(), before) + rc, out = self.run_command("fix-variant", "--strict") + self.assertEqual(rc, 0, out) + self.assertEqual(self.t.read("V", "process/dual.json")["outer_wall_speed"], ["30", "30"]) + self.assertEqual(self.t.read("V", "process/common.json")["outer_wall_speed"], ["30"]) + self.assertEqual(self.width_errors("strict")[0], 0) + + def test_a_strict_array_never_lands_in_a_file_of_another_width(self): + # common is list-less (width 1) and D2 inherits it directly, so D2 takes it. + self.processes(["30"]) + self.preset("process/D2.json", inherits="common", instantiation="true", + print_extruder_variant=["Direct Drive Standard", "Direct Drive High Flow"], + print_extruder_id=["1", "1"]) + self.run_command("fix-variant", "--strict") + self.assertEqual(self.t.read("V", "process/D2.json")["outer_wall_speed"], ["30", "30"]) + self.assertEqual(self.t.read("V", "process/common.json")["outer_wall_speed"], ["30"]) + self.assertEqual(self.width_errors("strict")[0], 0) + + def test_extra_values_are_dropped_where_they_are_written(self): + self.preset("process/base.json", instantiation="false", outer_wall_speed=["30", "0"]) + self.preset("process/P1.json", inherits="base", instantiation="true", + inner_wall_speed=["40", "0"]) + self.preset("process/P2.json", inherits="base", instantiation="true") + self.run_command("fix-variant") + self.assertEqual(self.t.read("V", "process/P1.json")["inner_wall_speed"], ["40"]) + self.assertEqual(self.t.read("V", "process/base.json")["outer_wall_speed"], ["30", "0"]) + self.run_command("fix-variant", "--strict") + self.assertEqual(self.t.read("V", "process/base.json")["outer_wall_speed"], ["30"]) + self.assertNotIn("outer_wall_speed", self.t.read("V", "process/P1.json")) + + def test_the_rest_of_the_file_keeps_its_bytes(self): + self.t.write_raw("V", "process/P.json", b'{\n "type": "process", "name": "P",\n' + b' "instantiation": "true",\n "outer_wall_speed": ["30", "0"],\n' + b' "inner_wall_speed": [\n "40",\n "0"\n ]\n}\n') + self.t.index("V", "process", "P", "process/P.json") + self.run_command("fix-variant") + self.assertEqual(self.t.raw("V", "process/P.json"), + b'{\n "type": "process", "name": "P",\n' + b' "instantiation": "true",\n "outer_wall_speed": ["30"],\n' + b' "inner_wall_speed": [\n "40"\n ]\n}\n') + + def test_a_dry_run_writes_nothing(self): + self.processes(["30"]) + before = self.t.bytes_map() + rc, out = self.run_command("fix-variant", "--strict", "--dry-run") + self.assertEqual(rc, 0, out) + self.assertIn("would resize V/process/dual.json: outer_wall_speed added -> 2", out) + self.assertEqual(self.t.bytes_map(), before) + + def test_padding_repeats_the_last_value_or_pair(self): + self.assertEqual(apt._fit_width(["a", "b"], 4, 1), ["a", "b", "b", "b"]) + self.assertEqual(apt._fit_width(["a", "b", "c"], 6, 2), ["a", "b"] * 3) + self.assertEqual(apt._fit_width(["a", "b", "c"], 2, 2), ["a", "b"]) + self.assertEqual(apt._fit_width(["a"], 4, 2), ["a"] * 4) + + +class TestVariantNames(TreeCase): + def preset(self, rel, vendor="V", **data): + section = rel.split("/")[0] + name = os.path.splitext(os.path.basename(rel))[0] + self.t.write(vendor, rel, {"type": section, "name": name, **data}) + self.t.index(vendor, section, name, rel) + + def names(self, vendor="V"): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + errors = apt.check_variant_names(self.t.profiles, vendor) + return errors, buf.getvalue() + + def test_a_legal_variant_layout_passes(self): + self.preset("machine/M.json", instantiation="true", nozzle_diameter=["0.4"], + extruder_type=["Direct Drive"], + extruder_variant_list=["Direct Drive Standard,Direct Drive High Flow"], + printer_extruder_id=["1", "1"], + printer_extruder_variant=["Direct Drive Standard", + "Direct Drive High Flow"], + nozzle_volume_type=["High Flow"], + default_nozzle_volume_type=["Standard"]) + self.preset("process/P.json", instantiation="true", print_extruder_id=["1", "1"], + print_extruder_variant=["Direct Drive Standard", + "Direct Drive High Flow"]) + self.preset("filament/F.json", instantiation="true", + filament_extruder_variant=["Direct Drive Standard", "Bowden High Flow"]) + self.assertEqual(self.names()[0], 0, self.names()[1]) + + def test_a_variant_the_enum_cannot_build_is_an_error(self): + # An unknown volume type, in a list and in a menu, an extruder type that is not + # one, and the legacy extruder spelling that is rewritten in extruder_type alone. + cases = (("process/P1.json", "print_extruder_variant", "Direct Drive Fast", + 'entry 1 holds "Direct Drive Fast"', + "not a nozzle volume type the enum has"), + ("machine/M.json", "extruder_variant_list", + "Direct Drive Standard,Direct Drive Fast", + 'extruder 1 holds "Direct Drive Fast"', + "not a nozzle volume type the enum has"), + ("process/P2.json", "print_extruder_variant", "DD Standard", + 'entry 1 holds "DD Standard"', + "does not start with an extruder type the enum has"), + ("filament/F.json", "filament_extruder_variant", "DirectDrive Standard", + 'entry 1 holds "DirectDrive Standard"', + "does not start with an extruder type the enum has")) + for rel, key, value, _held, _why in cases: + self.preset(rel, instantiation="false", **{key: [value]}) + errors, out = self.names() + self.assertEqual(errors, len(cases), out) + for rel, key, _value, held, why in cases: + self.assertIn(f"V/{rel}: {key} {held}", out) + self.assertIn(why, out) + + def test_the_legacy_names_the_loader_rewrites_are_errors(self): + self.preset("machine/M.json", extruder_type=["DirectDrive"], + nozzle_volume_type=["Big Traffic"], + default_nozzle_volume_type=["Normal"], + printer_extruder_variant=["Direct Drive Big Traffic"]) + errors, out = self.names() + self.assertEqual(errors, 4, out) + self.assertIn('extruder_type spells the legacy name "DirectDrive"; the loader ' + 'still rewrites it to "Direct Drive"', out) + self.assertIn('V/machine/M.json: nozzle_volume_type spells the legacy name ' + '"Big Traffic"', out) + self.assertIn('default_nozzle_volume_type spells the legacy name "Normal"', out) + self.assertIn('holds the legacy variant "Direct Drive Big Traffic"', out) + self.assertIn('a profile writes the enum name: "Direct Drive High Flow"', out) + + def test_an_empty_entry_is_an_error(self): + self.preset("filament/F.json", + filament_extruder_variant=["Direct Drive Standard", ""]) + errors, out = self.names() + self.assertEqual(errors, 1, out) + self.assertIn("filament_extruder_variant entry 2 is empty", out) + + def test_hybrid_is_runtime_only(self): + self.preset("machine/M.json", nozzle_volume_type=["Hybrid"], + default_nozzle_volume_type=["Hybrid"], + printer_extruder_variant=["Direct Drive Hybrid"]) + errors, out = self.names() + self.assertEqual(errors, 3, out) + self.assertIn("Hybrid names the sub-nozzles of one hybrid extruder at runtime", out) + self.assertIn('V/machine/M.json: nozzle_volume_type names "Hybrid"', out) + self.assertIn('default_nozzle_volume_type names "Hybrid"', out) + + def test_bbl_is_held_to_the_enums_like_every_bundle(self): + # E3D High Flow passes because the engine's enum has it; TPU285 is a name no enum has. + self.preset("machine/M.json", vendor="BBL", extruder_type=["Direct Drive"], + extruder_variant_list=["Direct Drive Standard,Direct Drive E3D High Flow"], + printer_extruder_id=["1", "1"], + printer_extruder_variant=["Direct Drive Standard", + "Direct Drive E3D High Flow"]) + self.preset("process/P.json", vendor="BBL", instantiation="true", + print_extruder_variant=["Direct Drive TPU285"]) + errors, out = self.names("BBL") + self.assertEqual(errors, 1, out) + self.assertNotIn('holds "Direct Drive E3D High Flow"', out) + self.assertIn('"TPU285" is not a nozzle volume type the enum has', out) + + def test_a_list_may_not_name_one_variant_twice(self): + self.preset("filament/F.json", + filament_extruder_variant=["Direct Drive Standard", + "Direct Drive High Flow", + "Direct Drive Standard"]) + self.preset("machine/M.json", + extruder_variant_list=["Direct Drive Standard,Direct Drive Standard," + "Direct Drive High Flow"]) + self.preset("process/P.json", print_extruder_id=["1", "1", "1"], + print_extruder_variant=["Direct Drive Standard", "Direct Drive High Flow", + "Direct Drive Standard"]) + errors, out = self.names() + self.assertEqual(errors, 3, out) + self.assertIn('filament_extruder_variant lists "Direct Drive Standard" 2 times', out) + self.assertIn('extruder_variant_list extruder 1 lists "Direct Drive Standard" 2 ' + "times", out) + self.assertIn('print_extruder_variant lists the pair (extruder 1, "Direct Drive ' + 'Standard") 2 times', out) + + def test_one_variant_on_two_extruders_is_two_pairs_not_a_repeat(self): + self.preset("process/P.json", print_extruder_id=["1", "2"], + print_extruder_variant=["Direct Drive Standard", + "Direct Drive Standard"]) + self.assertEqual(self.names()[0], 0, self.names()[1]) + + def test_an_unknown_enum_value_fails_the_bundle(self): + self.preset("machine/M.json", extruder_type=["Direct Drive", "Magnetic Drive"], + nozzle_volume_type=["Turbo"], + default_nozzle_volume_type=["Fast Flow"]) + errors, out = self.names() + self.assertEqual(errors, 3, out) + self.assertIn('extruder_type "Magnetic Drive" is not one of (Bowden, Direct Drive)', + out) + self.assertIn('V/machine/M.json: nozzle_volume_type "Turbo" is not one of (E3D High ' + "Flow, High Flow, Standard, TPU High Flow)", out) + self.assertIn('default_nozzle_volume_type "Fast Flow" is not one of (E3D High Flow, ' + "High Flow, Standard, TPU High Flow)", out) + + def test_a_file_no_list_references_is_not_judged(self): + # It never loads, so its variant names cannot reach anything; + # check_index_coverage reports the file itself. + self.t.write("V", "machine/M.json", {"type": "machine", "name": "M", + "printer_extruder_variant": ["Direct Drive Fast"]}) + self.assertEqual(self.names()[0], 0, self.names()[1]) + + def test_check_runs_it(self): + self.preset("filament/F.json", instantiation="false", + filament_extruder_variant=["Direct Drive Normal"]) + self.run_command("update-index") # so nothing but the name check can fail + rc, out = self.run_command("check") + self.assertEqual(rc, 1, out) + self.assertIn('holds the legacy variant "Direct Drive Normal"', out) + + class TestDispatch(TreeCase): def test_each_command_reaches_its_own_writer(self): self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"}) for command, expected in (("normalize", "normalized"), + ("fix-variant", "resized"), ("trim", "unreferenced"), ("update-index", "vendor index")): with self.subTest(command=command): @@ -931,7 +1258,8 @@ class TestDispatch(TreeCase): def test_an_option_belongs_to_one_command_only(self): for argv in (["trim", "--force"], ["update-index", "--filament-id"], - ["check", "--profile-type", "filament"]): + ["check", "--profile-type", "filament"], + ["normalize", "--strict"]): with self.subTest(argv=argv): with self.assertRaises(SystemExit) as cm, \ contextlib.redirect_stdout(io.StringIO()), \