github m3trik/pythontk v0.9.30
pythontk v0.9.30

3 hours ago
  • 2026-08-22 — ScratchTwins.create overwrote a twin the user had saved real work into, which is the one file the class promises to keep (file_utils/temp_artifacts.py). The twin path is deterministic per source, so the second create for the same source lands on the same file — and it went straight to shutil.copyfile with no existence or stamp check, silently discarding whatever was there. Two routes reached it: in-session, where discard_except correctly KEEPS a saved twin but stops tracking it, so the next open clobbers it; and across a host restart, where _stamps is process-local and every twin from the previous session reads as unrecognized — the more common one. The guard discard already implemented was simply never consulted. Fixed at both halves: the stamp is now persisted to a <twin>.stamp sidecar so the untouched-test survives a restart, and create moves anything that is not provably our own untouched copy aside to <stem>_saved<N><ext> (logged at WARNING with the location) before writing the fresh conversion. Preserving rather than refusing is deliberate: both panels recompute the twin statically through path_for to answer "is this row the current scene?", so the path must not move, and refusing would leave the row permanently un-openable. discard reads the same persisted stamp and cleans the sidecar up. NOT a regression — the released mayatk did the identical unconditional copy onto a flat <temp>/<stem>_opened.ma and never discarded twins at all; what was new is the invariant this class advertises. test_temp_artifacts +2 (..._never_overwrites_a_twin_the_user_saved_into covers both routes as subtests; ..._reuses_the_path_when_the_twin_is_untouched pins that the common case does not accumulate copies). Both fail against the pre-fix create, checked by running them against it. The age-sweep test also had to age the sidecar: sweep_stale keys off the NEWEST mtime under a directory, so "abandoned" means every file in it is old — in production the twin and its stamp are written together and age together.

  • 2026-08-22 — HandoffBridge.carrier_of(path) + CARRIER_BY_EXTENSION, and StrUtils.strip_suffix (core_utils/app_handoff.py, str_utils/_str_utils.py). The carrier vocabulary now answers the inverse question too — which carrier a payload PATH names, every USD spelling (.usda / .usdc / .usdz) folding to usd, and a ValueError (never a silent FBX) for an extension no carrier owns — which is what lets the DCC producers dispatch through a {carrier: writer} table instead of an if-chain per site. strip_suffix is the whitelist counterpart of os.path.splitext: only a LISTED suffix comes off (asset.v2 keeps its version token, asset.FBX loses its extension), the shape both Scene Exporters needed and had each re-implemented. CARRIER_PARAM / CARRIER_EXTENSIONS / CARRIER_BY_EXTENSION reach the root (ptk.CARRIER_PARAM) beside USD_EXTENSIONS. test_bridge +1, test_str +2.

  • 2026-08-22 — HandoffBridge grows a carrier: the payload's interchange format is a per-request choice on the one shared seam (core_utils/app_handoff.py). CARRIER_EXTENSIONS (fbx / usd) and CARRIER_PARAM are the vocabulary; a bridge declares the subset its target can read as carriers (default ("fbx",), first = default), carrier(request) / payload_extension(request) resolve params["CARRIER"], and _run refuses a carrier the bridge doesn't offer (or an unknown spelling) BEFORE anything is exported — the caller asked for something specific and must not get FBX silently. The template context exposes the payload as __PAYLOAD_PATH__ beside the historical __FBX_PATH__ (same path under both names; a template that must know the format reads the extension). Nothing here knows how either format is written — that stays with the DCC mixins. test_bridge.py +6 (HandoffCarrierTest): default, per-request choice, case, unoffered / unknown refusal, both tokens.

  • 2026-08-22 — ScratchTwins (file_utils/temp_artifacts.py): per-source scratch twins of foreign files, discarded only while untouched. The "open a foreign scene as a new document" lifecycle both DCC Reference Managers had hand-rolled (and, before today, hand-rolled badly: a raw tempfile.gettempdir()/<stem>_opened.<ext> that two same-named scenes in different projects shared and nothing ever deleted). path_for(source) is deterministic — <temp>/<prefix>_<hash-of-source-path>/<stem>_<ext><extension>, so scene.ma twins as scene_ma.blend: provenance in the host's title bar and Save-As default, never shadowing a sibling scene.blend, never colliding across projects; create(source, payload) copies + stamps (size, mtime_ns); discard(path) / discard_except(current) delete a twin only while it is still that untouched copy — one the user saved into is real work, kept and logged — and forget it either way. Built on TempArtifacts (one tracked, age-swept directory per source; 30-day default because a twin is a working document until saved elsewhere). test_temp_artifacts +5 (naming / per-source dirs / no creation on ask, copy + stamp, untouched-vs-saved discard, discard_except, age sweep of abandoned twin dirs). Found on the way and fixed in TempArtifacts.sweep_stale: a DIRECTORY was aged by its own mtime, which an in-place file rewrite (how Maya saves a .ma) never touches — a scratch dir someone kept saving into read as abandoned and would have been reclaimed with their work in it; a directory is now as fresh as its newest entry (+1 test; the existing reclaim test backdates the child too, as the premise requires).

  • 2026-08-22 — RenamePlan + FileNaming (file_utils/file_naming.py) and StrUtils.retain_suffix: the batch-rename executor the DCC naming tools now share, and its file-system tenant. RenamePlan.apply(plan, rename, dry_run=, logger=, link=) takes (key, old, new) triples plus the ONE per-host strategy (the callable that renames a single item) and owns everything that used to be re-implemented per engine: dry run (plan only, nothing touched), the per-item error policy (one locked item never aborts the batch; a host that uniquifies is reported with the name it actually assigned), and the panel-ready report — one log_group record of old → new lines (never a paragraph per item), an unchanged tally, and a result / notice summary. mayatk's and blendertk's Naming engines route every operation through it, so a tool that redirects its logger into a pane gets the listing for free. FileNaming runs the same find / rename / convert-case / strip-chars grammar on file stems (the extension is never touched; a directory contributes its direct files only; an existing target is never overwritten) — the Directory / Files scopes of the Naming panels. StrUtils.retain_suffix is the "retain type suffix" rule that was copy-pasted in both DCC engines (_GRP1_GRP comparison, numeric tokens never retained, a recognized new suffix replaced, an unrecognized one kept). test_file_naming 20 (executor contract + file ops on a temp tree), test_str +6.

  • 2026-08-22 — two DCC-agnostic unbake primitives: IterUtils.find_extrema_indices and MathUtils.fit_hermite_slopes. The key-selection half keeps endpoints, peaks, valleys and both boundaries of every flat run (sub-tolerance jitter on a hold never spawns keys); the tangent half solves one cubic-Hermite slope per kept key by least squares against every dropped sample (tridiagonal normal equations + a Thomas sweep — each sample couples only its two bounding keys, so a 27k-sample bake with thousands of extrema fits in ~0.15 s instead of a gigabyte-scale dense matrix), pins the slopes facing a hold to zero (the key's tangent breaks there, the other side stays fitted), and falls back to the sampled finite difference where adjacent kept samples leave a key unconstrained. Sibling of find_flat_interior_indices; mayatk / blendertk unbake_keys are thin DCC writers over the pair. One cubic per half-wave is the inherent limit (~2% of amplitude on a sine), and the tests pin that bound rather than pretend it away.

  • 2026-08-21 — an optional-Pillow guard that imported six names but bound only one left the other five UNDEFINED, so hosts without PIL got a NameError instead of the no-Pillow branch (img_utils/_img_utils.py, core_utils/engines/textures/map_factory/_map_factory.py). Both modules do try: from PIL import Image, ImageOps, … / except ImportError: Image = None — and a name that the failed import never created is not None, it does not exist. Every one of the 11 call sites behind those names (ImageOps ×5, ImageFilter ×6, ImageChops, ImageDraw, ImageMode, ImageEnhance) therefore raised NameError: name 'ImageOps' is not defined in any interpreter that lacked Pillow at import time, in place of the fallback the guard exists to provide. Measured in Blender 5.1 (whose bundled Python ships no PIL), where it is worse than a clean failure: blendertk provisions Pillow on demand after pythontk is imported and then re-binds these globals by looking for the None ones — a name that was never created is invisible to that repair, so the modules stayed broken for the whole session with Pillow installed and importable. Symptom: the blendertk Material Updater logged Error packing metallic/smoothness: name 'ImageOps' is not defined and silently shipped the raw Metallic map instead of the packed Metallic_Smoothness the preset asked for (the handler catches the exception and falls back). Both except branches now bind every name they import. Pinned by test_img.OptionalPILGuardTest, which is static and repo-wide rather than a reproduction of this one case: it parses every module in the package and asserts each module-level guarded from PIL import … assigns all of its names in the handler, so a module added later cannot reintroduce the shape — and it fails in an environment that DOES have Pillow, where the runtime NameError never appears. Fails against the pre-fix tree naming both files and all eight unbound names. Image/texture suites 540 passed.

  • 2026-08-21 — the run log kept unittest's report and threw away the OUTPUT that explained the failure (test/run_tests.py). The tee was handed to TextTestRunner(stream=...), so it captured unittest's own writes — while a bare print(), or the traceback.print_exc() of an exception the product swallowed, went to the real stdout/stderr and never reached the log file. Measured 2026-08-20: FileUtils.get_file_contents swallowed a FileNotFoundError and printed its traceback to stderr; the log showed only None != [...], which is why SIX instances of the full-suite file-IO flake were filed as unexplained assertion failures when the cause had been printed and discarded. Both console streams are now teed into the log buffer for the duration of runner.run(suite) and restored in a finally, so a runner crash cannot leave the process writing into a buffer nobody reads. Nothing is doubled: the runner's own stream holds the ORIGINAL stdout object, captured before the redirect.

    TeeStream had to become a real stand-in to be assigned to sys.stdout at all — write returns a character count, writelines is defined explicitly (delegating it would have written to the console and silently skipped the log), and everything else (isatty, fileno, encoding) is answered by the first stream, so product code asking stdout whether it is a terminal no longer raises mid-test. Two write failures are contained rather than raised, because a stand-in that raises turns an unrelated print into a test failure: a cp1252 console cannot encode every character a docstring or log line carries (retried with replacements — note U+2014 is not one of them, cp1252 has it at 0x97), and a detached or closed stream is skipped for that write. Anything else still propagates, so a real bug is not swallowed. test_run_tests.TestRunLogCapture +3; two of them fail against the old runner, and the third — that the streams are restored — is the invariant that must hold either way, asserted INSIDE the mock.patch because read afterwards it would pass with the finally deleted. 18/18.

    New public primitive: ptk.TeeStream (core_utils/process_stream.py, beside OutputStream). Four of the ecosystem's six test runners had written their own near-copy of this class — diverging on the cp1252 guard, on whether write returned anything, and on which failures were swallowed — and this change was about to add two more. It is the synchronous counterpart to the OutputStream already in that module (no threads, no line splitting, just a write-through fan-out), and it follows the same precedent as StatusBadge: an ecosystem-wide test-harness concern belongs at the bottom of the stack, not once per runner. pythontk's, tentacle's and uitk's runners now import it. mayatk's two copies deliberately stay — its suite driver's GUI entry point exec's run_suite without the sys.path setup that makes the ecosystem importable, and its launcher capture runs before that setup exists; both now say so in place. test_process_stream.TestTeeStream +8, including the counterweight that an unexpected write error still propagates. 28/28.

  • 2026-08-20 — an unreadable file crashed a version bump with an unrelated TypeError; FileUtils.get_file_contents stops mis-declaring what it returns (core_utils/package_manager.py, file_utils/_file_utils.py). get_file_contents catches OSError, prints the traceback to stderr, and falls off the end returning None — but it was annotated -> None while its docstring promised (list), and also claimed "Will create a file if one doesn't exist" when it opens "r" and creates nothing. Three statements about one function, no two agreeing. The only production caller, _PkgVersionUtils.update_version (:360), fed that result straight into enumerate, so a missing or locked path raised TypeError: 'NoneType' object is not iterable from inside a release version bump, with nothing in the message naming the file. Reproduced first as a failing test against a path that does not exist: TypeError at package_manager.py:366, the swallowed FileNotFoundError visible only in captured stderr. update_version now returns "" for an unreadable file — not a new behaviour but the one it already documents ("the new version number or empty string if not found"), since a file that cannot be read has no version to report. The annotation is corrected to Optional[Union[str, List[str]]] and the docstring now states the None-on-failure contract plainly so the next caller handles it. Found while chasing a full-suite-only flake, and it explains that flake's silence: traceback.print_exc() writes to stderr, which run_tests.py does not tee into its log (it tees stdout only), so the underlying OSError left no trace in the run output and the failure surfaced as a bare None != [...] assertion. Whether swallowing the error at all is right is a contract question and stays the maintainer's — logged rather than decided here. test_package_manager.py +1 (fails with the TypeError against the pre-fix code); those two modules 148 passed, run_tests.py 3418 tests / 3403 passed / 0 failed / 15 skipped. The runner's own stdout-only tee — the reason the traceback vanished — is a cross-package harness change and is logged rather than made here.

  • 2026-08-20 — the map-type suffix boundary applies at EVERY alias length; the len(alias) <= 3 special case is deleted (core_utils/engines/textures/map_registry.py). The defect was never "the long branch forgot a boundary check" — it was that there were TWO boundary policies keyed on alias LENGTH, and the length key is meaningless. The evidence a filename offers that a trailing token is a suffix (a separator, a lowercase→uppercase step, or the alias standing alone) has nothing to do with how many characters the alias has. The 2026-08-03 fix installed _short_alias_boundary only where the bug had been measured — Agilent instrument model numbers, all ≤3 chars — instead of making it the rule, and then had to mirror that same threshold into the regex builder, so the threshold lived twice and drifted apart. 524 of the 570 registered aliases are longer than three characters, so ordinary asset names classified on a bare endswith. Measured through the live resolver: char_thigh→Height, wall_watercolor→Base_Color, panel_gunmetal→Metallic, prop_raincoat→Clearcoat, bone_abnormal→Normal, road_borough→Roughness, cloth_lipgloss→Glossiness, wall_damask→Mask, wall_afterglow→Emissive. The damage was worse than misclassification: it SPLIT TEXTURE SETS. Because get_suffix_strip_pattern carried the same threshold, the base name lost the false suffix too — char_thigh.png based to char_t while char_thigh_Normal.png based to char_thigh, so one material grouped as two sets. The fix is a deletion, not a widening: the length branch is gone from _match_alias and short_suffixes/long_suffixes collapse into one alternation, so there is one policy and nothing left to drift (the deferred entry prescribed "widen both branches together", which is a second boundary implementation — the third copy of the threshold). It also offered a false binary — separator/CamelCase or "require a non-alphabetic predecessor" — and the second option is measurably wrong, not a live alternative: every CamelCase-glued alias has a lowercase letter in front of it, so it would kill mat_SpecularGloss→Glossiness (the fixture behind extapps' shipped Unpack SpecularGloss workflow), test_MetSmooth, test_SpecAlpha and rockBaseColor. Blast radius measured rather than argued: 2,295 spellings (four styles × 570 aliases) classified before and after — delimited authored-case 0 changed, delimited lowercase 0, CamelCase glued 0, all-lowercase glued 377. Every realistic spelling is untouched; the only class that moves is the ambiguous one the rule exists to reject. One improvement fell out that the corpus did not cover: a file named only after its map type used to base to the EMPTY string (Normal.png'', Roughness.png''), because the boundary-free branch matched the whole stem — so every such file collapsed into one anonymous texture set. A stem-initial alias has no lowercase character in front of it, so the attached branch no longer matches and the name survives (Normal.pngNormal); classification is unchanged. Pinned by test_a_bare_alias_filename_keeps_a_usable_base_name. The one wart left is a mixed-case bare stem, BaseColor.pngBase (resolve matches the BaseColor alias at index 0, strip matches Color at index 4) — arbitrary either way for a file with no asset name in it, and strictly better than the empty string it produced before. _short_alias_boundary is renamed _alias_boundary (private, absent from the registry) since its name was the last trace of the threshold. test_map_registry_short_alias_boundary.py gains LongAliasBoundaryTest — the nine ordinary words, the CamelCase counterweight that rules out option B, the delimited-any-case counterweight, and the set-split check — all four failing first (12 failures, 9 subtests). test_map_registry_ambiguity.py::test_new_aliases_do_not_claim_ordinary_words still passes but its docstring said aliases over 3 chars match with no boundary check, so it is corrected rather than left contradicting the code; alias hygiene still matters, since a delimited or CamelCase ordinary word classifies regardless (wall_metal→Metallic). Measured while the pattern was open: it grew 12,408→15,019 chars yet matches 2.7× faster (median 0.0470s→0.0172s, five runs of 1,200 substitutions, old pattern reconstructed in-process so nothing on disk moved), because a literal capital first letter lets the engine reject a position that (?i:…) had to attempt case-insensitively at every offset. run_tests.py: 3417 tests, 3402 passed, 0 failed, 15 skipped; extapps re-run as the downstream consumer, 605 passed.

  • 2026-08-20 — validate-chain.yml compiles tentacle's .ui tree recursively, so the downstream gate stops failing on files it never compiled. The validate-tentacle job enumerated two directories — tentacle/ui/*.ui and tentacle/ui/maya_menus/*.ui — and ui/blender_menus/ was added to tentacle after that step was written, so its 27 files were never compiled. _ui.py modules are gitignored build artifacts (tentacle/.gitignore:49), which is exactly why the omission stayed invisible: a developer tree already has them, so the pairing tests pass locally and only a fresh checkout can see the gap. In CI it failed test_ui_integrity.py::TestUiFilePairing::test_blender_menus_pairing on all 27 stems, which failed the summary job and reported the whole Validate Publish Chain workflow FAILURE — measured on release PRs #48 (2026-08-19) and #49 (2026-08-20), both of which merged anyway, because that workflow is not a required check. The cascade's only downstream-consumer gate had therefore been red and ignored on every release since blender_menus landed. Fixed by deleting the enumeration rather than extending it — adding the third directory would have left the same drift free to recur the next time a menu directory appears — so the step now walks ui/ with find and covers whatever exists. Coverage verified by mirroring the real tree into a sandbox and running the exact step body against a stub pyside6-uic: 91 → 118 files (58 root + 33 maya_menus + 27 blender_menus), every derived output path still <stem>_ui.py, including the # that every submenu stem carries. find -exec was written first and rejected: GitHub runs a run: block under bash -e, so the old for loop aborted the step when pyside6-uic failed, but find -exec reports success even when the command it ran failed — measured, it printed "REACHED END" and exited 0 on a stubbed failure, which would have compiled 117 files, skipped the broken one silently, and left the pairing test reporting a missing _ui.py instead of the actual uic error. The shipped form is while IFS= read -r ui; do ... done < <(find ...), which keeps the fail-fast (verified: exit 1, with the uic error surfaced) and still tolerates a space in a path. tentacle's own tests.yml listed all three directories and was NOT broken; it got the same treatment purely so the two copies of this step cannot drift apart again.

  • 2026-08-20 — ruff check pythontk/ test/ is green on a clean tree (test files only). Seven residual cosmetic findings blocked the ruff CI gate from being switched on: two E702 semicolon statements (test_map_compositor.py), two E731 lambda assignments rewritten as def (test_math.py, test_module_reloader.py), two dead imports removed (test_map_factory_grouping.py, test_script_run.py), and test_net_utils.py's deliberate availability probe marked # noqa: F401 rather than deleted — it is a probe, not a use, and removing it would silently drop the guard. No production code touched; the six affected modules pass unchanged (343 tests). The gate itself stays off — it is monorepo-wide and still blocked on the residual findings in the other packages.

  • 2026-08-20 — every map MeshConvert embeds now ships in the BIN chunk instead of as base64 in the JSON (file_utils/mesh_convert/_mesh_convert.py). The channel writers embed as data: URIs, which keeps each edit inside the JSON chunk — no buffer offsets to recompute, the part of GLB surgery that silently corrupts a file — at base64's ~33% premium, priced for a local preview. But create_glb ships those same writers as a deliverable: measured on TURRETS_WIRES.glb, the sidecar's packed ORM put 4.0 MB of base64 in the JSON chunk — 45% of an 8.9 MB file, 1.0 MB of it pure overhead, all of it parsed before a loader can draw, and sitting oddly beside FBX2glTF's own maps in the BIN. GlbEdit now records what a session embedded and _relocate_embedded_images moves it into the BIN once, on the owner's close, after every composed writer has had its say — so the mid-session no-offsets property is unchanged and the BIN is rebuilt a single time no matter how many channels wrote. Safe because it only ever appends: the existing BIN is copied verbatim and payloads land past its end, so every prior bufferView keeps its index, its byteOffset and its bytes, and no accessor is touched (that is the difference from optimize_glb_textures, which rewrites in place and must recompute them). Only images this session embedded move — a data: URI the file arrived with is the caller's, an external uri is unreadable from here, and rewriting either would be a side effect on input. Entries are tracked by identity, not index, so a prune_glb_textures in the same session cannot shift them onto the wrong image. One shape declines the move rather than risk the file: a first buffer that declares a uri (EXTERNAL — there is no BIN to append to, and writing one would strand the appended views while overwriting that buffer's byteLength), which keeps the base64, since a size cost is not a lost byte. A second hazard was fixed at its root instead, because it was never this pass's alone: replace_rest rebuilds every byte after the JSON chunk from the one payload it is handed, so any further chunk — GLB is a chunked container, and the spec tells a client meeting a type it does not know to IGNORE it, not discard it — was being deleted by whichever of the three repackers ran (this relocation, optimize_glb_textures, prune_glb_unreferenced_textures). Nothing here decodes such a chunk, so nothing here could put one back. replace_rest now carries the slice past the BIN over verbatim (_trailing_chunks) and emits the BIN first, where the spec wants it — so the result is valid even for a file that arrived with no BIN at all, and relocation no longer has to decline. Both shapes are pinned failing-first. The embedded texture also carries a name now (its image's stem): FBX2glTF names the textures it writes, so an unnamed one mid-list was a tell that a later pass added it. Repro'd file: JSON chunk 61.5% → 14.0%, no base64. test_mesh_convert.py +5, test_preview_server.py 3 retargeted (159 + suite green).

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.