Merge branch 'main' into pr/tommasobbianchi/15238

This commit is contained in:
SoftFever
2026-09-08 14:28:31 +08:00
4759 changed files with 44361 additions and 18139 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"
+11 -4
View File
@@ -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
@@ -324,6 +325,12 @@ modules:
sha256: 0ba163956f2d468b19a91b96c5aba66ee9610843ea41dda628ea44cdafde7db7
dest: external-packages/wxInspector
# FFmpeg n7.0.3
- type: file
url: https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.0.3.tar.gz
sha256: deedcabe339165214a3637df4c86a507aef0d793cf8774ff68735f4737e8ddbc
dest: external-packages/FFMPEG
# ---------------------------------------------------------------
# Fallback archives for deps normally provided by the GNOME SDK.
# These are only used if find_package() fails to locate them.
+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
+3
View File
@@ -25,6 +25,9 @@ export REQUIRED_DEV_PACKAGES=(
wayland-protocols
webkit2gtk-4.1
wget
pkgconf
yasm
nasm
)
if [[ -n "$UPDATE_LIB" ]]
+3
View File
@@ -25,6 +25,9 @@ export REQUIRED_DEV_PACKAGES=(
wayland-protocols
webkit2gtk
wget
pkgconf
yasm
nasm
)
if [[ -n "$UPDATE_LIB" ]]
+1
View File
@@ -20,6 +20,7 @@ export REQUIRED_BUNDLES=(
perl-basic
texinfo
wget
nasm
)
if [[ -n "$UPDATE_LIB" ]]
+3
View File
@@ -27,6 +27,9 @@ REQUIRED_DEV_PACKAGES=(
ninja-build
texinfo
wget
pkgconf
yasm
nasm
)
if [[ -n "$UPDATE_LIB" ]]
+3
View File
@@ -31,6 +31,9 @@ REQUIRED_DEV_PACKAGES=(
webkit2gtk4.1-devel
wget
libcurl-devel
pkgconf
yasm
nasm
)
if [[ -n "$UPDATE_LIB" ]]
+3
View File
@@ -32,6 +32,9 @@ REQUIRED_DEV_PACKAGES=(
sys-devel/m4
virtual/libudev
x11-libs/gtk+:3
dev-util/pkgconf
dev-lang/yasm
dev-lang/nasm
)
if [[ -n "$UPDATE_LIB" ]]
+3
View File
@@ -30,6 +30,9 @@ REQUIRED_DEV_PACKAGES=(
webkit2gtk4-devel
wget
libcurl-devel
pkgconf
yasm
nasm
)
if [[ -n "$UPDATE_LIB" ]]
+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
+963
View File
@@ -0,0 +1,963 @@
<#
.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
.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
# The pack stamp is checked against real dates, so a locale-dependent parse
# in the script cannot pass by looking date-shaped. Yesterday is accepted too,
# so a run that crosses midnight does not flake.
$dateStamps = @((Get-Date -Format 'yyyyMMdd'), (Get-Date).AddDays(-1).ToString('yyyyMMdd'))
$stampPattern = '_(' + ($dateStamps -join '|') + ')\.zip$'
$cases = @(
'argument handling'
@{ Name = 'no arguments prints help'; Args = @(); DryRun = $false
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')
Match = @($stampPattern) }
# powershell.exe is not in System32 itself, so a trimmed PATH used to
# leave the stamp empty and the bundle named OrcaSlicer_dep_win-x64_.zip.
@{ Name = 'the bundle is stamped even with a bare PATH'; Args = @('-p')
Env = @{ PATH = 'C:\Windows\system32;C:\Windows' }
Match = @($stampPattern) }
@{ 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'
)
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'] }
$result = Invoke-BuildScript -Arguments $argv -Environment $Case['Env']
$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/"
}
}
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())