Upgrading pyldraw3 from 1.2.0 to 1.3.0¶
Unlike 1.2.0's purely additive release, 1.3.0 is a correctness release: it fixes long-standing behavioral bugs, which means some results change — deliberately, because the old results were wrong. The headline fixes are a Y-axis rotation that turned the wrong way, bills of materials that never resolved the main colour (16) through submodels, an import hook that relied on a deprecated CPython code path, and a parts loader that a single malformed line could silently truncate. Alongside the fixes, whole subsystems became fail-fast (colour parsing, config loading) or fail-soft-with-warnings (parts.lst loading, categorization) instead of failing silently or catastrophically.
This guide covers every behavior change in the release: what changed, why the new behavior is correct, and what (if anything) you need to do.
At a glance¶
Results that change (the old ones were wrong)
| Change | Old behavior | New behavior |
|---|---|---|
Matrix.rotate(angle, YAxis) |
Rotated opposite to X/Z (transposed matrix) | Cyclic convention: 90° about Y maps +Z→+X |
Matrix.scale(...) |
Pre-multiplied (world frame), unlike rotate |
Post-multiplies (local frame), matching rotate |
bill_of_materials() / ldraw bom |
Colour 16 inside submodels counted as 16 | Resolved to the colour the submodel was placed with |
Model.add_submodel(same_object) |
Raised DuplicateSubmodelError |
Accepted; appends another placement piece |
StudReference.is_top_stud / Parts.stud_positions |
Counted sideways and inverted studs | Orientation-aware via the new StudReference.up vector |
| Validator colour checks | Warned on real colours in 256–511; missed decimal/uppercase direct colours | Catalog membership checked first; all direct-colour spellings accepted |
Person limb defaults |
3815b/3816b/3817b (absent pre-2019), hair as the default "hat" |
Plain 3815/3816/3817, plain head 3626b, hat 3624 |
Silent failures that now raise
| Input | Old behavior | New behavior |
|---|---|---|
Unparseable colour token (1 blue 0 0 0 …) |
Parsed; crashed later in to_ldraw() with a bare ValueError |
InvalidColourValueError at parse time, with file/line context |
Comment("NOFILE") / Comment("FILE x") serialized |
Silent data loss on the next parse | StructuralCommentError from to_ldraw() / save() |
0 NOFILE before any 0 FILE |
Rest of the document silently discarded | MisplacedNofileError |
Empty part file .description |
Bare StopIteration |
EmptyPartFileError |
Malformed/corrupt config.yml |
AttributeError / raw yaml.YAMLError |
ConfigLoadError naming the path and problem |
Silent breakage that now warns and continues
| Input | Old behavior | New behavior |
|---|---|---|
Malformed parts.lst line |
Everything after it silently dropped | Warning; loading continues |
| Part file missing or unparseable during categorization | Whole catalog build aborted | Warning; that part is skipped |
Descriptions containing .dat |
Truncated the catalog | Parsed correctly (split at the first .dat only) |
Removals affecting your code
| Removed | Replacement |
|---|---|
LibraryImporter.load_module / find_module / get_code |
The modern find_spec protocol (see below) |
Vector.norm() (in-place, returned None) |
Vector.normalized() (non-mutating copy) |
Vector.__rsub__ / __cmp__, Vector2D.__rsub__ / __cmp__ |
Dead in Python 3; nothing to migrate |
ldraw.utils.flatten, ldraw.schemas |
Unused; nothing to migrate |
NoLibrarySelectedError |
Was never raised; UnwritableOutputError is now actually raised |
Upgrade checklist¶
- Upgrade the package:
uv add "pyldraw3>=1.3.0"(orpip install -U pyldraw3). - Do nothing for caches: both the generated
ldraw.librarypackage and thecatalog.sqliteindex self-heal. 1.3.0 bumps the generation schema (v3) and the catalog schema (v4), so the first run after upgrading regenerates both automatically. - If you construct rotations about the Y axis, or chain
.rotate(...).scale(...), re-check the results — the fixed math is almost certainly what you meant, but serialized output changes (see below). - If you postprocess BOM output for models that recolour submodels,
expect rows split by the placed colour instead of one
colour_code=16row. - If you catch parse errors, note that context-wrapped errors now keep
their concrete class (
InvalidNumericValueError,InvalidColourValueError, …) —except PartErrorstill catches all of them.
Geometry: the Y-rotation fix and local-frame scaling¶
The Y-axis rotation matrix was the transpose of the convention used for X and Z, so composing "the same" 90° turns about different axes produced mirrored results:
from ldraw.geometry import Identity, Vector, XAxis, YAxis, ZAxis
# 1.3.0 — all three axes are cyclic:
Identity().rotate(90, XAxis) * Vector(0, 1, 0) # → (0, 0, 1): ŷ→ẑ
Identity().rotate(90, YAxis) * Vector(0, 0, 1) # → (1, 0, 0): ẑ→x̂ (was (-1, 0, 0))
Identity().rotate(90, ZAxis) * Vector(1, 0, 0) # → (0, 1, 0): x̂→ŷ
Serialized matrices for Y rotations change sign on the two sine terms; a
model built with rotate(angle, YAxis) now turns the direction the other
axes always did. If you compensated for the old behavior by negating Y
angles, remove the compensation.
Matrix.scale previously pre-multiplied (applying the scale in the world
frame) while rotate post-multiplied (local frame), so chained calls
silently mixed frames. Both now compose in the local frame:
m = Identity().rotate(90, ZAxis).scale(2, 1, 1)
m * Vector(1, 0, 0) # → (0, 2, 0): the *local* X axis was scaled
# Equivalent identity that now holds:
m == Identity().rotate(90, ZAxis) * Identity().scale(2, 1, 1)
Other geometry API changes:
vector * scalarnow works (previously onlyscalar * vector).matrix * unsupportedraisesTypeError(was a misleadingMatrixError("Invalid axis specified.")), and reflected operators of other types now get their chance.- Mutable
Matrix/Vector/Vector2Dare unhashable, likelist: their value-based hashes silently corrupted sets and dict keys when mutated. Use.copy()+ a tuple key if you need one. Vector.norm()(which normalized in place, returnedNone, and crashed withZeroDivisionErroron the zero vector) is replaced byVector.normalized(), which returns a unit-length copy and raisesValueErrorfor zero-length input.CoordinateSystemfields are non-optional with unit-vector defaults;projectno longer has an unreachableMatrixErrorbranch.
BOM: colour 16 resolves through submodels¶
bill_of_materials() (and ldraw bom) iterated raw pieces, so a
colour-16 brick inside a submodel was counted as "Main_Colour" no matter
what colour the submodel was placed with. It now traverses resolved
occurrences (Model.iter_occurrences), so the placed colour wins:
root = Model(name="main.ldr")
arm = Model(name="arm.ldr", objects=[Piece.place("3001", colour=16)])
root.add_submodel(arm, colour=4)
root.bill_of_materials()
# 1.2.0: [BomRow(part="3001", colour_code=16, quantity=1)]
# 1.3.0: [BomRow(part="3001", colour_code=4, quantity=1)]
Root-level colour-16 pieces still report code 16 (there is nothing to inherit from). Placing the same submodel in two colours now yields two rows, one per placed colour.
Models: repeated submodels, structural safety, identity semantics¶
add_submodel accepts the same object again. Placing a subassembly
twice — and diamond-sharing a nested submodel between two parents — is the
most common LEGO modeling pattern, and it used to raise
DuplicateSubmodelError:
root.add_submodel(wheel)
root.add_submodel(wheel, position=Vector(0, -24, 0)) # now: second placement
A different Model object under an already-registered name still
raises — that remains a genuine conflict.
Round-trip integrity is now guarded on both sides. Serializing a
comment whose text would re-parse as MPD structure (Comment("NOFILE"),
Comment("FILE x")) raises StructuralCommentError instead of silently
producing a document that loses content when re-read. Symmetrically,
parsing a document with 0 NOFILE before any 0 FILE raises
MisplacedNofileError instead of silently discarding everything after it.
Model and ModelOccurrence compare and hash by identity. Field-wise
Model equality was already meaningless (pieces compare by identity, and
the internal source-line map made two parses of identical text unequal);
now it is honest, and both types are hashable again. Compare content with
m1.to_ldraw() == m2.to_ldraw().
Smaller model fixes:
Model.source_line_for(piece)answers only for the exact parsed piece object; the old id-keyed map could return another piece's line after object churn.Model.stepsdrops all trailing empty step groups, as its docstring always claimed.Model.descriptionreturnsNonewhen the first line is a managed header (Name:,Author:,BFC), consistent withset_header.Piece.place("car.ldr", suffix=".ldr")no longer producescar.ldr.ldr.
Colour parsing: fail-fast, and all direct-colour spellings¶
colour_from_str silently returned None for unparseable tokens,
producing a Colour with neither code nor rgb that exploded much later —
in to_ldraw(), with a bare ValueError and no hint where the bad token
came from. Colour fields now fail at parse time with
InvalidColourValueError (both a PartError and a ValueError) carrying
file and line context.
Direct colours are also parsed in every spelling other tools accept:
colour_from_str("0x2FF0000") # canonical
colour_from_str("0X2FF0000") # uppercase prefix — was UnknownCommandError
colour_from_str("50266112") # the same value in decimal — was a plain code
# all three → Colour(rgb="#FF0000", alpha=255)
Non-canonical spellings re-serialize canonically as 0x2RRGGBB, so
byte-level round-trips change for these (rare) inputs. The 3-digit
0x2RGB shorthand is now rejected in files — CSS-style shorthand
expansion remains available in Colour(rgb="#f00") for user-constructed
colours, where it is a convenience rather than a spec violation.
Part files are read as utf-8-sig, so a UTF-8 BOM no longer breaks part
parsing (models already tolerated it).
Validator: fewer false positives¶
- Colour codes are checked against the catalog before the legacy-dithered range warning, so real LDConfig colours in 256–511 (Dark_Blue 272, Dark_Green 288, Sand_Blue 379, Dark_Orange 484, …) no longer warn.
!LDCADand!STUDIOare recognized meta-commands — files authored in LDCad or BrickLink Studio no longer produce per-line warning spam.- Decimal and uppercase-prefix direct colours validate clean; garbage
colour tokens report
Invalid colour value '<token>'(previouslyinvalid colour valuewith no token). ldraw validateandldraw bomreport non-UTF-8 input as an error and exit 1 instead of crashing with aUnicodeDecodeErrortraceback.
Stud geometry is orientation-aware¶
StudReference gains an up vector — the image of the stud's local up
direction under the placing transforms. is_top_stud (and therefore
Parts.stud_positions) uses it to exclude sideways and inverted studs:
parts.stud_positions("4070") # Headlight Brick
# 1.2.0: included the sideways front stud — a brick cannot sit on it
# 1.3.0: only the genuine top stud
The import hook uses the modern loader protocol¶
LibraryImporter.find_spec previously returned a spec whose loader
implemented none of the modern protocol, so every ldraw.library import
went through CPython's deprecated load_module() fallback — emitting
ImportWarning on current Pythons, breaking outright under
-W error::ImportWarning (or a filterwarnings = error test suite), and
scheduled for removal from CPython. It now returns a standard file-backed
spec, and CPython performs the load itself.
Behavior changes you might notice:
- Imports are silent under
-W error::ImportWarning. - When no generated library exists,
find_specreturnsNone:importlib.util.find_spec("ldraw.library")is now a truthful availability probe, and the import raises a standardModuleNotFoundError. A missing submodule of an existing generated library still raisesCouldNotFindModuleErrornaming both candidate paths. - The legacy
load_module,find_module, andget_codemembers are gone. If you calledload_moduledirectly, use a normalimportorldraw.imports.load_lib(generated_path, fullname). set_config/cleanare thread-safe. Reconfiguration now waits for in-flight generated-module imports to finish before evicting them, rather than deleting theirsys.modulesentries during execution. A spec resolved before reconfiguration but not yet executing is rejected withStaleModuleSpecError; retry that import against the new configuration.
Parts loading: resilient instead of brittle¶
One bad line in parts.lst used to silently truncate the whole
catalog (loading stopped at the first line that did not split cleanly),
and one missing .dat file aborted the entire categorization pass with
PartNotFoundError, taking ldraw generate, parts, and bom down with
it. Both now warn and continue: the bad line is skipped, the unloadable
part gets no catalog entry (it stays visible in by_code/by_name), and
everything else loads. Descriptions containing .dat — which used to
trigger the truncation — parse correctly.
Also in this area:
- Lazy categorization is thread-safe; concurrent first accesses no longer duplicate category entries in a shared instance.
Parts.getexpands~in paths, soParts.get("~/ldraw/parts.lst")works as documented.PartsCatalog.addon an existing code now replaces the entry in every index (previously stale entries lingered in the category lists).- Cross-prefix description collisions (e.g.
Minifig Torso PlainvsTorso Plainin one module) no longer silently drop a part — both survive to symbol generation, where collisions get part-code suffixes.
Freshness: .dat edits now invalidate caches¶
The catalog index and the generated library were keyed only to the MD5 of
parts.lst (plus LDConfig), so editing a part file's !CATEGORY or
!KEYWORDS header in place left both serving stale data forever. Both
fingerprints now hash every file's relative path, size, and nanosecond mtime
under the parts/ and p/ trees. Unlike the earlier aggregate
count/total-size/newest-mtime scheme, renames and offsetting metadata changes
cannot collide. Schema versions remain catalog v4 and generation v3; an older
aggregate fingerprint mismatches automatically, so the first run after
upgrading rebuilds both.
Generated modules are also compile-checked before the generation is
marked fresh, and code values are emitted with repr() — a part code
containing a backslash can no longer be corrupted by octal-escape
interpretation, and a pathological description can no longer produce an
unimportable module (invalid identifiers are skipped with a warning;
digit-leading ones get a P prefix). Generated Colour objects now carry
the LDConfig name (ColoursByName["Two-tone"].name == "Two-tone"), not
the sanitized identifier. Distinct colour names that sanitize to the same
Python symbol now receive colour-code suffixes, so their ColoursByCode and
ColoursByName entries cannot silently reference the last assignment.
ldraw stubs clears the previous ldraw-stubs output before writing, so
stale .pyi files no longer advertise symbols that are gone.
CLI and configuration hardening¶
- Errors and diagnostics go to stderr. Piping
ldraw bomorldraw partsoutput no longer mixes error text into the data stream. ldraw parts searchnormalizes whitespace, so"arch 1 x 6"matches the column-aligned catalog descriptionArch 1 x 6.ldraw generatebeforeldraw downloadprintsrun `ldraw download` firstinstead of aFileNotFoundErrortraceback, and an unwritable output directory actually produces the friendlyUnwritableOutputErrormessage (the handler existed; the exception was never raised).- A malformed
config.yml(bad YAML, a list instead of a mapping, a falsey scalar, a non-string value, or a directory at the path) raisesConfigLoadErrorwith the path and reason — previously every CLI command crashed with a raw traceback or silently treated falsey non-mappings as an empty config.Config.writeuses a unique sibling temporary file and atomic rename, so concurrent writers do not shareconfig.yml.tmp; failed writes clean up their temporary files. - If a download succeeds but persisting its selected library path fails, the
CLI now prints
Could not update configuration: ...and exits 1 instead of showing a traceback. - Importing
ldrawno longer creates config/cache directories on disk; directories are created when first needed, so the import works in read-only environments. - Network requests carry a 30-second timeout; a stalled server no longer
hangs
ldraw downloadorensure_libraryforever. Interrupted downloads clean up their.partfile. - Dashed release ids (
2024-01) are preserved in full when recording the downloaded complete-library release (previously truncated to01), and the parser now requires the ID to be its own dashed segment instead of accepting a malformed suffix such aszipfoo2024-01. - A cached
complete.zip(only ever left behind by a failed unpack) is discarded instead of reused — the complete release is a moving target. Versioned archives still cache (their tags are immutable). - Archive-wrapper entries are checked for case-insensitive collisions before
anything is hoisted. An archive containing both
LDRAWandldrawnow fails without deleting either tree. ensure_librarywraps all download failures (includingCouldNotDetermineLatestVersionErrorandOSError) in the documentedRuntimeError.
LDrawSession.rebuild_index() uses the same unique-temporary-file rule for
catalog.sqlite, preventing concurrent rebuilds from replacing or unlinking
one another's work.
Minifigure defaults that exist everywhere¶
Person's default hips and legs were 3815b / 3816b / 3817b —
variants that only exist in libraries from 2019 onwards. The defaults are
now the plain codes 3815 / 3816 / 3817, which resolve in every
library vintage (modern libraries keep them as moved-to aliases). The
default head is a plain 3626b instead of a Star Wars-patterned head
(HeadWithSwSmirkAndBrownEyebrowsPattern remains available as an explicit
constant), and the default hat is an actual hat (3624, police hat)
instead of hair — 3901 is Minifig Hair Male.
Two figure bug fixes: hat() no longer shares its position Vector with
the head (mutating one no longer moves the other), and the
dependent_piece machinery no longer swallows KeyErrors raised inside
a limb method — a missing anchor still returns None, but a genuine bug
in your subclass propagates.
Errors: context without class-erasure¶
Wrapping a parse error with file/line context used to re-raise it as the
base PartError, so except InvalidNumericValueError: never matched
errors that came through Part.objects or parse_model. The concrete
class is now preserved — the message is identical, and PartError gains
source and line_number attributes carrying the context in structured
form:
try:
read_model("broken.ldr")
except InvalidNumericValueError as exc: # now catchable by concrete class
print(exc.source, exc.line_number)
New exception types you can catch: InvalidColourValueError,
EmptyPartFileError, StructuralCommentError, MisplacedNofileError
(all PartError subclasses), ConfigLoadError, and
GeneratedModuleSyntaxError.