Merge branch 'main' into cad-mainline

This commit is contained in:
SoftFever
2026-09-18 14:01:23 +08:00
committed by GitHub
2596 changed files with 133083 additions and 118866 deletions
+16 -12
View File
@@ -9,12 +9,17 @@
same semantics: every check runs even after an earlier one fails (the workflow's
continue-on-error), then the script exits non-zero once at the end.
extra_json_check scripts/orca_extra_profile_check.py
profile_tool scripts/orca_profile_tool.py check
validate_system validator -p <profiles> -l <level>
validate_slice validator -p <profiles> -s -l <level>
validate_filament_subtypes validator -p <profiles> -l <level> -f
validate_custom validator against every released custom-preset fixture
profile_tool is the only check that is not the validator binary; it makes the static checks
the validator cannot, because the validator loads the tree the way the slicer does and so
never sees a profile no <vendor>.json indexes, a preset name two files claim, or a file
normalize and update-index would still rewrite.
Everything that has to be downloaded - the profile validator and the custom-preset fixture
archives - lands under <repo>\.test\check_profiles and is reused on the next run. That
directory also holds one log per check plus a copy of the comment CI would post on the PR.
@@ -33,15 +38,14 @@
under emulation on ARM64.
.PARAMETER ProfilesDir
Profile tree to validate (default: resources\profiles). extra_json_check always looks at the
tree next to the script, so this only redirects the validator checks.
Profile tree to validate (default: resources\profiles).
.PARAMETER Vendor
Check only this vendor, named after its <Vendor>.json (e.g. "Co Print"). validate_custom is
narrowed with it too, by keeping only that vendor's presets in each fixture tree. The one
check it cannot narrow is validate_slice for a vendor that ships no printers; the summary
reports that one as skipped, and naming it explicitly still runs it. extra_json_check keeps
its two cross-vendor checks (setting_id and filament_id) tree-wide, so a scoped run can still
reports that one as skipped, and naming it explicitly still runs it. profile_tool keeps its
two cross-vendor checks (setting_id and filament_id) tree-wide, so a scoped run can still
fail on another vendor's files.
.PARAMETER Validator
@@ -110,7 +114,7 @@ $HostArch = switch ($HostArch) {
default { 'x86' }
}
$AllChecks = @('extra_json_check', 'validate_system', 'validate_slice', 'validate_filament_subtypes', 'validate_custom')
$AllChecks = @('profile_tool', 'validate_system', 'validate_slice', 'validate_filament_subtypes', 'validate_custom')
$script:LogWriter = $null
$script:Python = ''
@@ -203,7 +207,7 @@ if ($Vendor) {
}
}
# The validator's -v and orca_extra_profile_check.py's --vendor both take that stem; an unscoped
# The validator's -v and orca_profile_tool.py check's --vendor both take that stem; an unscoped
# run passes neither, so the checks below splat these in either way.
$VendorArgs = if ($Vendor) { @('-v', $Vendor) } else { @() }
$VendorPyArgs = if ($Vendor) { @('--vendor', $Vendor) } else { @() }
@@ -434,8 +438,8 @@ function Expand-VendorPresets([string] $Zip, [string] $Tree, [string] $Prefix) {
$CheckBodies = @{
extra_json_check = {
Invoke-Tool -Exe (Resolve-Python) -Arguments (@((Join-Path $RepoRoot 'scripts\orca_extra_profile_check.py')) + $VendorPyArgs)
profile_tool = {
Invoke-Tool -Exe (Resolve-Python) -Arguments (@((Join-Path $RepoRoot 'scripts\orca_profile_tool.py'), 'check', '--profiles', $ProfilesDir) + $VendorPyArgs)
}
validate_system = {
@@ -571,7 +575,7 @@ $CheckBodies = @{
# Heading CI puts above this check's log in the PR comment.
$CommentHeadings = @{
extra_json_check = '### Extra JSON Check Failed'
profile_tool = '### Profile Check Failed (orca_profile_tool.py)'
validate_system = '### System Profile Validation Failed'
validate_slice = '### Slice Validation Failed (custom g-code expansion)'
validate_filament_subtypes = '### Filament Subtype Validation Failed'
@@ -618,7 +622,7 @@ try {
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
Push-UserPresets
if ($Checks | Where-Object { $_ -ne 'extra_json_check' }) { $Validator = Resolve-Validator }
if ($Checks | Where-Object { $_ -ne 'profile_tool' }) { $Validator = Resolve-Validator }
# An empty printer set is a failure to the sweep, so validate_slice is recorded as skipped
# rather than run for a vendor that ships no printers (the filament-only OrcaFilamentLibrary);
@@ -673,7 +677,7 @@ try {
''
}
'---'
'*Please fix the above errors and push a new commit.*'
'*Fix the errors above and push a new commit. To reproduce this run locally: `scripts/check_profile.sh`, or `scripts\check_profile.bat` on Windows.*'
)
$commentPath = Join-Path $WorkDir 'pr_comment.md'
[IO.File]::WriteAllLines($commentPath, [string[]] $comment)
+15 -11
View File
@@ -38,7 +38,7 @@ PROFILES_DIR="${REPO_ROOT}/resources/profiles"
WORK_DIR="${REPO_ROOT}/.test/check_profiles"
VALIDATOR="${ORCA_PROFILE_VALIDATOR:-}"
# Vendor to check, named after its <Vendor>.json - empty means every vendor, which is exactly what
# both the validator's -v and orca_extra_profile_check.py's --vendor take an empty value to mean.
# both the validator's -v and orca_profile_tool.py check's --vendor take an empty value to mean.
# So the flag is passed unconditionally below rather than kept in an array bash 3.2 cannot expand
# empty under `set -u`.
VENDOR=""
@@ -46,7 +46,7 @@ LOG_LEVEL=2
PREFER_DOWNLOAD=0
REFRESH=0
ALL_CHECKS=(extra_json_check validate_system validate_slice validate_filament_subtypes validate_custom)
ALL_CHECKS=(profile_tool validate_system validate_slice validate_filament_subtypes validate_custom)
CHECKS=()
# "<check><TAB>pass|fail" per check that ran, plus "<check><TAB>skip<TAB>why" for one a vendor
# scope left out; a string rather than an array because bash 3.2 (still the /bin/bash on macOS)
@@ -60,7 +60,7 @@ Run the profile checks from .github/workflows/check_profiles.yml locally.
Usage: scripts/check_profile.sh [OPTION]... [CHECK]...
Checks (default: all, in this order):
extra_json_check scripts/orca_extra_profile_check.py
profile_tool scripts/orca_profile_tool.py check
validate_system validator -p <profiles> -l <level>
validate_slice validator -p <profiles> -s -l <level>
validate_filament_subtypes validator -p <profiles> -l <level> -f
@@ -79,14 +79,16 @@ Options:
-l, --log-level N validator log level (default: ${LOG_LEVEL}, as in CI)
-h, --help show this help
Note: extra_json_check always looks at the tree next to the script
(<repo>/resources/profiles); --profiles only redirects the validator checks.
Note: profile_tool is the only check that is not the validator binary; it makes the static
checks the validator cannot, because the validator loads the tree the way the slicer does
and so never sees a profile no <vendor>.json indexes, a preset name two files claim, or a
file normalize and update-index would still rewrite.
Note: --vendor narrows validate_custom too, by keeping only that vendor's presets in each
fixture tree. The one check it cannot narrow is validate_slice for a vendor that ships no
printers; the summary reports that one as skipped, and naming it explicitly still runs it.
extra_json_check keeps its two cross-vendor checks (setting_id and filament_id) tree-wide,
so a scoped run can still fail on another vendor's files.
profile_tool keeps its two cross-vendor checks (setting_id and filament_id) tree-wide, so a
scoped run can still fail on another vendor's files.
EOF
}
@@ -361,8 +363,8 @@ resolve_validator() {
# ---------------------------------------------------------------------------- checks
check_extra_json_check() {
python3 "${REPO_ROOT}/scripts/orca_extra_profile_check.py" --vendor "${VENDOR}"
check_profile_tool() {
python3 "${REPO_ROOT}/scripts/orca_profile_tool.py" check --profiles "${PROFILES_DIR}" --vendor "${VENDOR}"
}
check_validate_system() {
@@ -548,7 +550,7 @@ EOF
# Heading CI puts above this check's log in the PR comment.
comment_heading() {
case "$1" in
extra_json_check) echo "### Extra JSON Check Failed" ;;
profile_tool) echo "### Profile Check Failed (orca_profile_tool.py)" ;;
validate_system) echo "### System Profile Validation Failed" ;;
validate_slice) echo "### Slice Validation Failed (custom g-code expansion)" ;;
validate_filament_subtypes) echo "### Filament Subtype Validation Failed" ;;
@@ -638,7 +640,9 @@ fi
${RESULTS}
INNER
echo "---"
echo "*Please fix the above errors and push a new commit.*"
# Single-quoted on purpose: the backticks below are markdown, not command substitution.
# shellcheck disable=SC2016
echo '*Fix the errors above and push a new commit. To reproduce this run locally: `scripts/check_profile.sh`, or `scripts\check_profile.bat` on Windows.*'
} > "${WORK_DIR}/pr_comment.md"
printf '\n%sOne or more profile checks failed.%s Logs: %s\n' "${C_RED}" "${C_RESET}" "${LOG_DIR}"
File diff suppressed because it is too large Load Diff
@@ -384,6 +384,17 @@ modules:
- cmake --build build_flatpak --target generate_system_cache -j$FLATPAK_BUILDER_N_JOBS
- ./scripts/build_preset_cache.sh -n -b build_flatpak /app/share/OrcaSlicer/profiles
# Built (not run) here via the action's run-tests, then shipped to a separate
# test job. Only the test sources compile; nothing installs to /app.
test-commands:
- cmake . -B build_flatpak -DBUILD_TESTS=ON
# A suite missing from this list fails the leg loudly, since ctest registers a
# <target>_NOT_BUILT test for it. (tests/all is a Ninja subdirectory target and
# this build uses the default Makefile generator, so it is not available here.)
- cmake --build build_flatpak -j"${FLATPAK_BUILDER_N_JOBS:-$(nproc)}" --target
libslic3r_tests fff_print_tests sla_print_tests libnest2d_tests slic3rutils_tests
filament_group_tests
cleanup:
- /include
@@ -420,6 +431,10 @@ modules:
- type: dir
path: ../../localization
dest: localization
# For the post-build unit-test step (BUILD_TESTS=ON); not built by the app.
- type: dir
path: ../../tests
dest: tests
- type: file
path: ../../CMakeLists.txt
@@ -433,6 +448,9 @@ modules:
- type: file
path: ../build_preset_cache.sh
dest: scripts
- type: file
path: ../run_unit_tests.sh
dest: scripts
# AppData metainfo for GNOME Software & Co.
- type: file
-660
View File
@@ -1,660 +0,0 @@
import os
import json
import argparse
from pathlib import Path
from orca_id_tool import generate_preset_setting_id, check_filament_ids
OBSOLETE_KEYS = {
"acceleration", "scale", "rotate", "duplicate", "duplicate_grid",
"bed_size", "print_center", "g0", "wipe_tower_per_color_wipe",
"support_sharp_tails", "support_remove_small_overhangs", "support_with_sheath",
"tree_support_collision_resolution", "tree_support_with_infill",
"max_volumetric_speed", "max_print_speed", "support_closing_radius",
"remove_freq_sweep", "remove_bed_leveling", "remove_extrusion_calibration",
"support_transition_line_width", "support_transition_speed", "bed_temperature",
"bed_temperature_initial_layer", "can_switch_nozzle_type", "can_add_auxiliary_fan",
"extra_flush_volume", "spaghetti_detector", "adaptive_layer_height",
"z_hop_type", "z_lift_type", "bed_temperature_difference", "long_retraction_when_cut",
"retraction_distance_when_cut", "extruder_type", "internal_bridge_support_thickness",
"extruder_clearance_max_radius", "top_area_threshold", "reduce_wall_solid_infill",
"filament_load_time", "filament_unload_time", "smooth_coefficient",
"overhang_totally_speed", "silent_mode", "overhang_speed_classic"
}
# Utility functions for printing messages in different colors.
def print_error(msg):
print(f"\033[91m[ERROR]\033[0m {msg}") # Red
def print_warning(msg):
print(f"\033[93m[WARNING]\033[0m {msg}") # Yellow
def print_info(msg):
print(f"\033[94m[INFO]\033[0m {msg}") # Blue
def print_success(msg):
print(f"\033[92m[SUCCESS]\033[0m {msg}") # Green
# Add helper function for duplicate key detection.
def no_duplicates_object_pairs_hook(pairs):
seen = {}
for key, value in pairs:
if key in seen:
raise ValueError(f"Duplicate key detected: {key}")
seen[key] = value
return seen
# NOTE: currently Orca expects compatible_printers to be a defined in every instantiation profile, inheritation is not supported in Profile page
def check_filament_compatible_printers(vendor, vendor_folder):
"""
Checks JSON files in the vendor folder for missing or empty 'compatible_printers'
when 'instantiation' is flagged as true.
In the OrcaFilamentLibrary 'compatible_printers' is optional: a profile without it is generic and
offered on every printer, while a profile that lists printers supersedes the generic one there.
Parameters:
vendor (str): The vendor name the folder belongs to.
vendor_folder (str or Path): The directory to search for JSON profile files.
Returns:
int: The number of profiles with missing or empty 'compatible_printers'.
"""
error = 0
vendor_path = Path(vendor_folder)
if not vendor_path.exists():
return 0
profiles = {}
# Use rglob to recursively find .json files.
for file_path in vendor_path.rglob("*.json"):
if file_path.name == 'filaments_color_codes.json': # Ignore non-profile file
continue
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
# Use custom hook to detect duplicates.
data = json.load(fp, object_pairs_hook=no_duplicates_object_pairs_hook)
except ValueError as ve:
print_error(f"Duplicate key error in {file_path}: {ve}")
error += 1
continue
except Exception as e:
print_error(f"Error processing {file_path}: {e}")
error += 1
continue
profile_name = data['name']
if profile_name in profiles:
print_error(f"Duplicated profile {profile_name}: {file_path}")
error += 1
continue
profiles[profile_name] = {
'file_path': file_path,
'content': data,
}
def get_property(profile, key):
content = profile['content']
if key in content:
return content[key]
return None
def get_inherit_property(profile, key):
content = profile['content']
if key in content:
return content[key]
if 'inherits' in content:
inherits = content['inherits']
if inherits not in profiles:
raise ValueError(f"Parent profile not found: {inherits}, referenced in {profile['file_path']}")
return get_inherit_property(profiles[inherits], key)
return None
for profile in profiles.values():
instantiation = str(profile['content'].get("instantiation", "")).lower() == "true"
if instantiation and vendor != 'OrcaFilamentLibrary':
try:
compatible_printers = get_property(profile, "compatible_printers")
if not compatible_printers or (isinstance(compatible_printers, list) and not compatible_printers):
print_error(f"'compatible_printers' missing in {profile['file_path']}")
error += 1
except ValueError as ve:
print_error(f"Unable to parse {profile['file_path']}: {ve}")
error += 1
continue
return error
def load_available_filament_profiles(profiles_dir, vendor_name):
"""
Load all available filament profiles from a vendor's directory.
Parameters:
profiles_dir (Path): The directory containing vendor profile directories
vendor_name (str): The name of the vendor directory
Returns:
set: A set of filament profile names
"""
profiles = set()
vendor_path = profiles_dir / vendor_name / "filament"
if not vendor_path.exists():
return profiles
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
data = json.load(fp)
if "name" in data:
profiles.add(data["name"])
except Exception as e:
print_error(f"Error loading filament profile {file_path}: {e}")
return profiles
def check_machine_default_materials(profiles_dir, vendor_name):
"""
Checks if default materials referenced in machine profiles exist in
the vendor's filament library or in the global OrcaFilamentLibrary.
Parameters:
profiles_dir (Path): The base profiles directory
vendor_name (str): The vendor name to check
Returns:
int: Number of missing filament references found
int: the number of warnings found (0 or 1)
"""
error_count = 0
machine_dir = profiles_dir / vendor_name / "machine"
if not machine_dir.exists():
print_warning(f"No machine profiles found for vendor: {vendor_name}")
return 0, 1
# Load available filament profiles
vendor_filaments = load_available_filament_profiles(profiles_dir, vendor_name)
global_filaments = load_available_filament_profiles(profiles_dir, "OrcaFilamentLibrary")
all_available_filaments = vendor_filaments.union(global_filaments)
# Check each machine profile
for file_path in machine_dir.rglob("*.json"):
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
data = json.load(fp)
default_materials = None
if "default_materials" in data:
default_materials = data["default_materials"]
elif "default_filament_profile" in data:
default_materials = data["default_filament_profile"]
if default_materials:
if isinstance(default_materials, list):
for material in default_materials:
if material not in all_available_filaments:
print_error(f"Missing filament profile: '{material}' referenced in {file_path.relative_to(profiles_dir)}")
error_count += 1
else:
# Handle semicolon-separated list of materials in a string
if ";" in default_materials:
for material in default_materials.split(";"):
material = material.strip()
if material and material not in all_available_filaments:
print_error(f"Missing filament profile: '{material}' referenced in {file_path.relative_to(profiles_dir)}")
error_count += 1
else:
# Single material in a string
if default_materials not in all_available_filaments:
print_error(f"Missing filament profile: '{default_materials}' referenced in {file_path.relative_to(profiles_dir)}")
error_count += 1
except Exception as e:
print_error(f"Error processing machine profile {file_path}: {e}")
error_count += 1
return error_count, 0
def check_name_consistency(profiles_dir, vendor_name):
"""
Make sure filament profile names match in both vendor json and subpath files.
Filament profiles work only if the name in <vendor>.json matches the name in sub_path file,
or if it's one of the sub_path file's `renamed_from`.
Parameters:
profiles_dir (Path): Base profiles directory
vendor_name (str): Vendor name
Returns:
int: Number of errors found
int: Number of warnings found (0 or 1)
"""
error_count = 0
vendor_dir = profiles_dir / vendor_name
vendor_file = profiles_dir / (vendor_name + ".json")
if not vendor_file.exists():
print_warning(f"No profiles found for vendor: {vendor_name} at {vendor_file}")
return 0, 1
try:
with open(vendor_file, 'r', encoding='UTF-8') as fp:
data = json.load(fp)
except Exception as e:
print_error(f"Error loading vendor profile {vendor_file}: {e}")
return 1, 0
for section in ['filament_list', 'machine_model_list', 'machine_list', 'process_list']:
if section not in data:
continue
for child in data[section]:
name_in_vendor = child['name']
sub_path = child['sub_path']
sub_file = vendor_dir / sub_path
if not sub_file.exists():
print_error(f"Missing sub profile: '{sub_path}' declared in {vendor_file.relative_to(profiles_dir)}")
error_count += 1
continue
try:
with open(sub_file, 'r', encoding='UTF-8') as fp:
sub_data = json.load(fp)
except Exception as e:
print_error(f"Error loading profile {sub_file}: {e}")
error_count += 1
continue
name_in_sub = sub_data['name']
if name_in_sub == name_in_vendor:
continue
# if 'renamed_from' in sub_data:
# renamed_from = [n.strip() for n in sub_data['renamed_from'].split(';')]
# if name_in_vendor in renamed_from:
# continue
print_error(f"{section} name mismatch: required '{name_in_vendor}' in {vendor_file.relative_to(profiles_dir)} but found '{name_in_sub}' in {sub_file.relative_to(profiles_dir)}")
error_count += 1
return error_count, 0
def check_filament_id(profiles_dir, vendor_name):
"""
Make sure filament_id is not longer than 8 characters, otherwise AMS won't work properly.
Runs tree-wide, every vendor alike (BBL included: the id format is what
matters, not the vendor). Every .json file under the vendor's filament
directory is still parsed through the duplicate-key hook below, so that
coverage is unchanged; only the length rule itself is scoped to presets
the vendor's index (<vendor>.json filament_list) actually references. A
file the index does not reference never loads, so its filament_id length
cannot break AMS -- and some vendors (e.g. SeeMeCNC) ship such orphaned
files pre-dating this check, with no bearing on what ships.
"""
error = 0
vendor_path = profiles_dir / vendor_name / "filament"
if not vendor_path.exists():
return 0
referenced = set()
vendor_file = profiles_dir / (vendor_name + ".json")
if vendor_file.exists():
try:
with open(vendor_file, 'r', encoding='UTF-8') as fp:
index = json.load(fp)
for entry in index.get('filament_list', []):
sub_path = entry.get('sub_path')
if sub_path:
referenced.add((profiles_dir / vendor_name / sub_path).resolve())
except Exception as e:
print_error(f"Error loading vendor profile {vendor_file}: {e}")
error += 1
# Use rglob to recursively find .json files.
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
# Use custom hook to detect duplicates.
data = json.load(fp, object_pairs_hook=no_duplicates_object_pairs_hook)
except ValueError as ve:
print_error(f"Duplicate key error in {file_path}: {ve}")
error += 1
continue
except Exception as e:
print_error(f"Error processing {file_path}: {e}")
error += 1
continue
if 'filament_id' not in data:
continue
filament_id = data['filament_id']
if len(filament_id) > 8 and file_path.resolve() in referenced:
error += 1
print_error(f"Filament id too long \"{filament_id}\": {file_path}")
return error
def check_obsolete_keys(profiles_dir, vendor_name):
"""
Check for obsolete keys in all filament profiles for a vendor.
Parameters:
profiles_dir (Path): Base profiles directory
vendor_name (str): Vendor name
obsolete_keys (set): Set of obsolete key names to check
Returns:
int: Number of obsolete keys found
"""
error_count = 0
vendor_path = profiles_dir / vendor_name / "filament"
if not vendor_path.exists():
return 0
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, "r", encoding="UTF-8") as fp:
data = json.load(fp)
except Exception as e:
print_warning(f"Error reading profile {file_path.relative_to(profiles_dir)}: {e}")
error_count += 1
continue
for key in data.keys():
if key in OBSOLETE_KEYS:
print_warning(f"Obsolete key: '{key}' found in {file_path.relative_to(profiles_dir)}")
error_count += 1
return error_count
CONFLICT_KEYS = [
['extruder_clearance_radius', 'extruder_clearance_max_radius'],
]
VECTOR_KEYS = {
"filament_type",
}
def check_vector_type_keys(profiles_dir, vendor_name):
"""
Check that properties expected to be vectors (JSON arrays) are not stored as scalars.
For example, `filament_type` must be a list like ["PA-CF"], not a string "PA-CF".
Parameters:
profiles_dir (Path): Base profiles directory
vendor_name (str): Vendor name
Returns:
int: Number of errors found
"""
error_count = 0
vendor_path = profiles_dir / vendor_name
if not vendor_path.exists():
return 0
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, "r", encoding="UTF-8") as fp:
data = json.load(fp)
except Exception as e:
print_error(f"Error processing {file_path.relative_to(profiles_dir)}: {e}")
error_count += 1
continue
if not isinstance(data, dict):
continue
for key in VECTOR_KEYS:
if key in data and not isinstance(data[key], list):
print_error(
f"'{key}' must be an array in {file_path.relative_to(profiles_dir)}, "
f"got {type(data[key]).__name__}: {data[key]!r}"
)
error_count += 1
return error_count
def check_conflict_keys(profiles_dir, vendor_name):
"""
Check for keys that could not be specified at the same time,
due to option renaming & backward compatibility reasons.
For example, `extruder_clearance_max_radius` and `extruder_clearance_radius` cannot co-exist
otherwise slicer won't know which one to use.
Parameters:
profiles_dir (Path): Base profiles directory
vendor_name (str): Vendor name
Returns:
int: Number of errors found
int: Number of warnings found
"""
error_count = 0
warn_count = 0
vendor_path = profiles_dir / vendor_name
if not vendor_path.exists():
print_warning(f"No machine profiles found for vendor: {vendor_name}")
return 0, 1
for file_path in vendor_path.rglob("*.json"):
try:
with open(file_path, 'r', encoding='UTF-8') as fp:
# Use custom hook to detect duplicates.
data = json.load(fp, object_pairs_hook=no_duplicates_object_pairs_hook)
except ValueError as ve:
print_error(f"Duplicate key error in {file_path.relative_to(profiles_dir)}: {ve}")
error_count += 1
continue
except Exception as e:
print_error(f"Error processing {file_path.relative_to(profiles_dir)}: {e}")
error_count += 1
continue
for key_sets in CONFLICT_KEYS:
if sum([1 if k in data else 0 for k in key_sets]) > 1:
print_error(f"Conflict keys {key_sets} co-exist in {file_path.relative_to(profiles_dir)}")
error_count += 1
return error_count, warn_count
# Bambu (BBL) keeps its authoritative "G*" cloud ids, which are NOT produced by the
# deterministic formula, so BBL is exempt from the formula match (Rule 2) only. It is
# still checked for presence, uniqueness, base-no-id and the typo key like every other
# vendor. Every other vendor (incl. OrcaFilamentLibrary and Custom) must also match the
# formula.
SETTING_ID_FORMULA_EXEMPT_VENDORS = {"BBL"}
PROFILE_SUBDIRS = ("filament", "process", "machine")
def check_setting_id_uniqueness(profiles_dir):
"""
Validate setting_id across every vendor (see scripts/orca_id_tool.py):
1. Every instantiated preset must HAVE a setting_id. (all vendors)
2. A stored setting_id must equal generate_preset_setting_id(vendor, type, name); a stale
value means the JSON was edited without rerunning
"python scripts/orca_id_tool.py --generate --setting-id".
(all vendors EXCEPT the formula-exempt ones, e.g. BBL)
3. Base profiles (instantiation != "true") must not carry a setting_id. (all vendors)
4. setting_id must be globally unique - no two files may share one. (all vendors)
5. No profile may use the misspelled key "settings_id". (all vendors)
Formula-exempt vendors (BBL) keep their authoritative ids, so only Rule 2 is skipped
for them; they are still held to presence, uniqueness, base-no-id and the typo check.
"""
errors = 0
owners = {} # setting_id -> list of relative_path (every vendor)
for vendor_dir in sorted(profiles_dir.iterdir()):
if not vendor_dir.is_dir():
continue
vendor = vendor_dir.name
formula_exempt = vendor in SETTING_ID_FORMULA_EXEMPT_VENDORS
for sub in PROFILE_SUBDIRS:
base = vendor_dir / sub
if not base.is_dir():
continue
for file_path in base.rglob("*.json"):
try:
data = json.loads(file_path.read_bytes())
except (ValueError, OSError):
continue
if not isinstance(data, dict):
continue
rel = file_path.relative_to(profiles_dir)
# Rule 5: catch the misspelled "settings_id" key.
if "settings_id" in data:
errors += 1
print_error(
f'profile {rel} uses the misspelled key "settings_id" '
f'(should be "setting_id"); run '
f'"python scripts/orca_id_tool.py --generate --setting-id"'
)
sid = data.get("setting_id")
instantiated = data.get("instantiation") == "true"
if not instantiated:
# Rule 3: base/template profiles must not carry a setting_id.
if sid:
errors += 1
print_error(
f'base profile {rel} (instantiation != "true") must not have a '
f'setting_id ("{sid}"); run '
f'"python scripts/orca_id_tool.py --generate --setting-id"'
)
continue
# Rule 1: every instantiated preset must have a setting_id.
if not sid:
errors += 1
print_error(
f"instantiated preset {rel} is missing a setting_id; "
f'run "python scripts/orca_id_tool.py --generate --setting-id"'
)
continue
# Rule 2: the stored id must match the deterministic rule. BBL keeps its
# authoritative G* ids and is exempt from this check only.
if not formula_exempt:
expected = generate_preset_setting_id(vendor, sub, data.get("name", ""))
if sid != expected:
errors += 1
print_error(
f'setting_id "{sid}" in {rel} does not match the expected '
f'"{expected}" for {vendor}/{sub}/{data.get("name", "")}; '
f'run "python scripts/orca_id_tool.py --generate --setting-id"'
)
continue
# Rule 4: collect for the global-uniqueness check below.
owners.setdefault(sid, []).append(rel)
# Rule 4: a setting_id shared by two files is an error. For managed vendors this means
# a duplicate vendor/type/name; for formula-exempt vendors (BBL) a copy-pasted id.
for sid, locs in sorted(owners.items()):
if len(locs) < 2:
continue
errors += 1
print_error(
f'setting_id "{sid}" is shared by {len(locs)} files ({sorted(map(str, locs))}); '
f"setting_id must be globally unique"
)
return errors
def main():
parser = argparse.ArgumentParser(
description="Check 3D printer profiles for common issues",
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
parser.add_argument("--vendor", type=str, help="Specify a single vendor to check")
parser.add_argument("--check-filaments", action="store_true", help="Check 'compatible_printers' in filament profiles")
parser.add_argument("--check-materials", action="store_true", help="Check default materials in machine profiles")
parser.add_argument("--check-obsolete-keys", action="store_true", help="Warn if obsolete keys are found in filament profiles")
args = parser.parse_args()
print_info("Checking profiles ...")
script_dir = Path(__file__).resolve().parent
profiles_dir = script_dir.parent / "resources" / "profiles"
checked_vendor_count = 0
errors_found = 0
warnings_found = 0
def run_checks(vendor_name):
nonlocal errors_found, warnings_found, checked_vendor_count
vendor_path = profiles_dir / vendor_name
if args.check_filaments or not (args.check_materials and not args.check_filaments):
errors_found += check_filament_compatible_printers(vendor_name, vendor_path / "filament")
if args.check_materials:
new_errors, new_warnings = check_machine_default_materials(profiles_dir, vendor_name)
errors_found += new_errors
warnings_found += new_warnings
if args.check_obsolete_keys:
warnings_found += check_obsolete_keys(profiles_dir, vendor_name)
new_errors, new_warnings = check_name_consistency(profiles_dir, vendor_name)
errors_found += new_errors
warnings_found += new_warnings
new_errors, new_warnings = check_conflict_keys(profiles_dir, vendor_name)
errors_found += new_errors
warnings_found += new_warnings
errors_found += check_vector_type_keys(profiles_dir, vendor_name)
errors_found += check_filament_id(profiles_dir, vendor_name)
checked_vendor_count += 1
if args.vendor:
run_checks(args.vendor)
else:
for vendor_dir in profiles_dir.iterdir():
if not vendor_dir.is_dir() or vendor_dir.name == "OrcaFilamentLibrary":
continue
run_checks(vendor_dir.name)
# Global (cross-vendor) check: setting_id must be unique and stay in-namespace.
# Runs once over the whole tree regardless of the --vendor filter.
errors_found += check_setting_id_uniqueness(profiles_dir)
# Global filament_id check (see scripts/orca_id_tool.py): effective ids
# are resolved loader-faithfully and validated against the sanctioned snapshot
# (scripts/filament_id_snapshot.json). Runs once over the whole tree.
errors_found += check_filament_ids(profiles_dir)
# ✨ Output finale in stile "compilatore"
print("\n==================== SUMMARY ====================")
print_info(f"Checked vendors : {checked_vendor_count}")
if errors_found > 0:
print_error(f"Files with errors : {errors_found}")
else:
print_success("Files with errors : 0")
if warnings_found > 0:
print_warning(f"Files with warnings : {warnings_found}")
else:
print_success("Files with warnings : 0")
print("=================================================")
if errors_found > 0 or warnings_found > 0 :
print_warning('Issue(s) found, try `orca_filament_lib.py --fix` to fix common issues automatically')
exit(-1 if errors_found > 0 else 0)
if __name__ == "__main__":
main()
-310
View File
@@ -1,310 +0,0 @@
import os
import json
import argparse
from collections import defaultdict
def create_ordered_profile(profile_dict, priority_fields=['name', 'type']):
"""Create a new dictionary with priority fields first"""
ordered_profile = {}
# Add priority fields first
for field in priority_fields:
if field in profile_dict:
ordered_profile[field] = profile_dict[field]
# Add remaining fields
for key, value in profile_dict.items():
if key not in priority_fields:
ordered_profile[key] = value
return ordered_profile
def topological_sort(filaments):
# Build a graph of dependencies
graph = defaultdict(list)
in_degree = defaultdict(int)
name_to_filament = {f['name']: f for f in filaments}
all_names = set(name_to_filament.keys())
# Create the dependency graph
processed_files = set()
for filament in filaments:
if 'inherits' in filament:
parent = filament['inherits']
child = filament['name']
# Only create dependency if parent exists
if parent in all_names:
graph[parent].append(child)
in_degree[child] += 1
if parent not in in_degree:
in_degree[parent] = 0
processed_files.add(child)
processed_files.add(parent)
# Initialize queue with nodes having no dependencies (now sorted)
queue = sorted([name for name, degree in in_degree.items() if degree == 0])
result = []
# Process the queue
while queue:
current = queue.pop(0)
result.append(name_to_filament[current])
processed_files.add(current)
# Process children (now sorted)
children = sorted(graph[current])
for child in children:
in_degree[child] -= 1
if in_degree[child] == 0:
queue.append(child)
# Add remaining files that weren't part of inheritance tree (now sorted)
remaining = sorted(all_names - processed_files)
for name in remaining:
result.append(name_to_filament[name])
return result
def update_profile_library(vendor="",profile_type="filament"):
# change current working directory to the relative path(..\resources\profiles) compare to script location
os.chdir(os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles'))
# Collect current profile entries
if vendor:
vendors = [vendor]
else:
profiles_dir = os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles')
vendors = [f[:-5] for f in os.listdir(profiles_dir) if f.lower().endswith('.json')]
for vendor in vendors:
current_profiles = []
base_dir = vendor
# Orca expects machine_model to be in the machine folder
if profile_type == 'machine_model':
profile_dir = os.path.join(base_dir, 'machine')
else:
profile_dir = os.path.join(base_dir, profile_type)
for root, dirs, files in os.walk(profile_dir):
for file in files:
if file.lower().endswith('.json'):
full_path = os.path.join(root, file)
# Get relative path from base directory
sub_path = os.path.relpath(full_path, base_dir).replace('\\', '/')
try:
with open(full_path, 'r', encoding='utf-8') as f:
_profile = json.load(f)
if _profile.get('type') != profile_type:
continue
name = _profile.get('name')
inherits = _profile.get('inherits')
if name:
entry = {
"name": name,
"sub_path": sub_path
}
if inherits:
entry['inherits'] = inherits
current_profiles.append(entry)
else:
print(f"Warning: Missing 'name' in {full_path}")
except Exception as e:
print(f"Error reading {full_path}: {str(e)}")
continue
# Sort profiles based on inheritance
sorted_profiles = topological_sort(current_profiles)
# Remove the inherits field as it's not needed in the final JSON
for p in sorted_profiles:
p.pop('inherits', None)
# Update library file
lib_path = f'{vendor}.json'
profile_section = profile_type+'_list'
try:
with open(lib_path, 'r+', encoding='utf-8') as f:
library = json.load(f)
library[profile_section] = sorted_profiles
f.seek(0)
json.dump(library, f, indent="\t", ensure_ascii=False)
f.write('\n')
f.truncate()
print(f"Profile library for {vendor} updated successfully!")
except Exception as e:
print(f"Error updating library file: {str(e)}")
def clean_up_profile(vendor="", profile_type="", force=False):
# change current working directory to the relative path(..\resources\profiles) compare to script location
os.chdir(os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles'))
# Collect current profile entries
if vendor:
vendors = [vendor]
else:
profiles_dir = os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles')
vendors = [f[:-5] for f in os.listdir(profiles_dir) if f.lower().endswith('.json')]
for vendor in vendors:
current_profiles = []
base_dir = vendor
# Orca expects machine_model to be in the machine folder
if profile_type == 'machine_model':
profile_dir = os.path.join(base_dir, 'machine')
else:
profile_dir = os.path.join(base_dir, profile_type)
for root, dirs, files in os.walk(profile_dir):
for file in files:
if file.lower().endswith('.json'):
if file == 'filaments_color_codes.json': # Ignore non-profile file
continue
full_path = os.path.join(root, file)
# Get relative path from base directory
sub_path = os.path.relpath(full_path, base_dir).replace('\\', '/')
try:
with open(full_path, 'r+', encoding='utf-8') as f:
_profile = json.load(f)
need_update = False
if not _profile.get('type') or _profile.get('type') == "":
need_update = True
name = _profile.get('name')
inherits = _profile.get('inherits')
if profile_type == "machine_model" or profile_type == "machine":
if "nozzle" in name or "Nozzle" in name:
_profile['type'] = "machine"
else:
_profile['type'] = "machine_model"
else:
_profile['type'] = profile_type
print(f"Added type: {_profile['type']} to {file}")
fields_to_remove = ['version', 'is_custom_defined']
for field in fields_to_remove:
if _profile.get(field):
# remove version field
del _profile[field]
print(f"Removed {field} field from {file}")
need_update = True
# Handle `extruder_clearance_radius`.
if 'extruder_clearance_radius' in _profile and 'extruder_clearance_max_radius' in _profile:
# BBS renamed `extruder_clearance_radius` to `extruder_clearance_max_radius`
# however some of their profiles have both options exists with different value, which
# could cause very bad consequence such as toolhead collision.
# Here we make sure only one of these options exist, and if both present, we keep
# the one with greater value.
need_update = True
if float(_profile['extruder_clearance_max_radius']) > float(_profile['extruder_clearance_radius']):
del _profile['extruder_clearance_radius']
else:
del _profile['extruder_clearance_max_radius']
# Convert filament fields to arrays if not already
if profile_type == 'filament':
fields_to_arrayify = ['filament_cost', 'filament_density', 'filament_type', "temperature_vitrification", "filament_max_volumetric_speed", "filament_vendor"]
for field in fields_to_arrayify:
if field in _profile and not isinstance(_profile[field], list):
original_value = _profile[field]
_profile[field] = [original_value]
print(f"Converted {field} to array in {file}")
need_update = True
# remove following fields from filament profile
fields_to_remove = ['initial_layer_print_speed', 'outer_wall_speed', 'inner_wall_speed', 'infill_speed', 'top_surface_speed', 'travel_speed']
for field in fields_to_remove:
if field in _profile:
del _profile[field]
print(f"Removed {field} field from {file}")
need_update = True
if need_update or force:
# write back to file
f.seek(0)
ordered_profile = create_ordered_profile(_profile, ['type', 'name', 'renamed_from', 'inherits', 'from', 'setting_id', 'filament_id', 'instantiation'])
json.dump(ordered_profile, f, indent="\t", ensure_ascii=False)
f.write('\n')
f.truncate()
print(f"Updated profile: {full_path}")
except Exception as e:
print(f"Error reading {full_path}: {str(e)}")
continue
# For each JSON file, it will:
# - Replace "BBL X1C" with "System" in the name field
# - Empty the compatible_printers array
# - Ensure setting_id starts with 'O'
def rename_filament_system(vendor="OrcaFilamentLibrary"):
# change current working directory to the relative path
os.chdir(os.path.join(os.path.dirname(__file__), '..', 'resources', 'profiles'))
base_dir = vendor
filament_dir = os.path.join(base_dir, 'filament')
for root, dirs, files in os.walk(filament_dir):
for file in files:
if file.lower().endswith('.json'):
full_path = os.path.join(root, file)
try:
with open(full_path, 'r', encoding='utf-8') as f:
data = json.load(f)
modified = False
# Update name if it contains "BBL X1C"
if 'name' in data and "BBL X1C" in data['name']:
data['name'] = data['name'].replace("BBL X1C", "System")
modified = True
# Empty compatible_printers if exists
if 'compatible_printers' in data:
data['compatible_printers'] = []
modified = True
# Update setting_id if needed
if 'setting_id' in data and not data['setting_id'].startswith('O'):
data['setting_id'] = 'O' + data['setting_id']
modified = True
if modified:
with open(full_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent="\t", ensure_ascii=False)
f.write('\n')
print(f"Updated {full_path}")
except Exception as e:
print(f"Error processing {full_path}: {str(e)}")
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Update filament library for specified vendor')
parser.add_argument('-v', '--vendor', type=str, default="",
help='Vendor name (default: "" which means all vendors)')
parser.add_argument('-u', '--update', action='store_true', help='update vendor.json')
parser.add_argument('-p', '--profile_type', type=str, choices=['machine_model', 'process', 'filament', 'machine'], help='profile type (default: "" which means all types)')
parser.add_argument('-f', '--fix', action='store_true', help='Fix errors like missing type field, and clean up the profile')
parser.add_argument('--force', action='store_true', help='Force update the profile files, for --fix option')
args = parser.parse_args()
if args.fix:
if(args.profile_type):
clean_up_profile(args.vendor, args.profile_type, args.force)
else:
clean_up_profile(args.vendor, 'machine_model', args.force)
clean_up_profile(args.vendor, 'process', args.force)
clean_up_profile(args.vendor, 'filament', args.force)
clean_up_profile(args.vendor, 'machine', args.force)
if args.update:
update_profile_library(args.vendor, 'machine_model')
update_profile_library(args.vendor, 'process')
update_profile_library(args.vendor, 'filament')
update_profile_library(args.vendor, 'machine')
# else:
# rename_filament_system(args.vendor)
File diff suppressed because it is too large Load Diff
+2419
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -7,8 +7,9 @@
#
# Usage: run_unit_tests.sh [TEST_DIR] [BUILD_CONFIG]
# TEST_DIR directory containing the built tests (default: build/tests)
# BUILD_CONFIG configuration to run; required for multi-config generators
# (Windows/macOS), harmless/omitted for single-config (Linux).
# BUILD_CONFIG configuration to run; required for multi-config generators, which all
# build scripts use (build_linux.sh too: Ninja Multi-Config). Without it,
# tests registered with plain add_test() lose their labels and report "Not Run".
ROOT_DIR="$(dirname "$0")/.."
@@ -17,8 +18,9 @@ cd "${ROOT_DIR}" || exit 1
TEST_DIR="${1:-build/tests}"
BUILD_CONFIG="${2:-}"
# Run the whole suite, excluding tests tagged [NotWorking].
# Run the whole suite, excluding tests tagged [NotWorking] and tests labelled RequiresApp,
# which run the built orca-slicer binary that this directory does not contain.
# --no-tests=error fails the job if the filter matches nothing (instead of passing green).
args=(--test-dir "${TEST_DIR}" -LE "NotWorking" --no-tests=error --output-junit "$(pwd)/ctest_results.xml" --output-on-failure -j)
args=(--test-dir "${TEST_DIR}" -LE "NotWorking|RequiresApp" --no-tests=error --output-junit "$(pwd)/ctest_results.xml" --output-on-failure -j)
[ -n "${BUILD_CONFIG}" ] && args+=(--build-config "${BUILD_CONFIG}")
ctest "${args[@]}"
+15 -9
View File
@@ -22,6 +22,7 @@
Match regexes; each must match at least one output line
NotMatch regexes; none may match any output line
NotExists paths that must not exist after the case runs
DateStampedZip require a bundle date from during this case's invocation
.PARAMETER Name
Run only the cases whose name matches this regex. Headings with no
@@ -109,12 +110,6 @@ $slnDir = Join-Path $fixtures 'sln'
New-Item -ItemType Directory -Force -Path $slnDir | Out-Null
Set-Content -Path (Join-Path $slnDir 'OrcaSlicer.sln') -Value '' -Encoding ascii
# The pack stamp is checked against real dates, so a locale-dependent parse
# in the script cannot pass by looking date-shaped. Yesterday is accepted too,
# so a run that crosses midnight does not flake.
$dateStamps = @((Get-Date -Format 'yyyyMMdd'), (Get-Date).AddDays(-1).ToString('yyyyMMdd'))
$stampPattern = '_(' + ($dateStamps -join '|') + ')\.zip$'
$cases = @(
'argument handling'
@{ Name = 'no arguments prints help'; Args = @(); DryRun = $false
@@ -326,12 +321,12 @@ $cases = @(
Contains = @('OrcaSlicer_dep_win-x64_')
NotContains = @('-clang', '-Release') }
@{ Name = 'the bundle is stamped with today, not a shuffled date'; Args = @('-p')
Match = @($stampPattern) }
DateStampedZip = $true }
# powershell.exe is not in System32 itself, so a trimmed PATH used to
# leave the stamp empty and the bundle named OrcaSlicer_dep_win-x64_.zip.
@{ Name = 'the bundle is stamped even with a bare PATH'; Args = @('-p')
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
Match = @($stampPattern) }
DateStampedZip = $true }
@{ Name = '-p packs without rebuilding'; Args = @('-p')
Match = @('^\+ .*(7z\.exe a|tar\.exe -a -c -f) ')
NotContains = @('cmake -S deps') }
@@ -861,7 +856,7 @@ function Invoke-BuildScript {
$knownFields = @(
'Name', 'Args', 'ExpectExit', 'DryRun', 'First', 'Env',
'Contains', 'NotContains', 'Match', 'NotMatch', 'NotExists'
'Contains', 'NotContains', 'Match', 'NotMatch', 'NotExists', 'DateStampedZip'
)
function Test-Case {
@@ -876,7 +871,9 @@ function Test-Case {
$expect = 0
if ($Case.ContainsKey('ExpectExit')) { $expect = $Case['ExpectExit'] }
$started = Get-Date
$result = Invoke-BuildScript -Arguments $argv -Environment $Case['Env']
$finished = Get-Date
$problems = @()
@@ -902,6 +899,15 @@ function Test-Case {
$problems += "no line matching /$pattern/"
}
}
if ($Case['DateStampedZip']) {
# Bound the accepted dates to this invocation so crossing midnight is
# valid without allowing an unrelated past or future date.
$dateStamps = @($started.ToString('yyyyMMdd'), $finished.ToString('yyyyMMdd')) | Select-Object -Unique
$pattern = '_(' + ($dateStamps -join '|') + ')\.zip$'
if (@($lines | Where-Object { $_ -match $pattern }).Count -eq 0) {
$problems += "no line matching /$pattern/"
}
}
foreach ($pattern in $Case['NotMatch']) {
foreach ($line in @($lines | Where-Object { $_ -match $pattern })) {
$problems += "line matches /$pattern/: $line"
+107 -323
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Tests for scripts/orca_id_tool.py (stdlib unittest, no external deps).
"""Tests for scripts/orca_profile_tool.py (stdlib unittest, no external deps).
Run from the repo root: python -m unittest discover -s scripts/tests -v
"""
@@ -17,7 +17,7 @@ import uuid
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import orca_id_tool as afi # noqa: E402
import orca_profile_tool as afi # noqa: E402
import update_bambu_filament_ids as ubfi # noqa: E402
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
@@ -55,13 +55,12 @@ def preset(name, filament_id=None, inherits=None, instantiation=True,
class SyntheticTree:
"""A throwaway resources/profiles-shaped directory plus a snapshot path."""
"""A throwaway resources/profiles-shaped directory."""
def __init__(self):
self.dir = tempfile.mkdtemp(prefix="filament_id_test_")
self.profiles = os.path.join(self.dir, "profiles")
os.makedirs(self.profiles)
self.snapshot = os.path.join(self.dir, "filament_id_snapshot.json")
def cleanup(self):
shutil.rmtree(self.dir, ignore_errors=True)
@@ -110,16 +109,6 @@ class SyntheticTree:
with open(idx_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=4, ensure_ascii=False)
def remove_preset(self, vendor, name):
os.remove(self.preset_path(vendor, name))
idx_path = os.path.join(self.profiles, vendor + ".json")
with open(idx_path, encoding="utf-8") as f:
index = json.load(f)
index["filament_list"] = [
e for e in index["filament_list"] if e["name"] != name]
with open(idx_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=4, ensure_ascii=False)
def bytes_map(self):
"""{relative path -> file bytes} over every .json in the tree."""
raw = {}
@@ -135,17 +124,11 @@ class SyntheticTree:
# -- pipeline wrappers ---------------------------------------------------
def update_snapshot(self, dry_run=False):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = afi.update_snapshot(self.profiles, self.snapshot, dry_run)
return rc, buf.getvalue()
def check(self, map_path=None):
buf = io.StringIO()
kwargs = {} if map_path is None else {"map_path": map_path}
with contextlib.redirect_stdout(buf):
errors = afi.check_filament_ids(self.profiles, self.snapshot, **kwargs)
errors = afi.check_filament_ids(self.profiles, **kwargs)
return errors, buf.getvalue()
# assign() and remint() are the same one pass over the tree — every filament
@@ -164,22 +147,22 @@ class SyntheticTree:
changed, errors = afi.generate_filament_ids(self.profiles, vendors, dry_run)
return changed, errors, buf.getvalue()
def cli(self, *flags):
def cli(self, *argv):
"""Run main() against this tree, capturing stdout."""
flags = [*argv, "--profiles", self.profiles]
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = afi.main([*flags, "--profiles", self.profiles,
"--snapshot", self.snapshot])
rc = afi.main(flags)
return rc, buf.getvalue()
def make_clean_tree(apla_id="AX01", generic_id="OGFL99"):
"""Baseline tree: OFL base+generic, a vendor filament, a clean tuned generic.
apla_id/generic_id default to arbitrary non-OF placeholders (sanctioned by
the snapshot below) since most tests only need "already assigned, don't
touch" and never run the checks. TestAssign and the check tests pass real
OF-format ids instead (OfCleanTreeCase).
apla_id/generic_id default to arbitrary non-OF placeholders since most tests
only need "already assigned, don't touch" and never run the checks.
TestAssign and the check tests pass real OF-format ids instead
(OfCleanTreeCase).
"""
t = SyntheticTree()
t.add_vendor(OFL, [
@@ -197,8 +180,6 @@ def make_clean_tree(apla_id="AX01", generic_id="OGFL99"):
preset("Generic PLA @P1", inherits="Generic PLA @System",
compatible_printers=["P1 0.4 nozzle"]),
])
rc, _out = t.update_snapshot()
assert rc == 0
return t
@@ -210,10 +191,9 @@ class SyntheticTreeCase(unittest.TestCase):
class OfCleanTreeCase(unittest.TestCase):
"""Like SyntheticTreeCase, but the baseline filament/generic already carry
real OF-format ids (check 1 now rejects "AX01"/"OGFL99" unconditionally,
with no snapshot exemption), so an otherwise-untouched tree still passes
check_filament_ids. Tests that specifically need a non-OF baseline to
remint (TestRemint, TestUpdateSnapshot) keep using SyntheticTreeCase
real OF-format ids (check 1 rejects "AX01"/"OGFL99"), so an
otherwise-untouched tree passes check_filament_ids. Tests that specifically
need a non-OF baseline to remint (TestRemint) keep using SyntheticTreeCase
instead.
"""
def setUp(self):
@@ -229,7 +209,7 @@ class OfCleanTreeCase(unittest.TestCase):
class TestMint(unittest.TestCase):
def test_namespace_literal(self):
# Frozen: derived from the setting_id namespace; baked into the snapshot.
# Frozen: derived from the setting_id namespace; baked into every shipped id.
self.assertEqual(afi.FILAMENT_ID_NAMESPACE,
uuid.UUID("c4d3ff49-4c32-5534-a3e3-00894157ab97"))
@@ -426,26 +406,6 @@ class TestTripleResolution(unittest.TestCase):
("MyVendor", "PLA", "MyPLA"))
# ---------------------------------------------------------------------------
# reserved namespaces
# ---------------------------------------------------------------------------
class TestReservedSpaces(unittest.TestCase):
def test_owners(self):
# Bambu AMS/RFID catalog: reserved, but no vendor (not even BBL) may declare it
self.assertEqual(afi.reserved_space_owner("GFL99"), (True, None))
# Qidi device protocol: reserved, but no vendor may declare it
self.assertEqual(afi.reserved_space_owner("QD_X4_PLA"), (True, None))
self.assertEqual(afi.reserved_space_owner("P1234abc"), (True, None))
self.assertEqual(afi.reserved_space_owner("pAbCdEf1"), (True, None)) # case-insensitive
self.assertEqual(afi.reserved_space_owner("null"), (True, None))
self.assertEqual(afi.reserved_space_owner("OF5CgdDq"), (False, None))
self.assertEqual(afi.reserved_space_owner("P1234abcd"), (False, None)) # 8 hex chars: not the user space
def test_gf_is_reserved_and_ownerless(self):
self.assertEqual(afi.reserved_space_owner("GFA00"), (True, None))
# ---------------------------------------------------------------------------
# checks on synthetic trees
# ---------------------------------------------------------------------------
@@ -468,22 +428,10 @@ class TestChecks(OfCleanTreeCase):
self.assertIn('is not a minted "OF" id', out)
self.assertIn("BOGUS_9", out)
def test_check2_new_claim_needs_snapshot_update(self):
self.t.write_preset("VendorA", preset("ANEW @P2", inherits="APLA @base",
compatible_printers=["P2"]))
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertIn('claim "VendorA/ANEW" is not sanctioned', out)
self.assertIn("--update-snapshot", out)
def test_check2_vanished_claim_is_stability_error(self):
self.t.remove_preset("VendorA", "APLA @P1")
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertIn("stability", out)
self.assertIn('"VendorA/APLA"', out)
def test_check2_triple_change_needs_snapshot_update(self):
def test_check2_triple_change_needs_a_remint(self):
# Correcting a triple changes the product's identity: the old id is no
# longer its mint, reported on the root and again under the variant
# inheriting it, until generate-id re-mints it.
apla_id = afi.generate_filament_id("AVendor", "PLA", "APLA")
self.t.write_preset("VendorA", preset("APLA @base", filament_id=apla_id,
instantiation=False,
@@ -491,26 +439,15 @@ class TestChecks(OfCleanTreeCase):
filament_type="PETG"),
register=False)
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertIn('triple "AVendor/PETG/APLA" is not sanctioned', out)
self.assertIn('which records "AVendor/PLA/APLA"', out)
# Sanctioning the new triple is not enough: the old id is no longer its
# mint (check 3, nothing grandfathered) — the identity fix is a re-mint,
# reported on the root and again under the variant inheriting it.
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 2, out)
self.assertIn("does not match the mint of its triple", out)
self.assertIn('"APLA @P1" (VendorA/filament/APLA @P1.json) inherits filament_id', out)
_changed, errors, out = self.t.remint(["VendorA"])
self.assertEqual(errors, 0, out)
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 0, out)
def test_check3_of_id_must_match_triple_mint(self):
def test_check2_of_id_must_match_triple_mint(self):
self.t.write_preset("VendorA", preset("BNEW @base", filament_id="OFZZZZZZ",
instantiation=False,
filament_vendor="BV", filament_type="PLA"))
@@ -521,39 +458,20 @@ class TestChecks(OfCleanTreeCase):
self.assertIn("does not match the mint of its triple", out)
self.assertIn(afi.generate_filament_id("BV", "PLA", "BNEW"), out)
def test_check3_no_grandfathering_of_a_wrong_declaration(self):
# Sanctioning the tree does not excuse a declaration from its mint.
self.t.write_preset("VendorA", preset("CNEW @base", filament_id="OFZZZZZZ",
instantiation=False,
filament_vendor="CV", filament_type="PLA"))
self.t.write_preset("VendorA", preset("CNEW @P1", inherits="CNEW @base",
compatible_printers=["P1"]))
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 2, out) # the declaration, and the variant inheriting it
self.assertIn("does not match the mint of its triple", out)
self.assertIn('"CNEW @P1" (VendorA/filament/CNEW @P1.json) inherits filament_id', out)
def test_check3_inherited_id_must_be_the_mint_of_own_triple(self):
def test_check2_inherited_id_must_be_the_mint_of_own_triple(self):
# A preset of another filament inheriting APLA's root takes APLA's id,
# which is not the mint of ITS triple (AVendor/PLA/Tuned PLA).
self.t.write_preset("VendorA", preset("Tuned PLA @P1", inherits="APLA @base",
compatible_printers=["P1"]))
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertEqual(errors, 1, out)
self.assertIn('"Tuned PLA @P1" (VendorA/filament/Tuned PLA @P1.json) inherits '
'filament_id "%s"' % afi.generate_filament_id("AVendor", "PLA", "APLA"),
out)
self.assertIn('mints "%s"' % afi.generate_filament_id("AVendor", "PLA", "Tuned PLA"),
out)
# ... and sanctioning the tree does not excuse it either.
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 1, out)
def test_check3_lists_every_preset_inheriting_a_wrong_id(self):
def test_check2_lists_every_preset_inheriting_a_wrong_id(self):
# A wrong declaration is reported under every preset inheriting it, its
# own product's variant and another product alike: each one's effective
# id is not the mint of its own triple, and each is listed. Nothing is
@@ -570,11 +488,10 @@ class TestChecks(OfCleanTreeCase):
self.assertIn('"DNEW @P1" (VendorA/filament/DNEW @P1.json) inherits filament_id', out)
self.assertIn('"Other DNEW @P1" (VendorA/filament/Other DNEW @P1.json) inherits '
'filament_id', out)
# The unsanctioned id (check 2), the declaration (3a), and both presets
# inheriting it (3b).
self.assertEqual(errors, 4, out)
# The declaration (2a), and both presets inheriting it (2b).
self.assertEqual(errors, 3, out)
def test_check3_reports_an_inherited_mismatch_even_when_its_own_product_misdeclares_the_id(self):
def test_check2_reports_an_inherited_mismatch_even_when_its_own_product_misdeclares_the_id(self):
# "Tuned PLA @P1" inherits APLA's root, so it carries APLA's id: wrong
# for its own product however the declarations around it are fixed.
# That "Tuned PLA @base" — its own product — misdeclares that same id
@@ -591,11 +508,11 @@ class TestChecks(OfCleanTreeCase):
'not match the mint of its triple', out)
self.assertIn('"Tuned PLA @P1" (VendorA/filament/Tuned PLA @P1.json) inherits '
'filament_id', out)
# The unsanctioned claim and triple (check 2), the declaration (3a) and
# the inherited id (3b): four distinct errors, nothing folded away.
self.assertEqual(errors, 4, out)
# The declaration (2a) and the inherited id (2b): two distinct errors,
# nothing folded away.
self.assertEqual(errors, 2, out)
def test_check3_reports_a_collision_between_two_products(self):
def test_check2_reports_a_collision_between_two_products(self):
# Two products whose triples mint one id is a base62 collision. There
# is no salted or hand-picked second id to fall back on: the check
# names both products, and the remedy is a rename so the triples differ.
@@ -619,13 +536,12 @@ class TestChecks(OfCleanTreeCase):
self.assertIn("V/PLA/X", out)
self.assertIn("W/ABS/Y", out)
# Each declaration is the mint of its own triple, so the collision is
# the only identity error — no product is pushed off its id — and the
# unsanctioned id (check 2) is the only other one.
# the only error — no product is pushed off its id.
self.assertNotIn("does not match the mint", out)
self.assertNotIn("inherits filament_id", out)
self.assertEqual(errors, 2, out)
self.assertEqual(errors, 1, out)
def test_check3_renamed_tuned_generic_is_an_identity_error(self):
def test_check2_renamed_tuned_generic_is_an_identity_error(self):
# Riding the OFL generic under another base name: same rule, same error.
self.t.write_preset("VendorA", preset("Tuned PLA @P1",
inherits="Generic PLA @System",
@@ -635,7 +551,7 @@ class TestChecks(OfCleanTreeCase):
self.assertIn("Tuned PLA @P1", out)
self.assertIn("inherits filament_id", out)
def test_check3_own_key_on_an_instantiated_preset_is_fine(self):
def test_check2_own_key_on_an_instantiated_preset_is_fine(self):
# Where the id comes from is irrelevant: a variant may carry the key.
apla_id = afi.generate_filament_id("AVendor", "PLA", "APLA")
self.t.write_preset("VendorA", preset("APLA @P1", filament_id=apla_id,
@@ -645,7 +561,7 @@ class TestChecks(OfCleanTreeCase):
errors, out = self.t.check()
self.assertEqual(errors, 0, out)
def test_check3_inheriting_a_real_filament_of_another_product_is_fine(self):
def test_check2_inheriting_a_real_filament_of_another_product_is_fine(self):
# A branded product may inherit the OFL generic (an instantiated
# preset) for its settings; it declares its own triple's id.
fid = afi.generate_filament_id("BV", "PLA", "Branded PLA")
@@ -653,8 +569,6 @@ class TestChecks(OfCleanTreeCase):
inherits="Generic PLA @System",
filament_vendor="BV",
compatible_printers=["P1"]))
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 0, out)
# With a wrong key it is a plain mint mismatch: the parent plays no
@@ -669,11 +583,12 @@ class TestChecks(OfCleanTreeCase):
self.assertGreater(errors, 0)
self.assertIn("does not match the mint of its triple", out)
def test_check4_reserved_namespace_claims(self):
for fid, marker in [("GFX99", "Bambu AMS/RFID catalog"),
("QD_X_PLA", "composed by the device"),
("P1a2b3c4", "user-custom"),
("null", "user-custom")]:
def test_check1_an_id_another_system_composed_is_not_a_mint(self):
# Nothing is reserved because nothing is exempt: an id some other system
# composes for its own purposes - Bambu's catalog, a Qidi box, the dialog
# that creates a user filament - is simply not the mint of a triple, and
# check 1 rejects it for that and nothing else.
for fid in ("GFX99", "QD_X_PLA", "P1a2b3c4", "null"):
with self.subTest(fid=fid):
name = f"R{fid} @base"
self.t.write_preset("VendorA", preset(name, filament_id=fid,
@@ -684,25 +599,19 @@ class TestChecks(OfCleanTreeCase):
compatible_printers=["P1"]))
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertIn("reserved id space", out)
self.assertIn(marker, out)
self.assertIn(f'filament_id "{fid}"', out)
self.assertIn('is not a minted "OF" id', out)
def test_check3c_unresolvable_instantiated_filament(self):
def test_check2c_unresolvable_instantiated_filament(self):
self.t.write_preset("VendorA", preset("DNEW @P1", compatible_printers=["P1"]))
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertIn("resolves no filament_id", out)
self.assertIn("hard load error", out)
def test_missing_snapshot_is_an_error(self):
os.remove(self.t.snapshot)
errors, out = self.t.check()
self.assertEqual(errors, 1)
self.assertIn("snapshot not found", out)
class TestCheck5(OfCleanTreeCase):
def test_5a_empty_vendor_is_hard_error(self):
class TestCheck3(OfCleanTreeCase):
def test_3a_empty_vendor_is_hard_error(self):
fid = afi.generate_filament_id("", "PLA", "NVPLA")
self.t.write_preset("VendorA", preset("NVPLA @base", filament_id=fid,
instantiation=False,
@@ -710,17 +619,11 @@ class TestCheck5(OfCleanTreeCase):
self.t.write_preset("VendorA", preset("NVPLA @P1", inherits="NVPLA @base",
compatible_printers=["P1"]))
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertIn("resolves empty filament_vendor", out)
self.assertIn('filament_vendor "Generic"', out)
# No grandfathering: sanctioning the tree does not silence check 5a.
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 1, out)
self.assertIn("resolves empty filament_vendor", out)
self.assertIn('filament_vendor "Generic"', out)
def test_5b_divergent_filament_triples(self):
def test_3b_divergent_filament_triples(self):
id1 = afi.generate_filament_id("MV", "PLA", "MPLA")
id2 = afi.generate_filament_id("MV", "PETG", "MPLA")
self.t.write_preset("VendorA", preset("MPLA @base1", filament_id=id1,
@@ -732,18 +635,12 @@ class TestCheck5(OfCleanTreeCase):
filament_vendor="MV",
filament_type="PETG"))
errors, out = self.t.check()
self.assertGreater(errors, 0)
self.assertEqual(errors, 1, out)
self.assertIn("divergent triples", out)
self.assertIn("MV/PLA/MPLA", out)
self.assertIn("MV/PETG/MPLA", out)
# No grandfathering: sanctioning the tree does not silence check 5b.
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 1, out)
self.assertIn("divergent triples", out)
def test_5_cross_bundle_divergence_is_warning_only(self):
def test_3_cross_bundle_divergence_is_warning_only(self):
fid = afi.generate_filament_id("BV", "PETG", "APLA")
self.t.add_vendor("VendorB", [
preset("APLA @base", filament_id=fid, instantiation=False,
@@ -751,8 +648,6 @@ class TestCheck5(OfCleanTreeCase):
preset("APLA @PB", inherits="APLA @base",
compatible_printers=["PB 0.4 nozzle"]),
])
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 0, out)
self.assertIn("[WARNING]", out)
@@ -760,7 +655,7 @@ class TestCheck5(OfCleanTreeCase):
self.assertIn('"APLA"', out)
class TestCheck6(OfCleanTreeCase):
class TestCheck4(OfCleanTreeCase):
def _write_map(self, rows):
path = os.path.join(self.t.dir, "bambu_filament_ids.json")
ubfi.write_map(path, rows, "testcommit", "2026-09-04")
@@ -862,101 +757,6 @@ class TestCheck6(OfCleanTreeCase):
self.assertIn('declares no "bambu_id"', out)
# ---------------------------------------------------------------------------
# --update-snapshot
# ---------------------------------------------------------------------------
class TestUpdateSnapshot(SyntheticTreeCase):
def test_idempotent_and_deterministic(self):
with open(self.t.snapshot, "rb") as f:
first = f.read()
rc, out = self.t.update_snapshot()
self.assertEqual(rc, 0)
self.assertIn("nothing changed", out)
with open(self.t.snapshot, "rb") as f:
self.assertEqual(f.read(), first)
self.assertTrue(first.endswith(b"\n"))
self.assertNotIn(b"\r", first)
snap = json.loads(first.decode("utf-8"))
self.assertEqual(list(snap), ["ids"]) # state only, no exception lists
self.assertEqual(list(snap["ids"]), sorted(snap["ids"]))
self.assertEqual(snap["ids"]["AX01"], {
"filaments": ["VendorA/APLA"], "name": "APLA",
"filament_type": "PLA", "filament_vendor": "AVendor"})
self.assertEqual(snap["ids"]["OGFL99"], {
"filaments": ["OrcaFilamentLibrary/Generic PLA", "VendorA/Generic PLA"],
"name": "Generic PLA", "filament_type": "PLA", "filament_vendor": "Generic"})
# Key order is part of the on-disk format.
self.assertEqual(list(snap["ids"]["AX01"]),
["filaments", "name", "filament_type", "filament_vendor"])
def test_refuses_a_tree_it_could_not_read(self):
# A bundle that does not parse contributes no ids, so sanctioning the
# rest would record the loss as a deliberate removal.
with open(self.t.snapshot, "rb") as f:
before = f.read()
with open(os.path.join(self.t.profiles, "VendorA",
"filament", "APLA @base.json"), "w",
encoding="utf-8") as f:
f.write("{ not json")
rc, out = self.t.update_snapshot()
self.assertEqual(rc, 1, out)
self.assertIn("unreadable filament profile", out)
with open(self.t.snapshot, "rb") as f:
self.assertEqual(f.read(), before)
def test_refuses_an_id_declared_under_two_triples(self):
# VendorB re-declares APLA's id for a different product: one id, two
# triples. No single entry can describe it, and check 3 rejects it anyway.
self.t.add_vendor("VendorB", [
preset("BPLA @base", filament_id="AX01", instantiation=False,
filament_vendor="BVendor", filament_type="PLA"),
preset("BPLA @P1", inherits="BPLA @base", compatible_printers=["P1"]),
])
with open(self.t.snapshot, "rb") as f:
before = f.read()
rc, out = self.t.update_snapshot()
self.assertEqual(rc, 1)
self.assertIn('refusing to sanction filament_id "AX01": declared under 2 triples '
'(AVendor/PLA/APLA; BVendor/PLA/BPLA)', out)
with open(self.t.snapshot, "rb") as f:
self.assertEqual(f.read(), before) # nothing written on refusal
def test_refuses_reserved_namespace_ids(self):
self.t.write_preset("VendorA", preset("CNEW @base", filament_id="GFX99",
instantiation=False,
filament_vendor="CV",
filament_type="PLA"))
self.t.write_preset("VendorA", preset("CNEW @P1", inherits="CNEW @base",
compatible_printers=["P1"]))
with open(self.t.snapshot, "rb") as f:
before = f.read()
rc, out = self.t.update_snapshot()
self.assertEqual(rc, 1)
self.assertIn("refusing to sanction", out)
with open(self.t.snapshot, "rb") as f:
self.assertEqual(f.read(), before) # nothing written on refusal
def test_dry_run_reports_without_writing(self):
self.t.write_preset("VendorA", preset("ANEW @P2", inherits="APLA @base",
compatible_printers=["P2"]))
with open(self.t.snapshot, "rb") as f:
before = f.read()
rc, out = self.t.update_snapshot(dry_run=True)
self.assertEqual(rc, 0)
self.assertIn("would be rewritten", out)
self.assertIn("claims added : 1", out)
with open(self.t.snapshot, "rb") as f:
self.assertEqual(f.read(), before)
# the real run writes exactly what the dry run reported
rc, out = self.t.update_snapshot()
self.assertEqual(rc, 0)
self.assertIn("snapshot written", out)
snap = load_json_file(self.t.snapshot)
self.assertEqual(snap["ids"]["AX01"]["filaments"],
["VendorA/ANEW", "VendorA/APLA"])
# ---------------------------------------------------------------------------
# --generate: one rule for inserts and rewrites alike
# ---------------------------------------------------------------------------
@@ -1040,7 +840,7 @@ class TestAssign(OfCleanTreeCase):
def test_parent_of_another_filament_never_receives_the_key(self):
# Members whose id-less parent belongs to another filament (here one
# parent shared by two filaments) carry the key themselves: the
# parent's own triple would mint a different id (check 3).
# parent's own triple would mint a different id (check 2).
self.t.write_preset("VendorA", preset("shared_base", instantiation=False,
filament_vendor="SV",
filament_type="PLA"))
@@ -1058,8 +858,6 @@ class TestAssign(OfCleanTreeCase):
parent = load_json_file(self.t.preset_path("VendorA", "shared_base"))
self.assertNotIn("filament_id", parent)
# ... and the tree they leave behind passes the identity check.
rc, _out = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 0, out)
@@ -1536,7 +1334,7 @@ class TestRemint(SyntheticTreeCase):
class TestCli(unittest.TestCase):
"""main(argv) over a synthetic tree. The clean tree's baseline ids are
deliberately non-conformant ("AX01"/"OGFL99"), so a --generate run always
deliberately non-conformant ("AX01"/"OGFL99"), so a generate-id run always
has both a filament_id rewrite and setting_id inserts to do."""
def setUp(self):
@@ -1544,47 +1342,24 @@ class TestCli(unittest.TestCase):
self.addCleanup(self.t.cleanup)
def test_bare_invocation_prints_help(self):
# Naming no command is not an error: it is how you find out what the
# commands are, and it must never be mistaken for a run that did work.
before = self.t.bytes_map()
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = afi.main([])
self.assertEqual(rc, 0)
self.assertIn("usage:", buf.getvalue())
self.assertIn("--generate", buf.getvalue())
# ... and so does any invocation naming no mode: help, and no work
before = self.t.bytes_map()
rc, out = self.t.cli()
self.assertEqual(rc, 0)
self.assertIn("usage:", out)
for command in ("check", "generate-id", "normalize", "trim", "update-index"):
self.assertIn(command, buf.getvalue())
self.assertEqual(self.t.bytes_map(), before)
def test_another_tree_needs_its_own_snapshot(self):
# --profiles retargets the tree, but the sanctioned state of that tree
# is not the repo snapshot: checking against it is meaningless and
# re-recording into it would overwrite the tracked file.
with open(afi.SNAPSHOT_PATH, "rb") as f:
repo_snapshot = f.read()
for mode in ("--check", "--update-snapshot"):
with self.assertRaises(SystemExit) as caught:
with contextlib.redirect_stderr(io.StringIO()):
afi.main([mode, "--profiles", self.t.profiles])
self.assertEqual(caught.exception.code, 2, mode)
with open(afi.SNAPSHOT_PATH, "rb") as f:
self.assertEqual(f.read(), repo_snapshot)
# Named explicitly, both modes run against that tree.
rc, out = self.t.cli("--update-snapshot")
self.assertEqual(rc, 0, out)
# --generate never reads the snapshot, so it keeps working without one.
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = afi.main(["--dry-run", "--profiles", self.t.profiles])
self.assertEqual(rc, 0, buf.getvalue())
def test_filament_id_and_setting_id_together_are_rejected(self):
# Each flag's help promises it skips the other kind, so the pair cannot
# quietly mean "both".
with self.assertRaises(SystemExit) as caught:
with contextlib.redirect_stderr(io.StringIO()):
afi.main(["--generate", "--filament-id", "--setting-id",
afi.main(["generate-id", "--filament-id", "--setting-id",
"--profiles", self.t.profiles])
self.assertEqual(caught.exception.code, 2)
@@ -1593,14 +1368,14 @@ class TestCli(unittest.TestCase):
path = self.t.preset_path("VendorA", "APLA @base")
with open(path, "w", encoding="utf-8") as f:
f.write("{ not json")
rc, out = self.t.cli("--generate")
rc, out = self.t.cli("generate-id")
self.assertEqual(rc, 1, out)
self.assertIn("error(s)", out)
self.assertNotIn("SUCCESS", out)
def test_dry_run_alone_previews_generate(self):
def test_dry_run_previews_generate_id(self):
before = self.t.bytes_map()
rc, out = self.t.cli("--dry-run")
rc, out = self.t.cli("generate-id", "--dry-run")
self.assertEqual(rc, 0, out)
self.assertIn("would ", out)
self.assertIn("nothing written", out)
@@ -1615,7 +1390,7 @@ class TestCli(unittest.TestCase):
filament_type="PLA", compatible_printers=["P1"]))
before = self.t.bytes_map()
rc, out = self.t.cli("--generate")
rc, out = self.t.cli("generate-id")
self.assertEqual(rc, 0, out)
after = self.t.bytes_map()
@@ -1629,7 +1404,7 @@ class TestCli(unittest.TestCase):
def test_dryrun_is_the_same_flag(self):
before = self.t.bytes_map()
rc, out = self.t.cli("--dryrun")
rc, out = self.t.cli("generate-id", "--dryrun")
self.assertEqual(rc, 0, out)
self.assertIn("would ", out) # the same preview, not a silent no-op
self.assertIn("nothing written", out)
@@ -1637,7 +1412,7 @@ class TestCli(unittest.TestCase):
def test_generate_vendor_writes_only_in_that_bundle(self):
before = self.t.bytes_map()
rc, out = self.t.cli("--generate", "--vendor", "VendorA")
rc, out = self.t.cli("generate-id", "--vendor", "VendorA")
self.assertEqual(rc, 0, out)
after = self.t.bytes_map()
changed = sorted(rel for rel in before if after[rel] != before[rel])
@@ -1646,7 +1421,7 @@ class TestCli(unittest.TestCase):
self.assertTrue(rel.startswith("VendorA" + os.sep), rel)
# The bundles it spared were not simply already conformant: the
# un-narrowed run goes on to write in them too.
rc, out = self.t.cli("--generate")
rc, out = self.t.cli("generate-id")
self.assertEqual(rc, 0, out)
final = self.t.bytes_map()
self.assertTrue(any(final[rel] != after[rel] for rel in after
@@ -1654,13 +1429,13 @@ class TestCli(unittest.TestCase):
def test_generate_unknown_vendor_returns_1(self):
before = self.t.bytes_map()
rc, out = self.t.cli("--generate", "--vendor", "Nope")
rc, out = self.t.cli("generate-id", "--vendor", "Nope")
self.assertEqual(rc, 1)
self.assertIn("unknown vendor", out)
self.assertEqual(self.t.bytes_map(), before)
def test_setting_id_only_leaves_filament_ids_alone(self):
rc, out = self.t.cli("--generate", "--setting-id")
rc, out = self.t.cli("generate-id", "--setting-id")
self.assertEqual(rc, 0, out)
root = load_json_file(self.t.preset_path("VendorA", "APLA @base"))
self.assertEqual(root["filament_id"], "AX01") # not re-minted
@@ -1671,7 +1446,7 @@ class TestCli(unittest.TestCase):
"APLA @P1"))
def test_filament_id_only_inserts_no_setting_id(self):
rc, out = self.t.cli("--generate", "--filament-id")
rc, out = self.t.cli("generate-id", "--filament-id")
self.assertEqual(rc, 0, out)
root = load_json_file(self.t.preset_path("VendorA", "APLA @base"))
self.assertEqual(root["filament_id"],
@@ -1680,24 +1455,42 @@ class TestCli(unittest.TestCase):
self.assertNotIn(
"setting_id", load_json_file(self.t.preset_path("VendorA", name)))
def test_check_mode_returns_1_on_errors(self):
# What CI keys off: --check exits nonzero when the tree does not match
# the snapshot it is validated against.
def test_check_returns_1_on_errors(self):
# What CI keys off: check exits nonzero when the tree breaks a rule, here
# the baseline's ids that are not minted.
before = self.t.bytes_map()
rc, out = self.t.cli("--check")
rc, out = self.t.cli("check")
self.assertEqual(rc, 1)
self.assertIn("error(s)", out)
self.assertEqual(self.t.bytes_map(), before) # --check never writes
self.assertIn("Files with errors", out)
self.assertEqual(self.t.bytes_map(), before) # check never writes
def test_check_vendor_narrows_the_per_vendor_pass(self):
# check_profile.sh passes --vendor to this command, so it has to be
# accepted -- and it must narrow only the per-vendor half.
rc, out = self.t.cli("check", "--vendor", "VendorA")
self.assertEqual(rc, 1, out) # the tree-wide checks still ran
self.assertIn("Checked vendors : 1", out)
def test_an_empty_vendor_means_every_vendor(self):
# check_profile.sh cannot expand an empty array under set -u, so it
# passes --vendor "" to mean "all of them".
_rc, scoped = self.t.cli("check", "--vendor", "")
_rc, unscoped = self.t.cli("check")
self.assertEqual(scoped, unscoped)
def test_removed_and_conflicting_flags_are_rejected(self):
for argv in (["--remint", "VendorA"], # removed mode
["--mint", "A/B/C"], # removed mode
["--drop-redundant-ids", "VendorA"], # removed mode
["--assign"], # removed mode
["--generate", "--check"], # two modes
["--vendor", "VendorA"], # narrowing without a mode
["--filament-id"], # narrowing without a mode
["--check", "--vendor", "VendorA"]): # narrowing on --check
for argv in (["--remint", "VendorA"], # removed mode
["--generate"], # the pre-subcommand flag
["--check"], # the pre-subcommand flag
["--update-snapshot"], # the pre-subcommand flag
["nonsense"], # not a command
["update-snapshot"], # removed command
["generate-id", "--filament-id", "--setting-id"],
["check", "--materials"], # removed flag
["check", "--obsolete-keys"], # removed flag
["check", "--snapshot", "x"], # removed flag
["check", "--filament-id"], # generate-id's option
["normalize", "--profile-type", "nozzle"]): # not a profile type
with self.subTest(argv=argv):
with self.assertRaises(SystemExit) as cm, \
contextlib.redirect_stdout(io.StringIO()), \
@@ -1712,7 +1505,7 @@ class TestCli(unittest.TestCase):
@unittest.skipUnless(os.path.isdir(REAL_PROFILES), "resources/profiles not present")
class TestRealTree(unittest.TestCase):
def test_shipped_snapshot_matches_tree(self):
def test_shipped_filament_ids_pass(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors = afi.check_filament_ids(REAL_PROFILES)
@@ -1722,7 +1515,7 @@ class TestRealTree(unittest.TestCase):
# The exact CI invocation, return code included.
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = afi.main(["--check"])
rc = afi.main(["check"])
self.assertEqual(rc, 0, buf.getvalue())
def test_every_instantiated_filament_resolves_an_id(self):
@@ -1774,10 +1567,10 @@ class TestReviewFixes(OfCleanTreeCase):
self.assertIn(path, str(caught.exception))
self.assertIn("test edit", str(caught.exception))
def test_check3_skips_of_id_inherited_from_other_vendor(self):
def test_check2_accepts_an_of_id_inherited_from_another_vendor(self):
# An OFL filament carries its own minted OF id and a vendor tunes it
# correctly (same base name, non-empty printers). The new claim must
# trip only the snapshot gate, never mint conformance.
# correctly (same base name, non-empty printers): the id it inherits is
# the mint of its own triple.
fid = afi.generate_filament_id("Generic", "PLA", "Generic PLA Matte")
self.t.write_preset(OFL, preset("Generic PLA Matte @base", filament_id=fid,
instantiation=False,
@@ -1786,22 +1579,13 @@ class TestReviewFixes(OfCleanTreeCase):
self.t.write_preset(OFL, preset("Generic PLA Matte @System",
inherits="Generic PLA Matte @base",
compatible_printers=[]))
rc, _ = self.t.update_snapshot()
self.assertEqual(rc, 0)
self.t.write_preset("VendorA", preset("Generic PLA Matte @P1",
inherits="Generic PLA Matte @System",
compatible_printers=["P1 0.4 nozzle"]))
errors, out = self.t.check()
self.assertNotIn("does not match the mint", out)
self.assertIn("not sanctioned", out)
self.assertEqual(errors, 1, out)
# After sanctioning the claim the tree is fully green again.
rc, _ = self.t.update_snapshot()
self.assertEqual(rc, 0)
errors, out = self.t.check()
self.assertEqual(errors, 0, out)
def test_check3c_prints_expected_mint(self):
def test_check2c_prints_expected_mint(self):
self.t.write_preset("VendorA", preset("Orphan PLA @P1",
compatible_printers=["P1 0.4 nozzle"],
filament_vendor="OV",
+942
View File
@@ -0,0 +1,942 @@
#!/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).
The id halves are covered by test_filament_id.py and test_setting_id.py.
Run from the repo root: python -m unittest discover -s scripts/tests -v
"""
import contextlib
import io
import json
import os
import re
import shutil
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import orca_profile_tool as apt # noqa: E402
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
REAL_PROFILES = os.path.join(REPO_ROOT, "resources", "profiles")
class Tree:
"""A throwaway resources/profiles-shaped directory built one file at a time.
Nothing is written implicitly: index entries are added by index(), so a test
can produce exactly the mismatch it is about (a file no list references, a
list naming a file that is not there, a preset whose name disagrees with the
index).
"""
def __init__(self):
self.dir = tempfile.mkdtemp(prefix="profile_tool_test_")
self.profiles = os.path.join(self.dir, "profiles")
os.makedirs(self.profiles)
def cleanup(self):
shutil.rmtree(self.dir, ignore_errors=True)
def index_path(self, vendor):
return os.path.join(self.profiles, vendor + ".json")
def add_vendor(self, vendor):
for sub in apt.PROFILE_SUBDIRS:
os.makedirs(os.path.join(self.profiles, vendor, sub), exist_ok=True)
if not os.path.exists(self.index_path(vendor)):
self.write_index(vendor, {"name": vendor, "version": "01.00.00.00"})
return self
def write_index(self, vendor, index):
with open(self.index_path(vendor), "w", encoding="utf-8", newline="\n") as f:
json.dump(index, f, indent=4, ensure_ascii=False)
f.write("\n")
def read_index(self, vendor):
with open(self.index_path(vendor), encoding="utf-8-sig") as f:
return json.load(f)
def index(self, vendor, section, name, sub_path):
index = self.read_index(vendor)
index.setdefault(section + "_list", []).append(
{"name": name, "sub_path": sub_path})
self.write_index(vendor, index)
def path(self, vendor, rel):
return os.path.join(self.profiles, vendor, rel.replace("/", os.sep))
def write(self, vendor, rel, data):
"""Write a preset at <vendor>/<rel>; returns its path."""
self.add_vendor(vendor)
path = self.path(vendor, rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
f.write("\n")
return path
def write_raw(self, vendor, rel, raw):
self.add_vendor(vendor)
path = self.path(vendor, rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(raw)
return path
def read(self, vendor, rel):
with open(self.path(vendor, rel), encoding="utf-8-sig") as f:
return json.load(f)
def raw(self, vendor, rel):
with open(self.path(vendor, rel), "rb") as f:
return f.read()
def bytes_map(self):
"""Every file in the tree -> its bytes, for "nothing was written" asserts."""
out = {}
for root, dirs, files in os.walk(self.profiles):
dirs.sort()
for name in sorted(files):
path = os.path.join(root, name)
with open(path, "rb") as f:
out[os.path.relpath(path, self.profiles)] = f.read()
return out
class TreeCase(unittest.TestCase):
def setUp(self):
self.t = Tree()
self.addCleanup(self.t.cleanup)
def run_command(self, *argv):
"""main() against this tree, capturing stdout."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = apt.main([*argv, "--profiles", self.t.profiles])
return rc, buf.getvalue()
# ---------------------------------------------------------------------------
# normalize
# ---------------------------------------------------------------------------
class TestObsoleteKeys(unittest.TestCase):
def test_obsolete_keys_match_the_loader_ignore_set(self):
path = os.path.join(REPO_ROOT, "src", "libslic3r", "PrintConfig.cpp")
with open(path, encoding="utf-8") as f:
source = f.read()
match = re.search(
r"void PrintConfigDef::handle_legacy\(.*?"
r"static\s+std::set<std::string>\s+ignore\s*=\s*\{(.*?)\};",
source, re.DOTALL)
self.assertIsNotNone(match, "Could not locate the loader's obsolete-key set")
keys = re.sub(r"//[^\n]*|/\*.*?\*/", "", match.group(1), flags=re.DOTALL)
self.assertEqual(apt.OBSOLETE_KEYS, set(re.findall(r'"([^"\n]+)"', keys)))
class TestNormalize(TreeCase):
def test_a_missing_type_is_filled_in_from_the_directory(self):
self.t.write("V", "filament/A.json", {"name": "A"})
self.t.write("V", "process/B.json", {"name": "B"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
self.assertEqual(self.t.read("V", "process/B.json")["type"], "process")
def test_the_machine_folder_splits_on_the_preset_name(self):
# Orca keeps machine models in machine/ next to the nozzle variants that
# are machines; only the name tells them apart.
self.t.write("V", "machine/M.json", {"name": "V Printer"})
self.t.write("V", "machine/N.json", {"name": "V Printer 0.4 nozzle"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.read("V", "machine/M.json")["type"], "machine_model")
self.assertEqual(self.t.read("V", "machine/N.json")["type"], "machine")
def test_dropped_keys_go_and_filament_vectors_are_arrayified(self):
self.t.write("V", "filament/A.json", {
"type": "filament", "name": "A", "version": "1.2.3",
"is_custom_defined": "1", "filament_type": "PLA",
"filament_vendor": "AV", "travel_speed": 200,
"filament_load_time": ["15"], "filament_unload_time": "0"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
data = self.t.read("V", "filament/A.json")
self.assertNotIn("version", data)
self.assertNotIn("is_custom_defined", data)
self.assertNotIn("travel_speed", data) # a process setting, not a filament one
self.assertNotIn("filament_load_time", data)
self.assertNotIn("filament_unload_time", data)
self.assertEqual(data["filament_type"], ["PLA"])
self.assertEqual(data["filament_vendor"], ["AV"])
def test_obsolete_keys_are_removed_from_every_profile_type(self):
for sub in ("filament", "process", "machine"):
with self.subTest(profile_type=sub):
expected = {"type": sub, "name": "A"}
self.t.write("V", f"{sub}/A.json", {
**expected, "silent_mode": "", "adaptive_layer_height": "0",
"anisotropic_surfaces": "1", "filament_load_time": ["0"],
"filament_unload_time": "0"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.read("V", f"{sub}/A.json"), expected)
before = self.t.bytes_map()
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
self.assertIn("0 profile(s) normalized", out)
self.assertEqual(self.t.bytes_map(), before)
def test_the_larger_extruder_clearance_wins(self):
# Keeping the smaller one would licence a toolhead collision.
self.t.write("V", "machine/M.json", {
"type": "machine", "name": "M 0.4 nozzle",
"extruder_clearance_radius": "45", "extruder_clearance_max_radius": "68"})
self.t.write("V", "machine/N.json", {
"type": "machine", "name": "N 0.4 nozzle",
"extruder_clearance_radius": "68", "extruder_clearance_max_radius": "45"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
kept = self.t.read("V", "machine/M.json")
self.assertNotIn("extruder_clearance_radius", kept)
self.assertEqual(kept["extruder_clearance_max_radius"], "68")
kept = self.t.read("V", "machine/N.json")
self.assertNotIn("extruder_clearance_max_radius", kept)
self.assertEqual(kept["extruder_clearance_radius"], "68")
def test_a_rewritten_file_leads_with_its_identifying_keys(self):
self.t.write("V", "filament/A.json", {
"filament_cost": [20], "name": "A", "instantiation": "true",
"inherits": "base", "version": "1"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
keys = list(self.t.read("V", "filament/A.json"))
self.assertEqual(keys[:4], ["type", "name", "inherits", "instantiation"])
def test_a_conforming_tree_is_left_byte_identical(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
# These used to be misclassified as obsolete: one is active, the other
# is a legacy alias that still supplies the toolhead clearance on load.
self.t.write("V", "machine/M.json", {
"type": "machine", "name": "M", "extruder_type": ["Direct Drive"],
"extruder_clearance_max_radius": "68", "machine_load_filament_time": "15",
"machine_unload_filament_time": "10"})
self.t.write("V", "process/P.json", {
"type": "process", "name": "P", "travel_speed": "200",
"top_surface_fill_order": "outward"})
before = self.t.bytes_map()
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.bytes_map(), before)
def test_force_rewrites_even_a_conforming_file(self):
self.t.write_raw("V", "filament/A.json",
b'{"name":"A","type":"filament"}')
rc, out = self.run_command("normalize", "--force")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.raw("V", "filament/A.json"),
b'{\n\t"type": "filament",\n\t"name": "A"\n}\n')
def test_dry_run_writes_nothing(self):
self.t.write("V", "filament/A.json", {"name": "A", "bed_temperature": ["60"]})
before = self.t.bytes_map()
rc, out = self.run_command("normalize", "--dry-run")
self.assertEqual(rc, 0, out)
self.assertIn("would be", out)
self.assertEqual(self.t.bytes_map(), before)
def test_profile_type_confines_the_run(self):
self.t.write("V", "filament/A.json", {"name": "A"})
self.t.write("V", "process/B.json", {"name": "B"})
rc, out = self.run_command("normalize", "--profile-type", "filament")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
self.assertNotIn("type", self.t.read("V", "process/B.json"))
def test_an_unreadable_profile_is_reported_not_swallowed(self):
self.t.write_raw("V", "filament/A.json", b"{ not json")
rc, out = self.run_command("normalize")
self.assertEqual(rc, 1, out)
self.assertIn("ERROR", out)
self.assertEqual(self.t.raw("V", "filament/A.json"), b"{ not json")
def test_a_directory_without_an_index_is_not_a_bundle(self):
# resources/profiles also holds non-bundle entries (blacklist.json, the
# untracked user/ directory); only a directory WITH an index is a vendor.
stray = os.path.join(self.t.profiles, "user", "filament")
os.makedirs(stray)
with open(os.path.join(stray, "A.json"), "wb") as f:
f.write(b'{"name": "A"}')
self.t.write("V", "filament/A.json", {"name": "A"})
rc, out = self.run_command("normalize")
self.assertEqual(rc, 0, out)
self.assertFalse(os.path.exists(os.path.join(self.t.profiles, "user.json")))
with open(os.path.join(stray, "A.json"), "rb") as f:
self.assertEqual(f.read(), b'{"name": "A"}')
# ---------------------------------------------------------------------------
# trim
# ---------------------------------------------------------------------------
class TestTrim(TreeCase):
def bundle(self):
self.t.write("V", "filament/Listed.json",
{"type": "filament", "name": "Listed"})
self.t.index("V", "filament", "Listed", "filament/Listed.json")
return self.t
def test_an_unindexed_preset_is_removed(self):
self.bundle().write("V", "filament/Orphan.json",
{"type": "filament", "name": "Orphan"})
rc, out = self.run_command("trim")
self.assertEqual(rc, 0, out)
self.assertTrue(os.path.exists(self.t.path("V", "filament/Listed.json")))
self.assertFalse(os.path.exists(self.t.path("V", "filament/Orphan.json")))
def test_a_dotted_sub_path_still_names_its_file(self):
# Index entries are hand-written; "filament/./X.json" is the same file.
self.bundle()
self.t.write("V", "filament/Dotted.json",
{"type": "filament", "name": "Dotted"})
self.t.index("V", "filament", "Dotted", "filament/./Dotted.json")
rc, out = self.run_command("trim")
self.assertEqual(rc, 0, out)
self.assertTrue(os.path.exists(self.t.path("V", "filament/Dotted.json")))
def test_an_unparsable_file_is_kept_and_reported(self):
self.bundle().write_raw("V", "filament/Broken.json", b"{ not json")
rc, out = self.run_command("trim")
self.assertEqual(rc, 0, out)
self.assertIn("WARNING", out)
self.assertTrue(os.path.exists(self.t.path("V", "filament/Broken.json")))
def test_a_data_file_is_not_a_preset(self):
self.bundle().write("V", "filament/filaments_color_codes.json",
{"data": [], "total": 0})
rc, out = self.run_command("trim")
self.assertEqual(rc, 0, out)
self.assertTrue(os.path.exists(
self.t.path("V", "filament/filaments_color_codes.json")))
def test_an_inherited_base_is_kept_and_reported(self):
# Neither file loads -- the loader only reads indexed sub_paths -- but
# deleting the parent destroys the only record of what the indexed child
# was written against, so that is a maintainer's call, not trim's.
self.bundle()
self.t.write("V", "machine/base.json",
{"type": "machine", "name": "V base"})
self.t.write("V", "machine/mid.json",
{"type": "machine", "name": "V mid", "inherits": "V base"})
self.t.write("V", "machine/M.json",
{"type": "machine", "name": "M 0.4 nozzle", "inherits": "V mid"})
self.t.index("V", "machine", "M 0.4 nozzle", "machine/M.json")
rc, out = self.run_command("trim")
self.assertEqual(rc, 0, out)
# ... and the chain is followed: mid rescues base in a second pass.
self.assertTrue(os.path.exists(self.t.path("V", "machine/mid.json")))
self.assertTrue(os.path.exists(self.t.path("V", "machine/base.json")))
self.assertIn("inherited from", out)
def test_a_stale_copy_of_an_indexed_profile_is_removed(self):
# "inherits" resolves by preset name, so the indexed base is the parent the
# child actually gets; the unindexed twin is a leftover the loader never
# reaches, and being named in an inherits does not earn it a reprieve.
self.bundle()
self.t.write("V", "machine/HSN/base.json",
{"type": "machine", "name": "V base"})
self.t.index("V", "machine", "V base", "machine/HSN/base.json")
self.t.write("V", "machine/base.json",
{"type": "machine", "name": "V base"})
self.t.write("V", "machine/M.json",
{"type": "machine", "name": "M 0.4 nozzle", "inherits": "V base"})
self.t.index("V", "machine", "M 0.4 nozzle", "machine/M.json")
rc, out = self.run_command("trim")
self.assertEqual(rc, 0, out)
self.assertTrue(os.path.exists(self.t.path("V", "machine/HSN/base.json")))
self.assertFalse(os.path.exists(self.t.path("V", "machine/base.json")))
self.assertNotIn("WARNING", out)
self.assertIn('machine/HSN/base.json is the profile named "V base"', out)
def test_dry_run_deletes_nothing(self):
self.bundle().write("V", "filament/Orphan.json",
{"type": "filament", "name": "Orphan"})
before = self.t.bytes_map()
rc, out = self.run_command("trim", "--dry-run")
self.assertEqual(rc, 0, out)
self.assertIn("would be removed", out)
self.assertEqual(self.t.bytes_map(), before)
# ---------------------------------------------------------------------------
# update-index
# ---------------------------------------------------------------------------
class TestUpdateIndex(TreeCase):
def test_every_profile_on_disk_lands_in_its_own_section(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
self.t.write("V", "process/B.json", {"type": "process", "name": "B"})
self.t.write("V", "machine/M.json", {"type": "machine", "name": "M"})
self.t.write("V", "machine/MM.json", {"type": "machine_model", "name": "MM"})
rc, out = self.run_command("update-index")
self.assertEqual(rc, 0, out)
index = self.t.read_index("V")
self.assertEqual(index["filament_list"],
[{"name": "A", "sub_path": "filament/A.json"}])
self.assertEqual(index["process_list"],
[{"name": "B", "sub_path": "process/B.json"}])
self.assertEqual(index["machine_list"],
[{"name": "M", "sub_path": "machine/M.json"}])
self.assertEqual(index["machine_model_list"],
[{"name": "MM", "sub_path": "machine/MM.json"}])
def test_parents_are_listed_before_their_children(self):
# The loader resolves inherits in one pass over the list.
for name, parent in (("C", "B"), ("A", None), ("B", "A")):
data = {"type": "filament", "name": name}
if parent:
data["inherits"] = parent
self.t.write("V", f"filament/{name}.json", data)
rc, out = self.run_command("update-index")
self.assertEqual(rc, 0, out)
self.assertEqual([e["name"] for e in self.t.read_index("V")["filament_list"]],
["A", "B", "C"])
# inherits is ordering input only; it never lands in the index.
for entry in self.t.read_index("V")["filament_list"]:
self.assertEqual(sorted(entry), ["name", "sub_path"])
def test_a_profile_with_no_usable_type_is_reported_not_dropped(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
self.t.write("V", "filament/B.json", {"name": "B"})
rc, out = self.run_command("update-index")
self.assertEqual(rc, 1, out)
self.assertIn("cannot be indexed", out)
self.assertIn("filament/B.json", out)
def test_two_profiles_claiming_one_name_leave_the_index_alone(self):
# The bundle holds one profile per name, so a rebuild would pick a winner by
# directory order and drop the other without a word.
self.t.write("V", "machine/base.json", {"type": "machine", "name": "base"})
self.t.write("V", "machine/HSN/base.json", {"type": "machine", "name": "base"})
self.t.index("V", "machine", "base", "machine/HSN/base.json")
before = self.t.bytes_map()
rc, out = self.run_command("update-index")
self.assertEqual(rc, 1, out)
self.assertIn('2 profiles are named "base"', out)
self.assertIn("machine/base.json", out)
self.assertIn("machine/HSN/base.json", out)
self.assertEqual(self.t.bytes_map(), before)
def test_profile_type_rebuilds_only_that_section(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
self.t.write("V", "process/B.json", {"type": "process", "name": "B"})
rc, out = self.run_command("update-index", "--profile-type", "filament")
self.assertEqual(rc, 0, out)
index = self.t.read_index("V")
self.assertEqual([e["name"] for e in index["filament_list"]], ["A"])
self.assertNotIn("process_list", index)
def test_an_up_to_date_index_is_left_byte_identical(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
self.run_command("update-index")
before = self.t.bytes_map()
rc, out = self.run_command("update-index")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.bytes_map(), before)
def test_dry_run_writes_nothing(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
before = self.t.bytes_map()
rc, out = self.run_command("update-index", "--dry-run")
self.assertEqual(rc, 0, out)
self.assertIn("would be rebuilt", out)
self.assertEqual(self.t.bytes_map(), before)
def test_a_json_file_with_no_bundle_is_never_touched(self):
# resources/profiles/blacklist.json is a .json with no directory beside
# it. Enumerating vendors by stem once wrote four empty *_list keys into it.
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
stray = os.path.join(self.t.profiles, "blacklist.json")
with open(stray, "wb") as f:
f.write(b'{"filament": ["GFSA03"]}')
rc, out = self.run_command("update-index")
self.assertEqual(rc, 0, out)
with open(stray, "rb") as f:
self.assertEqual(f.read(), b'{"filament": ["GFSA03"]}')
# ---------------------------------------------------------------------------
# check
# ---------------------------------------------------------------------------
class TestCheck(TreeCase):
def bundle(self):
"""A bundle that passes every per-vendor check."""
self.t.write("V", "filament/A.json", {
"type": "filament", "name": "A", "instantiation": "true",
"filament_id": "OFaaaaaa", "filament_type": ["PLA"],
"filament_vendor": ["AV"], "compatible_printers": ["M 0.4 nozzle"],
"setting_id": apt.generate_preset_setting_id("V", "filament", "A")})
self.t.index("V", "filament", "A", "filament/A.json")
return self.t
def per_vendor_errors(self, *argv):
"""Run the per-vendor checks alone, which is what --vendor narrows."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors = apt.check_filament_compatible_printers(self.t.profiles, "V")
name_errors, _warn = apt.check_name_consistency(self.t.profiles, "V")
errors += name_errors
errors += apt.check_vector_type_keys(self.t.profiles, "V")
errors += apt.check_filament_id_length(self.t.profiles, "V")
conflict, _warn = apt.check_conflict_keys(self.t.profiles, "V")
errors += conflict
materials, _warn = apt.check_machine_default_materials(self.t.profiles, "V")
errors += materials
return errors, buf.getvalue()
def test_a_clean_bundle_reports_nothing(self):
self.bundle()
errors, out = self.per_vendor_errors()
self.assertEqual(errors, 0, out)
def test_an_instantiated_filament_needs_compatible_printers(self):
self.bundle().write("V", "filament/B.json", {
"type": "filament", "name": "B", "instantiation": "true"})
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("'compatible_printers' missing", out)
def test_a_library_filament_may_leave_compatible_printers_empty(self):
# The shared library is exempt from that rule and nothing else.
self.t.write(apt.OFL, "filament/A.json",
{"type": "filament", "name": "A", "instantiation": "true"})
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors = apt.check_filament_compatible_printers(self.t.profiles, apt.OFL)
self.assertEqual(errors, 0, buf.getvalue())
def test_the_library_is_checked_like_any_other_bundle(self):
# A file the library's own index does not reference must fail plain
# `check`, now that the per-vendor pass no longer skips it.
self.t.write(apt.OFL, "filament/Stray.json",
{"type": "filament", "name": "Stray"})
rc, out = self.run_command("check")
self.assertEqual(rc, 1, out)
self.assertIn(f"{apt.OFL}/filament/Stray.json: no {apt.OFL}.json list "
f"references it", out)
def test_a_duplicate_key_is_an_error(self):
self.bundle().write_raw("V", "filament/B.json",
b'{"type":"filament","name":"B","name":"B2"}')
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("Duplicate key", out)
def test_the_index_and_the_file_must_agree_on_the_name(self):
self.bundle()
self.t.write("V", "filament/C.json", {"type": "filament", "name": "Other"})
self.t.index("V", "filament", "C", "filament/C.json")
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("name mismatch", out)
def test_an_index_entry_with_no_file_is_an_error(self):
self.bundle()
self.t.index("V", "filament", "Gone", "filament/Gone.json")
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("Missing sub profile", out)
def test_a_vector_option_may_not_be_a_scalar(self):
self.bundle().write("V", "filament/B.json", {
"type": "filament", "name": "B", "filament_type": "PLA"})
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("must be an array", out)
def test_renamed_and_old_option_may_not_co_exist(self):
self.bundle().write("V", "machine/M.json", {
"type": "machine", "name": "M 0.4 nozzle",
"extruder_clearance_radius": "45", "extruder_clearance_max_radius": "68"})
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("Conflict keys", out)
def test_the_length_rule_only_binds_indexed_presets(self):
# A file the index never loads cannot break AMS matching, and some
# bundles ship such orphans from before the rule existed.
self.bundle().write("V", "filament/Long.json", {
"type": "filament", "name": "Long", "filament_id": "OFtoolongforams"})
errors, out = self.per_vendor_errors()
self.assertEqual(errors, 0, out)
self.t.index("V", "filament", "Long", "filament/Long.json")
errors, out = self.per_vendor_errors()
self.assertGreater(errors, 0)
self.assertIn("Filament id too long", out)
def test_obsolete_key_warnings_exclude_active_and_renamed_options(self):
self.bundle().write("V", "filament/B.json", {
"type": "filament", "name": "B", "silent_mode": "0",
"anisotropic_surfaces": "0", "extruder_type": ["Direct Drive"],
"extruder_clearance_max_radius": "68"})
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
warnings = apt.check_obsolete_keys(self.t.profiles, "V")
self.assertEqual(warnings, 2)
self.assertIn("Obsolete key", buf.getvalue())
def test_obsolete_key_warnings_run_without_a_flag(self):
self.t.write("V", "filament/A.json", {
"type": "filament", "name": "A", "silent_mode": "0"})
self.run_command("update-index")
rc, out = self.run_command("check")
self.assertEqual(rc, 1, out) # normalization also rejects the obsolete key
self.assertIn("Obsolete key: 'silent_mode' found in V/filament/A.json", out)
self.assertIn("Files with warnings : 1", out)
def test_a_default_material_must_exist_somewhere(self):
self.bundle().write("V", "machine/M.json", {
"type": "machine", "name": "M 0.4 nozzle",
"default_materials": "A;Nope"})
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors, _warn = apt.check_machine_default_materials(self.t.profiles, "V")
self.assertEqual(errors, 1)
self.assertIn("'Nope'", buf.getvalue())
def test_a_default_material_fails_check_without_a_flag(self):
# The reference check is part of the default run, not an opt-in: a
# dangling name has to fail plain `check`.
self.bundle()
self.t.write("V", "machine/M.json", {
"type": "machine", "name": "M 0.4 nozzle",
"default_filament_profile": ["A", "Nope"]})
self.t.index("V", "machine", "M 0.4 nozzle", "machine/M.json")
rc, out = self.run_command("check")
self.assertEqual(rc, 1, out)
self.assertIn("Missing filament profile: 'Nope'", out)
def test_the_stray_user_directory_is_not_a_vendor(self):
# A local validator run leaves resources/profiles/user/ behind; an
# unscoped check must not count it as a bundle and warn about it.
self.bundle()
for sub in apt.PROFILE_SUBDIRS:
os.makedirs(os.path.join(self.t.profiles, apt.USER_DIR, "default", sub))
_rc, out = self.run_command("check")
self.assertIn("Checked vendors : 1", out)
self.assertNotIn("user", out)
def names(self, vendor="V"):
"""The preset name check for one bundle, which is what --vendor narrows."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors = apt.check_preset_name_uniqueness(self.t.profiles, vendor)
return errors, buf.getvalue()
def test_one_bundle_may_not_hold_two_profiles_of_a_name(self):
self.bundle().write("V", "filament/dup.json", {
"type": "filament", "name": "A", "instantiation": "false"})
errors, out = self.names()
self.assertEqual(errors, 1, out)
self.assertIn('V has 2 filament profiles named "A"', out)
def test_an_unindexed_twin_counts_as_a_duplicate(self):
# The case this check was written for: a stale copy of a base profile in
# machine/, which no per-vendor check walked, one index edit away from
# silently deciding which of the two a whole bundle inherits from.
self.bundle()
self.t.write("V", "machine/HSN/base.json",
{"type": "machine", "name": "V base"})
self.t.index("V", "machine", "V base", "machine/HSN/base.json")
self.t.write("V", "machine/base.json", {"type": "machine", "name": "V base"})
errors, out = self.names()
self.assertEqual(errors, 1, out)
self.assertIn("machine/HSN/base.json", out)
self.assertIn("machine/base.json", out)
def test_one_name_in_two_types_is_not_a_clash(self):
self.bundle()
self.t.write("V", "process/same.json", {"type": "process", "name": "A"})
errors, out = self.names()
self.assertEqual(errors, 0, out)
def test_a_name_is_per_bundle_not_global(self):
# fdm_machine_common exists in 60 shipped bundles; the name is scoped to the
# bundle that resolves it, so sharing one across vendors is not a clash.
for vendor in ("V", "W"):
self.t.write(vendor, "machine/common.json",
{"type": "machine", "name": "fdm_machine_common"})
self.t.index(vendor, "machine", "fdm_machine_common", "machine/common.json")
for vendor in ("V", "W"):
errors, out = self.names(vendor)
self.assertEqual(errors, 0, out)
def coverage(self, vendor="V"):
"""The index-coverage check for one bundle: (errors, gaps, output)."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors, gaps = apt.check_index_coverage(self.t.profiles, vendor)
return errors, gaps, buf.getvalue()
def test_a_file_no_list_references_is_an_error(self):
self.bundle().write("V", "filament/Stray.json",
{"type": "filament", "name": "Stray"})
errors, gaps, out = self.coverage()
self.assertEqual(errors, 1, out)
self.assertEqual(gaps["unindexed"], 1)
self.assertIn("no V.json list references it", out)
def test_a_file_with_no_type_is_its_own_category(self):
# update-index cannot place it, so "add it to the index" is not the remedy.
self.bundle().write("V", "filament/Stray.json", {"name": "Stray"})
errors, gaps, out = self.coverage()
self.assertEqual(errors, 1, out)
self.assertEqual(gaps["unindexable"], 1)
self.assertIn("declares no profile type", out)
def test_an_unparsable_unlisted_file_is_reported_too(self):
self.bundle().write_raw("V", "filament/Broken.json", b"{ not json")
errors, gaps, out = self.coverage()
self.assertEqual(errors, 1, out)
self.assertEqual(gaps["unindexable"], 1)
def test_a_dotted_sub_path_still_counts_as_listed(self):
self.bundle()
self.t.write("V", "filament/Dotted.json",
{"type": "filament", "name": "Dotted"})
self.t.index("V", "filament", "Dotted", "filament/./Dotted.json")
errors, _gaps, out = self.coverage()
self.assertEqual(errors, 0, out)
def test_a_data_file_is_not_expected_in_the_index(self):
self.bundle().write("V", "filament/filaments_color_codes.json",
{"data": [], "total": 0})
errors, _gaps, out = self.coverage()
self.assertEqual(errors, 0, out)
def test_a_bundle_with_no_index_is_left_to_the_name_check(self):
# Every file unlisted because there is no list at all is one problem, not
# one per file; check_name_consistency reports the missing index.
self.t.write("W", "filament/A.json", {"type": "filament", "name": "A"})
os.remove(self.t.index_path("W"))
errors, _gaps, out = self.coverage("W")
self.assertEqual(errors, 0, out)
def test_the_remedy_is_printed_once_not_once_per_file(self):
self.bundle()
for n in range(5):
self.t.write("V", f"filament/Stray{n}.json",
{"type": "filament", "name": f"Stray{n}"})
self.t.write("V", "filament/NoType.json", {"name": "NoType"})
rc, out = self.run_command("check")
self.assertEqual(rc, 1, out)
self.assertEqual(out.count("update-index\" to add them"), 1, out)
self.assertEqual(out.count("or delete them"), 1, out)
self.assertIn("5 unreferenced file(s)", out)
self.assertIn("1 unreferenced file(s)", out)
def test_setting_id_uniqueness_is_tree_wide(self):
# Two presets sharing vendor/type/name mint one id, so the collision
# only shows up in a pass that has seen the whole tree.
shared = apt.generate_preset_setting_id("V", "filament", "A")
for rel in ("filament/A.json", "filament/nested/A.json"):
self.t.write("V", rel, {"type": "filament", "name": "A",
"instantiation": "true", "setting_id": shared})
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors = apt.check_setting_id_uniqueness(self.t.profiles)
self.assertGreater(errors, 0)
self.assertIn("globally unique", buf.getvalue())
def test_a_base_profile_must_not_carry_a_setting_id(self):
self.t.write("V", "filament/base.json", {
"type": "filament", "name": "base", "instantiation": "false",
"setting_id": apt.generate_preset_setting_id("V", "filament", "base")})
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors = apt.check_setting_id_uniqueness(self.t.profiles)
self.assertEqual(errors, 1)
self.assertIn("must not have a", buf.getvalue())
# ---------------------------------------------------------------------------
# check: normalize and update-index would change nothing
# ---------------------------------------------------------------------------
class TestNormalized(TreeCase):
"""The pass that holds a bundle to the shape normalize and update-index write."""
def normalize(self):
"""Put the tree in that shape, the way a contributor is told to."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
apt.main(["normalize", "--profiles", self.t.profiles])
apt.main(["update-index", "--profiles", self.t.profiles])
return buf.getvalue()
def normalized(self, vendor="V"):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
errors, gaps = apt.check_normalized(self.t.profiles, vendor)
return errors, gaps, buf.getvalue()
def test_a_bundle_the_two_commands_just_wrote_reports_nothing(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
self.t.write("V", "process/B.json", {"type": "process", "name": "B"})
self.normalize()
errors, _gaps, out = self.normalized()
self.assertEqual(errors, 0, out)
def test_a_profile_fix_would_rewrite_is_an_error(self):
self.t.write("V", "filament/A.json", {"type": "filament", "name": "A"})
self.normalize()
# version belongs to the bundle, in <vendor>.json, never to a preset.
data = self.t.read("V", "filament/A.json")
data["version"] = "01.00.00.00"
self.t.write("V", "filament/A.json", data)
errors, gaps, out = self.normalized()
self.assertEqual(errors, 1, out)
self.assertEqual(gaps["unnormalized"], 1, out)
self.assertIn("V/filament/A.json: normalize would remove version", out)
def test_an_index_update_index_would_rebuild_is_an_error(self):
for name, parent in (("B", "A"), ("A", None)):
data = {"type": "filament", "name": name}
if parent:
data["inherits"] = parent
self.t.write("V", f"filament/{name}.json", data)
self.normalize()
# Parents-first is what lets the loader resolve inherits in one pass; a
# hand-edited list that puts the child first still names every file.
index = self.t.read_index("V")
index["filament_list"].reverse()
self.t.write_index("V", index)
errors, gaps, out = self.normalized()
self.assertEqual(errors, 1, out)
self.assertEqual(gaps["stale_index"], 1, out)
self.assertIn("V.json: update-index would rebuild filament_list", out)
def test_an_unbuildable_index_is_left_to_the_checks_that_name_it(self):
# update-index refuses to rebuild a bundle where two files claim one name,
# so "would be rebuilt" on top of the duplicate-name error would be noise.
self.t.write("V", "machine/base.json", {"type": "machine", "name": "base"})
self.normalize()
self.t.write("V", "machine/HSN/base.json", {"type": "machine", "name": "base"})
errors, gaps, out = self.normalized()
self.assertEqual(errors, 0, out)
self.assertEqual(gaps["stale_index"], 0, out)
def test_a_bundle_with_no_index_still_has_its_files_checked(self):
self.t.write("V", "filament/A.json",
{"type": "filament", "name": "A", "is_custom_defined": "0"})
os.remove(self.t.index_path("V"))
errors, gaps, out = self.normalized()
self.assertEqual(errors, 1, out)
self.assertEqual(gaps["unnormalized"], 1, out)
self.assertEqual(gaps["stale_index"], 0, out)
def test_the_shared_base_bundle_is_covered_too(self):
# normalize and update-index own the shape of every bundle, the shared
# library included.
self.t.write(apt.OFL, "filament/A.json",
{"type": "filament", "name": "A", "version": "01.00.00.00"})
rc, out = self.run_command("check")
self.assertEqual(rc, 1, out)
self.assertIn(f"{apt.OFL}/filament/A.json: normalize would remove version", out)
def test_each_remedy_is_printed_once_for_the_whole_run(self):
for n in range(3):
self.t.write("V", f"filament/A{n}.json",
{"type": "filament", "name": f"A{n}",
"version": "01.00.00.00"})
self.t.write("W", "filament/B.json", {"type": "filament", "name": "B"})
rc, out = self.run_command("check")
self.assertEqual(rc, 1, out)
self.assertIn("3 profile file(s) above are not what", out)
self.assertEqual(out.count('normalize" writes: run it and commit'), 1, out)
self.assertIn("2 vendor index(es) above are not what", out)
self.assertEqual(out.count('update-index" writes: run it and commit'), 1, out)
# ---------------------------------------------------------------------------
# CLI dispatch
# ---------------------------------------------------------------------------
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"),
("trim", "unreferenced"),
("update-index", "vendor index")):
with self.subTest(command=command):
rc, out = self.run_command(command, "--dry-run")
self.assertEqual(rc, 0, out)
self.assertIn(expected, out)
def test_an_option_belongs_to_one_command_only(self):
for argv in (["trim", "--force"],
["update-index", "--filament-id"],
["check", "--profile-type", "filament"]):
with self.subTest(argv=argv):
with self.assertRaises(SystemExit) as cm, \
contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
apt.main([*argv, "--profiles", self.t.profiles])
self.assertEqual(cm.exception.code, 2)
def test_an_unknown_vendor_stops_the_run(self):
self.t.write("V", "filament/A.json", {"name": "A"})
before = self.t.bytes_map()
rc, out = self.run_command("normalize", "--vendor", "Nope")
self.assertEqual(rc, 1)
self.assertIn("unknown vendor", out)
self.assertEqual(self.t.bytes_map(), before)
def test_an_empty_vendor_means_every_vendor(self):
self.t.write("V", "filament/A.json", {"name": "A"})
rc, out = self.run_command("normalize", "--vendor", "")
self.assertEqual(rc, 0, out)
self.assertEqual(self.t.read("V", "filament/A.json")["type"], "filament")
# ---------------------------------------------------------------------------
# the real tree
# ---------------------------------------------------------------------------
@unittest.skipUnless(os.path.isdir(REAL_PROFILES), "resources/profiles not present")
class TestRealTree(unittest.TestCase):
def test_check_passes(self):
# The exact CI invocation, return code included.
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = apt.main(["check"])
self.assertEqual(rc, 0, buf.getvalue())
def test_the_shipped_tree_needs_no_fix(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
changed, errors = apt.normalize_profiles(REAL_PROFILES, dry_run=True)
self.assertEqual(errors, 0, buf.getvalue())
self.assertEqual(changed, 0, buf.getvalue())
def test_the_shipped_indexes_need_no_rebuild(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
changed, errors = apt.update_profile_indexes(REAL_PROFILES, dry_run=True)
self.assertEqual(errors, 0, buf.getvalue())
self.assertEqual(changed, 0, buf.getvalue())
def test_no_shipped_bundle_is_a_stray_json_file(self):
# blacklist.json has no directory beside it, so it is not a vendor.
self.assertNotIn("blacklist", apt.list_vendor_names(REAL_PROFILES))
if __name__ == "__main__":
unittest.main()
+7 -7
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Tests for the setting_id half of scripts/orca_id_tool.py (stdlib unittest, no
"""Tests for the setting_id half of scripts/orca_profile_tool.py (stdlib unittest, no
external deps).
Run from the repo root: python -m unittest discover -s scripts/tests -v
@@ -17,7 +17,7 @@ import uuid
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import orca_id_tool as afi # noqa: E402
import orca_profile_tool as afi # noqa: E402
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
REAL_PROFILES = os.path.join(REPO_ROOT, "resources", "profiles")
@@ -246,7 +246,7 @@ class TestBase62Tail(unittest.TestCase):
class TestAssignment(SettingTreeCase):
def test_instantiation_is_read_exactly_as_the_validator_reads_it(self):
# orca_extra_profile_check.py tests `instantiation == "true"` strictly.
# check_setting_id_uniqueness tests `instantiation == "true"` strictly.
# Anything looser here would hand an id to a preset the validator calls
# a base profile, and the two would fight over it on every run.
for name, value in [("Boolean", True), ("Capitalised", "True"),
@@ -852,7 +852,7 @@ class TestCli(SettingTreeCase):
self.t.write("VendorA", "machine",
preset("P1 0.4 nozzle", type_name="machine"))
rc, out = self.main(["--generate", "--setting-id",
rc, out = self.main(["generate-id", "--setting-id",
"--profiles", self.t.profiles])
self.assertEqual(rc, 0, out)
@@ -874,13 +874,13 @@ class TestCli(SettingTreeCase):
def test_dry_run_setting_id_writes_nothing(self):
self.t.write("VendorA", "filament", preset("A PLA @P1"))
before = self.t.bytes_map()
rc, out = self.main(["--generate", "--setting-id", "--dry-run",
rc, out = self.main(["generate-id", "--setting-id", "--dry-run",
"--profiles", self.t.profiles])
self.assertEqual(rc, 0, out)
self.assertIn("1 file(s) would change", out) # there WAS one to write
self.assertEqual(self.t.bytes_map(), before)
# the real run then writes exactly it
rc, out = self.main(["--generate", "--setting-id",
rc, out = self.main(["generate-id", "--setting-id",
"--profiles", self.t.profiles])
self.assertEqual(rc, 0, out)
self.assertIn("1 file(s) changed", out)
@@ -888,7 +888,7 @@ class TestCli(SettingTreeCase):
afi.generate_preset_setting_id("VendorA", "filament",
"A PLA @P1"))
def test_setting_id_without_generate_is_a_usage_error(self):
def test_setting_id_without_a_command_is_a_usage_error(self):
with contextlib.redirect_stderr(io.StringIO()), \
self.assertRaises(SystemExit) as cm:
afi.main(["--setting-id", "--profiles", self.t.profiles])
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
Generate resources/printers/bambu_filament_ids.json: the map from Orca's
content-addressed filament_id ("OF" + 6 base62 chars, see orca_id_tool.py)
content-addressed filament_id ("OF" + 6 base62 chars, see orca_profile_tool.py)
to Bambu Lab's own AMS/RFID catalog id ("GF..." etc.) for the subset of filament
products Bambu ships.
@@ -54,7 +54,7 @@ import tempfile
import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from orca_id_tool import ( # noqa: E402
from orca_profile_tool import ( # noqa: E402
BAMBU_MAP_PATH,
OFL,
PROFILES_DIR,