github m3trik/pythontk v0.8.96
pythontk v0.8.96

5 hours ago
  • 2026-07-28 — UvPack: full audit of the xatlas option surface — a rotation leak fixed, every PackOptions field pinned, and the scale search corrected. Re-checking the engine's own flags (rather than only how we drive it) turned up one outright bug: rotate_charts_to_axis defaults to True and is independent of rotate_charts, so rotate=False still turned every island to an arbitrary angle (measured 8/8 charts at 93°/−169°/−147°/142°). It is now always set explicitly, and rotate=False genuinely means no rotation. Correcting the prior entry's claim that these flags are no-ops: rotate_charts_to_axis and blockAlign both move fill measurably; only bilinear is inert for add_uv_mesh input. Every PackOptions field is now set rather than inherited, guarded by a test that fails if a future engine version adds one. Hull-axis pre-rotation has no good fixed value (24 mixed rects prefer it off, 0.702 → 0.727; 24 irregular prefer it on, 0.522 → 0.564), so it is searched, not exposed — a user cannot predict which suits their mesh. blockAlign was measured (≈+5% on cube-like content) and deliberately rejected: it snaps each chart to a 4-texel grid, so a caller island the engine splits into two charts has its halves snapped independently (~1e-3 of relative drift), which broke mayatk's per-shell rigid write-back on 2 of 3 real Maya scenes. Two search bugs fixed alongside: (1) seeding each candidate from the running best is unsound — whether a scale fits is not monotonic, so a failing seed becomes an upper bracket the bisection can never climb back over, which capped better candidates and made the search pick the loser (a candidate reaching 377 was reported as 365.6); every candidate now searches from the same guess. (2) brute_force was a post-pass whose result was returned unconditionally and could hand back a looser pack than not asking for it; brute is now a competing candidate in the same max-scale competition, so it can also reorder which variant wins and can only hold or improve fill. Search convergence tightened 0.02 → 0.005 (it is a scale tolerance, so it cost ~2× that in area — the same order as the spread between candidates, which hid which one had actually won). Net, measured in Maya across 24 scene/coverage/brute combinations: no failures and no regressions, gains to +8.3%. test_uv_pack.py +6 (option-pinning contract, no-rotation regression guard, brute-never-looser, variant search ≥ any pinned choice, contradiction guard, variant collapse).

  • 2026-07-28 — MathUtils.step_offset(value, step, direction, snap=False) — one grid step, relative or snapped. Extracted for the Shell Xform move pad, which both DCC packages ship and which therefore cannot own the math without duplicating it. Relative mode is a plain signed step (sub-step drift preserved); snap=True returns the offset to the next grid line in direction, so an off-grid value absorbs its drift on the first press and an on-grid one advances a full step. The grid-line epsilon is load-bearing: without it a value on a line only up to float error (0.9999999 on a 0.5 grid) rounds the other way and the offset collapses to ~0, which reads as a dead button. test_math.py +4 (relative, snap across 7 value/step/direction combos with an on-grid landing assertion, the float-error case, zero-step raises).

  • 2026-07-28 — UvPack.pack_islands gains fixed-page packing (resolution + pages): square pages, scale-searched to fill edge-to-edge. Live report against the first cut: packing a cut-apart cube filled only 0.50 of a full square tile (0.25 of a half-tile region). Measured cause — in content-driven mode the engine picks the atlas aspect freely (a 6-shell cube came back 613×1025), and aspect-true fitting that atlas into a square region throws the mismatch away; no unexposed PackOptions knob fixes it (bilinear/blockAlign/rotate_charts_to_axis are no-ops on it, and raw resolution/texels_per_unit just rescale or spill to extra pages). The new mode drives the engine's fixed-resolution machinery properly: square pages of exactly resolution texels, with the largest texels_per_unit that fits the pages budget found by bisection from an area-based guess (~8–12 fast engine runs; atlases are single-shot so each probe is a fresh one). brute_force now re-searches upward from the non-brute optimum — brute placement fits larger scales, and re-running it at the searched-down scale would buy nothing (the first implementation's mistake; measured: full-tile fill 0.68 non-brute → 0.80 brute on the cube content). Per-island page assignment is recovered from the engine's charts (Chart.atlas_index + Chart.faces) and returned as per-UV pages arrays with page_count; multi-page UVs verified normalized 0-1 per page. Content-driven mode is unchanged and stays the default. Padding in fixed-page mode is exact pixels (a page texel is a texture pixel by construction). test_uv_pack.py +2 → 12: single-page fill floor (>0.55 where content-driven gave 0.50) and two-page balance/reporting.

  • 2026-07-28 — UvPack.pack_islands reports written, the rows the engine actually positioned. uvs is built by copying the input and scattering the engine's result into it, so any row the engine did not map would silently retain its input coordinates — and a consumer deriving a transform from before/after coordinates (mayatk's per-shell write-back does exactly that) would fit across a mix of moved and unmoved points and corrupt it with no error. written makes the authoritative rows explicit. Measured on xatlas 0.0.11 it covers every input vertex — including one no triangle references, pinned by a test — so it is a guard rather than a live filter, kept because the silent-corruption class it prevents is the one that cost this feature a debugging session. Also swept for the same investigation and recorded so it is not re-litigated: of the PackOptions knobs left unexposed, bilinear and blockAlign are measurable no-ops in pack-only mode, rotate_charts_to_axis changed nothing on real content, and resolution/texels_per_unit only move the atlas's absolute scale, which the aspect-true unit fit normalizes away (setting both can also spill into a second atlas, which this API does not support) — none of them affect inter-shell spacing.

  • 2026-07-28 — log_link label/verb parameters are now positional-only, so query params may use any key — text/action included. logger.log_link("copy", "copy", text=payload) — the natural shape for a copy-action link, and exactly what uitk's example console emits — raised TypeError: got multiple values for argument 'text' because the display-label parameter was also named text. A / after (text, action) frees the whole kwargs namespace for the query string (TDD'd red-first, test_logging_mixin.py +1). Callers passed both positionally everywhere in the monorepo; only a caller spelling log_link(text=…, action=…) as keywords would notice, and none exist.

  • 2026-07-28 — ptk.UvPack: UV island packing through the optional xatlas engine (arrays in, arrays out). The pack-only sibling of UvUnwrap: where that class round-trips OBJ files through unwrapper executables, UvPack.pack_islands(meshes) packs existing islands in-process through the xatlas Python bindings (MIT; pip install xatlas — cp311 wheels on PyPI, so it installs straight into mayapy/Blender's Python). The dependency is optional per the package charter: xatlas/numpy import lazily, and resolve(required=True) turns an absent module into a RuntimeError carrying the exact sys.executable-based pip command instead of an ImportError (available() polls without raising, so UIs can gate). Engine facts verified on 0.0.11 and pinned in tests: add_uv_mesh + generate packs the given parametrization — islands are detected from UV topology, never re-cut, and their relative input scale is preserved exactly (pack-only mode applies one global uniform scale); returned UVs are normalized 0-1 per axis of a generally non-square atlas — equal UV distances are unequal in texels — so pack_islands de-normalizes by the atlas dimensions and returns aspect-true coordinates uniformly fitted to the unit square (PackIslandsResult.extent carries the covered span); get_mesh's re-indexed vertices are scattered back through its vertex mapping so every returned array aligns 1:1 with the caller's input indices (a duplicated source vertex — which pack-only mode should never produce — raises rather than mis-mapping). Exposed knobs: padding (texels), rotate (90° chart re-orientation), brute_force (exhaustive placement). Consumed by mayatk's new UvUtils.pack_uvs and tentacle's Pack UVs Method combo. test_uv_pack.py: 8 cases (resolve contract + packing behaviors), skipping cleanly where xatlas is absent. Extracted in the same pass: MathUtils.udim_to_tile — the UDIM-number → (u, v) tile-offset rule (1001 + u + 10*v, 10-tile rows), previously duplicated inline by every packer call site; lives beside calculate_uv_padding under the same one-rule-every-consumer charter (mayatk UvUtils.udim_to_tile delegates, and the pending blendertk pack twin will too).

  • 2026-07-28 — ptk.UvUnwrap: automatic UV unwrapping through external CLI engines (OBJ in, OBJ out). Two engines behind one EngineSpec registry, so a third is one table entry rather than a code change: Ministry of Flat ("mof", hard surface — the technology Maxon licensed for Cinema 4D's Auto UV) and Boundary First Flattening ("bff", organic — MIT, conformal flattening with automatic cone singularities). unwrap(obj_in, engine=…) plus the semantic aliases hard_surface() / organic(); available_engines() polls resolution without ever installing, prompting or raising, so a UI can gate on it. Provisioning differs by license and is enforced in code: BFF installs from a pinned GitHub release URL + sha256 via AppInstaller behind MeshConvert's TTY gate (never a silent download inside a GUI host), while Ministry of Flat is discovery-only — its free license forbids redistribution, so auto_install=True still refuses and the FileNotFoundError carries the vendor URL and the PYTHONTK_MOF_EXE remedy instead (pinned by a regression test). Success is judged by the output file, not the exit code — verified against the real binaries, Ministry of Flat returns 1 after writing a perfectly good result, so a returncode check would reject everything it produces; the gate is "file exists, non-empty, contains vt lines", with the exit code and 2 KB stdout/stderr tails reported only when that gate fails. Curated parameters come from each tool's documented non-debug surface only (Ministry of Flat's own docs warn that the rest "is likely to result in worse UV mapping"); there is no force-hard-surface flag because it classifies automatically. Both engines were measured to accept n-gons and return the input topology unchanged — same vertex count, face count and winding — which is what lets the DCC layer map UVs back by component index instead of sampling spatially, and why nothing in the pipeline triangulates. 38 tests: hermetic suites mock the launcher, one drives a real subprocess, and PYTHONTK_INTEGRATION_TESTS=1 runs the real engines including a topology-preservation check.

  • 2026-07-28 — TaskFactory gains stage_deferred_restore / run_deferred_restores — the counterpart to the set_/revert_ pair, for mutations the caller's real work reads. _get_revert_method's docstring already warned that reverts fire when run_tasks returns, i.e. before the export write, so "only mutations the export itself doesn't depend on may be reverted this way" — but nothing enforced or offered an alternative, and both DCC consumers had quietly violated it: mayatk's set_linear_unit/set_workspace and blendertk's set_linear_unit/set_bake_animation_range were all fully inert, their revert landing before the FBX writer ever read the value. The new pair is that alternative: a task returns None (disarming the pairing) and registers a zero-arg restore under a key; the caller drains them from a finally once the work is done. Keying makes staging idempotent and gives the first stager priority, which is what lets one task build on another's mutation and still restore the true original (blendertk widens a frame range a prior task set). Restores run LIFO like _revert_states, and each is isolated — a failure is logged, never re-raised, since this runs where raising would mask the real error — with the registry cleared regardless so a failed restore can't make the next run treat its stale key as already staged. Lives here rather than twice downstream for the same reason the class itself does (see the module docstring). test_task_factory.py +3.

  • 2026-07-28 — New ptk.StatusBadge (core_utils/status_badge.py): the ecosystem's single writer for README test badges. Every package had its own hand-rolled update_readme_badge — four near-identical copies that had already drifted apart (uitk emitted a lowercase tests- label and a yellow skipped-only state; mayatk counted skipped tests as passed; each hard-coded a different insert anchor — "after the Python badge", "after the Maya badge", "after Version or License"). StatusBadge owns the wording, colours, shields escaping, badge matching and insert position once: test_status(passed, failed)("N passed"|"N passed, M failed"|"M failed", colour), and update_test_badge(readme, passed, failed, test_dir=…) resolves the link relative to the README's own location. The matcher is alt-text- and case-agnostic and handles bare ![…]() images, so legacy badges migrate in place instead of duplicating; first-time insertion lands at the end of the leading badge block, or above the title when a README has no badges. Generic half (render/update, arbitrary label/message/colour, optional shields style) is reusable for any status badge. Three rules worth calling out, each proven by a failing-first test: a run with zero passes and zero failures is lightgrey, not green — nothing ran, which is unknown, and a green badge over a run that produced no results is the worst of the four readings; the badge matcher tests the encoded label (matching the raw one silently fails for any label needing escaping, e.g. Unit TestsUnit%20Tests, and a matcher that never matches appends a second badge on every run); and stamping is best-effort I/O — a read-only or cloud-sync-locked README returns False rather than raising, because a cosmetic badge write must never turn a green run red (this tree lives on a synced drive). Rules the class encodes: m3trik/docs/TEST_BADGE_STANDARD.md. test_status_badge.py (15 tests). run_tests.py now delegates to it.

  • 2026-07-28 — AppLauncher.active_console_session_id now recognizes the no-user-logged-on sentinel. WTSGetActiveConsoleSessionId returns DWORD 0xFFFFFFFF when no session is attached, but the untyped ctypes call surfaces it through the default 32-bit signed restype as -1, so the == 0xFFFFFFFF check could never fire and the method returned -1 instead of None (headless/service contexts only — an interactive desktop always yields a real id). The return is now masked back to unsigned before the sentinel compare. Found while fixing the same untyped-ctypes handle-truncation class in uitk's test runner (see its CHANGELOG). Test test_app_launcher.py::TestAppLauncherSessions::test_active_console_session_id_no_session_returns_none (failing-first).

  • 2026-07-28 (keyable weights) — Region-mask engine: a group can now name the DCC attribute that carries its keyed weight (RegionGroup.attr, RegionGroupRegistry.set_attr). The opt-in DCC keying feature (mayatk/blendertk make_weights_keyable) needs one thing from the shared model: the manifest must tell the engine importer which animated custom property drives each group. RegionGroup gains an optional attr field, serialized only when set (schema stays 1 — additive, consumers ignore-or-use); RegionGroupRegistry.set_attr(name, attr) records or clears it per group (groups() passes it through); RegionMaskPacker.add_group gains the matching passthrough so the channels-encoding sidecar manifest agrees with the FBX one. test_region_masks.py +2 → 37/0.

  • 2026-07-28 (same-day follow-up) — Region-mask engine: the runtime gate no longer blacks out UNGROUPED regions, and the DCC-side slot bookkeeping moved in as RegionGroupRegistry. Two findings from a critique pass over the morning's work. (1) Correctness: the gate was saturate(dot(mask, weights)), so a texel belonging to no group has mask == 0 and gated to black — authoring a single group would silently kill every other emissive region on the mesh, the exact opposite of the feature's premise ("one all-on map, toggle some items"). It is now saturate(dot(mask, weights) + (1 - saturate(dot(mask, 1)))): the second term restores precisely the texels no group claims (in no group → gate 1; grouped at w=1 → 1; grouped at w=0 → 0; half-covered at w=0 → 0.5, the correct blend since half that texel is ungrouped). RegionMaskPacker.preview implements the same formula so a DCC preview matches the engine, and both sides cross-reference each other. test_region_masks.py +1 pinning that an ungrouped region stays lit at every weight. (2) DRY: RegionGroupRegistry — the slot/retirement/default/manifest bookkeeping mayatk and blendertk had character-identical copies of (~90 lines each), which is pure dict work with no DCC call in it and therefore SSoT-breaking duplication of the load-bearing slot contract. Persistence is injected as a (load, save) callable pair over one JSON string (each DCC's scene carrier), so every operation is a read-modify-write against the scene. It also owns the hygiene rule both DCCs now depend on: a registry holding no groups and no retired slots is cleared rather than stored, and a read never writes — so merely opening a panel can't stamp a carrier channel into a clean scene. +14 tests (slot assignment / retirement / compaction / exhaustion / clamping / corrupt-payload recovery / manifest-follows-encoding / no-write-on-read). Both DCC modules shed their copies and their now-unused re/json imports.

  • 2026-07-28 — New region-mask engine (engines/textures/region_masks.py): the shared model behind the ecosystem's new Emissive Groups tool (mayatk/blendertk → unitytk). A region group is a named face set a game engine gates at runtime with emission * dot(mask, weights); the engine owns the parts both DCCs and the Unity importer must agree on: RegionGroup (name + stable channel slot + default weight), RegionMaskManifest (the JSON wire schema, encodings vertex-color / channels / reserved id, versioned via schema), and RegionMaskPacker (channels encoding: rasterizes each group's UV triangles into its slot channel of an RGBA mask with AA + edge padding, writes mask + manifest sidecar, preview(weights) composites the exact runtime result for DCC panels). Supporting primitive extracted to the right layer per the placement contract: ImgUtils.rasterize_uv_triangles(triangles, size, supersample) — general UV-space polygon coverage (supersampled box-filter AA, V flipped to image coords), composing the existing _fill_triangle / dilate_image numpy internals. Overlap validation erodes each cover 1px first so adjacent shells sharing a UV seam don't false-positive (the 1px boundary line both rasterize onto is not membership overlap). The imaging deps are optional at module level (guarded like _img_utils): the manifest model is pure JSON and must resolve on a DCC Python without numpy/Pillow (vanilla Blender) — only rasterize/write/preview raise a clear RuntimeError when they're missing. MapRegistry gains the Emissive_Mask type (_EMask/_EmissiveMask/_EmissiveGroups suffixes; Linear RGBA, scale_as_mask) so the affix/compositor tooling classifies the new map like any other. test_region_masks.py (new, 23) + full suite green. Cross-package doc: mayatk/docs/emissive_groups.md. Release note: publish pythontk before mayatk / blendertk — both DCC tools and their manifests depend on this module.

  • 2026-07-27 — New MathUtils.max_axis_skew(axes, degenerate_length=1e-9): the ecosystem's ONE axis-orthogonality measurement, extracted to the bottom of the stack. Both DCC packages' transform diagnostics need the same test — how far are a matrix's axis vectors from mutually perpendicular (the condition FBX cannot represent; its "Non-orthogonal matrix support" warning) — and both had grown a private copy of the max-abs-pairwise-cosine math (mayatk over flat 16-float row lists, blendertk over mathutils 3×3 columns). Pure vector math over plain iterables, so per the placement contract it comes down here and both _matrix_skew internals become thin delegates feeding their own matrix layout in. 0.0 = orthogonal basis (pure scale, uniform or not, reads 0.0 — no false positives); degenerate zero-length axes read 0.0 (no direction to measure). test_math.py +3 (orthogonal incl. rotated + non-uniform-scale bases, measured shear incl. worst-pair-wins, degenerate axis) → 148/0. Release note: publish pythontk before mayatk / blendertk — both now delegate here.

  • 2026-07-27 — New MathUtils.calculate_uv_padding(map_size, normalize=False, factor=256): the ecosystem's ONE texture-gutter rule, extracted to the bottom of the stack. The formula lived in mayatk.UvUtils only, so when blendertk's RizomUV bridge needed the same gutter there was nowhere shared to take it from — the second consumer would have meant a second copy of the rule, free to drift from the first. It is pure ratio math over a data type (math_utils), not a domain engine, so per this package's placement contract it comes down here and both DCC packages keep their UvUtils.calculate_uv_padding name as a thin delegate (the documented "mayatk extends pythontk, doesn't duplicate logic" shadow, same as AudioUtils / CoreUtils). Behavior is byte-identical to the mayatk original — map_size / factor px, or 1 / factor normalized. The docstring now states the property consumers actually rely on: the normalized result is map-size-invariant ((size/factor)/size), which is what makes it safe to bake into a packer call — 4 px at 1024, 8 px at 2048, 16 px at 4096, always the same fraction of the tile. test_math.py +2 (pixel scaling across four map sizes; invariance across six, plus a custom factor) → 2160 tests, 0 failures. Release note: publish pythontk before mayatk / blendertk — both now delegate here.

  • 2026-07-26 — TempArtifacts handed two stores the same path: the monotonic tag counter was per-instance, so concurrent or same-tick producers silently overwrote each other (TDD'd red-first). path() mints a <prefix>_<tag> name from max(time.time_ns(), self._last_ns + 1) — but _last_ns was reset to 0 in __init__, so the floor only ever protected allocations within one instance. Distinct instances routinely share a dir+prefix namespace: CachedArtifact.get builds a fresh scoped store on every call, which is exactly the collision case. Windows' time_ns resolution (~15ms) is far coarser than the gap between two allocations, so both stores read the same clock value and returned an identical path — the second producer writing over the first's artifact with no error. This was already failing in CI as an intermittent test_use_cache_false_produces_into_scratch_every_time (2 of 3 runs locally); the flake was the bug reporting itself, not test noise. Under concurrency it was far worse than the flake suggested: with the clock frozen, 8 threads × 20 allocations produced 20 unique paths out of 160, every thread marching through the identical tag sequence. The counter is now class-level (_last_tag_ns) behind a threading.Lock via new _next_tag_ns(), making tags unique process-wide and the read-modify-write atomic; it assigns through TempArtifacts explicitly rather than cls, since a subclass write would bind a shadowing attribute and hand that subclass its own counter — reintroducing the collision across the base/subclass pair. Fixed-name allocation is unchanged (still deterministic and self-overwriting by design). test_temp_artifacts.py +2 deterministic regression pins (cross-instance under a frozen clock; cross-thread uniqueness) → 36/0, and the intermittent test now passes 6/6 consecutively. No public API change.

  • 2026-07-26 — HelpMixin.help(returns=True) returned terminal bold-overstrike instead of plain text, corrupting every programmatic consumer. Both render_doc sites (the whole-class default and the single-member path) returned pydoc.render_doc(...) raw, which emits C\bCl\bla\bas\bss backspace pairs for terminal bolding. The printing path is fine — the pager resolves those — but returns=True exists precisely for programmatic callers (the python -m pythontk CLI, agent tooling, cross-process RPC), where they surfaced as doubled-letter garbage: class CCoorreeUUttiillss(...). It went unnoticed because the existing assertIn("SampleClass", result) matches against the plain title line that precedes the bolded body. Both sites now route through new private _render_plain() (pydoc.plain(pydoc.render_doc(...))). Also extracted _signature_detail(name, member) out of signature() — the parameter/annotation/kind breakdown was reachable only as a classmethod resolving against cls, so the CLI could not render it for non-HelpMixin targets without duplicating the formatter; signature() is now a thin resolve-and-delegate wrapper with identical output and messages. Both new members are private, so the public surface is unchanged (registry regenerated: "No public API changes"). test_help_mixin.py +1 (subtested over both render paths) → 68/0.

  • 2026-07-26 — The HelpMixin introspection CLI moved from pythontk/help.py to pythontk/__main__.py; invocation is now python -m pythontk <dotted.path> [member] [options] (was python -m pythontk.help …). The module was orphaned since it shipped — nothing in the monorepo imported or referenced it, no doc linked it, and it squatted the generic name help at the package root, where every other module lives under a *_utils subpackage. It survives the audit because it does something the static API_REGISTRY.md structurally cannot: read the live object, so --source / --where report what is actually loaded rather than what was last generated, and its dotted-path resolver accepts both the root alias and the full module path (pythontk.CoreUtils and pythontk.core_utils._core_utils.CoreUtils), falling back to the public namespace for a bare name. __main__.py is the canonical home for a package CLI and is skipped outright by the registry walker (generate_api_registry.py), so the "stays off the public surface" property the old module got by underscore-prefixing every symbol is now guaranteed by rule — registry unchanged, no regen needed. Now linked from docs/README.md beside the HelpMixin entry, so it is reachable rather than merely present. Three defects in the moved code were fixed rather than carried over: (1) --signature was wired only into the class-and-member branch — on a bare class it fell through to full help() output and on any non-HelpMixin target (e.g. pythontk.core_utils.cli.CLI get_parser) it fell through to about(), so a documented flag silently printed something else on two of three dispatch paths; it now renders everywhere via the extracted HelpMixin._signature_detail, and a bare class gets an explicit "needs a member" error instead of quietly ignoring the flag. (2) The whole-class and single-member branches were near-identical copies; since source/where/help all take the member name as an optional first argument they collapse into one path (which also drops a brief= that as_json never consumed). (3) _resolve caught bare ImportError and fell through to a shorter prefix, so a module that exists but whose own imports are broken was reported as could not resolve '<target>' — burying the real cause; it now distinguishes a genuinely absent candidate from a broken one via ModuleNotFoundError.name and re-raises the latter.

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.