Merge Main into Belt Printer

Merge origin/main (00429da739) into belt-printer.

Conflicts resolved:
- src/CMakeLists.txt: keep both wxInspector workarounds.
- GCodeProcessor.cpp: keep the belt compare_pos / z_for_height lines.
- PrintObjectSlice.cpp: the belt bbox-Z guard also covers main's
  printable_region_ids bookkeeping.
- TreeSupport.cpp: the belt-floor check runs before main's PendingNode
  queueing.
- Tab.hpp: keep the belt fields, drop the removed upload description
  fields.
- tests/libslic3r/CMakeLists.txt: keep both test files.

Also included:
- eSUN PLA belt presets declare their own filament_id (OFkrxQC4) and
  scripts/filament_id_snapshot.json is regenerated, as main's filament_id
  check requires.
- Custom.json version bumped to 02.04.00.05 so the belt entries reach
  existing installs.
- Fix the ambiguous WithinRel call in the belt apron width test, which
  otherwise breaks the fff_print build.
This commit is contained in:
Hanif Koh
2026-09-14 16:33:08 +08:00
4930 changed files with 62617 additions and 21404 deletions
-258
View File
@@ -1,258 +0,0 @@
#!/usr/bin/env python3
"""
Assign deterministic, globally-unique setting_id to OrcaSlicer system profiles.
Policy (see AGENTS.md "Critical Constraints"):
* A preset's setting_id is a pure function of its identity:
setting_id = base62_16( uuid5(NAMESPACE, "<vendor>/<type>/<name>") )
The same value is recomputed on the fly by the C++ app
(Slic3r::generate_preset_setting_id); the two MUST stay byte-identical. The rule
(generate_preset_setting_id, below) is also imported by the validator
(orca_extra_profile_check.py). Uniqueness is therefore automatic: two presets
collide only if they share vendor + type + name, which the validator flags.
* Bambu (BBL) owns the authoritative "G*" id space and is the only reserved vendor:
its ids are never rewritten (preserves backward-compat with Bambu-synced presets).
Every other vendor - including OrcaFilamentLibrary and Custom - follows the
deterministic rule.
* Only instantiated presets (instantiation == "true") carry a setting_id; base /
template profiles do not.
Only setting_id is rewritten. filament_id is deliberately left untouched: it is a
per-material id, shared across a filament's nozzle variants and inherited from base
templates, so it must not be made per-file unique.
Run from anywhere: python3 scripts/assign_vendor_setting_ids.py
The script is idempotent: a second run over an unchanged tree produces no diff.
"""
import json
import os
import re
import sys
import uuid
# Deterministic preset setting_id rule. Imported by the validator
# (orca_extra_profile_check.py) and kept byte-identical to the C++
# Slic3r::generate_preset_setting_id. Dedicated namespace, distinct from the cloud
# namespace (f47ac10b-...) so the two id spaces never coincide; this constant is baked
# into both languages - never change it.
NAMESPACE = uuid.UUID("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f")
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
ID_LENGTH = 16
def generate_preset_setting_id(vendor, type_name, name):
"""Deterministic 16-char base62 setting_id for a preset.
input = f"{vendor}/{type_name}/{name}"; u = uuid5(NAMESPACE, input);
id = the low ID_LENGTH base62 digits of int(u.bytes, "big"), most-significant first.
"""
u = uuid.uuid5(NAMESPACE, f"{vendor}/{type_name}/{name}")
n = int.from_bytes(u.bytes, "big")
digits = []
for _ in range(ID_LENGTH):
digits.append(ALPHABET[n % 62])
n //= 62
return "".join(reversed(digits))
PROFILES_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "resources", "profiles"))
# Bambu (BBL) is the only reserved vendor: it keeps its authoritative "G*" cloud ids.
RESERVED_VENDORS = {"BBL"}
PROFILE_SUBDIRS = ("filament", "process", "machine")
def iter_profile_files(vendor_dir):
"""Yield (json path, type) under a vendor, in a deterministic order.
type is the subdir name ("filament"/"process"/"machine"), which matches
Preset::get_type_string() on the C++ side.
"""
for sub in PROFILE_SUBDIRS:
base = os.path.join(vendor_dir, sub)
if not os.path.isdir(base):
continue
for root, dirs, files in os.walk(base):
dirs.sort() # deterministic traversal across filesystems
for name in sorted(files):
if name.endswith(".json"):
yield os.path.join(root, name), sub
def read_profile(path):
"""Return (setting_id, instantiation, name) as present (or None)."""
try:
with open(path, "rb") as f:
data = json.loads(f.read())
except (ValueError, OSError):
return None, None, None
if not isinstance(data, dict):
return None, None, None
return data.get("setting_id"), data.get("instantiation"), data.get("name")
def list_vendors():
return sorted(
d for d in os.listdir(PROFILES_DIR)
if os.path.isdir(os.path.join(PROFILES_DIR, d))
)
_JSON_STR = r'"(?:[^"\\]|\\.)*"'
def remove_key_line(text, key):
"""Remove a top-level `"key": "..."` member, preserving formatting.
Handles both the common case (member has a trailing comma) and the member
being the LAST in its object (consume the preceding comma instead, so no
dangling comma is left). Returns (new_text, count).
"""
# Member followed by a comma (not the last in the object).
trailing = re.compile(
r'[ \t]*"' + re.escape(key) + r'"[ \t]*:[ \t]*' + _JSON_STR + r'[ \t]*,[ \t]*\r?\n'
)
new, n = trailing.subn("", text, count=1)
if n:
return new, n
# Member is the last one: drop the preceding comma and the member itself.
leading = re.compile(
r',[ \t]*\r?\n[ \t]*"' + re.escape(key) + r'"[ \t]*:[ \t]*' + _JSON_STR
)
return leading.subn("", text, count=1)
def _remove_key_in_tree(key, should_remove):
"""Remove `key` from files where should_remove(sid, inst, text) is True."""
removed = 0
for vendor in list_vendors():
for path, _type in iter_profile_files(os.path.join(PROFILES_DIR, vendor)):
with open(path, "rb") as f:
text = f.read().decode("utf-8")
sid, inst, _name = read_profile(path)
if not should_remove(sid, inst, text):
continue
new_text, n = remove_key_line(text, key)
if n == 0:
raise RuntimeError(f"Could not locate {key} line to remove: {path}")
json.loads(new_text) # fail loudly if removal broke the JSON
with open(path, "wb") as f:
f.write(new_text.encode("utf-8"))
removed += 1
return removed
def remove_misspelled_settings_id():
"""Delete the misspelled "settings_id" key (extra "s") wherever it appears.
The app never reads that key, so those presets effectively had no setting_id
and get a correct one assigned by the normal pass; here we drop the junk key.
"""
return _remove_key_in_tree(
"settings_id", lambda sid, inst, text: '"settings_id"' in text
)
def strip_base_setting_ids():
"""Remove setting_id from every base profile (instantiation != "true").
Convention: only instantiated, user-selectable presets carry a setting_id;
base/template profiles do not. Applied across all vendors.
"""
return _remove_key_in_tree(
"setting_id", lambda sid, inst, text: bool(sid) and inst != "true"
)
def replace_id_value(text, key, new_value):
"""Replace the first top-level `"key": "..."` value, preserving all formatting."""
pattern = re.compile(r'("' + re.escape(key) + r'"\s*:\s*)"(?:[^"\\]|\\.)*"')
repl = lambda m: m.group(1) + json.dumps(new_value, ensure_ascii=False)
new_text, n = pattern.subn(repl, text, count=1)
return new_text, n
def insert_setting_id(text, new_id):
"""Insert a `"setting_id"` line into a preset that lacks one.
Placed just before `filament_id` (or, failing that, `instantiation`) so it
matches the canonical key order, reusing that anchor line's indentation and
line ending. Only setting_id is added; filament_id is left untouched.
"""
for key in ("filament_id", "instantiation"):
m = re.search(r'^([ \t]*)"' + key + r'"[ \t]*:.*?(\r?\n)', text, re.MULTILINE)
if m:
line = f'{m.group(1)}"setting_id": {json.dumps(new_id, ensure_ascii=False)},{m.group(2)}'
return text[:m.start()] + line + text[m.start():], 1
return text, 0
def rewrite_file(path, new_id, has_setting_id):
"""Set the preset's setting_id to new_id (replacing or inserting as needed).
filament_id is intentionally left untouched. Uses binary IO so the file's
original line endings (LF or CRLF) and exact formatting are preserved
byte-for-byte apart from the changed/added line. The result is re-parsed to
guarantee it is still valid JSON.
"""
with open(path, "rb") as f:
text = f.read().decode("utf-8")
if has_setting_id:
text, n = replace_id_value(text, "setting_id", new_id)
else:
text, n = insert_setting_id(text, new_id)
if n == 0:
raise RuntimeError(f"Could not set setting_id on {path}")
json.loads(text) # fail loudly if the edit broke the JSON
with open(path, "wb") as f:
f.write(text.encode("utf-8"))
return True
def main():
# 0. Drop the misspelled "settings_id" key wherever it appears.
typos = remove_misspelled_settings_id()
# 1. Strip setting_id from base profiles everywhere (only instantiated presets keep one).
stripped = strip_base_setting_ids()
# 2. Assign the deterministic setting_id to every instantiated preset of every
# non-reserved vendor.
changed = added = 0
vendors_touched = []
for vendor in list_vendors():
if vendor in RESERVED_VENDORS:
continue
vendor_changed = 0
for path, type_name in iter_profile_files(os.path.join(PROFILES_DIR, vendor)):
sid, inst, name = read_profile(path)
if inst != "true":
continue
if not name:
raise RuntimeError(f"instantiated preset has no \"name\": {path}")
new_id = generate_preset_setting_id(vendor, type_name, name)
if sid == new_id:
continue # already correct - idempotent
rewrite_file(path, new_id, has_setting_id=sid is not None)
changed += 1
vendor_changed += 1
if sid is None:
added += 1
if vendor_changed:
vendors_touched.append((vendor, vendor_changed))
print(f"Misspelled settings_id removed : {typos}")
print(f"Base setting_ids stripped : {stripped}")
print(f"Reserved vendors : {sorted(RESERVED_VENDORS)}")
print(f"Vendors updated : {len(vendors_touched)}")
for v, n in vendors_touched:
print(f" {v} ({n} files)")
print(f"Files rewritten : {changed} (of which newly assigned: {added})")
return 0
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -122,6 +122,9 @@ sed "/name: OrcaSlicer/{
\1 git_commit_hash: \"$GIT_COMMIT_HASH\"|
}" > "$MANIFEST_DOCKER"
# ---------- pack deps/ ----------
./scripts/flatpak/make_deps_tar.sh
# ---------- run build in Docker ----------
DOCKER="${DOCKER:-docker}"
+8
View File
@@ -0,0 +1,8 @@
@echo off
REM Runs check_profile.ps1, the Windows twin of check_profile.sh, from cmd.
REM Arguments are passed straight through, so anything the .ps1 takes works here:
REM scripts\check_profile.bat -Vendor Elegoo validate_custom
REM -ExecutionPolicy Bypass is needed because a Windows client defaults to Restricted,
REM which refuses to run a checked-out .ps1 at all.
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0check_profile.ps1" %*
exit /b %ERRORLEVEL%
+689
View File
@@ -0,0 +1,689 @@
<#
.SYNOPSIS
Local twin of the "Check profiles" CI job (.github/workflows/check_profiles.yml), for Windows.
.DESCRIPTION
The Windows counterpart of scripts/check_profile.sh, and kept deliberately close to it.
Runs the same five checks, in the same order, with the same validator flags, and with the
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
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
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.
resources\profiles\user, which the validator creates as its data dir but a CI checkout never
has, is moved aside for the duration of the run and restored on exit. Only one run per work
dir at a time.
-Vendor narrows a run to one vendor while working on that vendor's profiles - the one
deliberate divergence from CI, which always checks the whole tree. A check that cannot be
narrowed is left out of the run and reported as skipped.
x64 and ARM64 hosts are both supported. A locally built validator is chosen by the machine
type in its PE header rather than by the name of its build tree, so build\ (x64) and
build-arm64\ side by side resolve correctly; the published nightly is x64 only and runs
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.
.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
fail on another vendor's files.
.PARAMETER Validator
OrcaSlicer_profile_validator.exe to use; also $env:ORCA_PROFILE_VALIDATOR. Default: the local
build*\ Release build (then RelWithDebInfo, MinSizeRel, Debug) for this architecture, else
the nightly release build is downloaded.
.PARAMETER Download
Ignore local builds and use the downloaded nightly validator.
.PARAMETER Refresh
Re-download the validator and fixtures instead of using the cache.
.PARAMETER WorkDir
Downloads, logs and fixture trees (default: .test\check_profiles). Point it somewhere short,
such as D:\t, if a fixture tree trips Windows' 260-character path limit.
.PARAMETER LogLevel
Validator log level (default: 2, as in CI).
.PARAMETER Checks
Checks to run, by name (default: all of them, in the order listed above).
.EXAMPLE
scripts\check_profile.bat
.EXAMPLE
powershell -ExecutionPolicy Bypass -File scripts\check_profile.ps1 validate_system validate_slice
.EXAMPLE
scripts\check_profile.bat -Vendor Elegoo
#>
# PositionalBinding is off so that the check names are the only positional arguments; left on,
# a bare "validate_system" would bind to whichever named parameter came next in this block.
[CmdletBinding(PositionalBinding = $false)]
param(
[Alias('p')] [string] $ProfilesDir,
[Alias('v')] [string] $Vendor,
[string] $Validator,
[string] $WorkDir,
[Alias('l')] [int] $LogLevel = 2,
[switch] $Download,
[switch] $Refresh,
[Alias('h')] [switch] $Help,
[Parameter(Position = 0, ValueFromRemainingArguments = $true)] [string[]] $Checks
)
$ErrorActionPreference = 'Stop'
# Windows PowerShell 5.1 still negotiates TLS 1.0/1.1, which github.com refuses.
[Net.ServicePointManager]::SecurityProtocol =
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$ValidatorReleaseUrl = 'https://github.com/OrcaSlicer/OrcaSlicer/releases/download/nightly-builds'
$FixtureReleaseUrl = 'https://github.com/OrcaSlicer/OrcaSlicer-profile-validator/releases/download/fixture-archive'
$RepoRoot = Split-Path -Parent $PSScriptRoot
# PROCESSOR_ARCHITECTURE reports the architecture of the *shell*, so a 32-bit PowerShell on a
# 64-bit OS says x86; ARCHITEW6432 is the machine's own in that case.
$HostArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
$HostArch = switch ($HostArch) {
'ARM64' { 'arm64' }
'AMD64' { 'x64' }
default { 'x86' }
}
$AllChecks = @('extra_json_check', 'validate_system', 'validate_slice', 'validate_filament_subtypes', 'validate_custom')
$script:LogWriter = $null
$script:Python = ''
# ---------------------------------------------------------------------------- helpers
function Die([string] $Message) {
Write-Host "check_profile.ps1: $Message" -ForegroundColor Red
exit 2
}
# UTF-8 without a BOM, so a log reads the same here as the artifact CI uploads.
function New-LogWriter([string] $Path) {
New-Object IO.StreamWriter($Path, $false, (New-Object Text.UTF8Encoding($false)))
}
# Output of the check being run: shown, and kept in that check's log.
function Write-CheckLog([string] $Text) {
Write-Host $Text
if ($script:LogWriter) { $script:LogWriter.WriteLine($Text) }
}
# Runs a program and returns its exit code, streaming stdout and stderr into the current check's
# log. -OutFile sends that output to a file of its own instead, silently.
function Invoke-Tool {
param([string] $Exe, [string[]] $Arguments, [string] $OutFile)
# Under 'Stop', 2>&1 turns every stderr line of a native command into a terminating error.
# Assigning the preference here scopes it to this function, so it undoes itself on return.
$ErrorActionPreference = 'Continue'
$writer = if ($OutFile) { New-LogWriter $OutFile } else { $null }
try {
& $Exe @Arguments 2>&1 | ForEach-Object {
if ($writer) { $writer.WriteLine("$_") } else { Write-CheckLog "$_" }
}
return $LASTEXITCODE
} catch {
if ($writer) { $writer.WriteLine("$_") } else { Write-CheckLog "$_" }
return 1
} finally {
if ($writer) { $writer.Dispose() }
}
}
# The first $Limit characters of a log, as CI truncates them for the PR comment.
function Get-LogHead([string] $Path, [int] $Limit) {
$text = if (Test-Path -LiteralPath $Path) { [IO.File]::ReadAllText($Path) } else { '' }
if (-not $text) { return 'No output captured' }
if ($text.Length -gt $Limit) { return $text.Substring(0, $Limit) }
return $text
}
# ---------------------------------------------------------------------------- arguments
if ($Help) { Get-Help $PSCommandPath -Detailed; exit 0 }
foreach ($name in $Checks) {
if ($name -like '-*') { Die "unknown option '$name' (try -Help)" }
if ($AllChecks -notcontains $name) { Die "unknown check '$name' (try -Help)" }
}
# A check named on the command line always runs; only the default set is narrowed (see the run
# loop below).
$NamedChecks = [bool] $Checks
if (-not $Checks) { $Checks = $AllChecks }
if (-not $ProfilesDir) { $ProfilesDir = Join-Path $RepoRoot 'resources\profiles' }
if (-not (Test-Path -LiteralPath $ProfilesDir -PathType Container)) { Die "profile directory not found: $ProfilesDir" }
$ProfilesDir = (Resolve-Path -LiteralPath $ProfilesDir).Path
# A vendor neither tool knows is not an error to them: the static checks load nothing of their own
# and still report success, so a typo would otherwise be three green checks and one baffling slice
# failure. The match has to be made on the names themselves rather than with a Test-Path - the
# validator compares the <Vendor>.json stem case-sensitively, while Windows' case-insensitive
# filesystem would let "creality" pass a path test and then match no vendor. A vendor is a
# <name>.json with a sibling <name> directory; that pair is also what tells one apart from
# blacklist.json, which sits in the same folder.
if ($Vendor) {
$found = $false
$suggestion = ''
foreach ($file in (Get-ChildItem -LiteralPath $ProfilesDir -Filter '*.json' -File)) {
$name = [IO.Path]::GetFileNameWithoutExtension($file.Name)
if (-not (Test-Path -LiteralPath (Join-Path $ProfilesDir $name) -PathType Container)) { continue }
if ($name -ceq $Vendor) { $found = $true; break }
if ($name -eq $Vendor) { $suggestion = $name }
}
if (-not $found) {
if ($suggestion) { Die "unknown vendor '$Vendor'; vendor names are case-sensitive, did you mean '$suggestion'?" }
Die "unknown vendor '$Vendor': no such vendor in $ProfilesDir"
}
}
# The validator's -v and orca_extra_profile_check.py'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 { @() }
if (-not $WorkDir) { $WorkDir = Join-Path $RepoRoot '.test\check_profiles' }
$LogDir = Join-Path $WorkDir 'logs'
try { New-Item -ItemType Directory -Force -Path $LogDir | Out-Null } catch { Die "cannot create ${LogDir}: $_" }
$WorkDir = (Resolve-Path -LiteralPath $WorkDir).Path
$LogDir = Join-Path $WorkDir 'logs'
if (-not $Validator) { $Validator = $env:ORCA_PROFILE_VALIDATOR }
# ------------------------------------------------------------------- clean profile tree
# The validator points its data dir at the profile tree, so it creates - and, with -g, fills -
# <profiles>\user. A CI checkout never has that directory, and anything left in it from an
# earlier local run would be loaded as user presets and validated too. Move it aside for the
# duration of the run so what gets checked is what CI checks.
$script:StashedUserDir = ''
function Push-UserPresets {
$user = Join-Path $ProfilesDir 'user'
if (-not (Test-Path -LiteralPath $user -PathType Container)) { return }
$script:StashedUserDir = Join-Path $WorkDir "user-presets-$PID"
Remove-Item -LiteralPath $script:StashedUserDir -Recurse -Force -ErrorAction SilentlyContinue
try { Move-Item -LiteralPath $user -Destination $script:StashedUserDir }
catch { $script:StashedUserDir = ''; Die "cannot move $user aside: $_" }
Write-Host "moved $user aside for the run (restored on exit)"
}
function Pop-UserPresets {
# The validator leaves an empty user\default\{filament,machine,process} skeleton behind.
# Prune it directory by directory, never wholesale: one that holds a real file survives and
# is reported instead of being deleted. Runs even when nothing was stashed, so a tree that
# had no user\ before the run does not gain one.
$user = Join-Path $ProfilesDir 'user'
Remove-EmptyDirs $user
if (-not $script:StashedUserDir) { return }
if (Test-Path -LiteralPath $user) {
Write-Host "$user is not empty; your presets stay in $($script:StashedUserDir)"
} else {
Move-Item -LiteralPath $script:StashedUserDir -Destination $user
}
$script:StashedUserDir = ''
}
function Remove-EmptyDirs([string] $Path) {
if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return }
Get-ChildItem -LiteralPath $Path -Recurse -Directory -Force |
Sort-Object { $_.FullName.Length } -Descending |
ForEach-Object {
if (-not (Get-ChildItem -LiteralPath $_.FullName -Force)) {
Remove-Item -LiteralPath $_.FullName -Force
}
}
if (-not (Get-ChildItem -LiteralPath $Path -Force)) { Remove-Item -LiteralPath $Path -Force }
}
# ---------------------------------------------------------------------------- downloads
# Cached; -Refresh, or -Force, pulls a new copy. Release assets never change, so caching them is
# safe; the fixture manifest is the index that grows with every release, and CI re-reads it on
# every run.
function Save-Asset {
param([string] $Url, [string] $Dest, [switch] $Force)
if (-not $Refresh -and -not $Force -and (Test-Path -LiteralPath $Dest -PathType Leaf) -and
(Get-Item -LiteralPath $Dest).Length -gt 0) {
return
}
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Dest) | Out-Null
Write-Host "downloading $(Split-Path -Leaf $Dest) ..."
# Invoke-WebRequest spends most of a large download repainting its progress bar.
$ProgressPreference = 'SilentlyContinue'
$part = "$Dest.part"
for ($attempt = 1; ; $attempt++) {
try {
Invoke-WebRequest -Uri $Url -OutFile $part -UseBasicParsing
break
} catch {
if ($attempt -ge 3) {
Remove-Item -LiteralPath $part -Force -ErrorAction SilentlyContinue
throw "download failed: $Url"
}
}
}
Move-Item -LiteralPath $part -Destination $Dest -Force
}
function Get-Sha256([string] $Path) {
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
}
# Directories a built validator can sit in, best first: build_win.bat names its trees build,
# build-dbginfo, build-minsize and build-dbg, each optionally -clang and -arm64 suffixed, and
# CMake puts the binary in src\<config>. Each pattern is matched both directly under a build root
# and one level down (build\x64), for trees laid out the way the macOS ones are.
function Get-ValidatorSearchDirs {
foreach ($config in 'Release', 'RelWithDebInfo', 'MinSizeRel', 'Debug') {
Join-Path $RepoRoot "build*\src\$config"
Join-Path $RepoRoot "build*\*\src\$config"
}
Join-Path $RepoRoot 'build*\src'
Join-Path $RepoRoot 'build*\*\src'
}
# The machine type from the PE header, which is what actually decides whether an .exe can run
# here - the name of the build tree only says what it was meant to be.
function Get-ExeArch([string] $Path) {
try {
$stream = [IO.File]::OpenRead($Path)
try {
$reader = New-Object IO.BinaryReader($stream)
$stream.Position = 0x3C # e_lfanew: offset of the PE header
$stream.Position = $reader.ReadInt32()
if ($reader.ReadUInt32() -ne 0x00004550) { return '' } # "PE\0\0"
switch ($reader.ReadUInt16()) {
0x8664 { 'x64' }
0xAA64 { 'arm64' }
0x014C { 'x86' }
default { '' }
}
} finally { $stream.Dispose() }
} catch { '' }
}
# First locally built OrcaSlicer_profile_validator.exe, in the order above. An x86 build, and on
# ARM64 an x64 one, is kept only as a fallback: it runs, but emulated. An ARM64 build on an x64
# host does not run at all and is never offered.
function Find-LocalValidator {
$emulated = ''
foreach ($pattern in (Get-ValidatorSearchDirs)) {
foreach ($dir in @(Resolve-Path -Path $pattern -ErrorAction SilentlyContinue)) {
$candidate = Join-Path $dir.Path 'OrcaSlicer_profile_validator.exe'
if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { continue }
$arch = Get-ExeArch $candidate
if (-not $arch -or $arch -eq $HostArch) { return $candidate }
if (-not $emulated -and ($arch -eq 'x86' -or $HostArch -eq 'arm64')) { $emulated = $candidate }
}
}
if ($emulated) { Write-Host "no $HostArch build found, falling back to $emulated (emulated)" }
return $emulated
}
# The nightly release build, same one CI uses. Windows ships the bare .exe, x64 only.
function Save-NightlyValidator {
if ($HostArch -ne 'x64') {
Write-Host "the nightly Windows validator is x64; it runs here under emulation"
}
$exe = Join-Path $WorkDir 'validator\OrcaSlicer_profile_validator.exe'
Save-Asset -Url "$ValidatorReleaseUrl/OrcaSlicer_profile_validator_Windows_nightly.exe" -Dest $exe
return $exe
}
function Resolve-Validator {
if ($Validator) {
if (-not (Test-Path -LiteralPath $Validator -PathType Leaf)) { Die "validator not found: $Validator" }
return (Resolve-Path -LiteralPath $Validator).Path
}
if (-not $Download) {
$local = Find-LocalValidator
if ($local) {
Write-Host "using locally built validator: $local"
return $local
}
}
try { $downloaded = Save-NightlyValidator } catch { Die "could not obtain a profile validator: $_" }
Write-Host "using downloaded validator: $downloaded"
return $downloaded
}
# python3 is rarely on PATH on Windows: the py launcher is the reliable way in, and a bare
# `python` may be the Store stub, which prints an advert and exits non-zero. Probe each for the
# interpreter it actually resolves to, and use that.
function Resolve-Python {
$ErrorActionPreference = 'Continue'
if ($script:Python) { return $script:Python }
foreach ($candidate in 'py -3', 'python', 'python3') {
$words = $candidate -split ' '
$exe = Get-Command $words[0] -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $exe) { continue }
$leading = @($words | Select-Object -Skip 1)
$found = & $exe.Source @leading -c 'import sys; print(sys.executable)' 2>$null
if ($LASTEXITCODE -eq 0 -and $found) {
$script:Python = "$found"
return $script:Python
}
}
Die 'no Python 3 found; install it (or the py launcher) and re-run'
}
# The fixtures name every preset "<vendor>_<parent preset>_orca_test", after the "name" inside
# <Vendor>.json rather than the file stem -Vendor takes - BBL.json is "Bambulab", iQ.json is
# "innovatiQ". Falls back to the stem for a vendor file with no readable name.
function Get-VendorDisplayName {
$name = ''
try { $name = (Get-Content -LiteralPath (Join-Path $ProfilesDir "$Vendor.json") -Raw -ErrorAction Stop | ConvertFrom-Json).name } catch { }
if ($name) { return "$name" }
return $Vendor
}
# Unpack just the presets one vendor's profiles own, and return how many that was. Each fixture
# preset was generated from a single system preset and inherits it by name - none inherits another
# user preset - so the selection is self-contained.
function Expand-VendorPresets([string] $Zip, [string] $Tree, [string] $Prefix) {
# .NET rather than Expand-Archive: a fixture holds around 20,000 entries, and unpacking every
# one of them to keep a twentieth costs more than the validation this is speeding up.
Add-Type -AssemblyName System.IO.Compression.FileSystem
$kept = 0
$archive = [IO.Compression.ZipFile]::OpenRead($Zip)
try {
foreach ($entry in $archive.Entries) {
# A directory entry has an empty Name, so it never matches and is never created.
if (-not $entry.Name.StartsWith($Prefix, [StringComparison]::Ordinal)) { continue }
$dest = Join-Path $Tree $entry.FullName.Replace('/', [IO.Path]::DirectorySeparatorChar)
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($dest)) | Out-Null
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true)
$kept++
}
} finally {
$archive.Dispose()
}
return $kept
}
# ---------------------------------------------------------------------------- checks
$CheckBodies = @{
extra_json_check = {
Invoke-Tool -Exe (Resolve-Python) -Arguments (@((Join-Path $RepoRoot 'scripts\orca_extra_profile_check.py')) + $VendorPyArgs)
}
validate_system = {
Invoke-Tool -Exe $Validator -Arguments (@('-p', $ProfilesDir) + $VendorArgs + @('-l', "$LogLevel"))
}
# Slices a two-colour cube through every printer so all custom g-code (incl.
# change_filament_gcode) is expanded - catches undefined-placeholder / invalid-flow bugs the
# static checks cannot see.
validate_slice = {
Invoke-Tool -Exe $Validator -Arguments (@('-p', $ProfilesDir) + $VendorArgs + @('-s', '-l', "$LogLevel"))
}
validate_filament_subtypes = {
Invoke-Tool -Exe $Validator -Arguments (@('-p', $ProfilesDir) + $VendorArgs + @('-l', "$LogLevel", '-f'))
}
# Every released fixture is a snapshot of user presets saved by that OrcaSlicer version; each
# is unpacked over the current system profiles and validated, so a profile change that would
# break an existing user's presets fails here.
#
# Under -Vendor only that vendor's presets are unpacked, which is what makes the validator's
# -v usable here: -v filters the system vendors but never the user presets, so on a whole-tree
# snapshot it reports every other vendor's presets as unresolvable parents - thousands of
# errors saying nothing about the vendor under test. Teaching -v to filter user presets too is
# the deeper fix, but this runs against whatever validator is to hand, the published nightly
# included, so the picking has to happen here. It picks on the "<vendor display name>_"
# filename prefix that generate_custom_presets() (the validator's -g mode) gave every preset in
# these fixtures; a fixture cut from a renamed generator would surface as the "checked nothing"
# warning below. Presets whose source had no vendor carry no prefix - a few of those still name
# one in the middle, like "PET @BBL A1_orca_test" - and only an unscoped run covers them.
validate_custom = {
$fixturesDir = Join-Path $WorkDir 'profile-fixtures'
$outputDir = Join-Path $WorkDir 'custom-preset-validation'
New-Item -ItemType Directory -Force -Path $fixturesDir, $outputDir | Out-Null
$manifestPath = Join-Path $fixturesDir 'manifest.json'
Save-Asset -Url "$FixtureReleaseUrl/manifest.json" -Dest $manifestPath -Force
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
$entries = if ($manifest -is [array]) { $manifest } else { $manifest.fixtures }
$fixtures = @($entries | Where-Object { $_.version -and $_.asset })
if (-not $fixtures) {
Write-CheckLog "No custom preset fixtures found in $FixtureReleaseUrl/manifest.json"
return 1
}
$vendorPrefix = if ($Vendor) { "$(Get-VendorDisplayName)_" } else { '' }
$totalKept = 0
$status = 0
$failedLogs = @()
$summary = @('## Custom Preset Fixture Validation', '', '| Version | Status | Log |', '| --- | --- | --- |')
foreach ($fixture in $fixtures) {
$version = $fixture.version
$asset = $fixture.asset
$fixtureZip = Join-Path $fixturesDir $asset
$profileTree = Join-Path $outputDir "profiles-$version"
$logPath = Join-Path $outputDir "$version.log"
$assetUrl = "$FixtureReleaseUrl/$([uri]::EscapeDataString($asset))"
Save-Asset -Url $assetUrl -Dest $fixtureZip
$expected = $fixture.asset_sha256
if ($expected -and $expected -ne '<sha256>' -and (Get-Sha256 $fixtureZip) -ne $expected.ToUpperInvariant()) {
# A cached zip can be stale or truncated; the release asset itself is immutable,
# so deleting it forces Save-Asset to pull a fresh copy.
Write-Host "checksum mismatch for $asset, re-downloading"
Remove-Item -LiteralPath $fixtureZip -Force
Save-Asset -Url $assetUrl -Dest $fixtureZip
$actual = Get-Sha256 $fixtureZip
if ($actual -ne $expected.ToUpperInvariant()) {
Write-CheckLog "${asset}: expected $expected, got $actual"
return 1
}
}
Write-Host "validating custom presets from $version ..."
Remove-Item -LiteralPath $profileTree -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $profileTree | Out-Null
# Piped rather than copied through <profiles>\*, so a vendor directory whose name
# holds a wildcard character is still copied by its literal path. Under -Vendor the
# validator opens only that vendor and OrcaFilamentLibrary and skips the other sixty-odd
# (PresetBundle::load_system_presets_from_json), so the rest are left out of the copy:
# ~3s a fixture that was going on files nothing opens.
Get-ChildItem -LiteralPath $ProfilesDir -Force |
Where-Object { -not $Vendor -or -not $_.PSIsContainer -or $_.Name -eq $Vendor -or $_.Name -eq 'OrcaFilamentLibrary' } |
Copy-Item -Destination $profileTree -Recurse -Force
Remove-Item -LiteralPath (Join-Path $profileTree 'user') -Recurse -Force -ErrorAction SilentlyContinue
if ($Vendor) {
$kept = Expand-VendorPresets $fixtureZip $profileTree $vendorPrefix
$totalKept += $kept
Write-CheckLog " $kept $Vendor preset file(s)"
} else {
Expand-Archive -LiteralPath $fixtureZip -DestinationPath $profileTree -Force
}
$result = Invoke-Tool -Exe $Validator -Arguments (@('-p', $profileTree) + $VendorArgs + @('-l', "$LogLevel")) -OutFile $logPath
if ($result -eq 0) {
$summary += "| $version | PASS | $version.log |"
# Only failures are worth keeping; each tree is a full copy of resources\profiles.
Remove-Item -LiteralPath $profileTree -Recurse -Force
} else {
$summary += "| $version | FAIL | $version.log |"
$failedLogs += $logPath
$status = 1
}
}
# A vendor added after the newest fixture was cut appears in none of them: nothing failed,
# but nothing was checked either.
if ($Vendor -and $totalKept -eq 0) {
Write-CheckLog "no $Vendor presets in any fixture; validate_custom checked nothing"
}
[IO.File]::WriteAllLines((Join-Path $outputDir 'summary.md'), [string[]] $summary)
$summary | ForEach-Object { Write-CheckLog $_ }
if ($failedLogs) {
Write-CheckLog ''
Write-CheckLog '## Failed Fixture Logs'
foreach ($logPath in $failedLogs) {
Write-CheckLog ''
Write-CheckLog "### $([IO.Path]::GetFileNameWithoutExtension($logPath))"
Write-CheckLog '```'
Write-CheckLog (Get-LogHead $logPath 12000)
Write-CheckLog '```'
}
}
return $status
}
}
# Heading CI puts above this check's log in the PR comment.
$CommentHeadings = @{
extra_json_check = '### Extra JSON Check Failed'
validate_system = '### System Profile Validation Failed'
validate_slice = '### Slice Validation Failed (custom g-code expansion)'
validate_filament_subtypes = '### Filament Subtype Validation Failed'
validate_custom = '### Custom Preset Validation Failed'
}
# ---------------------------------------------------------------------------- run
function Invoke-Check([string] $Name) {
$log = Join-Path $LogDir "$Name.log"
Write-Host ''
Write-Host "==> $Name$(if ($Vendor) { " ($Vendor)" })" -ForegroundColor Cyan
$script:LogWriter = New-LogWriter $log
try {
$result = & $CheckBodies[$Name] | Select-Object -Last 1
} catch {
Write-CheckLog "$_"
$result = 1
} finally {
$script:LogWriter.Dispose()
$script:LogWriter = $null
}
if ([int] $result -eq 0) {
Write-Host " $Name passed" -ForegroundColor Green
return $true
}
Write-Host " $Name failed (exit $result)" -ForegroundColor Red
return $false
}
# The fixture trees under the work dir are shared scratch space keyed by fixture version, so a
# second run would delete a tree the first one is validating.
$LockDir = Join-Path $WorkDir '.lock'
try { New-Item -ItemType Directory -Path $LockDir -ErrorAction Stop | Out-Null }
catch { Die "another run is using $WorkDir (pass -WorkDir, or remove $LockDir if no run is active)" }
# The validator writes UTF-8, but PowerShell decodes a child process's output using the console
# code page, which mangles the accented and CJK preset names in its messages.
$PreviousOutputEncoding = [Console]::OutputEncoding
try {
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
Push-UserPresets
if ($Checks | Where-Object { $_ -ne 'extra_json_check' }) { $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);
# naming the check explicitly still runs it.
$results = [ordered] @{}
$skipReasons = @{}
foreach ($name in $AllChecks) {
if ($Checks -notcontains $name) { continue }
if ($name -eq 'validate_slice' -and -not $NamedChecks -and $Vendor -and
-not (Test-Path -LiteralPath (Join-Path (Join-Path $ProfilesDir $Vendor) 'machine') -PathType Container)) {
$results[$name] = 'skip'
$skipReasons[$name] = "$Vendor ships no printers"
} else {
$results[$name] = if (Invoke-Check $name) { 'pass' } else { 'fail' }
}
}
Write-Host ''
Write-Host '==> summary' -ForegroundColor Cyan
foreach ($name in $results.Keys) {
switch ($results[$name]) {
'pass' { Write-Host " PASS $name" -ForegroundColor Green }
'skip' { Write-Host " SKIP $name ($($skipReasons[$name]))" -ForegroundColor Yellow }
default { Write-Host " FAIL $name ($(Join-Path $LogDir "$name.log"))" -ForegroundColor Red }
}
}
$failed = @($results.Keys | Where-Object { $results[$_] -eq 'fail' })
if (-not $failed) {
Remove-Item -LiteralPath (Join-Path $WorkDir 'pr_comment.md') -Force -ErrorAction SilentlyContinue
Write-Host ''
if (@($results.Values) -contains 'skip') {
Write-Host "Every check that ran passed, but CI runs the skipped ones too. Logs: $LogDir" -ForegroundColor Green
} else {
Write-Host "All checks passed. Logs: $LogDir" -ForegroundColor Green
}
exit 0
}
# The comment check_profiles_comment.yml would post when something fails.
$comment = @(
# Marker matched by check_profiles_comment.yml to delete prior comments.
'<!-- profile-validation-comment -->'
'## :x: Profile Validation Errors'
''
foreach ($name in $failed) {
$CommentHeadings[$name]
''
'```'
Get-LogHead (Join-Path $LogDir "$name.log") 30000
'```'
''
}
'---'
'*Please fix the above errors and push a new commit.*'
)
$commentPath = Join-Path $WorkDir 'pr_comment.md'
[IO.File]::WriteAllLines($commentPath, [string[]] $comment)
Write-Host ''
Write-Host "One or more profile checks failed. Logs: $LogDir" -ForegroundColor Red
Write-Host "The comment CI would post: $commentPath"
exit 1
} finally {
Pop-UserPresets
Remove-Item -LiteralPath $LockDir -Force -ErrorAction SilentlyContinue
[Console]::OutputEncoding = $PreviousOutputEncoding
}
+646
View File
@@ -0,0 +1,646 @@
#!/usr/bin/env bash
#
# Local twin of the "Check profiles" CI job (.github/workflows/check_profiles.yml).
#
# Runs the same five checks, in the same order, with the same validator flags, and with the
# 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.
#
# 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.
#
# resources/profiles/user, which the validator creates as its data dir but a CI checkout never
# has, is moved aside for the duration of the run and restored on exit. Only one run per work
# dir at a time.
#
# -v/--vendor narrows a run to one vendor while working on that vendor's profiles - the one
# deliberate divergence from CI, which always checks the whole tree. A check that cannot be
# narrowed is left out of the run and reported as skipped; see usage().
#
# Usage: scripts/check_profile.sh [OPTION]... [CHECK]...
# The check_* functions run through run_check, which dispatches on the check name, so
# ShellCheck cannot see that they (and what they call) are used.
# shellcheck disable=SC2329
set -uo pipefail
VALIDATOR_RELEASE_URL="https://github.com/OrcaSlicer/OrcaSlicer/releases/download/nightly-builds"
FIXTURE_RELEASE_URL="https://github.com/OrcaSlicer/OrcaSlicer-profile-validator/releases/download/fixture-archive"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
HOST_ARCH="$(uname -m)"
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.
# So the flag is passed unconditionally below rather than kept in an array bash 3.2 cannot expand
# empty under `set -u`.
VENDOR=""
LOG_LEVEL=2
PREFER_DOWNLOAD=0
REFRESH=0
ALL_CHECKS=(extra_json_check 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)
# cannot expand an empty array under `set -u`.
RESULTS=""
usage() {
cat <<EOF
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
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
Options:
-p, --profiles DIR profile tree to validate (default: resources/profiles)
-v, --vendor NAME check only this vendor, named after its <Vendor>.json (e.g. "Co Print")
--validator BIN OrcaSlicer_profile_validator to use; also \$ORCA_PROFILE_VALIDATOR.
Default: the local build*/ Release build (then RelWithDebInfo, then
Debug) for this architecture, else the nightly release build is
downloaded for this platform
--download ignore local builds and use the downloaded nightly validator
--refresh re-download the validator and fixtures instead of using the cache
--work-dir DIR downloads, logs and fixture trees (default: .test/check_profiles)
-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: --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.
EOF
}
msg() { printf '%s\n' "$*" >&2; }
die() { printf 'check_profile.sh: %s\n' "$*" >&2; exit 2; }
if [ -t 1 ]; then
C_RED=$'\033[91m'; C_GREEN=$'\033[92m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'
else
C_RED=''; C_GREEN=''; C_BOLD=''; C_RESET=''
fi
# ---------------------------------------------------------------------------- arguments
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-p|--profiles) [ $# -ge 2 ] || die "$1 needs a directory"; PROFILES_DIR="$2"; shift 2 ;;
-v|--vendor) [ $# -ge 2 ] || die "$1 needs a vendor name"; VENDOR="$2"; shift 2 ;;
--validator) [ $# -ge 2 ] || die "$1 needs a path"; VALIDATOR="$2"; shift 2 ;;
--work-dir) [ $# -ge 2 ] || die "$1 needs a directory"; WORK_DIR="$2"; shift 2 ;;
-l|--log-level) [ $# -ge 2 ] || die "$1 needs a number"; LOG_LEVEL="$2"; shift 2 ;;
--download) PREFER_DOWNLOAD=1; shift ;;
--refresh) REFRESH=1; shift ;;
-*) die "unknown option '$1' (try --help)" ;;
*)
known=0
for check in "${ALL_CHECKS[@]}"; do
[ "$1" = "${check}" ] && known=1
done
[ "${known}" -eq 1 ] || die "unknown check '$1' (try --help)"
CHECKS[${#CHECKS[@]}]="$1"
shift
;;
esac
done
# A check named on the command line always runs; only the default set is narrowed (see the run
# loop below).
NAMED_CHECKS="${#CHECKS[@]}"
[ "${NAMED_CHECKS}" -gt 0 ] || CHECKS=("${ALL_CHECKS[@]}")
[ -d "${PROFILES_DIR}" ] || die "profile directory not found: ${PROFILES_DIR}"
PROFILES_DIR="$(cd -- "${PROFILES_DIR}" && pwd)"
# A vendor neither tool knows is not an error to them: the static checks load nothing of their own
# and still report success, so a typo would otherwise be three green checks and one baffling slice
# failure. The match has to be made on the names themselves rather than with a -f test - the
# validator compares the <Vendor>.json stem case-sensitively, while a case-insensitive filesystem
# (macOS, Windows) would let "creality" pass a file test and then match no vendor. A vendor is a
# <name>.json with a sibling <name>/ directory; that pair is also what tells one apart from
# blacklist.json, which sits in the same folder.
if [ -n "${VENDOR}" ]; then
wanted="$(printf '%s' "${VENDOR}" | tr '[:upper:]' '[:lower:]')"
found=""
suggestion=""
for file in "${PROFILES_DIR}"/*.json; do
name="${file##*/}"; name="${name%.json}"
[ -d "${PROFILES_DIR}/${name}" ] || continue
if [ "${name}" = "${VENDOR}" ]; then
found=1
break
fi
[ "$(printf '%s' "${name}" | tr '[:upper:]' '[:lower:]')" = "${wanted}" ] && suggestion="${name}"
done
if [ -z "${found}" ]; then
[ -z "${suggestion}" ] || die "unknown vendor '${VENDOR}'; vendor names are case-sensitive, did you mean '${suggestion}'?"
die "unknown vendor '${VENDOR}': no such vendor in ${PROFILES_DIR}"
fi
fi
LOG_DIR="${WORK_DIR}/logs"
mkdir -p "${LOG_DIR}" || die "cannot create ${LOG_DIR}"
WORK_DIR="$(cd -- "${WORK_DIR}" && pwd)"
LOG_DIR="${WORK_DIR}/logs"
wants() {
local check
for check in "${CHECKS[@]}"; do
[ "${check}" = "$1" ] && return 0
done
return 1
}
# ------------------------------------------------------------------- clean profile tree
# The validator points its data dir at the profile tree, so it creates - and, with -g, fills -
# <profiles>/user. A CI checkout never has that directory, and anything left in it from an
# earlier local run would be loaded as user presets and validated too. Move it aside for the
# duration of the run so what gets checked is what CI checks.
STASHED_USER_DIR=""
stash_user_presets() {
[ -d "${PROFILES_DIR}/user" ] || return 0
STASHED_USER_DIR="${WORK_DIR}/user-presets-$$"
rm -rf "${STASHED_USER_DIR}"
mv "${PROFILES_DIR}/user" "${STASHED_USER_DIR}" || { STASHED_USER_DIR=""; die "cannot move ${PROFILES_DIR}/user aside"; }
msg "moved ${PROFILES_DIR}/user aside for the run (restored on exit)"
}
restore_user_presets() {
# The validator leaves an empty user/default/{filament,machine,process} skeleton behind.
# Prune it with rmdir, never rm -rf: a directory that holds a real file survives and is
# reported instead of being deleted. Runs even when nothing was stashed, so a tree that had
# no user/ before the run does not gain one.
find "${PROFILES_DIR}/user" -depth -type d -exec rmdir {} + 2>/dev/null
[ -n "${STASHED_USER_DIR}" ] || return 0
if [ -d "${PROFILES_DIR}/user" ]; then
msg "${PROFILES_DIR}/user is not empty; your presets stay in ${STASHED_USER_DIR}"
STASHED_USER_DIR=""
return 0
fi
mv "${STASHED_USER_DIR}" "${PROFILES_DIR}/user"
STASHED_USER_DIR=""
}
# The fixture trees under the work dir are shared scratch space keyed by fixture version, so a
# second run would delete a tree the first one is validating.
LOCK_DIR="${WORK_DIR}/.lock"
mkdir "${LOCK_DIR}" 2>/dev/null ||
die "another run is using ${WORK_DIR} (pass --work-dir, or remove ${LOCK_DIR} if no run is active)"
cleanup() {
restore_user_presets
rmdir "${LOCK_DIR}" 2>/dev/null
}
# Installed only once the lock is ours, so a refused start never releases someone else's.
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
stash_user_presets
# ---------------------------------------------------------------------------- downloads
# fetch URL DEST [force] - cached; --refresh, or a non-empty third argument, forces a new
# download. Release assets never change, so caching them is safe; the fixture manifest is the
# index that grows with every release, and CI re-reads it on every run.
fetch() {
local url="$1" dest="$2" force="${3:-}"
if [ "${REFRESH}" -eq 0 ] && [ -z "${force}" ] && [ -s "${dest}" ]; then
return 0
fi
mkdir -p "$(dirname -- "${dest}")" || return 1
msg "downloading $(basename -- "${dest}") ..."
if ! curl -fsSL --retry 3 -o "${dest}.part" "${url}"; then
rm -f "${dest}.part"
msg "download failed: ${url}"
return 1
fi
mv -f "${dest}.part" "${dest}"
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | cut -d' ' -f1
else
return 1
fi
}
# Directories a built validator can sit in, best first: every Ninja Multi-Config Release tree,
# then RelWithDebInfo, then Debug, then a single-config generator's plain src/. Each pattern is
# matched both directly under a build root (build/) and one level down (build/arm64, build/x64),
# and unmatched globs are dropped by the caller's -d test.
validator_search_dirs() {
local config
for config in Release RelWithDebInfo Debug; do
printf '%s\n' "${REPO_ROOT}"/build*/src/"${config}" "${REPO_ROOT}"/build*/*/src/"${config}"
done
printf '%s\n' "${REPO_ROOT}"/build*/src "${REPO_ROOT}"/build*/*/src
}
# A build tree named for the other architecture (build/x86_64 on an arm64 host, build/arm64 on
# an x64 one) is a last resort: on Linux it will not run at all, on macOS it goes via Rosetta.
# Takes a repo-relative path - an absolute one would also match an arch name in the checkout path.
is_foreign_arch_dir() {
case "${HOST_ARCH}" in
arm64|aarch64) case "$1" in *x86_64*|*x86-64*|*x64*|*amd64*) return 0 ;; esac ;;
x86_64|amd64) case "$1" in *arm64*|*aarch64*) return 0 ;; esac ;;
esac
return 1
}
# First locally built OrcaSlicer_profile_validator, in the order above. macOS puts it in an .app
# bundle, Linux and Windows next to the other binaries.
find_local_validator() {
local dir candidate foreign=""
while IFS= read -r dir; do
[ -d "${dir}" ] || continue
for candidate in \
"${dir}/OrcaSlicer_profile_validator" \
"${dir}/OrcaSlicer_profile_validator.exe" \
"${dir}/OrcaSlicer_profile_validator.app/Contents/MacOS/OrcaSlicer_profile_validator"; do
[ -f "${candidate}" ] && [ -x "${candidate}" ] || continue
if ! is_foreign_arch_dir "${dir#"${REPO_ROOT}"/}"; then
printf '%s\n' "${candidate}"
return 0
fi
[ -n "${foreign}" ] || foreign="${candidate}"
done
done <<EOF
$(validator_search_dirs)
EOF
[ -n "${foreign}" ] || return 1
msg "no ${HOST_ARCH} build found, falling back to ${foreign}"
printf '%s\n' "${foreign}"
}
# The nightly release build, same one CI uses. Linux ships the bare binary, macOS a .dmg
# holding the signed .app, Windows an .exe.
download_validator() {
local dest="${WORK_DIR}/validator" binary dmg app mounted app_src
case "$(uname -s)" in
Linux*)
case "${HOST_ARCH}" in
arm64|aarch64) msg "the nightly Linux validator is x86_64; build it locally for ${HOST_ARCH}" ;;
esac
binary="${dest}/OrcaSlicer_profile_validator"
fetch "${VALIDATOR_RELEASE_URL}/OrcaSlicer_profile_validator_Linux_Ubuntu2404_nightly" "${binary}" || return 1
chmod +x "${binary}" || return 1
;;
Darwin*)
dmg="${dest}/OrcaSlicer_profile_validator.dmg"
app="${dest}/OrcaSlicer_profile_validator.app"
binary="${app}/Contents/MacOS/OrcaSlicer_profile_validator"
fetch "${VALIDATOR_RELEASE_URL}/OrcaSlicer_profile_validator_Mac_universal_nightly.dmg" "${dmg}" || return 1
if [ ! -x "${binary}" ] || [ "${REFRESH}" -eq 1 ]; then
mounted="${dest}/mnt"
rm -rf "${mounted}" "${app}"
mkdir -p "${mounted}" || return 1
hdiutil attach -nobrowse -readonly -mountpoint "${mounted}" "${dmg}" >/dev/null || return 1
app_src="$(find "${mounted}" -maxdepth 1 -name '*.app' -print 2>/dev/null | head -n 1)"
if [ -n "${app_src}" ]; then
cp -R "${app_src}" "${app}"
fi
hdiutil detach "${mounted}" >/dev/null 2>&1
rmdir "${mounted}" 2>/dev/null
[ -x "${binary}" ] || { msg "no validator app inside ${dmg}"; return 1; }
fi
;;
MINGW*|MSYS*|CYGWIN*)
binary="${dest}/OrcaSlicer_profile_validator.exe"
fetch "${VALIDATOR_RELEASE_URL}/OrcaSlicer_profile_validator_Windows_nightly.exe" "${binary}" || return 1
chmod +x "${binary}" || return 1
;;
*)
msg "no nightly validator published for $(uname -s); build it (-DORCA_TOOLS=ON) and pass --validator"
return 1
;;
esac
printf '%s\n' "${binary}"
}
resolve_validator() {
if [ -n "${VALIDATOR}" ]; then
[ -x "${VALIDATOR}" ] || die "validator not executable: ${VALIDATOR}"
return 0
fi
if [ "${PREFER_DOWNLOAD}" -eq 0 ]; then
VALIDATOR="$(find_local_validator)"
if [ -n "${VALIDATOR}" ]; then
msg "using locally built validator: ${VALIDATOR}"
return 0
fi
fi
VALIDATOR="$(download_validator)" || die "could not obtain a profile validator"
msg "using downloaded validator: ${VALIDATOR}"
}
# ---------------------------------------------------------------------------- checks
check_extra_json_check() {
python3 "${REPO_ROOT}/scripts/orca_extra_profile_check.py" --vendor "${VENDOR}"
}
check_validate_system() {
"${VALIDATOR}" -p "${PROFILES_DIR}" -v "${VENDOR}" -l "${LOG_LEVEL}"
}
# Slices a two-colour cube through every printer so all custom g-code (incl. change_filament_gcode)
# is expanded - catches undefined-placeholder / invalid-flow bugs the static checks cannot see.
check_validate_slice() {
"${VALIDATOR}" -p "${PROFILES_DIR}" -v "${VENDOR}" -s -l "${LOG_LEVEL}"
}
check_validate_filament_subtypes() {
"${VALIDATOR}" -p "${PROFILES_DIR}" -v "${VENDOR}" -l "${LOG_LEVEL}" -f
}
# The fixtures name every preset "<vendor>_<parent preset>_orca_test", after the "name" inside
# <Vendor>.json rather than the file stem --vendor takes - BBL.json is "Bambulab", iQ.json is
# "innovatiQ". Falls back to the stem for a vendor file with no readable name.
vendor_display_name() {
local name
name="$(VENDOR_JSON="${PROFILES_DIR}/${VENDOR}.json" python3 - <<'PY'
import json
import os
try:
with open(os.environ["VENDOR_JSON"], encoding="utf-8") as fh:
print(json.load(fh).get("name", ""))
except Exception:
pass
PY
)"
printf '%s\n' "${name:-${VENDOR}}"
}
# Every released fixture is a snapshot of user presets saved by that OrcaSlicer version; each is
# unpacked over the current system profiles and validated, so a profile change that would break
# an existing user's presets fails here.
#
# Under --vendor only that vendor's presets are unpacked, which is what makes the validator's -v
# usable here: -v filters the system vendors but never the user presets, so on a whole-tree
# snapshot it reports every other vendor's presets as unresolvable parents - thousands of errors
# saying nothing about the vendor under test. Teaching -v to filter user presets too is the deeper
# fix, but this runs against whatever validator is to hand, the published nightly included, so the
# picking has to happen here. It picks on the "<vendor display name>_" filename prefix that
# generate_custom_presets() (the validator's -g mode) gave every preset in these fixtures; a
# fixture cut from a renamed generator would surface as the "checked nothing" warning below.
# Presets whose source had no vendor carry no prefix - a few of those still name one in the
# middle, like "PET @BBL A1_orca_test" - and only an unscoped run covers them.
check_validate_custom() {
local fixtures_dir="${WORK_DIR}/profile-fixtures"
local output_dir="${WORK_DIR}/custom-preset-validation"
local summary="${output_dir}/summary.md"
local status=0 failed_logs="" vendor_prefix="" total_kept=0
local version asset expected_sha256 asset_url fixture_zip profile_tree log_path actual_sha256 result kept
command -v unzip >/dev/null 2>&1 || { msg "unzip is required for validate_custom"; return 1; }
[ -z "${VENDOR}" ] || vendor_prefix="$(vendor_display_name)_"
mkdir -p "${fixtures_dir}" "${output_dir}" || return 1
fetch "${FIXTURE_RELEASE_URL}/manifest.json" "${fixtures_dir}/manifest.json" force || return 1
MANIFEST_PATH="${fixtures_dir}/manifest.json" python3 - > "${fixtures_dir}/fixtures.tsv" <<'PY'
import json
import os
with open(os.environ["MANIFEST_PATH"], encoding="utf-8") as fh:
manifest = json.load(fh)
if isinstance(manifest, dict):
entries = manifest.get("fixtures", [])
else:
entries = manifest
for entry in entries:
version = entry.get("version", "")
asset = entry.get("asset", "")
sha256 = entry.get("asset_sha256", "")
if not version or not asset:
continue
print(f"{version}\t{asset}\t{sha256}")
PY
if [ ! -s "${fixtures_dir}/fixtures.tsv" ]; then
echo "No custom preset fixtures found in ${FIXTURE_RELEASE_URL}/manifest.json"
return 1
fi
{
echo "## Custom Preset Fixture Validation"
echo ""
echo "| Version | Status | Log |"
echo "| --- | --- | --- |"
} > "${summary}"
while IFS=$'\t' read -r version asset expected_sha256; do
[ -n "${version}" ] || continue
fixture_zip="${fixtures_dir}/${asset}"
profile_tree="${output_dir}/profiles-${version}"
log_path="${output_dir}/${version}.log"
asset_url="${FIXTURE_RELEASE_URL}/$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "${asset}")"
fetch "${asset_url}" "${fixture_zip}" || return 1
if [ -n "${expected_sha256}" ] && [ "${expected_sha256}" != "<sha256>" ]; then
actual_sha256="$(sha256_of "${fixture_zip}")"
if [ -z "${actual_sha256}" ]; then
msg "no sha256 tool available, skipping checksum of ${asset}"
elif [ "${actual_sha256}" != "${expected_sha256}" ]; then
# A cached zip can be stale or truncated; the release asset itself is immutable,
# so deleting it forces fetch to pull a fresh copy.
msg "checksum mismatch for ${asset}, re-downloading"
rm -f "${fixture_zip}"
fetch "${asset_url}" "${fixture_zip}" || return 1
actual_sha256="$(sha256_of "${fixture_zip}")"
[ "${actual_sha256}" = "${expected_sha256}" ] || { msg "${asset}: expected ${expected_sha256}, got ${actual_sha256}"; return 1; }
fi
fi
msg "validating custom presets from ${version} ..."
rm -rf "${profile_tree}"
mkdir -p "${profile_tree}" || return 1
if [ -n "${VENDOR}" ]; then
# -v has the validator open this vendor and OrcaFilamentLibrary and skip the rest
# (PresetBundle::load_system_presets_from_json), and unzip's * spans '/', so one
# pattern reaches every preset type. Copying and unpacking only those turns a ~5s
# setup per fixture into ~0.5s. Naming the vendor twice, when it is the library
# itself, is not an error to cp; exit 11 is unzip's "nothing matched", which is the
# fixture-predates-the-vendor case the warning below reports.
cp -a "${PROFILES_DIR}"/*.json "${PROFILES_DIR}/${VENDOR}" \
"${PROFILES_DIR}/OrcaFilamentLibrary" "${profile_tree}/" || return 1
unzip -q "${fixture_zip}" "user/*/${vendor_prefix}*" -d "${profile_tree}"
result=$?
[ "${result}" -eq 0 ] || [ "${result}" -eq 11 ] || return 1
kept="$(find "${profile_tree}/user" -type f 2>/dev/null | wc -l | tr -d ' ')"
total_kept=$((total_kept + kept))
msg " ${kept} ${VENDOR} preset file(s)"
else
cp -a "${PROFILES_DIR}/." "${profile_tree}/" || return 1
rm -rf "${profile_tree}/user"
unzip -q "${fixture_zip}" -d "${profile_tree}" || return 1
fi
"${VALIDATOR}" -p "${profile_tree}" -v "${VENDOR}" -l "${LOG_LEVEL}" > "${log_path}" 2>&1
result=$?
if [ "${result}" -eq 0 ]; then
echo "| ${version} | PASS | ${version}.log |" >> "${summary}"
# Only failures are worth keeping; each tree is a full copy of resources/profiles.
rm -rf "${profile_tree}"
else
echo "| ${version} | FAIL | ${version}.log |" >> "${summary}"
failed_logs="${failed_logs}${log_path}"$'\n'
status=1
fi
done < "${fixtures_dir}/fixtures.tsv"
# A vendor added after the newest fixture was cut appears in none of them: nothing failed, but
# nothing was checked either.
if [ -n "${VENDOR}" ] && [ "${total_kept}" -eq 0 ]; then
msg "no ${VENDOR} presets in any fixture; validate_custom checked nothing"
fi
cat "${summary}"
if [ -n "${failed_logs}" ]; then
echo ""
echo "## Failed Fixture Logs"
while IFS= read -r log_path; do
[ -n "${log_path}" ] || continue
echo ""
echo "### $(basename "${log_path}" .log)"
echo '```'
head -c 12000 "${log_path}" || echo "No output captured"
echo '```'
done <<EOF
${failed_logs}
EOF
fi
return "${status}"
}
# 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" ;;
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" ;;
validate_custom) echo "### Custom Preset Validation Failed" ;;
esac
}
# ---------------------------------------------------------------------------- run
run_check() {
local name="$1"
local log="${LOG_DIR}/${name}.log"
local result
printf '\n%s==> %s%s%s\n' "${C_BOLD}" "${name}" "${VENDOR:+ (${VENDOR})}" "${C_RESET}"
"check_${name}" 2>&1 | tee "${log}"
result="${PIPESTATUS[0]}"
if [ "${result}" -eq 0 ]; then
printf '%s %s passed%s\n' "${C_GREEN}" "${name}" "${C_RESET}"
RESULTS="${RESULTS}${name}"$'\t'"pass"$'\n'
else
printf '%s %s failed (exit %s)%s\n' "${C_RED}" "${name}" "${result}" "${C_RESET}"
RESULTS="${RESULTS}${name}"$'\t'"fail"$'\n'
fi
}
if wants validate_system || wants validate_slice || wants validate_filament_subtypes || wants validate_custom; then
resolve_validator
fi
# 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); naming the
# check explicitly still runs it.
for check in "${ALL_CHECKS[@]}"; do
wants "${check}" || continue
if [ "${check}" = validate_slice ] && [ "${NAMED_CHECKS}" -eq 0 ] && [ -n "${VENDOR}" ] &&
[ ! -d "${PROFILES_DIR}/${VENDOR}/machine" ]; then
RESULTS="${RESULTS}${check}"$'\t'"skip"$'\t'"${VENDOR} ships no printers"$'\n'
else
run_check "${check}"
fi
done
# Summary, plus the comment check_profiles_comment.yml would post when something fails.
failed=0
skipped=0
printf '\n%s==> summary%s\n' "${C_BOLD}" "${C_RESET}"
while IFS=$'\t' read -r name result why; do
[ -n "${name}" ] || continue
if [ "${result}" = "pass" ]; then
printf '%s PASS%s %s\n' "${C_GREEN}" "${C_RESET}" "${name}"
elif [ "${result}" = "skip" ]; then
printf '%s SKIP%s %s (%s)\n' "${C_BOLD}" "${C_RESET}" "${name}" "${why}"
skipped=1
else
printf '%s FAIL%s %s (%s)\n' "${C_RED}" "${C_RESET}" "${name}" "${LOG_DIR}/${name}.log"
failed=1
fi
done <<EOF
${RESULTS}
EOF
if [ "${failed}" -eq 0 ]; then
rm -f "${WORK_DIR}/pr_comment.md"
if [ "${skipped}" -eq 0 ]; then
printf '\n%sAll checks passed.%s Logs: %s\n' "${C_GREEN}" "${C_RESET}" "${LOG_DIR}"
else
printf '\n%sEvery check that ran passed%s, but CI runs the skipped ones too. Logs: %s\n' \
"${C_GREEN}" "${C_RESET}" "${LOG_DIR}"
fi
exit 0
fi
{
# Marker matched by check_profiles_comment.yml to delete prior comments.
echo "<!-- profile-validation-comment -->"
echo "## :x: Profile Validation Errors"
echo ""
while IFS=$'\t' read -r name result; do
[ "${result}" = "fail" ] || continue
comment_heading "${name}"
echo ""
echo '```'
head -c 30000 "${LOG_DIR}/${name}.log" || echo "No output captured"
echo '```'
echo ""
done <<INNER
${RESULTS}
INNER
echo "---"
echo "*Please fix the above errors and push a new commit.*"
} > "${WORK_DIR}/pr_comment.md"
printf '\n%sOne or more profile checks failed.%s Logs: %s\n' "${C_RED}" "${C_RESET}" "${LOG_DIR}"
printf 'The comment CI would post: %s\n' "${WORK_DIR}/pr_comment.md"
exit 1
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,3 +1,4 @@
builddir
.flatpak-builder
*.docker.yml
deps.tar*
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# Check a Flatpak manifest for `type: dir` sources at or before the orca_deps
# module.
#
# Usage: check_manifest_cacheable.sh [manifest]
# Defaults to com.orcaslicer.OrcaSlicer.yml next to this script.
#
# Exits 0 when none are found, 1 when any are, listing them as file:line, and
# 2 when the manifest is missing or has no orca_deps module.
set -euo pipefail
anchor=orca_deps
manifest=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/com.orcaslicer.OrcaSlicer.yml}
module_re='^ - name: '
dir_re='(^|[-{,[:space:]])type:[[:space:]]*dir([,}[:space:]]|$)'
if [ ! -f "$manifest" ]; then
echo "$manifest: no such file" >&2
exit 2
fi
anchor_start=$(grep -n "${module_re}${anchor}[[:space:]]*$" "$manifest" | cut -d: -f1 || true)
if [ -z "$anchor_start" ]; then
echo "$manifest: no module named '$anchor'; this check needs updating" >&2
exit 2
fi
# First module header after the anchor, or EOF if the anchor is last.
anchor_end=$(grep -n "$module_re" "$manifest" | cut -d: -f1 |
awk -v s="$anchor_start" '$1 > s { print $1; exit }')
[ -n "$anchor_end" ] || anchor_end=$(awk 'END { print NR + 1 }' "$manifest")
violations=$(awk -v e="$anchor_end" -v f="$manifest" -v mre="$module_re" -v dre="$dir_re" '
{ sub(/\r$/, "") }
/^[[:space:]]*#/ { next }
$0 ~ mre { module = $3 }
NR < e && $0 ~ dre {
printf "%s:%d: %s (module %s)\n", f, NR, $0, module
}' "$manifest")
if [ -n "$violations" ]; then
printf '%s\n' "$violations" >&2
echo >&2
echo "flatpak-builder cannot checksum a directory, so each of these makes" >&2
echo "$anchor and every module after it rebuild from scratch on every run." >&2
echo "See the $anchor sources in this manifest for the tarball used instead." >&2
exit 1
fi
echo "manifest OK: no 'type: dir' source at or before $anchor"
@@ -144,6 +144,7 @@ modules:
env:
BUILD_DIR: deps/build_flatpak
build-commands:
- tar xf deps.tar
- |
cmake -S deps -B $BUILD_DIR \
-DFLATPAK=ON \
@@ -169,10 +170,10 @@ modules:
- /libpython/include
sources:
# OrcaSlicer deps/ directory (avoids copying .git from worktree)
- type: dir
path: ../../deps
dest: deps
# flatpak-builder cannot checksum a directory, so a `type: dir` here would
# rebuild every dependency on every run. Generated by make_deps_tar.sh.
- type: file
path: deps.tar
# ---------------------------------------------------------------
# Pre-downloaded dependency archives
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# Pack deps/ into scripts/flatpak/deps.tar for the Flatpak manifest's orca_deps
# module.
#
# Usage: make_deps_tar.sh
# Requires GNU tar. On macOS, brew install gnu-tar.
#
# The archive is byte-reproducible. Member order, mtimes, ownership and the
# group/other write bits are pinned, so identical deps/ contents always produce
# an identical file. deps/build* and deps/DL_CACHE are excluded.
#
# Prints the output path, size and sha256.
set -euo pipefail
if [ "$#" -ne 0 ]; then
echo "usage: ${0##*/}" >&2
exit 2
fi
tar_bin=$(command -v gtar || command -v tar || true)
tar_version=$([ -n "$tar_bin" ] && "$tar_bin" --version 2>/dev/null || true)
case $tar_version in
*"GNU tar"*) ;;
*) echo "${0##*/}: needs GNU tar; on macOS run 'brew install gnu-tar'" >&2
exit 2 ;;
esac
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
repo_root=$(cd "$script_dir/../.." && pwd)
out=$script_dir/deps.tar
tmp=$out.tmp
trap 'rm -f "$tmp"' EXIT
"$tar_bin" --format=gnu \
--sort=name \
--mtime=@0 \
--owner=0 --group=0 --numeric-owner \
--mode=go-w \
--exclude='deps/build*' \
--exclude='deps/DL_CACHE' \
-cf "$tmp" \
-C "$repo_root" deps
mv -f "$tmp" "$out"
if command -v sha256sum >/dev/null 2>&1; then
sum=$(sha256sum "$out" | cut -d' ' -f1)
elif command -v shasum >/dev/null 2>&1; then
sum=$(shasum -a 256 "$out" | cut -d' ' -f1)
else
sum=unavailable
fi
echo "Wrote $out ($(du -h "$out" | cut -f1), sha256 $sum)"
+4 -3
View File
@@ -8,8 +8,9 @@ flatpak install flathub org.gnome.Platform//50 org.gnome.Sdk//50 org.freedesktop
##
# in OrcaSlicer folder, run following command to build Orca
# # First time build
# flatpak-builder --state-dir=.flatpak-builder --keep-build-dirs --user --force-clean build-dir scripts/flatpak/com.orcaslicer.OrcaSlicer.yml
# # Subsequent builds (only rebuilding OrcaSlicer)
# # First time build
# ./scripts/flatpak/make_deps_tar.sh && flatpak-builder --state-dir=.flatpak-builder --keep-build-dirs --user --force-clean build-dir scripts/flatpak/com.orcaslicer.OrcaSlicer.yml
# # Subsequent builds (only rebuilding OrcaSlicer; run make_deps_tar.sh first if deps/ changed)
# flatpak-builder --state-dir=.flatpak-builder --keep-build-dirs --user build-dir scripts/flatpak/com.orcaslicer.OrcaSlicer.yml --build-only=OrcaSlicer
+45 -17
View File
@@ -3,7 +3,7 @@ import json
import argparse
from pathlib import Path
from assign_vendor_setting_ids import generate_preset_setting_id
from orca_id_tool import generate_preset_setting_id, check_filament_ids
OBSOLETE_KEYS = {
"acceleration", "scale", "rotate", "duplicate", "duplicate_grid",
@@ -289,18 +289,38 @@ def check_name_consistency(profiles_dir, vendor_name):
return error_count, 0
def check_filament_id(vendor, vendor_folder):
def check_filament_id(profiles_dir, vendor_name):
"""
Make sure filament_id is not longer than 8 characters, otherwise AMS won't work properly
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.
"""
if vendor not in ('BBL', 'OrcaFilamentLibrary'):
return 0
error = 0
vendor_path = Path(vendor_folder)
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:
@@ -318,13 +338,13 @@ def check_filament_id(vendor, vendor_folder):
if 'filament_id' not in data:
continue
filament_id = data['filament_id']
if len(filament_id) > 8:
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):
@@ -467,10 +487,11 @@ PROFILE_SUBDIRS = ("filament", "process", "machine")
def check_setting_id_uniqueness(profiles_dir):
"""
Validate setting_id across every vendor (see scripts/assign_vendor_setting_ids.py):
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 assign_vendor_setting_ids.py.
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)
@@ -502,7 +523,8 @@ def check_setting_id_uniqueness(profiles_dir):
errors += 1
print_error(
f'profile {rel} uses the misspelled key "settings_id" '
f'(should be "setting_id"); run assign_vendor_setting_ids.py'
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"
@@ -512,7 +534,8 @@ def check_setting_id_uniqueness(profiles_dir):
errors += 1
print_error(
f'base profile {rel} (instantiation != "true") must not have a '
f'setting_id ("{sid}"); run assign_vendor_setting_ids.py'
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.
@@ -520,7 +543,7 @@ def check_setting_id_uniqueness(profiles_dir):
errors += 1
print_error(
f"instantiated preset {rel} is missing a setting_id; "
f"run assign_vendor_setting_ids.py"
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
@@ -532,7 +555,7 @@ def check_setting_id_uniqueness(profiles_dir):
print_error(
f'setting_id "{sid}" in {rel} does not match the expected '
f'"{expected}" for {vendor}/{sub}/{data.get("name", "")}; '
f"run assign_vendor_setting_ids.py"
f'run "python scripts/orca_id_tool.py --generate --setting-id"'
)
continue
# Rule 4: collect for the global-uniqueness check below.
@@ -595,7 +618,7 @@ def main():
errors_found += check_vector_type_keys(profiles_dir, vendor_name)
errors_found += check_filament_id(vendor_name, vendor_path / "filament")
errors_found += check_filament_id(profiles_dir, vendor_name)
checked_vendor_count += 1
if args.vendor:
@@ -610,6 +633,11 @@ def main():
# 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}")
+1429
View File
File diff suppressed because it is too large Load Diff
+969
View File
@@ -0,0 +1,969 @@
<#
.SYNOPSIS
Tests build_win.bat's option handling and the commands it generates.
.DESCRIPTION
Cases run the script with --dry-run, so nothing is configured, built or
deleted and the suite finishes in seconds. Each case asserts on the exit
code and on the command lines the script echoes.
Adding a case means adding one row to $cases. A bare string starts a new
group. Defaults: ExpectExit is 0 and --dry-run is appended, so a row only
states what is unusual about it.
Name what the case proves, in words
Args arguments, as an array
ExpectExit expected exit code (default 0)
DryRun append --dry-run (default $true)
First regex the first output line must match
Env environment for this case only
Contains literal strings the output must have
NotContains literal strings it must not have
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
matching case are not printed, and a pattern that matches nothing is a
failure rather than an empty pass.
.EXAMPLE
powershell -File scripts/test_build_win.ps1
.EXAMPLE
powershell -File scripts/test_build_win.ps1 -Name solution
#>
[CmdletBinding()]
param(
[string] $Script,
[string] $Name
)
$ErrorActionPreference = 'Stop'
if (-not $Script) {
$here = $PSScriptRoot
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
$Script = Join-Path (Split-Path -Parent $here) 'build_win.bat'
}
if (-not (Test-Path $Script)) { throw "build_win.bat not found at $Script" }
# cmd resumes a batch file by byte offset after `call :label`, and with LF
# endings that offset lands wrong and the label lookup fails. .gitattributes
# pins CRLF; this catches a checkout or an editor that did not honour it.
if ((Get-Content -Raw $Script) -match "(?<!`r)`n") {
throw "$Script has LF line endings; cmd needs CRLF to resume after call :label"
}
$Script = (Resolve-Path $Script).Path
# Read the long options out of the script itself, so this cannot go stale when
# an option is added. Field order is: add_arg <var> <type> <short> <long>.
$longFlags = @(
Select-String -Path $Script -Pattern '^call :add_arg \S+ \S+ \S+ (\S+) ' |
ForEach-Object { '--' + $_.Matches[0].Groups[1].Value }
)
if ($longFlags.Count -lt 20) { throw "only found $($longFlags.Count) options in $Script; the parser above is wrong" }
# A winget that always fails, so the prerequisite failure path runs without
# touching the machine. It has to be an .exe: a .bat invoked without `call`
# transfers control and never comes back, which would end the script instead.
# where.exe returns 1 when its patterns match nothing and never prompts.
$fixtures = Join-Path ([IO.Path]::GetTempPath()) 'build_win_test_fixtures'
New-Item -ItemType Directory -Force -Path $fixtures | Out-Null
Copy-Item "$env:SystemRoot\System32\where.exe" (Join-Path $fixtures 'winget.exe') -Force
$stubPath = "$fixtures;C:\Windows\system32;C:\Windows"
# Stand-in ninjas that only report a version, so the 1.12 boundary in the
# progress format can be exercised on a machine whose real ninja is newer.
# A dry run skips the dev shell, so PATH here is what the script sees.
$ninjaPaths = @{}
foreach ($v in @{ old = '1.11.1'; new = '1.12.0' }.GetEnumerator()) {
$d = Join-Path $fixtures "ninja-$($v.Key)"
New-Item -ItemType Directory -Force -Path $d | Out-Null
Set-Content -Path (Join-Path $d 'ninja.bat') -Encoding ascii -Value @('@echo off', "echo $($v.Value)")
$ninjaPaths[$v.Key] = "$d;$env:PATH"
}
# A clang-cl earlier on PATH than the Visual Studio one, which is what the
# compiler used to resolve to. Nothing runs it; the script only locates it.
$clangDir = Join-Path $fixtures 'clang'
New-Item -ItemType Directory -Force -Path $clangDir | Out-Null
Copy-Item "$env:SystemRoot\System32\where.exe" (Join-Path $clangDir 'clang-cl.exe') -Force
$clangOnPath = "$clangDir;$env:PATH"
# A ccache that only has to exist. Nothing runs it; the script only locates it.
$cacheDir = Join-Path $fixtures 'cache'
New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null
Copy-Item "$env:SystemRoot\System32\where.exe" (Join-Path $cacheDir 'ccache.exe') -Force
$ccacheOnPath = "$cacheDir;$env:PATH"
# ProgramFiles(x86) is where the script looks for vswhere, so an empty one
# stands in for a machine whose Visual Studio has no clang toolset.
$noVs = Join-Path $fixtures 'no-vs'
New-Item -ItemType Directory -Force -Path $noVs | Out-Null
# A build directory that already holds a classic solution, for the case where
# what is on disk disagrees with what the generator would write.
$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
$cases = @(
'argument handling'
@{ Name = 'no arguments prints help'; Args = @(); DryRun = $false
Contains = @('Usage: build_win.bat [options]', '--clang-cl') }
@{ Name = "--help lists all $($longFlags.Count) options the script defines"; Args = @('--help'); DryRun = $false
Contains = $longFlags }
@{ Name = 'help is grouped and shows usage, examples and environment'; Args = @('--help'); DryRun = $false
Contains = @('Usage: build_win.bat [options]', 'Actions:', 'Build configuration:', 'Toolchain:',
'How much gets rebuilt:', 'Paths and extra arguments:', 'Diagnostics:',
'Examples:', 'Environment:') }
@{ Name = 'the environment section shows what to set'; Args = @('--help'); DryRun = $false
Contains = @('ORCA_DEPS_CMAKE_ARGS', 'ORCA_SLICER_CMAKE_ARGS', 'ORCA_UPDATER_SIG_KEY', 'NINJA_STATUS',
'set ORCA_SLICER_CMAKE_ARGS=-DSLIC3R_BUILD_SANDBOXES=ON', '(PowerShell)', 'debugscript') }
@{ Name = 'section headers do not widen the flag column'; Args = @('--help'); DryRun = $false
Match = @('^ -d, --deps +Download') }
# Windows Terminal opens at 120 columns and wraps at 120, so 119 is the
# limit. Anyone still on the old conhost gets 80 and will see wrapping.
@{ Name = 'every help line fits a 120 column console'; Args = @('--help'); DryRun = $false
NotMatch = @('^.{120,}$') }
@{ Name = 'an unknown long option is rejected'; Args = @('--nonsense'); ExpectExit = 1
Contains = @('Failed to find arg') }
@{ Name = 'an unknown short option is rejected'; Args = @('-Z'); ExpectExit = 1
Contains = @('Failed to find arg') }
@{ Name = 'a bare argument is rejected'; Args = @('deps'); ExpectExit = 1
Contains = @('Unknown argument') }
@{ Name = 'an unknown architecture is rejected'; Args = @('-d', '--arch', 'sparc'); ExpectExit = 1
Contains = @('Unknown architecture') }
@{ Name = 'a string option without a value is rejected'; Args = @('-d', '--arch'); ExpectExit = 1
Contains = @('requires a value') }
@{ Name = 'short options can be bundled'; Args = @('-dx')
Contains = @('-G "Ninja Multi-Config"', '--target deps') }
'generator and compiler selection'
@{ Name = 'deps default to the Visual Studio generator'; Args = @('-d')
Contains = @('-G "Visual Studio', '-A x64', '--target deps')
NotContains = @('Ninja', 'clang-cl') }
@{ Name = '-x selects Ninja without changing compiler'; Args = @('-d', '-x')
Contains = @('-G "Ninja Multi-Config"')
NotContains = @('clang-cl', '-A x64') }
@{ Name = '-l -x builds with clang-cl under Ninja'; Args = @('-d', '-l', '-x')
Contains = @('-G "Ninja Multi-Config"')
Match = @('-DCMAKE_C_COMPILER="[^"]+/clang-cl\.exe"', '-DCMAKE_CXX_COMPILER="[^"]+/clang-cl\.exe"') }
# PATH order used to decide the compiler. VsDevCmd appends the Visual
# Studio LLVM directory to the end of PATH, so a standalone LLVM already
# there was resolved instead, and an old one failed the compiler check.
@{ Name = 'the compiler is resolved from Visual Studio, not PATH'; Args = @('-s', '-l', '-x')
Env = @{ PATH = $clangOnPath }
Match = @('^Compiler: .*/VC/Tools/Llvm/[^/]+/bin/clang-cl\.exe$') }
@{ Name = 'msvc names no compiler, having resolved none'; Args = @('-s')
NotContains = @('Compiler: ') }
@{ Name = '-l without -x names none either, the toolset picks it'; Args = @('-s', '-l')
NotContains = @('Compiler: ') }
# An empty ProgramFiles(x86) puts vswhere out of reach, which is a machine
# whose Visual Studio has no clang toolset.
@{ Name = 'without a Visual Studio clang the one on PATH is used and named'; Args = @('-s', '-l', '-x')
Env = @{ 'ProgramFiles(x86)' = $noVs; PATH = $clangOnPath }
Contains = @('Visual Studio has no clang-cl')
Match = @('^Compiler: .*/clang/clang-cl\.exe$') }
@{ Name = 'no clang-cl anywhere stops before configuring'; Args = @('-s', '-l', '-x'); ExpectExit = 1
Env = @{ 'ProgramFiles(x86)' = $noVs; PATH = 'C:\Windows\system32;C:\Windows' }
Contains = @('No clang-cl found', '--install-vs ide -l')
NotContains = @('cmake -B') }
# Only a configure passes the compiler to CMake, so an action that does
# not configure resolves none, and cannot start needing one installed.
@{ Name = 'packing resolves no compiler'; Args = @('-p', '-l', '-x')
Contains = @('Packing the dependencies')
NotContains = @('Compiler: ') }
@{ Name = '--no-configure resolves none either'; Args = @('-s', '-l', '-x', '--no-configure')
Contains = @('cmake --build "build-clang"')
NotContains = @('Compiler: ') }
@{ Name = '-l alone uses the ClangCL toolset on the VS generator'; Args = @('-d', '-l')
Contains = @('-G "Visual Studio', '-T ClangCL')
NotContains = @('-DCMAKE_C_COMPILER') }
# --msvc and --msbuild name the defaults, so a caller can be explicit and a
# contradictory pair can be caught rather than silently resolved.
@{ Name = '--msvc is the compiler default spelled out'; Args = @('-d', '--msvc')
Contains = @('-G "Visual Studio', '-A x64')
NotContains = @('clang-cl') }
@{ Name = '--msbuild is the generator default spelled out'; Args = @('-d', '--msbuild')
Contains = @('-G "Visual Studio')
NotContains = @('Ninja') }
@{ Name = '--msvc with -x gives Ninja driving cl'; Args = @('-d', '--msvc', '-x')
Contains = @('-G "Ninja Multi-Config"')
NotContains = @('clang-cl') }
@{ Name = '--msbuild with -l gives the VS generator and the ClangCL toolset'; Args = @('-d', '--msbuild', '-l')
Contains = @('-G "Visual Studio', '-T ClangCL') }
# A developer with a standalone LLVM points at it, and the path is passed
# with forward slashes so CMake cannot read a backslash as an escape.
@{ Name = '--clang-path names the compiler, quoted for its spaces'; Args = @('-d', '-x', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe')
Contains = @('-DCMAKE_C_COMPILER="C:/Program Files/LLVM/bin/clang-cl.exe"',
'-DCMAKE_CXX_COMPILER="C:/Program Files/LLVM/bin/clang-cl.exe"') }
@{ Name = '--clang-path beats the Visual Studio clang'; Args = @('-s', '-x', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe')
Contains = @('Compiler: C:/Program Files/LLVM/bin/clang-cl.exe') }
@{ Name = '--clang-path is a clang request on its own'; Args = @('-d', '-x', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe')
Contains = @('deps/build-clang') }
@{ Name = '--clang-path needs Ninja to take effect'; Args = @('-d', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe'); ExpectExit = 1
Contains = @('needs the Ninja generator') }
# `exist` is true for a directory too, and a directory would reach CMake
# as the compiler.
@{ Name = '--clang-path must name the exe, not its folder'; Args = @('-d', '-x', '--clang-path', 'C:\Program Files\LLVM'); ExpectExit = 1
Contains = @('is a directory') }
@{ Name = 'a clang-cl that is not there is caught early'; Args = @('-d', '-x', '--clang-path', 'C:\nope\clang-cl.exe'); ExpectExit = 1
Contains = @('No clang-cl at')
NotContains = @('cmake -S deps') }
@{ Name = '--clang-path contradicting --msvc is rejected'; Args = @('-d', '-x', '--msvc', '--clang-path', 'C:\Program Files\LLVM\bin\clang-cl.exe'); ExpectExit = 1
Contains = @('select different compilers') }
@{ Name = '--clang-cl and --msvc together are rejected'; Args = @('-d', '-l', '--msvc'); ExpectExit = 1
Contains = @('select different compilers') }
@{ Name = '--ninja and --msbuild together are rejected'; Args = @('-d', '-x', '--msbuild'); ExpectExit = 1
Contains = @('select different generators') }
# One option with a value, driven by a table, rather than a flag per
# release. Adding a release should not need a new flag.
@{ Name = '--vs 2019 pins that release and skips autodetect'; Args = @('-d', '--vs', '2019')
Contains = @('-G "Visual Studio 16 2019"')
NotContains = @('Detecting Visual Studio') }
@{ Name = '--vs 2022 pins that release'; Args = @('-d', '--vs', '2022')
Contains = @('-G "Visual Studio 17 2022"') }
@{ Name = '--vs 2026 pins that release'; Args = @('-d', '--vs', '2026')
Contains = @('-G "Visual Studio 18 2026"') }
@{ Name = 'an unknown release is rejected and the known ones listed'; Args = @('-d', '--vs', '2015'); ExpectExit = 1
Contains = @('Unknown Visual Studio release', '2019, 2022, 2026') }
@{ Name = '--vs and --ninja together are rejected'; Args = @('-d', '--vs', '2022', '-x'); ExpectExit = 1
Contains = @('select different generators') }
@{ Name = 'without --vs the release is autodetected'; Args = @('-d')
Contains = @('Detecting Visual Studio') }
'architecture'
@{ Name = 'x64 is the default'; Args = @('-d')
Contains = @('Configuration: Release, x64')
NotContains = @('build-arm64') }
@{ Name = 'arm64 sets the generator platform and deps tree'; Args = @('-d', '--arch', 'arm64')
Contains = @('-A ARM64', 'deps/build-arm64') }
@{ Name = 'the architecture is matched case-insensitively'; Args = @('-d', '--arch', 'ARM64')
Contains = @('-A ARM64', 'deps/build-arm64') }
@{ Name = 'arm64 under Ninja has no -A but keeps the arm64 tree'; Args = @('-d', '--arch', 'arm64', '-x', '-l')
Contains = @('deps/build-clang-arm64')
Match = @('-DCMAKE_C_COMPILER="[^"]+/clang-cl\.exe"')
NotContains = @('-A ') }
'build configurations'
# One option with a value, driven by a table, so Release is named rather
# than being whatever is left when no flag is passed.
@{ Name = 'release is the default'; Args = @('-s')
Contains = @('-DCMAKE_BUILD_TYPE=Release', 'cmake -B "build" ') }
@{ Name = '--config release is the default spelled out'; Args = @('-s', '--config', 'release')
Contains = @('-DCMAKE_BUILD_TYPE=Release', 'cmake -B "build" ') }
@{ Name = '--config debug builds into build-dbg'; Args = @('-s', '--config', 'debug')
Contains = @('-DCMAKE_BUILD_TYPE=Debug', 'cmake -B "build-dbg" ') }
@{ Name = '--config relwithdebinfo builds into build-dbginfo'; Args = @('-s', '--config', 'relwithdebinfo')
Contains = @('-DCMAKE_BUILD_TYPE=RelWithDebInfo', 'cmake -B "build-dbginfo" ') }
@{ Name = '--config minsizerel builds into build-minsize'; Args = @('-s', '--config', 'minsizerel')
Contains = @('-DCMAKE_BUILD_TYPE=MinSizeRel', 'cmake -B "build-minsize" ') }
# Batch variable names are case-insensitive, so the table lookup is too.
@{ Name = 'the configuration name is matched case-insensitively'; Args = @('-s', '--config', 'RelWithDebInfo')
Contains = @('-DCMAKE_BUILD_TYPE=RelWithDebInfo') }
@{ Name = 'an unknown configuration is rejected and the known ones listed'; Args = @('-s', '--config', 'bogus'); ExpectExit = 1
Contains = @('Unknown configuration', 'release, debug, relwithdebinfo, minsizerel') }
# Release, RelWithDebInfo and MinSizeRel all link the /MD dependencies, so
# one tree serves all three. Debug is /MDd and cannot share.
@{ Name = 'minsizerel builds against the release deps'; Args = @('-d', '-s', '--config', 'minsizerel')
Contains = @('cmake -S deps -B "deps/build"', 'cmake -B "build-minsize" ')
NotContains = @('deps/build-minsize') }
@{ Name = 'relwithdebinfo builds against them too'; Args = @('-d', '-s', '-l', '--config', 'relwithdebinfo')
Contains = @('cmake -S deps -B "deps/build-clang"', 'cmake -B "build-dbginfo-clang" ')
NotContains = @('deps/build-dbginfo') }
@{ Name = 'debug keeps a dependency tree of its own'; Args = @('-d', '--config', 'debug')
Contains = @('cmake -S deps -B "deps/build-dbg"') }
# The build tree and the deps tree now have different names, so the
# derivation from the binary directory name cannot work and it is named.
@{ Name = 'a shared deps tree is named outright'; Args = @('-s', '-l', '--config', 'relwithdebinfo')
Match = @('-DDEP_BUILD_DIR="[A-Za-z]:\\.*\\deps\\build-clang"') }
@{ Name = 'configuration and arch combine into build-dbg-arm64'; Args = @('-s', '--config', 'debug', '--arch', 'arm64')
Contains = @('cmake -B "build-dbg-arm64" ') }
'what gets built'
@{ Name = 'the deps target defaults to deps'; Args = @('-d')
Contains = @('--target deps') }
@{ Name = '-t overrides the deps target'; Args = @('-d', '-t', 'dep_Boost')
Contains = @('--target dep_Boost') }
@{ Name = 'unit tests are off unless asked for'; Args = @('-s')
Contains = @('-DBUILD_TESTS=OFF') }
@{ Name = '--tests turns the unit tests on'; Args = @('-s', '--tests')
Contains = @('-DBUILD_TESTS=ON') }
@{ Name = '-a enables ASAN for the slicer'; Args = @('-s', '-a')
Contains = @('-DSLIC3R_ASAN=ON') }
@{ Name = '--no-pch turns the precompiled header off'; Args = @('-s', '--no-pch')
Contains = @('-DSLIC3R_PCH=OFF') }
@{ Name = '--no-pch says so in the banner'; Args = @('-s', '--no-pch')
Contains = @('Precompiled header: off') }
@{ Name = 'the precompiled header is on unless asked'; Args = @('-s')
NotContains = @('SLIC3R_PCH') }
@{ Name = '--no-pch works without a cache'; Args = @('-s', '--no-pch')
NotContains = @('COMPILER_LAUNCHER') }
@{ Name = 'the slicer build runs gettext'; Args = @('-s')
Contains = @('run_gettext.bat') }
# tools\7z.exe needs a 7z.dll beside it, which the repo does not carry,
# so the pack falls back to the bsdtar Windows ships. Either is correct;
# what matters is that one is chosen and handed the right names.
@{ Name = '-p packs the deps tree with whichever archiver is usable'; Args = @('-d', '-p')
Match = @('^\+ .*(7z\.exe a|tar\.exe -a -c -f) OrcaSlicer_dep_win-\S+\.zip OrcaSlicer_dep$') }
# The bundle is only good for what built it, so it carries the same three
# axes as the tree. Release x64 on cl keeps the historical plain name.
@{ Name = 'the bundle name carries compiler and deps flavour'; Args = @('-p', '-l', '--config', 'debug', '--arch', 'arm64')
Contains = @('OrcaSlicer_dep_win-ARM64-clang-dbg_') }
@{ Name = 'a relwithdebinfo pack is the release bundle'; Args = @('-p', '--config', 'relwithdebinfo')
Contains = @('OrcaSlicer_dep_win-x64_')
NotContains = @('-dbg', 'dbginfo') }
@{ Name = 'a plain release bundle keeps its old name'; Args = @('-p')
Contains = @('OrcaSlicer_dep_win-x64_')
NotContains = @('-clang', '-Release') }
@{ Name = 'the bundle is stamped with today, not a shuffled date'; Args = @('-p')
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' }
DateStampedZip = $true }
@{ Name = '-p packs without rebuilding'; Args = @('-p')
Match = @('^\+ .*(7z\.exe a|tar\.exe -a -c -f) ')
NotContains = @('cmake -S deps') }
@{ Name = 'deps and slicer build in one invocation'; Args = @('-d', '-s', '-x', '-l')
Contains = @('cmake -S deps', 'cmake -B "build-clang" ') }
'the compiler cache'
@{ Name = '--cache needs clang-cl and Ninja'; Args = @('-s', '--cache', 'ccache'); ExpectExit = 1
Contains = @('needs clang-cl and Ninja') }
# cl.exe is out of scope, since ccache refuses every compile under /Zi.
@{ Name = '--cache under Ninja still needs clang-cl'; Args = @('-s', '-x', '--cache', 'ccache'); ExpectExit = 1
Contains = @('needs clang-cl and Ninja') }
@{ Name = 'an unknown --cache value is rejected'; Args = @('-s', '-x', '--cache', 'nope'); ExpectExit = 1
Contains = @('Expected ccache, sccache or off') }
# A bare PATH, since the machine running the tests may have sccache installed.
@{ Name = 'a --cache tool that is not there is caught early'; Args = @('-s', '-l', '-x', '--cache', 'sccache'); ExpectExit = 1
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
Contains = @('is not on PATH') }
@{ Name = '--cache takes any casing'; Args = @('-s', '-l', '-x', '--cache', 'CCACHE')
Env = @{ PATH = $ccacheOnPath }
Contains = @('ccache.exe') }
@{ Name = '--cache off asks for no launcher'; Args = @('-s', '-x', '--cache', 'off')
NotContains = @('COMPILER_LAUNCHER') }
@{ Name = 'no --cache asks for no launcher'; Args = @('-s', '-x')
NotContains = @('COMPILER_LAUNCHER') }
@{ Name = '--cache turns the precompiled header off'; Args = @('-s', '-l', '-x', '--cache', 'ccache')
Env = @{ PATH = $ccacheOnPath }
Contains = @('-DSLIC3R_PCH=OFF', 'COMPILER_LAUNCHER') }
# Without it the objects name the build directory and only that tree can use them.
@{ Name = '--cache asks for relative debug paths'; Args = @('-s', '-l', '-x', '--cache', 'ccache')
Env = @{ PATH = $ccacheOnPath }
Contains = @('-DSLIC3R_RELATIVE_DEBUG_PATHS=ON') }
@{ Name = 'no --cache leaves the debug paths alone'; Args = @('-s', '-l', '-x')
NotContains = @('SLIC3R_RELATIVE_DEBUG_PATHS') }
# The resolved path, not the bare name, so PATH cannot change it later.
@{ Name = '--cache names the resolved path in the banner'; Args = @('-s', '-l', '-x', '--cache', 'ccache')
Env = @{ PATH = $ccacheOnPath }
Match = @('^Compiler cache: .*/ccache\.exe$') }
@{ Name = '--cache reaches the dependency configure too'; Args = @('-d', '-l', '-x', '--cache', 'ccache')
Env = @{ PATH = $ccacheOnPath }
Contains = @('-DCMAKE_C_COMPILER_LAUNCHER=') }
# Nothing records a launcher without a configure, so the tool is not needed.
# Reaching the cmake check on a bare PATH is what proves it was skipped.
@{ Name = '--no-configure asks for no cache tool'; Args = @('-s', '-l', '-x', '--no-configure', '--cache', 'ccache'); ExpectExit = 1
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
Contains = @('CMake was not found')
NotContains = @('is not on PATH') }
'the developer loop'
@{ Name = '--slicer-target builds one target'; Args = @('-s', '--slicer-target', 'libslic3r')
Contains = @('--config Release --target libslic3r') }
@{ Name = '--no-configure skips the slicer configure'; Args = @('-s', '--no-configure')
Contains = @('cmake --build "build"')
NotContains = @('cmake -B "build" ') }
@{ Name = '--no-configure skips the deps configure'; Args = @('-d', '--no-configure')
Contains = @('cmake --build "deps/build"')
NotContains = @('cmake -S deps') }
@{ Name = '--no-gettext skips the translation step'; Args = @('-s', '--no-gettext')
Contains = @('cmake --build "build"')
NotContains = @('run_gettext') }
# Installing copies the whole tree again for a layout only releases need,
# so it is asked for rather than assumed.
@{ Name = 'nothing is installed unless asked'; Args = @('-s')
Contains = @('run_gettext')
NotContains = @('--target install') }
@{ Name = '-i adds the install step'; Args = @('-s', '-i')
Contains = @('--target install') }
# install(TARGETS OrcaSlicer) has no OPTIONAL, so this would fail partway
# through a build instead of before it.
@{ Name = 'installing a tree with no executable is refused'; Args = @('-s', '-i', '--slicer-target', 'glad'); ExpectExit = 1
Contains = @('--install needs the executable')
NotContains = @('cmake --build') }
# Without -s there is no install step, so there is nothing to refuse.
@{ Name = 'the same flags without a slicer build are left alone'; Args = @('-d', '-i', '--slicer-target', 'glad')
Contains = @('cmake -S deps')
NotContains = @('--install needs the executable') }
@{ Name = 'naming the executable target is allowed'; Args = @('-s', '-i', '--slicer-target', 'OrcaSlicer')
Contains = @('--target install') }
# Ninja counts compilers, so -j goes on the command line. MSBuild's -j
# counts projects while /MP still runs one cl per core inside each, so the
# cap goes to CL_MPCount in the environment instead. A dry run can only
# show that no -j is passed.
@{ Name = '-j on Ninja passes -j to cmake'; Args = @('-s', '-x', '-j', '4')
Contains = @('Parallel jobs: 4', '--config Release -j 4') }
@{ Name = '-j on Ninja reaches the deps build'; Args = @('-d', '-x', '-j', '2')
Contains = @('--target deps -j 2') }
@{ Name = '-j on MSBuild does not pass -j, which would count projects'; Args = @('-s', '-j', '4')
Contains = @('Parallel jobs: 4')
NotContains = @('-j 4') }
@{ Name = '-j on MSBuild leaves the deps build without -j too'; Args = @('-d', '-j', '2')
Contains = @('--target deps')
NotContains = @('-j 2') }
@{ Name = 'no -j means no job limit'; Args = @('-s')
NotContains = @('parallel jobs', '-j ') }
@{ Name = 'a non-numeric -j is rejected'; Args = @('-s', '-j', 'abc'); ExpectExit = 1
Contains = @('Invalid --jobs value') }
@{ Name = 'a zero -j is rejected'; Args = @('-s', '-j', '0'); ExpectExit = 1
Contains = @('Invalid --jobs value') }
@{ Name = 'the loop options combine into a single build command'; Args = @('-s', '-x', '--no-configure', '--no-gettext', '--slicer-target', 'libslic3r_tests', '-j', '8')
Contains = @('--target libslic3r_tests -j 8')
NotContains = @('cmake -B "build" ', 'run_gettext', '--target install') }
'one tree per configuration, compiler and architecture'
# CMake resets its cache and carries on when the compiler changes under an
# existing tree, leaving stamps from the old toolchain. Give each its own.
@{ Name = 'clang builds land in their own trees'; Args = @('-d', '-s', '-l')
Contains = @('cmake -S deps -B "deps/build-clang"', 'cmake -B "build-clang" ') }
@{ Name = 'MSVC keeps the historical plain names'; Args = @('-d', '-s', '--msvc')
Contains = @('cmake -S deps -B "deps/build"', 'cmake -B "build" ')
NotContains = @('build-clang') }
@{ Name = 'configuration, compiler and arch all name the tree'; Args = @('-d', '-s', '-l', '--config', 'debug', '--arch', 'arm64')
Contains = @('cmake -S deps -B "deps/build-dbg-clang-arm64"', 'cmake -B "build-dbg-clang-arm64" ') }
'locating the dependency tree'
# Without --deps-dir nothing is passed, so CMakeLists derives the path from
# the binary directory name as it always has.
@{ Name = 'the deps path is left to CMake by default'; Args = @('-s')
NotContains = @('-DDEP_BUILD_DIR') }
@{ Name = '--deps-dir tells the slicer where the deps are'; Args = @('-s', '--deps-dir', 'D:\orca-deps')
Contains = @('-DDEP_BUILD_DIR="D:\orca-deps"') }
@{ Name = '--deps-dir also redirects the deps build'; Args = @('-d', '--deps-dir', 'D:\orca-deps')
Contains = @('cmake -S deps -B "D:\orca-deps"', 'cmake --build "D:\orca-deps"')
NotContains = @('deps/build') }
# Paths are quoted throughout, so one with spaces survives the whole run.
@{ Name = 'a deps path with spaces survives'; Args = @('-d', '-s', '--deps-dir', 'C:\Program Files\deps')
Contains = @('-B "C:\Program Files\deps"', '-DDEP_BUILD_DIR="C:\Program Files\deps"') }
@{ Name = '--deps-dir also redirects the pack'; Args = @('-p', '--deps-dir', 'D:\orca-deps')
Contains = @('cd /d "D:\orca-deps"') }
@{ Name = 'pack uses an absolute default path'; Args = @('-p')
Match = @('^\+ cd /d "[A-Za-z]:\\.*\\deps\\build"') }
# CMakeLists derives DEP_BUILD_DIR from the build directory's name, so
# pointing the build elsewhere has to name the deps tree outright.
@{ Name = '--build-dir moves the slicer build'; Args = @('-s', '-l', '-x', '--build-dir', 'out/build/x64-clang')
Contains = @('cmake -B "out/build/x64-clang" ') }
@{ Name = '--build-dir still names the deps tree'; Args = @('-s', '-l', '-x', '--build-dir', 'out/build/x64-clang')
Match = @('-DDEP_BUILD_DIR="[A-Za-z]:\\.*\\deps\\build-clang"') }
@{ Name = '--deps-dir wins over the derived tree'; Args = @('-s', '--build-dir', 'out/build/x64-clang', '--deps-dir', 'D:\orca-deps')
Contains = @('-DDEP_BUILD_DIR="D:\orca-deps"') }
# A value ending in a backslash escapes the closing quote it is spliced
# into, so cmake would receive D:\tree" and swallow the next argument.
@{ Name = 'a trailing backslash is trimmed off a path'; Args = @('-s', '--build-dir', 'D:\tree\')
Contains = @('cmake -B "D:\tree" ') }
@{ Name = 'a build directory with spaces survives'; Args = @('-s', '--build-dir', 'C:\Program Files\tree')
Contains = @('cmake -B "C:\Program Files\tree" ') }
@{ Name = 'the default build names no deps tree'; Args = @('-s')
NotContains = @('DEP_BUILD_DIR') }
'saying what mode and toolchain are in play'
@{ Name = 'a dry run announces itself before the first command'; Args = @('-u')
First = '^Dry run: printing commands without running them\.$' }
@{ Name = 'the announcement leads even a full build'; Args = @('-ds')
First = '^Dry run: ' }
# Autodetect only runs for the Visual Studio generator, and only when no
# release was pinned, so it needs a Visual Studio on the machine.
@{ Name = 'the detected Visual Studio names its release year'; Args = @('-s')
Match = @('^Detected Visual Studio \d+ \(20\d\d\)$') }
@{ Name = 'pinning a release skips detection'; Args = @('-s', '--vs', '2022')
NotContains = @('Detected Visual Studio') }
'an action has to be asked for'
# Neither can happen without building the slicer, so they stand alone the
# way --install-vs does.
@{ Name = '--run-tests is an action on its own'; Args = @('--run-tests')
Contains = @('-DBUILD_TESTS=ON', 'ctest --test-dir')
NotContains = @('Nothing to do') }
@{ Name = '--tests is too'; Args = @('--tests')
Contains = @('-DBUILD_TESTS=ON')
NotContains = @('Nothing to do', 'ctest --test-dir') }
# Naming an action means that action, not a fuller build.
@{ Name = 'they do not add a slicer build to one already asked for'; Args = @('-d', '--tests')
Contains = @('cmake -S deps')
NotContains = @('cmake -B "build"') }
@{ Name = 'shaping options alone are not an action'; Args = @('--config', 'debug'); ExpectExit = 1
Contains = @('Nothing to do.')
NotContains = @('Build completed') }
@{ Name = '-j alone is not an action either'; Args = @('-j', '4'); ExpectExit = 1
Contains = @('Nothing to do.') }
@{ Name = '--install-vs counts as an action on its own'; Args = @('--install-vs', 'ide')
NotContains = @('Nothing to do.') }
'diagnostics'
@{ Name = '-v asks cmake for the command lines'; Args = @('-s', '-v')
Contains = @('--verbose') }
@{ Name = '-v applies to the deps build too'; Args = @('-d', '-v')
Contains = @('--target deps', '--verbose') }
# ninja's own default shows neither a percentage nor a time. %w and %W
# need ninja 1.12, and an unknown placeholder is fatal, so the format is
# chosen from the version rather than hardcoded.
@{ Name = '-v names the ninja progress format'; Args = @('-s', '-x', '-v')
Match = @('^Ninja progress format: \[.*%p.*\]') }
@{ Name = 'a ninja older than 1.12 gets the format it understands'; Args = @('-s', '-x', '-v')
Env = @{ PATH = $ninjaPaths['old'] }
Contains = @('Ninja progress format: [%s/%t %p :: %e]') }
@{ Name = 'ninja 1.12 gets elapsed and remaining'; Args = @('-s', '-x', '-v')
Env = @{ PATH = $ninjaPaths['new'] }
Contains = @('Ninja progress format: [%f/%t %p :: %w / %W]') }
@{ Name = 'a format you set yourself is left alone'; Args = @('-s', '-x', '-v')
Env = @{ NINJA_STATUS = '[mine] ' }
Contains = @('Ninja progress format: [mine]') }
@{ Name = 'MSBuild builds mention no ninja format'; Args = @('-s', '-v')
NotContains = @('Ninja progress format') }
@{ Name = 'builds are quiet without -v'; Args = @('-s')
NotContains = @('--verbose') }
'installing prerequisites'
# -u installs CMake, Perl and Git. Visual Studio is a separate ask, because
# most people already have it, and which one you want is a real choice.
@{ Name = '-u alone installs no Visual Studio'; Args = @('-u')
Contains = @('Kitware.CMake', 'StrawberryPerl', 'Git.Git')
NotContains = @('Microsoft.VisualStudio') }
# Asking for Visual Studio is asking to install prerequisites, so it does
# not also need -u; requiring both meant --install-vs alone did nothing.
@{ Name = '--install-vs works without -u'; Args = @('--install-vs', 'ide')
Contains = @('id=Microsoft.VisualStudio.Community', 'Kitware.CMake', 'Git.Git') }
@{ Name = '--install-vs buildtools asks for the build tools'; Args = @('-u', '--install-vs', 'buildtools')
Contains = @('id=Microsoft.VisualStudio.BuildTools')
NotContains = @('VC.CoreIde') }
@{ Name = '--install-vs ide asks for Community with the IDE component'; Args = @('-u', '--install-vs', 'ide')
Contains = @('id=Microsoft.VisualStudio.Community', 'VC.CoreIde') }
@{ Name = 'an unknown edition is rejected and the known ones listed'; Args = @('-u', '--install-vs', 'bogus'); ExpectExit = 1
Contains = @('Unknown Visual Studio edition', 'buildtools, ide') }
@{ Name = '--vs picks which release to install'; Args = @('-u', '--vs', '2022', '--install-vs', 'buildtools')
Contains = @('id=Microsoft.VisualStudio.2022.BuildTools') }
@{ Name = '-l adds the clang compiler and the MSBuild toolset'; Args = @('-u', '--install-vs', 'buildtools', '-l')
Contains = @('VC.Llvm.Clang ', 'VC.Llvm.ClangToolset') }
# CMake 4.x breaks Boost.Context on ARM64, so the installer pins 3.31 there
# and leaves x64 on the current release.
@{ Name = 'CMake is pinned to 3.31 when installing for arm64'; Args = @('-u', '--arch', 'arm64')
Contains = @('Kitware.CMake --version 3.31.8') }
@{ Name = 'CMake is not pinned for x64'; Args = @('-u', '--arch', 'x64')
Contains = @('Kitware.CMake')
NotContains = @('--version') }
@{ Name = 'installing for arm64 asks for the ARM64 toolset'; Args = @('--install-vs', 'buildtools', '--arch', 'arm64')
Contains = @('Microsoft.VisualStudio.Component.VC.Tools.ARM64') }
@{ Name = 'an x64 install asks only for the x64 toolset'; Args = @('--install-vs', 'buildtools')
Contains = @('Microsoft.VisualStudio.Component.VC.Tools.x86.x64')
NotContains = @('VC.Tools.ARM64') }
'dry run changes nothing'
# clean_tree resolves the path before removing it, so the line echoed is
# absolute. It is the last thing printed before a directory goes.
@{ Name = '-c echoes the deps rmdir rather than running it'; Args = @('-d', '-c')
Match = @('^\+ rmdir /S /Q "[A-Za-z]:\\.*\\deps\\build"$') }
@{ Name = '-c removes the tree --deps-dir named, not the default one'; Args = @('-d', '-c', '--deps-dir', 'D:\orca-deps')
Match = @('^\+ rmdir /S /Q "D:\\orca-deps"$')
NotContains = @('\deps\build') }
@{ Name = '-c echoes the slicer rmdir rather than running it'; Args = @('-s', '-c')
Match = @('^\+ rmdir /S /Q "[A-Za-z]:\\.*\\build"$') }
@{ Name = 'cleaning a slicer build leaves the deps tree alone'; Args = @('-s', '-c')
NotMatch = @('rmdir.*\\deps\\') }
@{ Name = 'cleaning both builds removes both trees'; Args = @('-d', '-s', '-c')
Match = @('^\+ rmdir /S /Q "[A-Za-z]:\\[^"]*\\deps\\build"$',
'^\+ rmdir /S /Q "[A-Za-z]:\\(?!.*\\deps\\)[^"]*\\build"$') }
# Nothing below should be reachable. They are a floor under a bug that
# hands clean_tree a path far shorter than it looks.
@{ Name = 'a configuration whose lookup comes back empty is rejected'; Args = @('-d', '-c', '--config', 'release '); ExpectExit = 1
Contains = @('Unknown configuration')
NotContains = @('rmdir') }
@{ Name = 'a drive root is refused'; Args = @('-d', '-c', '--deps-dir', 'D:\'); ExpectExit = 1
Contains = @('that is a drive root')
NotContains = @('+ rmdir') }
@{ Name = 'the repository itself is refused'; Args = @('-d', '-c', '--deps-dir', '.'); ExpectExit = 1
Contains = @('that is the repository itself')
NotContains = @('+ rmdir') }
@{ Name = '-k echoes the taskkills rather than running them'; Args = @('-k')
Contains = @('+ taskkill /F /IM MSBuild.exe', '+ taskkill /F /IM cl.exe') }
@{ Name = '-k also covers the Ninja toolchain'; Args = @('-k')
Contains = @('+ taskkill /F /IM ninja.exe', '+ taskkill /F /IM clang-cl.exe') }
@{ Name = '-k announces the dry run like every other action'; Args = @('-k')
First = '^Dry run: ' }
@{ Name = '-u echoes the winget installs rather than running them'; Args = @('-u')
Contains = @('+ winget install', 'Kitware.CMake')
NotContains = @('cmake -S deps') }
# Not a dry run: winget is a stub that fails, so nothing is installed.
@{ Name = 'a failed install is reported, not claimed as success'; Args = @('-u')
DryRun = $false; ExpectExit = 1
Env = @{ PATH = $stubPath }
Contains = @('Failed to install:', 'CMake', 'Perl', 'Git')
NotContains = @('Installed the prerequisites') }
# The reason belongs inside the frame. Every command this path ran had
# already succeeded, so naming the last one would point at the wrong thing.
@{ Name = 'a failed install names the reason, not the last command'; Args = @('-u')
DryRun = $false; ExpectExit = 1
Env = @{ PATH = $stubPath }
Contains = @('####', 'Failed to install:')
NotContains = @('Failed: winget') }
@{ Name = 'a dry run does not claim the install happened'; Args = @('-u')
Contains = @('Dry run: nothing was installed.')
NotContains = @('are in place') }
@{ Name = '-u with a build defers that build, not a fuller one'; Args = @('-u', '-d')
Contains = @('build_win.bat -d')
NotContains = @('build_win.bat -ds') }
@{ Name = '-u on its own suggests the whole build'; Args = @('-u')
Contains = @('build_win.bat -ds') }
@{ Name = 'a dry run only echoes, it never configures'; Args = @('-d')
Contains = @('+ cmake')
NotContains = @('CMake Error', 'Configuring done') }
'extra configure arguments'
# --deps-args and --slicer-args are declared "rawstring", so a value that
# looks like an option is allowed through. Plain string options still
# reject one, since there it almost always means a forgotten value.
@{ Name = '--deps-args reaches the deps configure'; Args = @('-d', '--deps-args', 'FOO')
Contains = @('-DCMAKE_BUILD_TYPE=Release FOO') }
@{ Name = '--slicer-args reaches the slicer configure'; Args = @('-s', '--slicer-args', 'BAZ')
Contains = @('BAZ') }
@{ Name = '--deps-args accepts a value that starts with a dash'; Args = @('-d', '--deps-args', '-DFOO')
Contains = @('-DFOO') }
@{ Name = 'a plain string option still rejects a dash-leading value'; Args = @('-d', '--deps-target', '--tests'); ExpectExit = 1
Contains = @('looks like another option') }
# cmd splits arguments on "=" as well as spaces, so an unquoted -D reaches
# the script as two arguments however it was invoked.
@{ Name = 'an unquoted value containing = is rejected'; Args = @('-d', '--deps-args', 'FOO=BAR'); ExpectExit = 1
Contains = @('Unknown argument') }
# The environment overrides exist for that reason; nothing tokenises them.
@{ Name = 'ORCA_DEPS_CMAKE_ARGS reaches the deps configure'; Args = @('-d')
Env = @{ ORCA_DEPS_CMAKE_ARGS = '-DFOO=BAR -DBAZ=QUX' }
Contains = @('-DFOO=BAR -DBAZ=QUX') }
@{ Name = 'ORCA_SLICER_CMAKE_ARGS reaches the slicer configure'; Args = @('-s')
Env = @{ ORCA_SLICER_CMAKE_ARGS = '-DWANTED=1' }
Contains = @('-DWANTED=1') }
@{ Name = 'the deps override does not leak into the slicer configure'; Args = @('-s')
Env = @{ ORCA_DEPS_CMAKE_ARGS = '-DDEPSONLY=1' }
NotContains = @('-DDEPSONLY=1') }
@{ Name = 'the help points at the environment for a spaced argument'; Args = @('--help'); DryRun = $false
Contains = @('Neither form supports a value containing an ampersand') }
@{ Name = 'the help lists the environment overrides'; Args = @('--help'); DryRun = $false
Contains = @('Environment:', 'ORCA_DEPS_CMAKE_ARGS', 'ORCA_SLICER_CMAKE_ARGS') }
'running the unit tests'
@{ Name = '--tests builds them without running them'; Args = @('-s', '--tests')
Contains = @('-DBUILD_TESTS=ON')
NotContains = @('ctest') }
@{ Name = '--run-tests builds and runs them'; Args = @('-s', '--run-tests')
Contains = @('-DBUILD_TESTS=ON', 'ctest --test-dir "build/tests" -C Release --output-on-failure') }
@{ Name = '--run-tests follows the build type and directory'; Args = @('-s', '--run-tests', '--config', 'debug')
Contains = @('ctest --test-dir "build-dbg/tests" -C Debug') }
@{ Name = 'no tests are run by default'; Args = @('-s')
NotContains = @('ctest') }
'failures are reported'
@{ Name = 'a missing cmake is caught and exits non-zero'; Args = @('-d'); ExpectExit = 1
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
Contains = @('CMake was not found') }
@{ Name = 'packing does not need cmake, only an archiver'; Args = @('-p')
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
NotContains = @('CMake was not found') }
# Not a dry run: a cd to a missing drive is a real failure inside a
# parenthesised block, which is where exit /b silently loses its code.
# Without the jump to :die this exits 0 and a failed build reads as a
# successful one.
@{ Name = 'a failure inside a build block reaches the caller'; Args = @('-p', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('Exit code 1.', '####')
NotContains = @('Build completed', 'Try') }
# The retry follows the stage that failed. Offering it for the whole run
# would clean a dependency tree that was not at fault.
@{ Name = 'a failure names a retry scoped to the stage that failed'; Args = @('-d', '-s', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('build_win.bat -d --deps-dir "Z:\nope" -c')
NotContains = @('build_win.bat -ds') }
# CMake's own failure here is hundreds of lines about package resolution.
@{ Name = 'a missing dependency tree is named, not left to CMake'; Args = @('-s', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('Dependencies not found at', 'Build them with build_win.bat -d --deps-dir "Z:\nope"')
NotContains = @('cmake -B', 'Try') }
# Every other suggestion carries the flags that reproduce the run; a bare
# -d would point at the MSVC tree after a clang build.
@{ Name = 'the missing-deps hint names this toolchain'; Args = @('-s', '-l', '-x', '--deps-dir', 'deps/not-built')
DryRun = $false; ExpectExit = 1
Contains = @('Build them with build_win.bat -d -l -x --deps-dir "deps/not-built"')
NotExists = @('deps/not-built') }
# A dry run configures nothing, so it must not depend on which trees happen
# to exist on the machine running the suite.
@{ Name = 'a dry run does not check for the deps tree'; Args = @('-s', '--deps-dir', 'Z:\nope')
Contains = @('cmake -B "build"')
NotContains = @('Dependencies not found') }
# -d is about to build them, so there is nothing to report yet.
@{ Name = 'building the deps in the same run skips the check'; Args = @('-d', '-s', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
NotContains = @('Dependencies not found') }
# A configure fails before any compiler runs, so -v has nothing to show.
@{ Name = 'a configure failure is not offered a verbose rebuild'; Args = @('-d', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('-c discard that tree')
NotContains = @('-v show the failing') }
# A bare --no-configure succeeds on a machine that already has a usable
# build tree, so name one that cannot exist instead.
@{ Name = 'a build failure is'; Args = @('-s', '--no-configure', '--build-dir', 'deps/no-such-tree')
DryRun = $false; ExpectExit = 1
Contains = @('-v show the failing')
NotExists = @('deps/no-such-tree') }
# --build-dir names the slicer tree, which a deps failure has nothing to do
# with. --deps-dir stays, because that is the tree that failed.
@{ Name = 'a deps retry leaves out the slicer tree'; Args = @('-d', '-s', '--deps-dir', 'Z:\nope', '--build-dir', 'D:\b')
DryRun = $false; ExpectExit = 1
Contains = @('build_win.bat -d --deps-dir "Z:\nope" -c')
NotContains = @('--build-dir') }
@{ Name = 'an unknown configuration stays a single line'; Args = @('-s', '--config', 'bogus'); ExpectExit = 1
Contains = @('Unknown configuration')
NotContains = @('####', 'Try') }
@{ Name = 'a bad --jobs value is not framed either'; Args = @('-s', '-j', 'x'); ExpectExit = 1
Contains = @('Invalid --jobs value')
NotContains = @('####') }
'the summary says what to do next'
# Every suggested command carries the flags that reproduce this run.
@{ Name = 'a deps build points at the slicer build'; Args = @('-d', '-l')
Contains = @('Build the slicer build_win.bat -s -l')
NotContains = @('Run it') }
@{ Name = 'a ninja slicer build offers a single target'; Args = @('-s', '-l', '-x')
Contains = @('Rebuild after edits build_win.bat -s -l -x --no-configure', 'Rebuild one target')
NotContains = @('Solution', 'Open in Visual Studio') }
@{ Name = 'a visual studio build names the solution instead'; Args = @('-s')
Contains = @('Solution ', 'Open in Visual Studio build\OrcaSlicer.sln',
'Rebuild after edits build_win.bat -s --no-configure')
NotContains = @('Rebuild one target') }
@{ Name = 'the configuration and architecture come back'; Args = @('-s', '-l', '-x', '--config', 'debug', '--arch', 'arm64')
Contains = @('build_win.bat -s -l -x --config debug --arch arm64 --no-configure') }
@{ Name = 'the tree overrides come back quoted'; Args = @('-s', '--deps-dir', 'D:\d', '--build-dir', 'D:\b')
Contains = @('--deps-dir "D:\d" --build-dir "D:\b"') }
@{ Name = 'a pinned visual studio release comes back'; Args = @('-s', '--vs', '2022')
Contains = @('build_win.bat -s --vs 2022 --no-configure') }
# Autodetection writes what it found into the same variable, so a detected
# release must not come back as though it had been asked for.
@{ Name = 'a detected release does not'; Args = @('-s')
NotContains = @('--vs') }
@{ Name = 'the binary is named in the build tree it was built in'; Args = @('-s', '-l', '-x')
Contains = @('build-clang\src\Release\orca-slicer.exe') }
@{ Name = 'installing names the installed copy instead'; Args = @('-s', '-l', '-x', '-i')
Contains = @('build-clang\OrcaSlicer\orca-slicer.exe') }
# -i changes where the binary lands, so a rebuild that dropped it would
# leave the path above pointing at a stale copy.
@{ Name = 'the rebuild suggestion keeps -i'; Args = @('-s', '-l', '-x', '-i')
Contains = @('Rebuild after edits build_win.bat -s -l -x -i --no-configure') }
# A deps retry has no install step to repeat.
@{ Name = 'a deps retry drops it'; Args = @('-d', '-s', '-i', '--deps-dir', 'Z:\nope')
DryRun = $false; ExpectExit = 1
Contains = @('build_win.bat -d --deps-dir "Z:\nope" -c')
NotContains = @('-d -i') }
# Naming a target builds it and its dependencies, not its dependents, so
# the binary on disk is whatever the last full build left there.
@{ Name = 'a single-target build does not claim the whole binary'; Args = @('-s', '-l', '-x', '--slicer-target', 'glad')
Contains = @('Target glad', 'Relink the binary build_win.bat -s -l -x --no-configure')
NotContains = @('Run it', 'orca-slicer.exe', 'Rebuild after edits') }
# The executable has a target of its own, and naming that one does relink.
@{ Name = 'naming the executable target still claims the binary'; Args = @('-s', '-l', '-x', '--slicer-target', 'OrcaSlicer')
Contains = @('Run it', 'orca-slicer.exe', 'Rebuild after edits')
NotContains = @('Target OrcaSlicer', 'Relink the binary') }
@{ Name = '--run-tests offers the ctest line'; Args = @('-s', '-l', '-x', '--run-tests')
Contains = @('Re-run the tests ctest --test-dir build-clang/tests -C Release') }
@{ Name = 'packing names the bundle and how to use it'; Args = @('-p', '-l')
Contains = @('Bundle ', 'Share the bundle') }
# The line supplies its own tree, so the one this run used must not ride
# along and contradict it.
@{ Name = 'the bundle line names one tree, not two'; Args = @('-s', '-p', '-l', '-x', '--deps-dir', 'D:\shared')
Contains = @('then build_win.bat -s -l -x --deps-dir <path>')
NotContains = @('--deps-dir "D:\shared" --deps-dir') }
@{ Name = 'installing prerequisites suggests the build that follows'; Args = @('-u', '-l')
Contains = @('Restart this shell', 'build_win.bat -ds -l')
NotContains = @('Run it') }
# Everything below the header line is worked out the same way in either
# run, which is why a dry run can cover it.
@{ Name = 'a dry run does not claim a build happened'; Args = @('-s', '-l', '-x')
Contains = @('Dry run: nothing was built.')
NotContains = @('Build completed in') }
# --no-configure is the iteration loop and still gets the block; four
# lines after a rebuild is not enough to be worth suppressing.
@{ Name = '--no-configure still gets the summary'; Args = @('-s', '-l', '-x', '--no-configure')
Contains = @('Next', 'Rebuild after edits') }
'pointing at the solution'
# The extension follows the generator, so these two pin the release and a
# build directory that cannot already hold a solution of either kind.
@{ Name = 'the 2026 generator gets the XML solution'; Args = @('-s', '--vs', '2026', '--build-dir', 'D:\tree')
Contains = @('Solution D:\tree\OrcaSlicer.slnx', 'Open in Visual Studio D:\tree\OrcaSlicer.slnx') }
@{ Name = 'the releases before it get the classic one'; Args = @('-s', '--vs', '2022', '--build-dir', 'D:\tree')
Contains = @('Solution D:\tree\OrcaSlicer.sln', 'Open in Visual Studio D:\tree\OrcaSlicer.sln') }
@{ Name = 'a solution already on disk wins over the generator'; Args = @('-s', '--vs', '2026', '--build-dir', $slnDir)
Match = @('^ Solution .*\\OrcaSlicer\.sln$') }
# Extension-agnostic from here: these cases are about the directory, and
# the release is whatever is installed.
@{ Name = 'the VS generator says where the solution is'; Args = @('-s')
Match = @('^ Solution .*\\build\\OrcaSlicer\.slnx?$') }
@{ Name = 'the solution path follows the configuration'; Args = @('-s', '--config', 'debug')
Match = @('^ Solution .*\\build-dbg\\OrcaSlicer\.slnx?$') }
@{ Name = 'the solution line survives an install'; Args = @('-s', '-i')
Contains = @(' Solution ') }
# The path is resolved, not pasted onto the repository root, so it is
# right whether --build-dir came absolute or with forward slashes.
@{ Name = 'a moved build still prints one real path'; Args = @('-s', '--build-dir', 'out/build/x64-clang')
Match = @('^ Solution [A-Za-z]:\\[^/]+\\OrcaSlicer\.slnx?$') }
@{ Name = 'an absolute --build-dir is not glued onto the repo root'; Args = @('-s', '--build-dir', 'D:\tree')
Match = @('^ Solution D:\\tree\\OrcaSlicer\.slnx?$') }
)
function Invoke-BuildScript {
param([string[]] $Arguments, [hashtable] $Environment)
$saved = @{}
if ($Environment) {
foreach ($key in $Environment.Keys) {
$saved[$key] = [Environment]::GetEnvironmentVariable($key)
Set-Item -Path "env:$key" -Value $Environment[$key]
}
}
try {
# 'Stop' turns a native command's stderr into a terminating error, and
# a case that exercises a real failure writes to stderr. Let the output
# through and judge the run by its exit code instead. The assignment is
# scoped to this function, so the rest of the suite keeps 'Stop'.
$ErrorActionPreference = 'Continue'
if ($Arguments.Count -eq 0) {
$out = & $Script 2>&1 | Out-String
} else {
$out = & $Script @Arguments 2>&1 | Out-String
}
return [pscustomobject]@{ Output = $out; Exit = $LASTEXITCODE }
} finally {
foreach ($key in $saved.Keys) {
if ($null -eq $saved[$key]) { Remove-Item -Path "env:$key" -ErrorAction SilentlyContinue }
else { Set-Item -Path "env:$key" -Value $saved[$key] }
}
}
}
$knownFields = @(
'Name', 'Args', 'ExpectExit', 'DryRun', 'First', 'Env',
'Contains', 'NotContains', 'Match', 'NotMatch', 'NotExists', 'DateStampedZip'
)
function Test-Case {
param([hashtable] $Case)
# Read fields with the indexer, not dot notation. A hashtable exposes its
# own members too, so $Case.Contains returns the Contains *method* whenever
# the case has no key by that name.
$argv = @($Case['Args'])
if (-not $Case.ContainsKey('DryRun') -or $Case['DryRun']) { $argv += '--dry-run' }
$expect = 0
if ($Case.ContainsKey('ExpectExit')) { $expect = $Case['ExpectExit'] }
$started = Get-Date
$result = Invoke-BuildScript -Arguments $argv -Environment $Case['Env']
$finished = Get-Date
$problems = @()
# A misspelled field is silently ignored by the checks below, which
# leaves the case asserting nothing at all and passing.
foreach ($field in $Case.Keys) {
if ($knownFields -notcontains $field) { $problems += "unknown field '$field'" }
}
if ($result.Exit -ne $expect) { $problems += "exit $($result.Exit), expected $expect" }
foreach ($needle in $Case['Contains']) {
if (-not $result.Output.Contains($needle)) { $problems += "missing '$needle'" }
}
foreach ($needle in $Case['NotContains']) {
if ($result.Output.Contains($needle)) { $problems += "unexpected '$needle'" }
}
$lines = $result.Output -split "`r?`n"
if ($Case['First'] -and $lines[0] -notmatch $Case['First']) {
$problems += "first line was '$($lines[0])'"
}
foreach ($pattern in $Case['Match']) {
if (@($lines | Where-Object { $_ -match $pattern }).Count -eq 0) {
$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"
}
}
# Output cannot show what a run did not create.
foreach ($path in $Case['NotExists']) {
$full = Join-Path (Split-Path -Parent $Script) $path
if (Test-Path $full) {
$problems += "created '$path'"
}
}
return ,$problems
}
$pass = 0
$failed = @()
# Held back so a filtered run does not print headings for groups it skipped.
$heading = $null
foreach ($case in $cases) {
if ($case -is [string]) {
$heading = $case
continue
}
if ($Name -and $case['Name'] -notmatch $Name) { continue }
if ($heading) {
Write-Host ''
Write-Host $heading -ForegroundColor Cyan
$heading = $null
}
$problems = Test-Case -Case $case
if ($problems.Count -eq 0) {
$pass++
Write-Host (' ok ' + $case['Name'])
} else {
$failed += $case['Name']
Write-Host (' FAIL ' + $case['Name']) -ForegroundColor Red
foreach ($problem in $problems) { Write-Host (' ' + $problem) -ForegroundColor Red }
Write-Host (' args: ' + (@($case['Args']) -join ' '))
}
}
Remove-Item -Recurse -Force $fixtures -ErrorAction SilentlyContinue
Write-Host ''
# A pattern that matched nothing has proved nothing, so do not report it as
# a clean run.
if ($Name -and $pass -eq 0 -and $failed.Count -eq 0) {
Write-Host "no case matched /$Name/" -ForegroundColor Red
exit 1
}
Write-Host "$pass passed, $($failed.Count) failed"
if ($failed.Count -gt 0) {
foreach ($name in $failed) { Write-Host " failed: $name" -ForegroundColor Red }
exit 1
}
exit 0
+114 -1
View File
@@ -5,10 +5,15 @@ Inserts/deletes/modifies random lane data in Moonraker database,
then reads back and displays with colored output.
"""
import requests
try:
import requests
except ImportError:
requests = None # only needed for live-printer operations, not --check-ofl-map
import random
import argparse
import json
import os
import re
import time
import sys
@@ -16,6 +21,11 @@ import sys
DEFAULT_HOST = "192.168.88.9"
DEFAULT_PORT = 7125
NAMESPACE = "lane_data"
# Repo-relative paths for the offline generic-map check
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MOONRAKER_AGENT_CPP = os.path.join(REPO_ROOT, "src", "slic3r", "Utils", "MoonrakerPrinterAgent.cpp")
OFL_FILAMENT_DIR = os.path.join(REPO_ROOT, "resources", "profiles", "OrcaFilamentLibrary", "filament")
LANE_KEYS = [f"lane{i}" for i in range(1, 9)] # lane1-lane8
MATERIALS = ["PLA", "ABS", "PETG", "ASA", "ASA Sparkle", "TPU", ""]
@@ -30,6 +40,95 @@ MATERIAL_TEMPS = {
"": {"nozzle": None, "bed": None},
}
def parse_cpp_type_map():
"""Extract the normalized-type -> OFL generic family table from MoonrakerPrinterAgent.cpp.
Reads MoonrakerPrinterAgent::map_filament_type_to_generic_id's type_to_ofl_family
initializer so the check tracks the C++ normalization without a duplicated list.
"""
with open(MOONRAKER_AGENT_CPP, encoding="utf-8") as f:
src = f.read()
m = re.search(r"type_to_ofl_family\s*=\s*\{(.*?)\n\s*\};", src, re.DOTALL)
if not m:
raise RuntimeError(f"type_to_ofl_family table not found in {MOONRAKER_AGENT_CPP}")
pairs = re.findall(r'\{\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\}', m.group(1))
if not pairs:
raise RuntimeError("type_to_ofl_family table parsed empty")
return dict(pairs)
def load_ofl_presets():
"""Map preset name -> parsed JSON for every OrcaFilamentLibrary filament profile."""
presets = {}
for root, _dirs, files in os.walk(OFL_FILAMENT_DIR):
for fn in files:
if not fn.endswith(".json"):
continue
try:
with open(os.path.join(root, fn), encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
continue
name = data.get("name")
if isinstance(name, str) and name:
presets[name] = data
return presets
def resolve_ofl_filament_id(presets, name):
"""Follow the inherits chain (within OFL) until a filament_id is declared."""
seen = set()
while name and name not in seen:
seen.add(name)
preset = presets.get(name)
if preset is None:
return None
fid = preset.get("filament_id")
if fid:
return fid
name = preset.get("inherits")
return None
def check_ofl_generic_map():
"""Assert every material type the C++ normalization handles resolves to a shipped
OrcaFilamentLibrary generic preset carrying a filament_id.
Expectations are derived from the shipped profiles, not pinned id literals, so the
check stays valid across filament_id re-mints.
"""
print("Checking C++ generic-type map against shipped OrcaFilamentLibrary presets...")
try:
type_map = parse_cpp_type_map()
except (OSError, RuntimeError) as e:
print(f" FAIL: {e}")
return False
presets = load_ofl_presets()
if not presets:
print(f" FAIL: no OFL filament profiles found under {OFL_FILAMENT_DIR}")
return False
errors = []
for family in sorted(set(type_map.values())):
preset_name = f"Generic {family} @System"
if preset_name not in presets:
errors.append(f"{preset_name}: no such OFL preset")
continue
if str(presets[preset_name].get("instantiation", "")).lower() != "true":
errors.append(f"{preset_name}: not instantiated — the C++ runtime lookup "
f"only sees presets loaded into the PresetBundle")
continue
fid = resolve_ofl_filament_id(presets, preset_name)
if not fid:
errors.append(f"{preset_name}: no filament_id resolvable through inherits")
continue
aliases = ", ".join(sorted(t for t, fam in type_map.items() if fam == family))
print(f" {fid:10s} {preset_name:32s} <- {aliases}")
if errors:
for e in errors:
print(f" FAIL: {e}")
print(f"OFL generic map check FAILED ({len(errors)} error(s))")
return False
print(f"OFL generic map check passed: {len(type_map)} type aliases, "
f"{len(set(type_map.values()))} OFL generic presets\n")
return True
def test_connection(host, port, api_key=None, verbose=False):
"""Test basic connectivity to Moonraker."""
url = f"http://{host}:{port}/server/info"
@@ -404,11 +503,25 @@ def main():
help="Only read and display current lane data")
parser.add_argument("--load", metavar="FILE",
help="Load lane data from JSON file and overwrite printer lanes")
parser.add_argument("--check-ofl-map", action="store_true",
help="Only run the offline check that the C++ generic-type map "
"resolves against shipped OrcaFilamentLibrary presets")
parser.add_argument("--verbose", "-v", action="store_true",
help="Verbose output for debugging")
args = parser.parse_args()
# Offline check first: the C++ type normalization must resolve against shipped
# OFL presets (no printer needed).
if not check_ofl_generic_map():
return 1
if args.check_ofl_map:
return 0
if requests is None:
print("The 'requests' module is required for live printer operations (pip install requests).")
return 1
print(f"\nConnecting to Moonraker at {args.host}:{args.port}...")
# First test basic connectivity
View File
File diff suppressed because it is too large Load Diff
+899
View File
@@ -0,0 +1,899 @@
#!/usr/bin/env python3
"""Tests for the setting_id half of scripts/orca_id_tool.py (stdlib unittest, no
external deps).
Run from the repo root: python -m unittest discover -s scripts/tests -v
"""
import contextlib
import io
import json
import os
import shutil
import sys
import tempfile
import unittest
import uuid
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import orca_id_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")
# ---------------------------------------------------------------------------
# helpers: synthetic profile trees
# ---------------------------------------------------------------------------
def preset(name, instantiation=True, setting_id=None, settings_id=None,
filament_id=None, type_name="filament", **extra):
"""A preset in the canonical key order the shipped profiles use."""
data = {"type": type_name, "name": name, "from": "system"}
if setting_id is not None:
data["setting_id"] = setting_id
if settings_id is not None:
data["settings_id"] = settings_id
if filament_id is not None:
data["filament_id"] = filament_id
data["instantiation"] = "true" if instantiation else "false"
data.update(extra)
return data
class SettingTree:
"""A throwaway resources/profiles-shaped directory of setting_id-bearing bundles.
A bundle is a `<vendor>/` directory plus the sibling `<vendor>.json` index
that makes list_vendor_names() see it; presets live under filament/,
process/ and machine/, the three subdirs generate_setting_ids walks.
write() also registers filament presets in the index's filament_list, the
way a shipped bundle does. generate_setting_ids never reads that list, but
generate_filament_ids does: without it the filament half of the tool is a
no-op on this tree, and the tests that assert --setting-id leaves
filament_ids alone would hold for the wrong reason.
"""
def __init__(self):
self.dir = tempfile.mkdtemp(prefix="setting_id_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):
"""Create the bundle dir and its index; idempotent, keeps the list."""
for sub in afi.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",
"filament_list": []})
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)
def register(self, vendor, subdir, name):
"""Add a filament preset to the bundle index's filament_list."""
with open(self.index_path(vendor), encoding="utf-8") as f:
index = json.load(f)
sub_path = os.path.join(subdir, name + ".json").replace(os.sep, "/")
index["filament_list"].append({"name": name, "sub_path": sub_path})
self._write_index(vendor, index)
def path(self, vendor, subdir, name):
return os.path.join(self.profiles, vendor, subdir, name + ".json")
def write(self, vendor, subdir, data, name=None):
"""Write a preset as indented LF JSON; returns its path."""
self.add_vendor(vendor)
file_name = name if name is not None else data["name"]
path = self.path(vendor, subdir, file_name)
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")
if subdir.split(os.sep)[0] == "filament" and data.get("name"):
self.register(vendor, subdir, file_name)
return path
def write_raw(self, vendor, subdir, name, raw):
"""Write exact bytes (BOM, CRLF, tabs, broken JSON); returns its path."""
self.add_vendor(vendor)
path = self.path(vendor, subdir, name)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(raw)
return path
def read(self, vendor, subdir, name):
with open(self.path(vendor, subdir, name), encoding="utf-8-sig") as f:
return json.load(f)
def raw(self, vendor, subdir, name):
with open(self.path(vendor, subdir, name), "rb") as f:
return f.read()
def bytes_map(self):
"""relative path -> bytes, for every file in the tree."""
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
# -- pipeline wrappers ---------------------------------------------------
def run(self, vendors=None, dry_run=False):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
changed, errors = afi.generate_setting_ids(self.profiles, vendors, dry_run)
return changed, errors, buf.getvalue()
def run_filament_ids(self, vendors=None, dry_run=False):
"""The OTHER half of --generate."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
changed, errors = afi.generate_filament_ids(self.profiles, vendors, dry_run)
return changed, errors, buf.getvalue()
class SettingTreeCase(unittest.TestCase):
def setUp(self):
self.t = SettingTree()
self.addCleanup(self.t.cleanup)
# ---------------------------------------------------------------------------
# mint: the C++/Python byte-identity contract
# ---------------------------------------------------------------------------
class TestSettingIdMint(unittest.TestCase):
# Copied verbatim from tests/libslic3r/test_preset_setting_id.cpp: the C++
# generate_preset_setting_id() recomputes these ids on the fly, so the two
# implementations must stay byte-identical.
GOLDEN = [
("Afinia", "filament", "Afinia ABS @Afinia H400", "TL34qSVkppBvMvgH"),
("Afinia", "process", "0.20mm Standard @Afinia H400", "FzmtNsy7XQvpd7w0"),
("Afinia", "machine", "Afinia H400 0.4 nozzle", "r4FZagW0S8uoaJPd"),
("Anycubic", "filament", "Generic PLA @Anycubic Kobra 2", "YIWGGLQ8Oepd30Fv"),
("Creality", "process", "0.16mm Optimal @Creality Ender-3 V3", "2Nrbq8PxssUPBLza"),
("Elegoo", "machine", "Elegoo Neptune 4 0.4 nozzle", "69QdWuRQwAZk9rFu"),
]
def test_golden_vectors(self):
for vendor, type_name, name, expected in self.GOLDEN:
with self.subTest(name=name):
self.assertEqual(
afi.generate_preset_setting_id(vendor, type_name, name), expected)
def test_namespace_and_length_are_frozen(self):
# Baked into the C++ side and into every shipped profile; never change it.
self.assertEqual(afi.NAMESPACE,
uuid.UUID("c1f4d9e2-7a3b-5c8d-9e0f-1a2b3c4d5e6f"))
self.assertEqual(afi.SETTING_ID_LENGTH, 16)
self.assertEqual(
afi.ALPHABET,
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
self.assertEqual(len(afi.ALPHABET), 62)
def test_format_is_sixteen_alphabet_chars(self):
for vendor, type_name, name in [("Creality", "filament", "CR PLA @Ender"),
("Elegoo", "machine", "Elegoo Neptune 4"),
("拓竹", "filament", "拓竹 PLA @P1")]:
with self.subTest(name=name):
sid = afi.generate_preset_setting_id(vendor, type_name, name)
self.assertEqual(len(sid), afi.SETTING_ID_LENGTH)
self.assertTrue(set(sid) <= set(afi.ALPHABET), sid)
def test_is_the_low_base62_digits_of_the_uuid5(self):
# Independent re-implementation of the whole rule, key layout included.
for vendor, type_name, name, _expected in self.GOLDEN:
u = uuid.uuid5(afi.NAMESPACE, f"{vendor}/{type_name}/{name}")
n = int.from_bytes(u.bytes, "big")
digits = ""
for _ in range(afi.SETTING_ID_LENGTH):
digits = afi.ALPHABET[n % 62] + digits
n //= 62
self.assertEqual(afi.generate_preset_setting_id(vendor, type_name, name),
digits)
def test_deterministic(self):
a = afi.generate_preset_setting_id("VendorX", "filament", "My PLA")
self.assertEqual(a, afi.generate_preset_setting_id("VendorX", "filament", "My PLA"))
def test_every_identity_component_changes_the_id(self):
base = afi.generate_preset_setting_id("VendorX", "filament", "My PLA")
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorY", "filament", "My PLA"))
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorX", "process", "My PLA"))
self.assertNotEqual(base, afi.generate_preset_setting_id("VendorX", "filament", "My PETG"))
def test_key_is_a_flat_slash_join(self):
# The mint key is "<vendor>/<type>/<name>" with no escaping, so a "/" in
# a component shifts the split — harmless in practice (vendor is a
# directory name and type is one of PROFILE_SUBDIRS), but it is what the
# C++ side does too and the two must agree byte for byte.
self.assertEqual(afi.generate_preset_setting_id("A/B", "filament", "C"),
afi.generate_preset_setting_id("A", "B/filament", "C"))
class TestBase62Tail(unittest.TestCase):
def test_hand_computed_digits(self):
self.assertEqual(afi._base62_tail(0, 4), "0000")
self.assertEqual(afi._base62_tail(61, 1), "z") # last alphabet char
self.assertEqual(afi._base62_tail(62, 2), "10") # 1*62 + 0
self.assertEqual(afi._base62_tail(3843, 2), "zz") # 61*62 + 61
self.assertEqual(afi._base62_tail(3907, 3), "111") # 62^2 + 62 + 1
def test_keeps_only_the_low_digits(self):
self.assertEqual(afi._base62_tail(62, 1), "0") # high digit dropped
self.assertEqual(afi._base62_tail(3907, 2), "11")
# ---------------------------------------------------------------------------
# assignment
# ---------------------------------------------------------------------------
class TestAssignment(SettingTreeCase):
def test_instantiation_is_read_exactly_as_the_validator_reads_it(self):
# orca_extra_profile_check.py 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"),
("Numeric", 1), ("Absent", None)]:
with self.subTest(instantiation=value):
data = {"type": "filament", "name": name, "from": "system"}
if value is not None:
data["instantiation"] = value
self.t.write("VendorA", "filament", data)
before = self.t.bytes_map()
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (0, 0), out)
self.assertEqual(self.t.bytes_map(), before)
def test_a_bundle_without_an_index_is_still_assigned(self):
# setting_id is a per-file property, and the validator walks every
# directory. A bundle whose index has not landed yet must be fixable,
# or the validator flags files this tool refuses to touch.
path = os.path.join(self.t.profiles, "Noindex", "process", "Q.json")
os.makedirs(os.path.dirname(path))
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(preset("Q", type_name="process"), f, indent=4)
self.assertFalse(os.path.exists(self.t.index_path("Noindex")))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
with open(path, encoding="utf-8") as f:
self.assertEqual(json.load(f)["setting_id"],
afi.generate_preset_setting_id("Noindex", "process", "Q"))
def test_assigns_across_every_profile_subdir(self):
self.t.write("VendorA", "filament", preset("A PLA @P1"))
self.t.write("VendorA", "process",
preset("0.20mm Standard @P1", type_name="process"))
self.t.write("VendorA", "machine",
preset("P1 0.4 nozzle", type_name="machine"))
# os.walk recursion: a preset in a nested directory is walked too.
self.t.write("VendorA", os.path.join("filament", "nested"),
preset("A PETG @P1"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (4, 0), out)
for subdir, type_name, name in [
("filament", "filament", "A PLA @P1"),
("process", "process", "0.20mm Standard @P1"),
("machine", "machine", "P1 0.4 nozzle"),
(os.path.join("filament", "nested"), "filament", "A PETG @P1")]:
self.assertEqual(
self.t.read("VendorA", subdir, name)["setting_id"],
afi.generate_preset_setting_id("VendorA", type_name, name),
msg=name)
def test_type_comes_from_the_subdirectory_not_the_type_field(self):
# The subdir name is the type name (Preset::get_type_string()); a stale
# "type" field inside the file does not enter the id.
self.t.write("VendorA", "process", preset("Odd @P1", type_name="filament"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(self.t.read("VendorA", "process", "Odd @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "process", "Odd @P1"))
def test_idempotent(self):
self.t.write("VendorA", "filament", preset("A PLA @P1"))
self.t.write("VendorA", "process",
preset("0.20mm Standard @P1", type_name="process"))
self.t.write("VendorA", "filament", preset("A PLA @base", instantiation=False,
setting_id="LEFTOVER00000000"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (3, 0), out)
after_first = self.t.bytes_map()
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (0, 0), out)
self.assertEqual(self.t.bytes_map(), after_first)
def test_stale_value_is_replaced_in_place(self):
path = self.t.write("VendorA", "filament",
preset("A PLA @P1", setting_id="0000000000000000",
filament_id="OFabc123"))
before = self.t.raw("VendorA", "filament", "A PLA @P1")
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
want = afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1")
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
want)
raw = self.t.raw("VendorA", "filament", "A PLA @P1")
self.assertEqual(raw.count(b'"setting_id"'), 1) # replaced, not appended
self.assertEqual(raw, before.replace(b'"0000000000000000"',
b'"%s"' % want.encode()))
self.assertTrue(os.path.isfile(path))
def test_missing_value_is_inserted_before_filament_id(self):
self.t.write("VendorA", "filament",
preset("A PLA @P1", filament_id="OFabc123"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
text = self.t.raw("VendorA", "filament", "A PLA @P1").decode("utf-8")
want = afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1")
self.assertIn(f'"setting_id": "{want}"', text)
self.assertLess(text.index('"setting_id"'), text.index('"filament_id"'))
def test_base_profiles_are_stripped(self):
self.t.write("VendorA", "filament",
preset("A PLA @base", instantiation=False,
setting_id="0000000000000000", filament_id="OFabc123"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
data = self.t.read("VendorA", "filament", "A PLA @base")
self.assertNotIn("setting_id", data)
self.assertEqual(data["filament_id"], "OFabc123") # nothing else touched
self.assertEqual(data["instantiation"], "false")
def test_base_profile_without_instantiation_key_is_stripped(self):
# No "instantiation" key at all == not instantiated (str(None) != "true").
data = {"type": "filament", "name": "A PLA @root", "from": "system",
"setting_id": "0000000000000000"}
self.t.write("VendorA", "filament", data)
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
self.assertNotIn("setting_id", self.t.read("VendorA", "filament", "A PLA @root"))
def test_misspelled_settings_id_is_dropped(self):
self.t.write("VendorA", "filament",
preset("A PLA @base", instantiation=False,
settings_id="0000000000000000"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
data = self.t.read("VendorA", "filament", "A PLA @base")
self.assertNotIn("settings_id", data)
self.assertNotIn("setting_id", data)
def test_typo_drop_and_assignment_are_one_file_change(self):
self.t.write("VendorA", "filament",
preset("A PLA @P1", settings_id="0000000000000000",
filament_id="OFabc123"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out) # ONE counted file change
data = self.t.read("VendorA", "filament", "A PLA @P1")
self.assertNotIn("settings_id", data)
self.assertEqual(data["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
def test_reserved_vendor_keeps_instantiated_ids_and_loses_base_ones(self):
# BBL owns the authoritative "G*" cloud id space: its instantiated
# presets are never rewritten, its base declarations still are stripped.
self.t.write("BBL", "filament",
preset("Bambu ABS @BBL A1", setting_id="GFSB00_07"))
self.t.write("BBL", "filament", preset("Bambu ABS @P1 no id"))
self.t.write("BBL", "filament",
preset("Bambu ABS @base", instantiation=False,
setting_id="GFSB00_00"))
kept = self.t.raw("BBL", "filament", "Bambu ABS @BBL A1")
kept_idless = self.t.raw("BBL", "filament", "Bambu ABS @P1 no id")
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out) # only the base profile
self.assertEqual(self.t.raw("BBL", "filament", "Bambu ABS @BBL A1"), kept)
self.assertEqual(self.t.raw("BBL", "filament", "Bambu ABS @P1 no id"),
kept_idless)
self.assertNotIn("setting_id",
self.t.read("BBL", "filament", "Bambu ABS @base"))
def test_reserved_vendors_misspelled_key_is_corrected_not_dropped(self):
# A reserved vendor's id is authoritative, so there is no formula to
# fall back on. Dropping the typo and stopping there would leave the
# preset with no setting_id at all and no way for the tool to give it
# one - a validator error nothing can clear. Fix the key, keep the value.
self.t.write("BBL", "filament",
preset("Bambu PLA @P1", settings_id="GFSA00_01"))
# A base profile still just loses the key; it may not carry an id.
self.t.write("BBL", "filament",
preset("Bambu PLA @base", instantiation=False,
settings_id="GFSA00_00"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (2, 0), out)
fixed = self.t.read("BBL", "filament", "Bambu PLA @P1")
self.assertNotIn("settings_id", fixed)
self.assertEqual(fixed["setting_id"], "GFSA00_01")
base = self.t.read("BBL", "filament", "Bambu PLA @base")
self.assertNotIn("settings_id", base)
self.assertNotIn("setting_id", base)
# Idempotent: the corrected file is what the next run expects to see.
self.assertEqual(self.t.run()[:2], (0, 0))
def test_a_managed_vendors_misspelled_key_is_still_replaced_by_the_mint(self):
self.t.write("VendorA", "filament",
preset("A PLA @P1", settings_id="whatever"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
data = self.t.read("VendorA", "filament", "A PLA @P1")
self.assertNotIn("settings_id", data)
self.assertEqual(data["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
def test_reserved_vendors_constant(self):
self.assertEqual(afi.RESERVED_VENDORS, {"BBL"})
self.assertEqual(afi.PROFILE_SUBDIRS, ("filament", "process", "machine"))
# ---------------------------------------------------------------------------
# --vendor narrowing
# ---------------------------------------------------------------------------
class TestVendorNarrowing(SettingTreeCase):
def setUp(self):
super().setUp()
self.t.write("VendorA", "filament", preset("A PLA @P1"))
self.t.write("VendorB", "filament", preset("B PLA @P1"))
def test_restricts_writes_to_the_named_vendor(self):
before = self.t.bytes_map()
changed, errors, out = self.t.run(vendors=["VendorA"])
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
self.assertEqual(self.t.raw("VendorB", "filament", "B PLA @P1"),
before[os.path.join("VendorB", "filament", "B PLA @P1.json")])
# ... and the vendor left out is written by a later run, unchanged in kind.
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(self.t.read("VendorB", "filament", "B PLA @P1")["setting_id"],
afi.generate_preset_setting_id("VendorB", "filament", "B PLA @P1"))
def test_unknown_vendor_reports_and_writes_nothing(self):
before = self.t.bytes_map()
changed, errors, out = self.t.run(vendors=["Nope"])
self.assertEqual((changed, errors), (0, 1))
self.assertIn("Nope", out)
self.assertEqual(self.t.bytes_map(), before)
def test_unknown_vendor_blocks_the_known_ones_too(self):
before = self.t.bytes_map()
changed, errors, _out = self.t.run(vendors=["VendorA", "Nope"])
self.assertEqual((changed, errors), (0, 1))
self.assertEqual(self.t.bytes_map(), before)
# ---------------------------------------------------------------------------
# --dry-run
# ---------------------------------------------------------------------------
class TestDryRun(SettingTreeCase):
def test_writes_nothing_and_previews_the_real_run(self):
self.t.write("VendorA", "filament", preset("A PLA @P1"))
self.t.write("VendorA", "process",
preset("0.20mm Standard @P1", type_name="process"))
self.t.write("VendorA", "filament",
preset("A PLA @base", instantiation=False,
setting_id="0000000000000000"))
before = self.t.bytes_map()
dry_changed, dry_errors, out = self.t.run(dry_run=True)
self.assertEqual((dry_changed, dry_errors), (3, 0), out)
self.assertIn("would", out)
self.assertEqual(self.t.bytes_map(), before) # nothing written
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (dry_changed, dry_errors), out)
self.assertNotEqual(self.t.bytes_map(), before)
# ---------------------------------------------------------------------------
# byte preservation
# ---------------------------------------------------------------------------
class TestBytePreservation(SettingTreeCase):
CRLF_TEXT = (
'{\r\n'
'\t"type": "filament",\r\n'
'\t"name": "CRLF PLA @P1",\r\n'
'\t"from": "system",\r\n'
'\t"filament_id": "OFabc123",\r\n'
'\t"instantiation": "true",\r\n'
'\t"filament_type": [\r\n'
'\t\t"PLA"\r\n'
'\t]\r\n'
'}\r\n'
)
def test_crlf_and_tab_indentation_survive(self):
self.t.write_raw("VendorA", "filament", "CRLF PLA @P1",
self.CRLF_TEXT.encode("utf-8"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
raw = self.t.raw("VendorA", "filament", "CRLF PLA @P1")
self.assertEqual(raw.count(b"\n"), raw.count(b"\r\n")) # still CRLF-only
want = afi.generate_preset_setting_id("VendorA", "filament", "CRLF PLA @P1")
inserted = ('\t"setting_id": "%s",\r\n' % want).encode("utf-8")
# Every original byte survives: dropping the inserted line restores the file.
self.assertEqual(raw.replace(inserted, b"", 1),
self.CRLF_TEXT.encode("utf-8"))
def test_bom_survives(self):
raw_in = b"\xef\xbb\xbf" + json.dumps(
preset("BOM PLA @P1", filament_id="OFabc123"),
indent=4, ensure_ascii=False).encode("utf-8") + b"\n"
self.t.write_raw("VendorA", "filament", "BOM PLA @P1", raw_in)
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
raw = self.t.raw("VendorA", "filament", "BOM PLA @P1")
self.assertTrue(raw.startswith(b"\xef\xbb\xbf"))
self.assertEqual(raw.count(b"\xef\xbb\xbf"), 1)
want = afi.generate_preset_setting_id("VendorA", "filament", "BOM PLA @P1")
self.assertEqual(self.t.read("VendorA", "filament", "BOM PLA @P1")["setting_id"],
want)
self.assertEqual(
raw.replace((' "setting_id": "%s",\n' % want).encode("utf-8"), b"", 1),
raw_in)
def test_non_ascii_name_round_trips(self):
name = "拓竹 PLA @P1 0.4 nozzle"
raw_in = (json.dumps(preset(name, filament_id="OFabc123"), indent=4,
ensure_ascii=False).encode("utf-8") + b"\n")
self.t.write_raw("VendorA", "filament", name, raw_in)
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
raw = self.t.raw("VendorA", "filament", name)
self.assertIn(name.encode("utf-8"), raw) # not escaped to \uXXXX
data = self.t.read("VendorA", "filament", name)
self.assertEqual(data["name"], name)
self.assertEqual(data["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", name))
def test_surrounding_formatting_is_untouched_on_a_strip(self):
text = ('{\r\n'
'\t"type": "filament",\r\n'
'\t"name": "Odd @base",\r\n'
'\t"setting_id": "0000000000000000",\r\n'
'\t"instantiation": "false",\r\n'
'\t"compatible_printers": []\r\n'
'}\r\n')
self.t.write_raw("VendorA", "filament", "Odd @base", text.encode("utf-8"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(
self.t.raw("VendorA", "filament", "Odd @base").decode("utf-8"),
text.replace('\t"setting_id": "0000000000000000",\r\n', "", 1))
# ---------------------------------------------------------------------------
# byte-preserving key edits
# ---------------------------------------------------------------------------
class TestInsertAnchor(unittest.TestCase):
WITH_BOTH = ('{\n "name": "K @P1",\n "filament_id": "OFabc123",\n'
' "instantiation": "true"\n}\n')
WITH_INSTANTIATION = ('{\n "name": "K @P1",\n'
' "instantiation": "true"\n}\n')
def test_inserts_before_filament_id(self):
text, n = afi.insert_setting_id(self.WITH_BOTH, "0123456789abcdef")
self.assertEqual(n, 1)
json.loads(text)
self.assertEqual(text, self.WITH_BOTH.replace(
' "filament_id"',
' "setting_id": "0123456789abcdef",\n "filament_id"', 1))
def test_falls_back_to_instantiation(self):
text, n = afi.insert_setting_id(self.WITH_INSTANTIATION, "0123456789abcdef")
self.assertEqual(n, 1)
json.loads(text)
self.assertIn('"setting_id": "0123456789abcdef",\n "instantiation"', text)
def test_falls_back_to_name(self):
# Last-resort anchor: every preset has a name, so the insert does not
# depend on filament_id having been written first — a dry run, which
# writes none, must reach the same verdict as the real run.
text, n = afi.insert_setting_id('{\n "name": "K",\n "x": 1\n}\n',
"0123456789abcdef")
self.assertEqual(n, 1)
json.loads(text)
self.assertIn('"name": "K",\n "setting_id": "0123456789abcdef",', text)
def test_no_anchor_returns_zero(self):
src = '{\n "type": "filament"\n}\n'
text, n = afi.insert_setting_id(src, "0123456789abcdef")
self.assertEqual(n, 0)
self.assertEqual(text, src)
class TestKeyLineHelpers(unittest.TestCase):
def test_delete_trailing_comma_form(self):
text = ('{\n "name": "K",\n "setting_id": "OLD0000000000000",\n'
' "instantiation": "false"\n}\n')
out, n = afi.delete_key_line(text, "setting_id")
self.assertEqual(n, 1)
self.assertEqual(json.loads(out), {"name": "K", "instantiation": "false"})
self.assertEqual(out, text.replace(
' "setting_id": "OLD0000000000000",\n', "", 1))
def test_delete_last_property_form_consumes_the_preceding_comma(self):
text = ('{\n "name": "K",\n "instantiation": "false",\n'
' "setting_id": "OLD0000000000000"\n}\n')
out, n = afi.delete_key_line(text, "setting_id")
self.assertEqual(n, 1)
self.assertEqual(json.loads(out), {"name": "K", "instantiation": "false"})
self.assertEqual(out, '{\n "name": "K",\n "instantiation": "false"\n}\n')
def test_delete_requires_the_exact_old_value(self):
text = ('{\n "name": "K",\n "setting_id": "OLD0000000000000",\n'
' "instantiation": "false"\n}\n')
out, n = afi.delete_key_line(text, "setting_id", old_value="OTHER")
self.assertEqual((out, n), (text, 0))
_out, n = afi.delete_key_line(text, "setting_id",
old_value="OLD0000000000000")
self.assertEqual(n, 1)
def test_delete_missing_key_returns_zero(self):
text = '{\n "name": "K"\n}\n'
self.assertEqual(afi.delete_key_line(text, "setting_id"), (text, 0))
def test_delete_does_not_confuse_the_two_spellings(self):
text = ('{\n "name": "K",\n "settings_id": "TYPO000000000000",\n'
' "setting_id": "REAL000000000000",\n'
' "instantiation": "true"\n}\n')
out, n = afi.delete_key_line(text, "settings_id")
self.assertEqual(n, 1)
self.assertEqual(json.loads(out)["setting_id"], "REAL000000000000")
def test_replace_refuses_a_stale_old_value(self):
text = '{\n "setting_id": "OLD0000000000000"\n}\n'
out, n = afi.replace_key_value(text, "setting_id", "NEW0000000000000",
old_value="NOTTHIS000000000")
self.assertEqual((out, n), (text, 0))
out, n = afi.replace_key_value(text, "setting_id", "NEW0000000000000",
old_value="OLD0000000000000")
self.assertEqual(n, 1)
self.assertEqual(json.loads(out)["setting_id"], "NEW0000000000000")
def test_insert_key_line_prefers_before_over_after(self):
text = ('{\n "name": "K",\n "filament_id": "OFabc123",\n'
' "instantiation": "true"\n}\n')
out, n = afi.insert_key_line(text, "setting_id", "V",
before=("filament_id", "instantiation"),
after=("name",))
self.assertEqual(n, 1)
self.assertLess(out.index('"setting_id"'), out.index('"filament_id"'))
self.assertGreater(out.index('"setting_id"'), out.index('"name"'))
# The `before` tuple's own order decides, not the order in the file.
out, _n = afi.insert_key_line(text, "setting_id", "V",
before=("instantiation", "filament_id"))
self.assertGreater(out.index('"setting_id"'), out.index('"filament_id"'))
def test_insert_key_line_falls_back_to_after(self):
text = '{\n "name": "K",\n "from": "system"\n}\n'
out, n = afi.insert_key_line(text, "setting_id", "V",
before=("filament_id",), after=("name",))
self.assertEqual(n, 1)
self.assertEqual(out, '{\n "name": "K",\n "setting_id": "V",\n'
' "from": "system"\n}\n')
def test_insert_key_line_without_any_anchor(self):
text = '{\n "from": "system"\n}\n'
self.assertEqual(
afi.insert_key_line(text, "setting_id", "V", before=("filament_id",),
after=("name",)),
(text, 0))
# ---------------------------------------------------------------------------
# error paths: reported and counted, never raised
# ---------------------------------------------------------------------------
class TestErrorPaths(SettingTreeCase):
def test_unparsable_profile_is_reported_and_the_run_continues(self):
broken = b'{\n "name": "Broken @P1",\n oops\n}\n'
self.t.write_raw("VendorA", "filament", "Broken @P1", broken)
self.t.write("VendorA", "filament", preset("Good @P1"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("Broken @P1", out)
self.assertEqual(self.t.raw("VendorA", "filament", "Broken @P1"), broken)
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
def test_non_object_top_level_is_reported(self):
raw = b'[\n {"name": "K"}\n]\n'
self.t.write_raw("VendorA", "filament", "List @P1", raw)
self.t.write("VendorA", "filament", preset("Good @P1"))
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("List @P1", out)
self.assertEqual(self.t.raw("VendorA", "filament", "List @P1"), raw)
def test_nameless_instantiated_preset_is_reported(self):
nameless = {"type": "filament", "from": "system", "instantiation": "true"}
self.t.write("VendorA", "filament", nameless, name="Nameless")
self.t.write("VendorA", "filament", preset("Good @P1"))
before = self.t.raw("VendorA", "filament", "Nameless")
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("Nameless", out)
self.assertEqual(self.t.raw("VendorA", "filament", "Nameless"), before)
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
def test_a_nameless_preset_still_gets_its_misspelled_key_dropped(self):
# The nameless-preset error must not abandon an edit already queued for
# the same file: leaving "settings_id" behind would keep the validator
# red with no way for this tool to clear it.
nameless = {"type": "filament", "from": "system", "settings_id": "JUNK123",
"instantiation": "true"}
self.t.write("VendorA", "filament", nameless, name="Nameless")
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (1, 1), out)
self.assertNotIn("settings_id", self.t.read("VendorA", "filament", "Nameless"))
self.assertNotIn("setting_id", self.t.read("VendorA", "filament", "Nameless"))
self.assertIn('misspelled "settings_id" dropped : 1', out)
def test_an_unanchorable_file_is_reported_not_raised(self):
# One oddly formatted profile must not abort the pass over all the
# others, and it must fail the same way in a dry run as in a real one.
raw = b'{ "type":"filament", "name":"Flat @P1", "instantiation":"true" }\n'
self.t.write_raw("VendorA", "filament", "Flat @P1", raw)
self.t.write("VendorA", "filament", preset("Good @P1"))
dry_changed, dry_errors, dry_out = self.t.run(dry_run=True)
changed, errors, out = self.t.run()
self.assertEqual((dry_changed, dry_errors), (changed, errors), dry_out)
self.assertEqual((changed, errors), (1, 1), out)
self.assertIn("Flat @P1", out)
self.assertEqual(self.t.raw("VendorA", "filament", "Flat @P1"), raw)
self.assertEqual(self.t.read("VendorA", "filament", "Good @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "Good @P1"))
def test_nameless_base_preset_is_fine(self):
# Only instantiated presets need an identity; a nameless base profile is
# simply left alone.
nameless = {"type": "filament", "from": "system", "instantiation": "false"}
self.t.write("VendorA", "filament", nameless, name="Nameless base")
before = self.t.bytes_map()
changed, errors, out = self.t.run()
self.assertEqual((changed, errors), (0, 0), out)
self.assertEqual(self.t.bytes_map(), before)
# ---------------------------------------------------------------------------
# the real tree
# ---------------------------------------------------------------------------
@unittest.skipUnless(os.path.isdir(REAL_PROFILES), "resources/profiles not present")
class TestRealTree(unittest.TestCase):
def test_shipped_tree_needs_no_setting_id_change(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
changed, errors = afi.generate_setting_ids(REAL_PROFILES, dry_run=True)
self.assertEqual((changed, errors), (0, 0), buf.getvalue())
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
class TestCli(SettingTreeCase):
def main(self, argv):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
rc = afi.main(argv)
return rc, buf.getvalue()
def test_setting_id_only_run_touches_no_filament_id(self):
# "OFZZZZZZ" is not the mint of the preset's own triple, so the filament
# half of --generate has real work waiting on this tree (proven at the
# end): leaving the id alone is the narrowing's doing, not an idle tree.
self.t.write("VendorA", "filament",
preset("A PLA @P1", filament_id="OFZZZZZZ",
filament_vendor=["AV"], filament_type=["PLA"]))
self.t.write("VendorA", "machine",
preset("P1 0.4 nozzle", type_name="machine"))
rc, out = self.main(["--generate", "--setting-id",
"--profiles", self.t.profiles])
self.assertEqual(rc, 0, out)
filament = self.t.read("VendorA", "filament", "A PLA @P1")
self.assertEqual(filament["filament_id"], "OFZZZZZZ")
self.assertEqual(filament["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament", "A PLA @P1"))
machine = self.t.read("VendorA", "machine", "P1 0.4 nozzle")
self.assertNotIn("filament_id", machine)
self.assertEqual(machine["setting_id"],
afi.generate_preset_setting_id("VendorA", "machine", "P1 0.4 nozzle"))
# ... and the skipped half does re-mint that id when it is allowed to run.
changed, errors, out = self.t.run_filament_ids()
self.assertEqual((changed, errors), (1, 0), out)
self.assertEqual(
self.t.read("VendorA", "filament", "A PLA @P1")["filament_id"],
afi.generate_filament_id("AV", "PLA", "A PLA"))
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",
"--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",
"--profiles", self.t.profiles])
self.assertEqual(rc, 0, out)
self.assertIn("1 file(s) changed", out)
self.assertEqual(self.t.read("VendorA", "filament", "A PLA @P1")["setting_id"],
afi.generate_preset_setting_id("VendorA", "filament",
"A PLA @P1"))
def test_setting_id_without_generate_is_a_usage_error(self):
with contextlib.redirect_stderr(io.StringIO()), \
self.assertRaises(SystemExit) as cm:
afi.main(["--setting-id", "--profiles", self.t.profiles])
self.assertEqual(cm.exception.code, 2)
if __name__ == "__main__":
unittest.main()
+246
View File
@@ -0,0 +1,246 @@
#!/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)
to Bambu Lab's own AMS/RFID catalog id ("GF..." etc.) for the subset of filament
products Bambu ships.
The map is generated from BambuStudio's OWN shipped BBL bundle, never from
Orca's: Orca's BBL bundle is a fork of Bambu's, tuned and extended
independently, so it is not the source of truth for Bambu's catalog ids.
src/slic3r/Utils/BBLPrinterAgent.cpp loads it at runtime and translates an id
only where it crosses to or from a Bambu printer, so the correspondence never
has to be hand-maintained. See docs/HLSD/filament_id.md.
One row per BambuStudio filament PRODUCT: one named spool product = one
"@base"-declared filament_id, shared by every per-printer/per-nozzle
instantiation of it (BambuStudio follows the same one-product-one-id shape
Orca's own filament_id policy does). A row's key is the OF id that product's
(filament_vendor, filament_type, filament) triple mints — the id Orca carries
for it wherever it ships it, since the id is a function of the triple alone.
Map format:
{
"source": "https://github.com/bambulab/BambuStudio",
"bambustudio_commit": "66e405477",
"generated": "2026-09-04",
"filaments": {
"OFhuaUQB": {"bambu_id": "GFB00", "vendor": "Bambu Lab", "type": "ABS", "name": "Bambu ABS"}
}
}
Run from anywhere: python3 scripts/update_bambu_filament_ids.py
(default) shallow-clone BambuStudio's master branch (sparse: just
the BBL filament bundle) and regenerate the map
--bambustudio-dir DIR
read DIR (a BambuStudio resources/profiles checkout)
instead of cloning
--ref REF clone this BambuStudio ref instead of master
--output PATH write here instead of resources/printers/bambu_filament_ids.json
After writing, an informational drift report is printed: BambuStudio filaments
Orca ships nothing with the same identity for, and a count of Orca's own BBL
filaments that matched no BambuStudio row. Neither blocks the write; both are
for a human to read.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from orca_id_tool import ( # noqa: E402
BAMBU_MAP_PATH,
OFL,
PROFILES_DIR,
analyze_tree,
base_name,
generate_filament_id,
load_vendor_filaments,
print_error,
print_info,
print_success,
resolve_filament_id,
resolve_triple,
)
BAMBUSTUDIO_REPO = "https://github.com/bambulab/BambuStudio"
# ---------------------------------------------------------------------------
# Row derivation
# ---------------------------------------------------------------------------
def derive_rows(bs_filaments):
"""One row per BambuStudio product, keyed by the OF id its triple mints."""
by_filament = {} # filament_name -> (bambu_id, triple)
for rec in bs_filaments.values():
if not rec["instantiation"]:
continue
bambu_id, _src, _entry = resolve_filament_id(rec["name"], bs_filaments, {})
if not bambu_id:
continue
filament_name = base_name(rec["name"])
triple = resolve_triple(rec["name"], bs_filaments, {})
prev = by_filament.setdefault(filament_name, (bambu_id, triple))
if prev != (bambu_id, triple):
raise SystemExit(f"BambuStudio filament {filament_name!r} is not one "
f"product: {prev} vs {(bambu_id, triple)}")
rows, seen = {}, {}
for filament_name, (bambu_id, triple) in sorted(by_filament.items()):
if bambu_id in seen:
raise SystemExit(f"Bambu id {bambu_id} is shared by "
f"{seen[bambu_id]!r} and {filament_name!r}")
seen[bambu_id] = filament_name
rows[generate_filament_id(*triple)] = {
"bambu_id": bambu_id, "vendor": triple[0], "type": triple[1], "name": triple[2]}
return rows
# ---------------------------------------------------------------------------
# Drift report (informational only)
# ---------------------------------------------------------------------------
def drift_report(rows, orca_analysis):
"""Two triple-identity comparisons between the map just derived and Orca's
own BBL bundle (never id-based: the two bundles assign filament_id
independently, so only the (vendor, type, name) identity is comparable).
Returns print-ready lines: one per BambuStudio row triple with no
same-triple filament in Orca's BBL bundle (upstream ships it, we ship
nothing with that identity there — sometimes a genuinely missing product,
sometimes a renamed one), then a count of Orca's own BBL filaments that
matched no BambuStudio row (Orca-only products, e.g. a name-drifted
duplicate of one already counted in the first list).
"""
row_triples = {(r["vendor"], r["type"], r["name"]) for r in rows.values()}
bbl_filaments = orca_analysis["vendors"].get("BBL", {})
ofl_filaments = orca_analysis["vendors"].get(OFL, {})
orca_filaments = {}
for rec in bbl_filaments.values():
if not rec["instantiation"]:
continue
triple = resolve_triple(rec["name"], bbl_filaments, ofl_filaments)
orca_filaments.setdefault(base_name(rec["name"]), triple)
lines = []
for triple in sorted(row_triples - set(orca_filaments.values())):
lines.append(f'upstream ships {triple[2]!r} ({triple[0]}/{triple[1]}), '
"we ship nothing with that identity")
orca_only = [name for name, triple in orca_filaments.items()
if triple not in row_triples]
lines.append(f"Orca BBL filaments with no row: {len(orca_only)} Orca-only product(s)")
return lines
# ---------------------------------------------------------------------------
# Map IO
# ---------------------------------------------------------------------------
def write_map(path, rows, commit, date):
"""Write the map: sorted keys, indent 2, LF, trailing newline."""
payload = {
"source": BAMBUSTUDIO_REPO,
"bambustudio_commit": commit,
"generated": date,
"filaments": rows,
}
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(payload, f, indent=2, ensure_ascii=False, sort_keys=True)
f.write("\n")
# ---------------------------------------------------------------------------
# BambuStudio fetch
# ---------------------------------------------------------------------------
def run(command, cwd=None):
"""Run a command, streaming its output; raise SystemExit on failure."""
print("+ " + " ".join(command))
try:
subprocess.run(command, cwd=cwd, check=True)
except subprocess.CalledProcessError as e:
raise SystemExit(f"command failed ({e.returncode}): {' '.join(command)}") from e
def run_out(command, cwd=None):
"""Run a command and return its stripped stdout; raise SystemExit on failure."""
try:
result = subprocess.run(command, cwd=cwd, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as e:
raise SystemExit(
f"command failed ({e.returncode}): {' '.join(command)}\n{e.stderr}") from e
return result.stdout.strip()
def fetch_bambustudio(ref, workdir):
run(["git", "clone", "--depth=1", "--filter=blob:none", "--sparse", "--branch", ref,
BAMBUSTUDIO_REPO + ".git", workdir])
run(["git", "-C", workdir, "sparse-checkout", "set", "--no-cone",
"resources/profiles/BBL.json", "resources/profiles/BBL/filament"])
return os.path.join(workdir, "resources", "profiles"), run_out(["git", "-C", workdir, "rev-parse", "--short", "HEAD"])
def local_dir_commit(dir_path):
"""git rev-parse --short HEAD of dir_path, or "local" when it is not a repo."""
result = subprocess.run(["git", "-C", dir_path, "rev-parse", "--short", "HEAD"],
capture_output=True, text=True)
return result.stdout.strip() if result.returncode == 0 else "local"
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main(argv=None):
parser = argparse.ArgumentParser(
description="Regenerate resources/printers/bambu_filament_ids.json from "
"BambuStudio's shipped BBL bundle.")
source = parser.add_mutually_exclusive_group()
source.add_argument("--bambustudio-dir", metavar="DIR",
help="a BambuStudio resources/profiles directory to read "
"instead of cloning")
source.add_argument("--ref", default="master",
help='BambuStudio git ref to shallow-clone when '
'--bambustudio-dir is not given (default: "master")')
parser.add_argument("--output", default=BAMBU_MAP_PATH,
help="output path (default: resources/printers/bambu_filament_ids.json)")
args = parser.parse_args(argv)
workdir = None
if args.bambustudio_dir:
profiles_dir = args.bambustudio_dir
commit = local_dir_commit(profiles_dir)
else:
workdir = tempfile.mkdtemp(prefix="bambustudio_")
profiles_dir, commit = fetch_bambustudio(args.ref, workdir)
try:
bs_filaments, errors = load_vendor_filaments(profiles_dir, "BBL")
if errors:
for e in errors:
print_error(e)
raise SystemExit("unreadable BambuStudio filament profile(s)")
rows = derive_rows(bs_filaments)
write_map(args.output, rows, commit, datetime.date.today().isoformat())
print_success(f"wrote {len(rows)} row(s) to {args.output} (BambuStudio @ {commit})")
for line in drift_report(rows, analyze_tree(PROFILES_DIR)):
print_info(line)
finally:
if workdir:
shutil.rmtree(workdir, ignore_errors=True)
return 0
if __name__ == "__main__":
sys.exit(main())