update profile checks

This commit is contained in:
SoftFever
2026-09-28 19:08:35 +08:00
parent 71bb400e46
commit 00a2c5a087
3 changed files with 1131 additions and 10 deletions
+3
View File
@@ -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:
+797 -7
View File
@@ -6,6 +6,7 @@ usage: python scripts/orca_profile_tool.py <command> [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 <vendor>.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<std::string>\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 "<extruder type> <nozzle volume
type>", 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 <vendor>.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 "<extruder type> <nozzle volume type>"
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'"<extruder type> <nozzle volume type>"')
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'"<extruder type> <nozzle volume type>"')
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":
+331 -3
View File
@@ -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()), \