github m3trik/pythontk v0.10.1
pythontk v0.10.1

4 hours ago
  • 2026-09-17 -- HandoffManifest.VERSION: the hand-off sidecar has ONE document version, and format is the instance discriminator it always really was. The FBX route wrote 1 and the USD route 2 although nothing about the document differed except that only the USD route carries instances, so the number named the CARRIER's dialect and a genuine schema change had no number left to turn; the instance replay gated on version == 2 AND format, of which only the second discriminates anything (a Maya producer spells a group's members as DAG paths, a Blender one as names, and replaying one as the other matches nothing). Every producer now writes VERSION, and the gates key on format alone. Sidecars already on disk are re-MADE rather than refused, for free and without a cache bump: each bridge's _cache_key already carries its conversion template's own file identity, precisely so a template fix invalidates stale payloads, and the version literal lives in the templates. 6 writers, 3 readers, inventoried by grep. Measured gotcha for a reader: "version": 1 also appears SECTION-locally (the rig graph, the shots section) -- those are different documents and are untouched.

  • 2026-09-17 -- Ktx2Encoder(uastc_rdo_dictionary=...) / args_for(uastc_rdo_dictionary=): --uastc_rdo_d becomes a dial, and the default does not move. The GLB texture pass is bound by total UASTC+RDO encode work, not thread contention (the thread split was benched and refuted 2026-09-15), and the RDO match window is the dominant cost: measured on one 4K normal map alone on 20 cores at UASTC quality 2 / RDO 0.75, toktx's own default took 56.6 s for 18.22 MB, 1024 24.9 s for 18.47 MB, 256 15.4 s for 18.60 MB, RDO off 12.1 s for 18.67 MB. The encoder could not express the option at all, so the trade could not be measured on real content -- which is the only thing that should move the default, because that probe was an UPSCALED SYNTHETIC map and RDO's size win on real normal maps is probably larger than it shows, making the penalty for a cheaper dictionary probably larger too. Same discipline the flat-keys decision took: a 56% win worth pursuing, a risk class (a bigger deliverable shipping to a headset over a network) not worth taking on reasoning alone. Emitted only inside the RDO branch -- toktx rejects the flag when RDO is off. test_ktx2_encoder 33, +1.

  • 2026-09-17 -- RigMachinery (core_utils/engines/rig_graph/rig_machinery.py): the rule for what a baked rig leaves behind, with no DCC in it. Deciding which nodes are a rig's inert apparatus is three questions and only the first is about a DCC: what IS each node (a per-node fact), what does that make apparatus (an algebra over the hierarchy), and of what was delivered what may safely go (the same algebra from the other end, against a carrier that kept names and lost paths). The first belongs to whoever can see the scene; the other two are here, as classify / unambiguous / select / tally over plain values. Protection propagates UP and never down, so content keeps every ancestor holding it; apparatus means CONNECTED TO A RIG (seeded by the graph's own nodes plus constraint/IK nodes, swept to unprotected descendants and wrapper groups) rather than "draws nothing", which would take a scene's own export marker with the controls; and an unclassifiable kind counts as content, so an unrecognised shape survives. Every entry point takes the PRODUCER's separator ("|" from Maya, "/" from Blender's prim paths): select first hardcoded Maya's, which would have taken the whole path for the leaf on the return leg and silently stripped nothing -- caught by reading the other direction's extractor rather than by a test, and pinned by one now. Written because the first cut lived inside two AST-identical copies in blendertk's conversion templates, where it could only be tested by slicing the function out of the file and running it against a stub, and where the return leg could not reach it at all. test_rig_graph 110, +20 -- and they run in 0.14 s with no DCC.

  • 2026-09-17 -- HandoffManifest.MACHINERY (core_utils/handoff_manifest.py). A new section in the hand-off vocabulary: {node: kind}, the rig apparatus a bake leaves inert -- constraint nodes, IK handles, control curves, up-vector locators, the groups holding only those, and joints nothing content is skinned to. Without it a baked rig arrives twice over, its motion in keys AND the whole apparatus that used to produce that motion, selectable and drawn and driving nothing (924 of 2727 transforms on the production module). Named by the producer and dropped by the consumer, never deleted at the source: one route bakes before it writes and the other samples the live scene, so removing a constraint there would remove the motion it was about to record. test_handoff_manifest 46.

  • 2026-09-17 -- the hierarchy diff baseline is per SCENE, and its set algebra lives here (core_utils/hierarchy_baseline.py, core_utils/export_profile.py). Both exporters kept the baseline in the export's .scene_data.json sidecar, keyed by the deliverable's file stem, which made it a property of the NAME rather than of the scene: renaming the Output Filename -- a prefix, a regex, a literal name -- pointed the next export at a different sidecar and silently started over, so the FIRST export after any rename passed no matter what had changed. Measured: one scene under six output names produced four independent baselines. The _v<N> stripping patched exactly one renaming axis, and naming_report asked the user to end names in _v<N> to keep the key working -- that warning is gone, along with the coupling it propped up (version_suffix is accepted and ignored for one release). HierarchyBaseline is the DCC-agnostic rule both exporters now run: a flat path set plus a hash, with scope derived at COMPARE time rather than stored, so nothing has to be keyed or migrated when an export is renamed or re-scoped. compare diffs only the part of the baseline in the current export's scope, so exporting asset B never reports asset A as missing; merge replaces only that part, so ONE record accumulates every scope a scene exports. Three cases decided the scope rule, each pinned by a test: a scope with nothing recorded is NEW rather than wholly "extra" (a first export has nothing to diff, exactly as a missing manifest had nothing to diff); a root that MOVED is still in play, because wrapping an export in a group rewrites every path and a roots-only test would report a wrapped scene as a clean first export -- the exact accident the check exists to catch, and it matches on the root's own name, not its leaves, since body/Mesh collide constantly and would drag an unrelated asset into scope; and an EMPTY export puts the whole baseline in play, because "no scope" must not read as "nothing to check". Storage and path-normalisation stay with each DCC, which is where they legitimately differ (Maya's exportSelected ships a node's parent chain, Blender's use_selection does not). Three defects the self-critique found in the first cut, each now pinned: merge ERASED the whole baseline for an empty export -- relevant_roots rightly puts everything in play when nothing is exporting (the diff should say so) but the merge then replaced all of it with nothing, so the next export saw a clean slate and the collapse that had just happened became invisible; it is a no-op now. is_record separates "no paths recorded" from "nothing readable here", which decode alone cannot -- both return an empty set, and a consumer using decode reported an intact empty record as a LOST baseline. And top_level ordered by depth alone over a SET, which is not a total order: the diff report rolls up to these roots and shows the first 20, so an unchanged scene could name different nodes run to run; it is (depth, path) now. Published surface +2 (HierarchyBaseline, and SceneDataSidecar.get_top_level in both DCCs now delegates to HierarchyBaseline.top_level rather than keeping a third copy of the same loop; snapshot updated). New test_hierarchy_baseline.py, 22 tests.

  • 2026-09-17 -- the hand-off sidecar has a type, and replaying it is a declared plan (core_utils/handoff_manifest.py, core_utils/manifest_plan.py). Every conversion ships <payload>.manifest.json beside its FBX/USD intermediate, but nothing owned that contract: the suffix was spelled at ten call sites, mayatk and blendertk each had their own tolerant reader plus seven more appliers that re-opened the same file to read one key, and the section names were bare literals in roughly eighty places across four producer templates, two bridges and two consumers -- a producer writing "visibilty" would have been caught by nothing. HandoffManifest is that contract: the suffix and its idempotent path_for, the section vocabulary as constants (MATERIALS, SHOTS, RIG, ... and SECTIONS), a tolerant read over FileUtils.read_json, build that drops only None (an empty list is a producer saying "I looked"), and an atomic write over FileUtils.write_json -- a conversion that dies mid-write can no longer leave a truncated sidecar that the consumer's tolerant read would silently treat as "no sections". It is a Mapping, so it drops straight into the appliers that took a plain dict. A section's CONTENTS stay with the codec that owns them (ShotTransfer for shots, the rig engine for rig); adding a section is a constant plus a codec, never a change here. ManifestPlan replaces each consumer's hand-sequenced chain: add(section, label, apply, when=, best_effort=) admits a step only when its gates pass, so the plan's length is what the progress bar should count, and run applies one progress/cancel protocol. A step raises unless it is declared best_effort -- the failure that must never be silent (a guaranteed instance replay leaving the scene flattened) is exactly the one an author would forget to mark -- and OperationCancelled derives from BaseException, so a stop request can never be swallowed by a fidelity step's error policy.

  • 2026-09-17 -- RigTransfer: one applier for both bridges, and a destructive edit is now stated (core_utils/engines/rig_graph/rig_transfer.py). Building a rig on the far side is four steps in a fixed order -- build, verify, let the survivors take their channels, report -- and only the BUILDER is DCC-specific. Both importers had grown their own copy anyway, 76% identical in _apply_rig_section and 83% in the capability digest, which is precisely the drift this engine exists to prevent: two importers quietly disagreeing about what 'transferred' means. RigTransfer.apply owns the sequence and a DCC passes a builder (build/commit/remove/sample_world/scope/linear_unit/up_axis); RigTransfer.capability_key replaces the duplicated sha1-of-sorted-json. The one thing that is NOT shared is the source-unit fallback: a graph that omits its own unit is assumed to be the PRODUCER's convention, and the two directions disagree (Maya samples centimetres Y-up, Blender metres Z-up), so collapsing them into one constant scales every sample by 100 and swaps two axes for one leg -- which diverges every record and reads as "the rig would not transfer". It is a parameter each side states, a graph that omits the key is warned about rather than absorbed, and three tests pin it. Commit is LAST and that is load-bearing: it DELETES the payload's baked keys on the channels a verified record now drives, so committing before the measurement would leave a failed record with no motion at all -- pinned by a test that asserts the call order. The same pass makes destruction visible: the number of key curves deleted and everything a builder reports in its result's edits are logged, where before commit returned a count that nobody read.

  • 2026-09-17 -- a rig component is all-or-nothing, and the verify tolerance is a physical centimetre (core_utils/engines/rig_graph/rig_model.py, rig_plan.py, rig_verify.py). The first production rig transfer built and verified 119 of 217 records in Blender and every one of them drove a bake: each wire loom's spline IK and auto-bend math had baked underneath the constraints that did build, so the result was constraints and helpers over baked children -- worse than the bake. RigGraph.components() groups the records that touch a common node (RigRecord.node_ids()); the planner's component rule bakes a component whole when any record in it bakes, drops or cycles (baked / component, the entry naming its blocker and the blocker's rule; a disabled record neither blocks nor binds), and RigVerify.verify_plan(..., graph=) takes a diverged or failed record's whole component back after the measurement (cascaded, through the DCC's own remove). A consumer now ends with a complete, verified rig or exactly the bake, and logs it through ONE vocabulary (RigVerify.summary / RigVerify.demoted, DEMOTED_KINDS / PLANNED_KINDS) instead of a tally duplicated per DCC. Second: the default verify.tolerance was 1.0 in whatever unit the source used -- a centimetre from Maya, a METRE from Blender (a return leg "passed" 86 records at 1 m; re-measured at 1 cm, 33 of them were 2.4-2.6 m off). It is now VERIFY_TOLERANCE_M (0.01 m) expressed in the graph's unit, attached by the planner and defaulted the same way by the verifier. Also test/test_rig_graph.py's __main__ guard sat before its last two classes, so a direct run skipped 13 verifier tests; moved to the end.

  • 2026-09-17 -- a token carries its own regex, and the wildcard stops corrupting one (str_utils/_str_utils.py, core_utils/export_profile.py). The scene exporters shaped an export name with a regex typed into a SEPARATE field, parsed by a helper duplicated verbatim in mayatk and blendertk (already drifting -- blendertk's copy had lost two comments). A name rule split across two fields cannot be recalled as one: the panels' recent-filenames history records the name and not the regex, so picking a name off the list paired it with whatever regex happened to be in the option box. The modifier moves INTO the token grammar -- {name:PATTERN->REPLACEMENT} -- landing in the one formatter every {token} in this class resolves through (_SafeFormatter, now module scope rather than rebuilt per call), so replace_placeholders, resolve_placeholders and resolve_name_pattern gain it at once and with them every consumer: both reference managers, map_factory, uitk's tooltip preview. A spec is a modifier only when it carries a delimiter, so {n:03d} is still padding and the two grammars cannot collide -- both compose in one pattern ({name:_bar.*->}_v{n:03d}). | is deliberately NOT a delimiter: it is regex alternation, and the retired field's split-on-pipe made (foo|bar)->baz unwritable. A pattern that will not compile is returned, not raised -- the text comes from a user mid-edit, where half a regex is a keystroke -- and surfaces as regex_errors on both resolver dicts. expand_wildcard was rewriting every *, including one inside a token: {name:_bar.*->} became {name:_bar.{name}->}, silently breaking every regex using .*, \d* or [a-z]*. It now expands only in LITERAL runs -- the wildcard is literal-text sugar, and a * inside braces belongs to that token's own grammar -- re-doubling escaped braces so {{lit}}_* still survives. ExportProfile.fold_legacy_regex folds the retired field's two extra spellings (A|B -> A->B, a bare PATTERN -> PATTERN->) into the shared grammar, so the saved field keeps working through ONE implementation rather than a second copy -- and fold_legacy_naming now folds it INTO the pattern (StrUtils.attach_modifier, new) rather than onto the context value, so retiring the field cannot silently drop the rule and the user can see what it does. It reaches every spelling of the name token, retired ones included: the field shaped the NAME, not one way of writing it, and a saved {name}_x must keep the RegEx it has always had. NAME_KEY names the ONE token a blank field and * stand for, and it is scene -- a second token meaning "the scene name with the RegEx already applied" existed only while the RegEx lived in its own field. NAME_KEY_ALIASES keeps the old spelling resolvable for a release. Published surface +3 (StrUtils.split_regex_modifier, apply_regex_modifier, attach_modifier; snapshot updated). test_str +11, test_export_profile +9, the wildcard regression red first.

  • 2026-09-17 -- the shot transfer carries the sequencer's content, not only its topology: keyed custom channels and audio (core_utils/engines/shots/shot_transfer.py, schema v2). A pull landed the shots and none of what they contained: neither FBX nor USD animates a custom attribute, so the keyed opacity fade, the highlight pulse and its colour arrived as nothing, and neither carrier holds a sound, so the audio clips stayed behind. The section gains two payloads with the same contract as the store: channels -- {object: {label: {value, keys}}} with a key as [time, value, interpolation] in the one vocabulary a far side can reproduce (KEY_INTERPOLATIONS: a hold, a straight line, the rest), labels in mayatk's attribute spelling so a colour travels as its three leaves -- and audio, the clips as {name, file, start, end, offset}. What is DCC-specific stays injected: encode(channels=, audio=) takes what each side read (its RenderEffects.channel_records, its clips) and only spells, scopes and retimes; decode(write_channels=, write_audio=) lands them BEFORE the ledger is read, because a claim on a channel the transfer itself creates must find its key. A section with content but no shots now exists and merge leaves the scene's own store alone for it. This is the general mechanism the user asked for: a new effect is a new row in the DCCs' channel tables and travels with no transport change, and so does any keyed user attribute that is not in any table. test_shot_transfer +5 (25), red first.

  • 2026-09-17 -- the shot store crosses the Maya <-> Blender hand-off as a manifest section (core_utils/engines/shots/shot_transfer.py). Neither FBX nor USD has a place for a shot list, a marker, a locked gap or the ledger of samples the sequencer planted on shot bounds, so a scene sent either way arrived with its animation and none of its shots. ShotTransfer is the ONE codec both DCC adapters run: encode turns a store's to_dict() into the sidecar's shots section with every scene name respelled the way the carrier writes it and the ledger regrouped by object + channel label (mayatk's translateX vocabulary, which blendertk's sequencer already speaks), scoped to the exported set when one is given -- memberships and claims follow what ships, the shots themselves always cross; decode reads it back against the receiving scene through injected callables (name resolution, curve lookup, key existence), rescales to the receiving clock as rescale_to_fps would, shifts by the importer's frame offset, and drops a claim whose key is not there -- the reducer may have taken it, and a claim without its key is debris; merge folds the result into the scene's own store: a shot-less scene adopts it whole, one with shots gains the incoming ones after its own under fresh ids, every id reference (a locked gap's pair, a claim's owner) remapped. The section is the store's own dict shape, so a future store field travels without a codec change. One rule came from the live pulls rather than the design: both importers put ROOT objects through the Y-up / Z-up crossing and leave a child's parent-space channels alone, so a root's translateZ claim names the far side's Y channel -- UP_AXIS_SWAP / swap_up_axis and decode(converted=...) carry it, the consumers pass "has no parent". Published surface +1 (ShotTransfer, snapshot updated). test_shot_transfer new, 20 tests, red first.

  • 2026-09-17 -- the rig engine gains its verifier and the hand-off its rig-mode vocabulary (core_utils/engines/rig_graph/rig_verify.py, core_utils/app_handoff.py). Phase 1 of RigGraph shipped the schema, capability model and planner; this is the first of the pieces that connect it to a scene. RigVerify is section 9.4 made concrete -- ONE point-cloud comparison that mayatk's exporter, blendertk's importer and the tests all measure through, so the number a diverged report entry carries is the same number everywhere. It splits worst into rigid (the length of the mean delta: the whole object displaced as one, a placement or parent-space error) and residual (the worst distance from that mean: the shape itself deformed differently, a skinning or solver error), because the production round trip's return leg read 0.0126 mm rigid against 0.0605 mm residual and only the split says which thing to fix. A point-count mismatch RAISES rather than measuring: a topology failure reported as a large distance would pass a generous tolerance. compare_frames keeps the per-frame worst so a report can say WHEN it diverged, and verdict returns both numbers beside the boolean, since a warning without its number is not actionable. Pure math over point arrays -- a DCC contributes only a sampler. RIG_MODE_PARAM / RIG_MODES sit beside the carrier vocabulary for the same reason it lives here: one spelling for every panel (uitk) and every producer (the DCC mixins). ("auto", "bake", "rig", "raw"), ordered because the first is what an undecided request gets, mirroring HandoffBridge.carriers. rig extracts a graph, plans it against the CONSUMER's capability and bakes only what the plan says, so it can never carry less than bake; that is the carrier decision of 2026-09-17 (FBX and USD both stay) made into one parameter instead of a per-carrier flag. test_rig_graph +6, test_bridge +2, red first. Later the same day, the verify-and-demote LOOP moved here too (RigVerify.verify_plan, convert_point): blendertk's importer had its own copy, and its sampler could not see BONES, so every bone-targeted record -- half the production module's -- compared zero points and passed vacuously. Now a DCC contributes only sample(node_id, frame) -> point | None; the unit and up-axis conversion (a Y-up cm source's (x, y, z) is a Z-up metre target's (x, -z, y) / 100), the comparison, the demotion (remove(record_id)) and its diverged entry live once, and a record that had samples but nothing measurable is reported unverified and left built -- the check did not happen, and calling that a pass is the one thing worse than a miss. test_rig_graph +3, red first.

  • 2026-09-16 -- Deprecation: one mechanism for retiring public surface, and the first one that records WHICH release an alias stops working in (core_utils/deprecation.py, core_utils/symbol_record.py, core_utils/help_mixin.py, core_utils/git.py, file_utils/_file_utils.py, net_utils/preview_server.py, core_utils/engines/textures/map_factory/conversions.py). The public-API contract (CODE_STANDARD.md s5) gives a renamed or removed name an alias for ONE release. The first half of that rule was honoured seven different ways across the ecosystem -- an inline warnings.warn with a hand-picked stacklevel, a per-cluster warn helper with a different one, a module __getattr__ that warned and another that did not, a cmds.warning, a logger call, a silent binding alias, a docstring line for a renamed keyword -- and the second half not at all: "removed in the next release" names no release, so nothing could fail when the release came and went. Measured: UvUtils.flip_uvs was deprecated 2025-12-17 and has shipped in 50 mayatk releases since; the FileManager aliases in 34 uitk releases. So the point is not that seven shapes collapse into one, it is remove_in: every deprecation names its removal version, an unparseable one raises at import (a version that cannot be compared is an alias that never expires), and the records are readable at runtime (Deprecation.registered / expired / report) AND statically. Four shapes, one per thing the ecosystem actually retires: symbol (function, method or class -- on a class it wraps __new__, so a SUBCLASS warns too, which the hand-written __init__ warn it replaces could not see), parameter (one keyword of a function that stays, including the rename-and-remap case, which retires the use_object_axes-style docstring line nothing enforced), attributes (moved module attributes, chaining onto any __getattr__ already installed rather than clobbering a lazy loader), and values (a retired member of a value vocabulary), plus Deprecation.warn from a function body for a shape none of the four reach -- the only sanctioned alternative, so that an odd case cannot justify a hand-rolled notice. A property is handled in either decorator order. Two rules are enforced rather than documented: a replacement is MANDATORY (ValueError otherwise -- a notice the caller cannot act on is noise), and warning is never breaking. Attribution is computed, not guessed: the machinery counts its own frames, so a warning points at the caller's line through any of the four shapes and through the lazy package resolver -- the six hand-rolled call sites carried two different hardcoded stacklevels between them, each worked out by hand. __deprecated__ is set to the message string, which is exactly what PEP 702's warnings.deprecated does on 3.13+, so the eventual migration is a deletion; it is stamped on the WRAPPED function as well as the wrapper, because functools.wraps sets __wrapped__ and HelpMixin unwraps to recover a signature -- marking only the wrapper would have reported every deprecated member as live. Deprecation.sink is the DCC escape hatch (DeprecationWarning is hidden outside __main__, which is every DCC session): additive, once per record, and a raising sink cannot take the call down with it. Two dead consumers now have a producer: HelpMixin._is_deprecated has read a __deprecated__ marker nothing ever set, and the registry generator has recognised a decorator nothing ever defined. SymbolRecord gains remove_in (the one field added since the set was frozen; the sidecar omits it where empty, so the committed JSON grows only where a retirement was recorded). Converted pythontk's own four hand-rolled sites -- Git, the FileUtils JSON key-value cluster, the preview_server alias module, ConversionRegistry.register_from_class -- all of which said "next release" and now say 0.11.0. New test_deprecation.py, 65 tests + 30 subtests, including the live gate over pythontk's own surface (every module imported via pkgutil, 0.3 s, because export_all() resolves DEFAULT_INCLUDE and a pure alias module is named by nothing). Downstream conversion is release-gated: pythontk 0.10.0 is on PyPI and every consumer floors at >=0.10.0, so uitk/mayatk/blendertk adopt after this publishes.

  • 2026-09-16 -- new core_utils/engines/rig_graph/: a DCC-agnostic model of a rig's INTENT, what one target can build, and the pure planner that resolves the two. A rig converter written as source node type -> target construct is N x M and never converges, which is why in-house ones get abandoned. This engine carries what a rigger MEANT instead, on the observation that every rig relationship has the same shape: a target receives a value computed by an operator from some sources. rig_model is the document -- FIVE record shapes (transform / channel / points / order / opaque), strictly validated, with the op inside a shape left OPEN and resolved against a target rather than this schema, so the vocabulary grows without touching the validator or the planner (an unknown op is a planner outcome, never a malformed document -- pinned by a test). Identity is the payload's prim path, so a record joins to a carrier prim with no name matching, no namespace rewriting and no collision-suffix tolerance; a node's parent is its path's parent, so the hierarchy has no second spelling to keep consistent. Any parameter value may be {"plug": "<id>.<channel>"} instead of a literal, which is how a driven constraint weight or a driven twist is said without a new record kind; a sanitised prim name cannot contain ., so the FIRST dot ends the path and the grammar needs no escaping. channel/expr carries a restricted grammar (arithmetic, comparison, and 14 named functions including select(c, a, b) -- a FUNCTION, not control flow, which is what lets a condition node translate) validated by an AST walk that refuses attribute access, subscripting, comprehensions and lambdas outright, so the escape a nulled-__builtins__ evaluator still allows does not exist here. Deliberately NOT unified with MathUtils.eval_expression: that is a calculator -- no variables, the whole math module, a formatted string back -- and the two share only the idea of walking an AST, so bending either into the other would cost both their contracts. rig_capability is what ONE target can build, as data (fidelity, channels, roles, params with enum values, and the parameter paths a plug may drive), answering "could you build this record?" without planning a graph. rig_plan is a PURE function of the two: it returns what to build, what to bake, what must be verified, and a report entry for every loss -- emitted by the code that causes the loss, so it cannot drift from what happened the way a hand-maintained caveat list does. Two rules carry it: degrade, never break (each record owns its fallback, and "drop and bake" is not a second code path but this planner against a target with no builders registered), and a half-built node is worse than a baked one (a node stays procedural only if EVERY record targeting it was built) -- which cascades, since demoting a record bakes its other targets too, so it is resolved to a fixpoint rather than in one sweep. Cycles are found once per graph with an ITERATIVE Tarjan (a 3000-deep chain must not exhaust the stack -- tested) and baked as a component: a cycle the rigger meant still plays, it is only frozen. Because the planner touches no scene, a consumer can run a PRE-FLIGHT -- "12 rigs will be baked; 3 deformers have no equivalent" -- before committing to a ten-minute conversion, with nothing open and nothing written. Root exports: RigGraph / RigNode / RigRecord / RigPolicy / RigCapability / RigOpCapability / RigPlanner / PlanResult / ReportEntry / RigPlanRefused. Specification: .claude/RIG_GRAPH_SCHEMA.md. A policy field is UNSET until stated, not filled with a default at parse time -- otherwise a record that says nothing overrides the graph with a default it never asked for, and the graph-level default applies to nothing (caught in review, with a repro: a graph declaring fallback: drop resolved to bake). Measured end to end against a production module before anything downstream was built, which paid for itself twice: a first-cut extractor emitted 231 records for 539 driver nodes and the planner reported a clean bill of health for the 43% it had read, so RigGraph.coverage() now makes "emit opaque rather than nothing" a CHECK rather than a plea -- an extractor records what it saw in source.census, every record records what it was read from in provenance, and the difference is returned by type (the real run: 308 unaccounted, multMatrix 196 at the top). That same census refuted the vocabulary: multMatrix 196 + blendMatrix 14 is the LARGEST driver population in the scene and the scalar expr grammar cannot express a matrix product at all, so the spec gains a transform/matrix op. Also corrected in review: a policy field is UNSET until stated, not filled with a default at parse time -- otherwise a record that says nothing overrides the graph with a default it never asked for, and the graph-level default applies to nothing (repro: a graph declaring fallback: drop resolved to bake). Provenance is counted as DISTINCT source nodes, not occurrences -- two records legitimately read the same constraint, and tallying both let one node stand in for another and report unaccounted=0 while a real driver went unread, which is the exact false negative the check exists to catch (caught in review, with a repro). Provenance naming a type the census never mentioned is surfaced separately as uncensused: an extractor contradicting itself is a reason to trust neither number. New test_rig_graph.py, 58 checks. test/surface_snapshot.json refreshed for the new exports -- it was ALREADY stale on ProgressRelay from the 2026-09-15 work, so the suite had a pre-existing failure.

  • 2026-09-15 -- a blocking headless run can report progress and be stopped while it works (core_utils/app_launcher.py, core_utils/script_run.py). AppLauncher.run(on_output=...) streams the child's merged stdout+stderr line by line on the caller's thread (composing the existing OutputStream / ProcessReader), calling back with each line as it arrives and with None whenever poll_interval passes quietly, so a caller driving a UI keeps it alive through a long silent step. Returning False -- or a cancelled ambient CancelScope -- kills the child and raises OperationCancelled; ScriptRunner.run_script_to_artifact passes the hook through and, on a stop, removes its rendered script and any partial artifact (nothing to debug in a run the caller ended). New ProgressRelay maps a child's printed ::progress:: k/n text markers AND in-process steps onto one progress(current, total, message) callback -- the shape uitk's progress_adapter gives a footer bar -- with each stage owning a slice of the bar, keep-alive ticks throttled, and the value never moving backwards. Why: a production Maya->Blender conversion is silent for minutes behind a fixed 600 s budget; on 2026-09-15 that budget killed a conversion that was still working, and the panel had nothing to show while it ran. The marker is one plain line so a dependency-free DCC template can print it. test_script_run.py +13 (delivery while the child runs, keep-alive ticks, kill-on-False, ambient scope, timeout under streaming, output_file exclusivity, and the relay's mapping / throttle / monotonic bar).

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.