github m3trik/pythontk v0.9.5
pythontk v0.9.5

3 hours ago
  • 2026-08-05 — find_str_and_format silently ignored the filter on every input more complex than one plain wildcard term. The "from" text was derived once as fltr.strip("*"), which is only meaningful for a single term. A pipe-separated filter collapsed into one literal ('pCube*|nurbs') that appears in no string, so *chars* and strip became no-ops and *chars / chars* degraded to a blind append — strings matched the filter and then came back unformatted, with nothing to indicate the format had been skipped. Regex mode had the same shape of defect for a different reason: the compiled pattern drove the search only, while the substitution ran str.replace / str.split against the raw regex source, and strip("*") corrupted that source (.*Cube.*.*Cube.); ignore_case never reached the substitution at all, because the case-folding pattern was built only if frm_ and ignore_case and not regex. Measured before: find_str_and_format(['pCube1'], '*box*', r'pCube\d+', regex=True) returned ['pCube1'], unchanged.

    The formatter is now term-aware: each string is re-matched against the filter's terms, and the term that matched supplies its own "from" text — so multi-term filters format correctly, and pipe-separated to terms pair positionally with them (fltr '*_L|*_R' with to '*_lt|*_rt' renames each side differently; a single to term still applies to every filter term, and a pipe in to stays literal when the filter has no terms to pair with). In regex mode the compiled pattern drives the substitution as well as the search, so | stays alternation rather than a term separator, asterisks keep their regex meaning, and the filter's capture groups are available in to as \1 / \2 / \g<name> — an escape that cannot be expanded, or a backref naming a group the pattern lacks, is used verbatim rather than raising. That second case was caught by fuzzing rather than by review: re.sub reports an unknown group name as IndexError, not re.error, so the substitution path's except re.error let it escape while the equivalent expand path (which already caught both) did not. A 2128-combination sweep over to × fltr × regex × ignore_case now reports zero raises, which is what makes the mayatk simplification in that package's entry safe. Mode inference carried a third, separate bug: the replace_chars test caught all-asterisk patterns first, so ** — an append of an empty suffix, i.e. a no-op — deleted the matched token instead; the ** tests now come first. The blind-append fallback for replace-prefix/suffix is deliberately kept (it is what makes *_GEO against an empty filter suffix everything) but is now documented rather than implicit.

    Making the formatter term-aware surfaced a fourth defect underneath the others: filtering was delegated to find_str, which deduplicates. Two equal input strings therefore collapsed to one result, so a filtered rename of two objects sharing a short name formatted only the first and left the second silently untouched — the no-filter path was unaffected because it formats one name at a time, which is why mayatk's existing duplicate-hierarchy test never caught it. find_str_and_format now does its own filtering in the same pass that resolves the matching term, preserving input order and duplicates; find_str keeps its dedup, which is correct for a search. The wildcard term grammar those two share is no longer written twice: new private StrUtils._parse_wildcard_terms / ._match_wildcard_term own it, and find_str (now a classmethod, matching its find_str_and_format sibling — cls is bound, so callers are unaffected) consumes them, which also drops the redundant second matching pass the first cut of this fix introduced. +22 tests, with the 7 pre-existing ones left untouched to pin the unchanged grammar; 2513 green. Consumed by mayatk's and blendertk's Naming.rename, whose panel tooltips now document the grammar the code actually implements.

  • 2026-08-05 — PluginInstaller short-circuited on mere presence, so a DCC plugin installed once was never updated again. install_plugin returned early on dest.exists(), and its symlink path — the one that would have tracked the source — needs Developer Mode, so every ordinary Windows machine landed on the copytree fallback and froze the install at whatever shipped that day. A package update adding an op left the DCC answering unknown op, which surfaces as the feature quietly not happening rather than as an error. Measured: Painter's substance_rpc had no project.set_resolution / bake.set_high_poly / textures.apply_mesh_maps (9 ops installed vs 12 shipped), so mayatk's Substance panel sent 4K Map Resolution and Painter stayed at its own 1024 default. Installs are now content-gated: new PluginInstaller.is_plugin_current compares every shipped file (symlink installs are current by construction), install_plugin rebuilds on drift, and force=True narrows to "rebuild even when it already matches". A matching install is still left untouched, so per-hand-off calls stay cheap — and files at the destination with no source counterpart (the DCC's own __pycache__) are not drift. test_rpc.py covers add/edit/nested drift, the no-rebuild path, and the symlink case.

  • 2026-08-04 — The texture taxonomy was blind to two everyday filename conventions: lowercase short suffixes and UDIM tiles. Both were silent — the maps grouped into a set and then vanished from it.

    (1) rock_ao.png grouped as rock but classified as nothing. get_suffix_strip_pattern's underscore branch has always been case-insensitive for every alias, while resolve_type_from_path demanded a capital first letter for short (≤3 char) aliases regardless of what preceded them. So the base-name stripper took _ao / _nrm / _rgh / _bc — putting the file in the right texture set — and the resolver then reported no map type, so the shader builders dropped it as unrecognized. Measured: a rock_basecolor/_nrm/_ao/_rgh set wired base color and nothing else. The case rule now applies only where case is the sole evidence a suffix exists — the ATTACHED CamelCase boundary (rockN yes, wood_green no, or every word ending in an alias letter would classify). After an explicit separator the suffix is a deliberate authoring decision, so it matches in any case, which also brings the classic _d / _n / _s convention in. The model-number guard that motivated the original rule is untouched: it keys off a digit/uppercase predecessor (Agilent_E4419B), not case. test_map_registry_ambiguity.py / test_map_registry_short_alias_boundary.py updated — the old blanket-case test asserted the behaviour that caused the drop.

    (2) Every UDIM tile classified as None. os.path.splitext removes only the extension, so rock_Normal.1001.png reached alias matching as rock_Normal.1001 — ending in no alias at all. Measured before: group_textures_by_set shattered a 2-tile × 3-map set into six single-file "sets" and sort_images_by_type returned {}, i.e. MapFactory and both game-shader panels were complete no-ops on UDIM. New MapRegistry.split_tile_token is the single owner of the tile grammar (.1001 in the real 1001–1999 range, <UDIM>/<UVTILE>, u#_v#), consumed by resolve_type_from_path, get_base_texture_name (drops it — that is the material's name) and the new MapFactory.get_tile_token (reads it back). The bare _#### form is deliberately NOT recognized: _1024 / _2048 is the everyday resolution tag and 1024 sits inside the UDIM range, so accepting it would rename wall_Normal_1024.png. Each tile stays its own texture set (one tile is one image to convert; collapsing them would overflow the factory's one-path-per-map-type inventory and drop all but the last), and TextureProcessor.tile_token re-appends the token after the map type so outputs stay a readable sequence Maya and Substance still read as tiles — rock_Normal_OpenGL.1001.png — and two tiles cannot resolve to one path. Verified end to end: a 2-tile set converts per tile, green-flips per tile, and writes no colliding name.

  • 2026-08-04 — MapFactory.resolve_normal_maps: reduce an inventory to ONE normal map, optionally in a requested convention. The sibling of filter_redundant_maps, for the redundancy that one cannot see: Normal / Normal_OpenGL / Normal_DirectX have no replaces relationship, so redundancy filtering never collapses them — yet all three drive the same shader input, and a set carrying two wired it twice with the last connection silently winning. Same contract as its sibling (mutates {map_type: path-or-[paths]} in place, returns {"dropped": …, "converted": …}), so both DCC packages fold it into the inventory pass they already run.

    target_format is a parameter, not a branch, because that is the only part of the problem this layer cannot know: a renderer whose shading graph can flip green does it there and passes None (blendertk), one that cannot has to correct the FILE and names its own convention (mayatk, whose bump2d has no flip attribute). The ambiguous generic Normal is never converted — its convention is unknown, so flipping it would invert a map that may already be right. The converted source is reported under dropped as well as converted: a caller mapping the report back onto real paths would otherwise keep the original file beside the new one and reintroduce the double-wire. test_map_factory_grouping.py +9. (This logic first landed inside mayatk's GameShader; it touches no DCC API at all, so it belongs here — the app-agnostic rule.)

  • 2026-08-04 — MapRegistry.NORMAL_TYPES / select_normal_type: one owner for "which normal map does a shader wire". Normal, Normal_OpenGL and Normal_DirectX are three distinct map types, so redundancy filtering never collapses them and a set carrying two drives one shader input twice. Both DCC packages had to pick one, both hardcoded the precedence, and — measured — they had already drifted: blendertk tried the ambiguous Normal FIRST, shadowing a labeled map with an unlabeled one and then guessing its convention from a combo box, while mayatk preferred the explicit tag. Same placement rationale as LOGICAL_CHANNEL_TYPES: the two consumers cannot import each other, so the table belongs here. output_template._NORMAL_TYPES and NormalMapHandler.get_consumed_types now read the same constant instead of keeping their own copies — a normal type added to the registry would otherwise silently keep the default output container and leak through as an unhandled passthrough map. test_map_registry_ambiguity.py +5.

  • 2026-08-04 — detect_normal_map_format: measured, and its one blind spot written down. No behaviour change. Stress-tested over synthetic height fields × {clean, JPEG q40–q70, quarter-res}: 40/42 correct, 2 indeterminate, 0 wrong-sign; real normal maps land at |r| ≈ 0.64–0.95 against a 0.25 threshold, and photographs, random noise, flat fills and OBJECT-space normals all fall below it and return None rather than guessing. The limitation worth knowing: the statistic is the relative handedness of two channels, so it cannot separate "G is inverted" from "R is inverted" — a red-flipped map (an X− bake, a mirrored-UV export) reports the opposite convention confidently. Recorded in the docstring together with why it is contained: the caller only consults it for a map that classified as the ambiguous generic Normal, never to override a Normal_OpenGL / Normal_DirectX filename tag.

  • 2026-08-03 — UvUnwrap: documented that the two engines disagree on UV indexing, not just layout. No behaviour change; the class docstring previously promised only that both engines return the input topology unchanged, which led a consumer to assume the UVs came back equally well-formed. They do not: BFF deduplicates its vt table and shares an index wherever two faces meet inside an island, while Ministry of Flat writes one vt per face corner even when the coordinates are bit-identical. A consumer whose UVs are indexed rather than per-corner (Maya — not Blender, which stores UVs per loop) therefore reads every edge as a UV border unless it welds coincident UVs after import. This cost mayatk's auto_unwrap a shipped bug (see mayatk 2026-08-03); recorded here so the next consumer doesn't rediscover it.

  • 2026-08-03 — AppLauncher.process_environ: the OCIO hand-off strip was reading a snapshot that could never contain the variable it exists to strip. handoff_env (below, 2026-08-02) was built on os.environ — but that mapping is a snapshot taken at interpreter init, and Blender 5.x sets OCIO to its bundled v2.5 config at the C level AFTER embedding Python (during color-management init). Measured live: inside blender --background, os.environ.get("OCIO") is None while ctypes GetEnvironmentVariableW("OCIO") and a child cmd /c echo %OCIO% both return Blender's datafiles/colormanagement/config.ocio. So the strip never fired, handoff_env returned None (= inherit), and every bridge-launched Maya still inherited the unloadable config — Warning: Color Management Initialization failed … is version 2.5 … not able to load that config version as the FIRST line of maya -log, the exact live report. New process_environ() decodes the LIVE environment block via GetEnvironmentStringsW (skipping the hidden per-drive =C:=… CWD entries; POSIX returns os.environ — the same C-level blindness exists there but no supported host exercises it); handoff_env now reads and copies from it. Existing tests keep passing because os.environ.__setitem__/__delitem__ call putenv/unsetenv, so patch.dict-driven values reach the real block too. Verified live: re-ran the send e2e's GUI leg — the launched Maya's log carries zero color-management lines.

  • 2026-08-03 — Hand-off bridges gain a second delivery SHAPE: save_as — run the target app headlessly and keep the file it wrote. The kit could only ever launch an app and return; "give me a .blend / a .ma" had no route through it, even though every ingredient existed (ScriptRunner.run_script_to_artifact already ran a DCC blocking and judged it by its artifact — that is what the pull-direction engines use). The missing piece was a seam letting ONE bridge deliver two ways off ONE export pipeline. Three small additions, no rewrite: HandoffBridge.deliverers maps a request mode to a Deliverer (absent mode → the existing deliverer, so every current bridge is untouched); ScriptRunDeliverer is the blocking sibling of ScriptLaunchDeliverer, inheriting its template discovery / rendering / env sanitizing and replacing only the launch — the artifact path rides request.extras["output"] and reaches the template as __OUT_FILE__; ScriptLaunchBridge.save_as(out_path, …) wires it up, with run_spec (a second ScriptLaunchSpec — headless argv, and a different executable where the target has one: mayapy vs maya.exe, resolved through the new HandoffBridge.headless_app_path) and save_extensions (a bare path gets the default). objects=None means the WHOLE SCENE here via the new _scene_objects() hook, not the selection — "save the scene as ..." is about the scene; _resolve_objects keeps its selection-first meaning for send(). The target app writes a staging sibling that is promoted with os.replace only on success: run_script_to_artifact clears the artifact path before running (a leftover would fake success), which is right for a cache artifact and destructive for a save-over — without staging, "save over my scene" would take the previous file with it the moment the child app failed. The sibling keeps the extension, because templates branch on it (.mb → mayaBinary), and sits in the same directory so the promotion is an atomic same-filesystem replace. The two failure paths are deliberately opposite: a failed run sweeps the sibling (a killed child leaves a partial file, and an EMPTY one is a failure the runner reports without removing — neither may litter the output folder), while a failed promotion KEEPS it and names it in the error, because at that point the scene is written and only the rename failed — discarding the result the user waited minutes for would be the worse bug. Consumed by both DCC packages (see their CHANGELOGs); live round trip verified in both directions.

  • 2026-08-03 — Mode declarations are read STRICTLY where the template contract is non-negotiable. ScriptTemplate.template_modes falls back to the caller's primary mode when a template declares nothing — deliberate, so a custom template a user drops into a bridge folder just works. But the fallback also fires when a template declares only OTHER modes, so the interactive import recipe (BRIDGE_MODES = ("send_to",)) validated as a save_as template: it has no __OUT_FILE__ and never saves, so the run would have failed on the missing artifact minutes later instead of in preflight. New ScriptTemplate.declared_modes returns the raw declaration, None when there is none — the distinction is the point, since it is what makes the lenient fallback safe to apply only where it belongs. ScriptLaunchDeliverer.strict_modes (False) / ScriptRunDeliverer (True) pick the reading; template_modes is now expressed on top of declared_modes, so there is one parser. test_bridge.py +14.

  • 2026-08-03 — ScriptLaunchBridge.render_context has a working default instead of NotImplementedError. Every template in the ecosystem is Python, and repr renders bool / str / number exactly as a .py template needs — so the base now formats params itself and subclasses override only to reach a richer formatter. That is not cosmetic: the DCC bridges' formatter lives in uitk (Qt), and save_as must run where Qt cannot be imported (headless blender --background has no Qt binding — verified on 5.1), so both bridges now fall back to this when the panel stack is unavailable. ScriptLaunchSpec also gained timeout (blocking route only).

  • 2026-08-03 — TempArtifacts.dir_path(): scratch DIRECTORIES are first-class, and cleanup/sweep are no longer file-only. The class managed files but not directories, so every site needing scratch space hand-rolled tempfile.mkdtemp + finally: shutil.rmtree — re-implementing the lifecycle per site, and getting no reclamation at all when the finally could not run (a DCC crash mid-operation leaves nothing to execute it). dir_path() allocates a tracked directory in the same prefix namespace; cleanup now recurses into directories (os.remove cannot delete one) and sweep_stale reclaims stale ones by age, so an abandoned scratch tree is collected by a later run instead of leaking forever. __exit__ also switched from isfile to exists, or a failed run reported "nothing kept" while a whole tree sat on disk. Audited every allocation across the ecosystem and routed the raw ones through the primitive; new gate m3trik/scripts/check_temp_artifacts.py keeps it that way. test_temp_artifacts.py +9 → 45.

  • 2026-08-03 — Two temp leaks fixed in pythontk itself. NetUtils.connect_rdp wrote a .rdp with mkstemp and deleted it only on the FAILURE path — mstsc reads the file after the call returns, so the success path (the common one) leaked one per connection with nothing to reclaim it. UsdUtils' two mkdtemp scratch dirs and MapOptimizer._encoded_size's dry-run scratch had a finally but no safety net if the process died. All now allocate through TempArtifacts, keeping their existing cleanup and gaining the age-gated sweep.

  • 2026-08-03 — MapRegistry.resolve_type_from_channel + LOGICAL_CHANNEL_TYPES: the join between a shader's INPUT and a map's TYPE. A bridge manifest records which logical channel each texture was read from (baseColor, normal, ambientOcclusion, …); the registry knows map types. Both DCC packages need the mapping and cannot import each other, so it belongs here. Deliberately scoped as a fallback, never a replacement for filename classification: a channel says only which socket consumed a file, not how it is packed — an MSAO wired into a metallic slot is still an MSAO, and only the filename reveals that. Consumed by blendertk's manifest replay for files that classify to nothing (see mayatk/blendertk CHANGELOGs). test_map_registry_short_alias_boundary.py +3 (every channel resolves to a REGISTERED map type, case-insensitive, unknown → None).

  • 2026-08-03 — Short map-type aliases matched glued onto model numbers, silently wiring color maps into the wrong socket. MapRegistry.resolve_type_from_path matched aliases of <=3 chars as a bare trailing substring with no boundary check, so a texture named after a hardware model number resolved as a map type: measured on a real production scene (VDATS_RF), Agilent_E4419B.png -> Bump, Agilent_PSG.png -> Glossiness, Agilent_8757D.png -> Diffuse. All three are plain color maps, so the bridge's material rebuild wired them into normal/glossiness inputs — worse than not classifying at all, because it is silent. The sibling path (MapFactory.resolve_map_type(key=False)) had always required an underscore boundary and documented exactly this hazard ("avoid mid-word matches like diffuse_cube matching the single-letter alias E"); the two disagreed. A short alias now needs a real boundary: a separator (rock_AO), a lowercase->uppercase CamelCase step (rockN — the existing case-sensitivity rule exists precisely to support this, so it is preserved), or the alias standing alone (N.png). A digit or uppercase predecessor is rejected. get_suffix_strip_pattern got the same boundary, which is the half that is easy to miss: it is a SECOND implementation of "does this name end in a map-type suffix", feeding get_base_texture_name/group_textures_by_set. Tightening only the resolver left the two disagreeing — Agilent_E4419B classified as None but still had its B stripped, so Agilent_E4419A and Agilent_E4419B collapsed into ONE texture set. test_map_registry_short_alias_boundary.py (new, 6 tests) pins both the boundary and the strip/resolve agreement itself; full suite 2419 green, so nothing depended on the loose matching.

  • 2026-08-03 — HandoffBridge.import_roots: make a toolkit importable in a launched child app. A launched app does not inherit the parent's importable set, and Blender ignores PYTHONPATH outright (verified on 5.1 — the variable is present in the child's environment and never reaches sys.path). A bridge template that imports a toolkit to do post-import work therefore fell through to its "toolkit unavailable" branch every time, silently. Returns only the roots for the named packages — never the parent's whole sys.path, which for a cross-version child (Maya's 3.11 -> Blender's 3.13) would prepend the parent's site-packages and shadow the child's stdlib with binary-incompatible modules. Namespace-package aware: with a monorepo root on sys.path, <repo>/pkg/ resolves as an EMPTY namespace module whose naive parent directory is the unusable repo root, so when a spec has no real __init__.py it looks one level in for the actual package. Consumed by mayatk's Blender bridge (see its CHANGELOG).

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.