mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-29 11:51:24 +00:00
update profile checks
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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()), \
|
||||
|
||||
Reference in New Issue
Block a user