Skip to content

pythontk v0.9.20

Latest

Choose a tag to compare

@m3trik m3trik released this 16 Aug 11:05
  • 2026-08-15 — PyPI index-propagation cover widened from 3x30s to 8x30s (.github/workflows/publish.yml). The post-publish step installs the freshly uploaded wheel WITH dependencies, to prove its pins resolve on PyPI exactly as users will install them. Its retry budget was measured too short on 2026-08-13: the simple index lagged past the old ~1.5-minute window and the release cascade aborted on "No matching distribution found" for an upstream published minutes earlier in the same run — a red run on a wheel that was fine. Eight attempts give ~3.5 minutes of cover; the retry only absorbs propagation lag, so a genuinely unresolvable pin still fails exactly as before, just later. Landed identically across pythontk / uitk / mayatk / blendertk / tentacle, since the cascade fails as a unit.

  • 2026-08-15 — MeshCleanerMeshOps: the PyMeshLab wrapper had never executed (dep uninstalled and undeclared everywhere), and its one weld step was a silent no-op even on paper. mesh_cleaner.py becomes file_utils/mesh_ops.py on the UvUnwrap registry design: typed classmethods (measure/compare/clean/remesh/decimate/bake_vertex_color) over a curated OPS: Dict[str, OpSpec] single-filter vocabulary, plus apply() as the unvalidated escape hatch and a context-managed session() so composed ops skip the load→save→reload round-trip. Availability is the Style-B contract (resolve(required=)/available()/install note naming the new pythontk[mesh] extra — the package's first [project.optional-dependencies] table, floored at pymeshlab>=2023.12 for the PureValue rename). Every filter name and parameter was verified by enumeration against an installed pymeshlab 2025.7 rather than assumed, which surfaced the bug the rename exists to mark: clean() passed merge_distance (documented as a distance, default 1e-5) through PercentageValue — 0.00001% of the bbox diagonal, sub-micron on any real asset — so the close-vertex weld merged nothing, masked by the exact-duplicate pass before it; live-reproduced (jittered duplicates: 5124→5124 verts under the old call, 5124→2562 under PureValue) and pinned by a failing-first regression test. measure()/compare() return flat gate-safe dicts — numeric-or-None, never a fabricated 0 (a 0 falsely passes max_* QcGate rules), and no min_/max_ substrings in metric keys because QcGate strips rule prefixes with an unanchored replace (hausdorff_peak, not hausdorff_max). CI now installs pymeshlab so the real paths run instead of skipping. Read and write support are gated separately (SUPPORTED_EXTS vs SAVE_EXTS, both probed: glTF/GLB load — verified with a hand-rolled minimal GLB — but have no exporter), and every op validates its output extension before any work runs, so clean("scan.glb") fails in milliseconds with an actionable error instead of after the whole chain with a raw PyMeshLabException at save time. 28 tests added (test_mesh_ops.py); full suite 3188 collected / 3173 passed / 0 failed. Breaking: one in-repo consumer (extapps photogrammetry) migrates in the same change; recorded in API_CHANGES.md.

  • 2026-08-15 — Audit fixes: 16-bit maps are rescaled instead of clipped to white, palettised alpha survives KTX2 staging, the optimizer twins agree on containers that cannot hold 16 bits, and assess can read back the KTX2 it just wrote. Thirteen defects found in a full-package audit of the uncommitted texture/doc-audit/CLI work — none of which the (green) suite caught. Each is pinned by a new failing-first test; 40 tests added, full suite 3156 collected / 3142 passed / 0 failed.

    The same clip-vs-rescale trap on the float path (ImgUtils.convert_f_to_l). Found by critiquing the fixes above: MapOptimizer._coerce_mode reduced a mode-F source with a plain convert, and Pillow implements F -> L as a truncation — so a float map's 0..1 data sent every texel below 1.0 to 0. Probe-proven end to end: a 0..1 float-TIFF height ramp came out of optimize_map as a 2-value, essentially black 84-byte PNG, reported as a successful -99% optimization, with assess predicting a clean L and no test covering the path at all. New ImgUtils.convert_f_to_l is the float twin of convert_i_to_l and lives beside it: 0..1 is the float convention's full scale exactly as 0..65535 is the 16-bit one, so it maps onto the whole 0..255, and out-of-range data is clamped rather than normalized — dividing by the actual maximum would silently tonemap, changing every texel's meaning to make one bright one fit (genuine HDR that needs its range kept still wants encode_hdr_for_web, which returns the scalar). Clamping also keeps the optimizer twins in agreement, where refusing the mode outright — the choice Ktx2Encoder._stage_image correctly makes for a direct encode — would have made the writer raise on a prediction assess had already called clean. The two compose: an EXR into output_type="ktx2" now reduces to L in the optimizer and reaches the encoder as an 8-bit image, so the refusal only fires where nothing reduced it.

    Both reduction twins now dispatch on the array dtype rather than trusting the caller to have picked the matching entry point, because each destroys the other's input if its name is taken literally: convert_f_to_l on 8-bit data drives every value >= 1 to white, and convert_i_to_l on 0..1 floats — a latent trap that predates this work — never exceeds 255, so it skips the rescale and rounds straight to 0/1. Both collapsed a 256-step ramp to two values; either entry point is now safe for either dtype.

    Two silent data-loss bugs in the day-old KTX2 path. Ktx2Encoder._stage_image reduced a high-bit-depth source with a plain im.convert("L") — and Pillow implements I;16 -> L as a clip at 255, not a range rescale, so a smooth 0..65535 height ramp staged as 2 unique values, 99.6% of them pure white. OutputTemplates._PRECISION_16 authors Height/Displacement/Bump at 16-bit and _save_ktx2 bypasses save_image's bit-depth handling, so ktx2 was the one container the profile system could hand a live I;16 image to. The reduction now goes through ImgUtils.convert_i_to_l, which already owned this rule package-wide (numpy, ÷257 full-scale) — the private point-based rescale first written here was not only a duplicate but crashed outright on I;16B, the mode Pillow returns for a big-endian 16-bit TIFF, because point refuses byte-order-qualified modes. Separately, a palettised source was staged through the mode-only effective_mode table, which maps P -> RGB and destroys tRNS alpha while dropped_channels reports no loss (mode P has no A band to notice); both Basis codecs carry alpha, so nothing forced the drop. P/PA now route through the existing ImgUtils.depalettize_image.

    The optimizer twins disagreed on every container that cannot store 16 bits. plan()'s high-precision tolerance was gated on the resolved OutputSpec's bit depth but never on the container actually being written — plan() was not even passed output_type — and since Height/Displacement/Bump resolve to OutputSpec("png", 16) under every built-in profile, the tolerance was live for any output type. Probe-proven: optimize_map(<16-bit Height>, output_type="dds") raised OSError: cannot write mode I;16 as DDS while assess predicted a clean no-op; tga silently widened a linear height map to 24-bit RGB (+4434%); a mode-I source additionally raised on tga and jpg. All five wrote valid single-channel L at HEAD, so this was a regression, not an inherited gap. plan() now takes the container and consults _HIGH_PRECISION_CONTAINERS — which records the mode each container actually stores, measured by save-and-reopen, rather than being derived from _CONTAINER_MODE_FALLBACKS (that table lists only modes whose stored form differs, so every container missing from it reads as "keeps I;16", which is the bug). A container with no entry gets no tolerance and coerces to the map type's own target mode — L, never RGB. assess and optimize_map now agree on mode and dimensions for dds/tga/jpg/png/webp across both I and I;16 sources, pinned by a twin-agreement walk.

    assess could not read its own deliverable. A .ktx2 written by optimize_map fed straight back in returned Failed to read image: cannot identify image file and an empty predicted dict — so the scene exporters' optimize-textures gate, which by design assesses the staged output the task just wrote, reported a read failure for every KTX2 map it delivered, and any caller reading predicted["width"] got a KeyError. New Ktx2Encoder.read_header parses the fixed-layout KTX2 header (identifier + vkFormat/typeSize/pixelWidth/pixelHeight/pixelDepth), giving KTX2_MAGIC its first production user — it was previously referenced only by tests, both of which skip where toktx is absent.

    Spec conformance, state leakage, and exit codes. optimize_glb_textures in KTX2 mode rewrote an image's mimeType to the non-core image/ktx2 from replacements (every decodable image) while gating the KHR_texture_basisu declaration on bound_basisu (set only when a texture rebinds), so a GLB whose images are sampled by nothing shipped a non-core mimeType with no extension declaring it; the encode is now gated on the image actually being sampled, keeping the file valid either way without forcing extensionsRequired onto an asset that uses none. PreviewDeliverer.texture_format was documented as a per-push knob but lived as instance state on a class-level shared deliverer with no per-request override, so one KTX2 push stuck process-wide for every bridge in the session — now request-scoped like the adjacent open_browser, falling back through the instance default to WEBP (a falsy default would otherwise hand the optimizer None, which raises into the broad except and silently ships an unoptimized GLB). MapOptimizer._snap_pot validates pot_mode instead of silently taking round() for any unrecognized string. python -m pythontk <HelpMixin target> <missing member> returned 0 while the same mistake on a non-HelpMixin target exited 1 — the message went to stdout, so --source > file || fallback never took the fallback and wrote the error text into the file; it now probes the member with the mixin's own getattr(cls, name, None) predicate (not a string match on the message), prints to stderr, and exits 1, with the --json error payload shape preserved on stdout.

    DocAudit went blind on wildcard imports. from <mod> import * passed the literal "*" to getattr, so any wildcard in an audited block always reported name not found — and because the star names never entered the namespace, every later reference in that block was silently skipped as an unknown root, i.e. the audit failed open on exactly the blocks that use a wildcard. The star surface is now expanded the way the interpreter does (__all__ when defined, else the public dir()), and that expansion is guarded: reading it runs the module's own code, so a non-iterable __all__, a lazy __getattr__ raising for an uninstalled extra, or a custom __dir__ that blows up now leaves the surface unknown instead of taking the whole docs gate down with a traceback.

    Also: refresh_export_view's dropped empty-store guard — deliberate (mayatk's companion entry documents the stale-takes fix it enables) but shipped with zero coverage — is now pinned by six tests; test_assess_surfaces_the_etc1s_refusal was asserting an unrelated lossy-quality refusal and is renamed to what it covers, with a real ETC1S-through-a-profile case added; the test_net_utils RDP test asserted against a tempfile.mkstemp patch that no longer bound after the TempArtifacts migration (it passed while its redirect was a no-op and leaked a live .rdp into the real temp dir) and now locks the migration in; and two dead locals went (gltf in _embed_image, import tempfile in _net_utils).

  • 2026-08-14 — Review fixes: the 16-bit grayscale tolerance is now spec-gated, a missing KTX2 encoder always raises instead of sometimes shipping unoptimized, toktx gets a subprocess timeout, doc_audit survives a broken optional-dep import, and --index rejects a target instead of dropping it. Six defects found in review of the same day's texture-output/doc-audit work, each pinned by a new failing-first test.

    MapOptimizer's high-precision (I/I;16) mode-coercion tolerance was keyed only on the target mode ("L"), so it tolerated 16-bit input for every L-target map type — but only Height/Displacement/Bump actually carry a 16-bit OutputSpec; Roughness/AO/Metallic stay 8-bit and were silently shipping 16-bit PNGs with assess reporting recommended=False. plan() now resolves the map type's OutputSpec bit depth (via OutputTemplates.resolve, gracefully falling back to DEFAULT outside a profiled run) and only tolerates the high-precision modes when that resolved spec is itself >= 16-bit.

    PreviewDeliverer.deliver() wrapped MeshConvert.optimize_glb_textures in a broad except Exception, so a texture_format="KTX2" push with no toktx installed silently shipped the unoptimized GLB — contradicting docs/webxr_preview.md's promise that the push raises with the install URL. deliver() now calls ImgUtils.resolve_ktx2_encoder(required=True) before the try-block, so the fix-shaped FileNotFoundError always propagates for a KTX2 push. ImgUtils.resolve_ktx2_encoder(required=True) itself had a latent fall-through: if Ktx2Encoder.available() said False but Ktx2Encoder.resolve_toktx(required=True) unexpectedly succeeded, it returned None instead of raising — now it raises the fix-shaped error unconditionally. The two existing tests that only mocked available() (silently non-deterministic on a machine where toktx really is installed) now also mock resolve_toktx to raise.

    Ktx2Encoder._run had no subprocess timeout, so a hung toktx blocked the calling thread forever — fatal for a DCC's single-threaded UI. New constructor timeout param (default 300s), passed to subprocess.run; a TimeoutExpired is re-raised as the same fix-shaped RuntimeError style the method already uses for a non-zero exit.

    DocAudit.audit_code's import-probing caught only ImportError, so an optional-dep module that raised anything else at import time (RuntimeError/OSError — a missing native library, a misconfigured environment) crashed the whole audit instead of leaving that one name "unknown", same as a genuinely missing package. Now catches Exception on both the import and from...import probes.

    python -m pythontk sometarget --index silently dropped the target and ran --index as if it had never been given — a typo'd invocation looked like it did something unrelated instead of surfacing the mistake. Now parser.errors on the combination.

    Also: two open(path, "rb").read() calls in test_mesh_convert.py's KTX2 no-partial-rewrite test leaked file handles — moved to with blocks. test/test_map_optimizer.py test/test_img.py test/test_mesh_convert.py test/test_doc_audit.py test/test_main_cli.py: 448 passed, 1 skipped (toktx-gated integration test), 34 subtests passed.

  • 2026-08-14 — 16-bit grayscale no longer planned down to 8-bit L: the writer and its dry-run twin agree again (MapOptimizer _HIGH_PRECISION_EQUIV). Probe-proven divergence: optimize_map under a profile whose Height/Displacement/Bump spec is 16-bit (_PRECISION_16) writes a PNG that reads back as PIL mode I;16 — which plan/assess then flagged for an I;16 -> L coercion on every subsequent look, so the twins disagreed about the optimizer's own output forever (and the plan, if executed, would flatten to 8 bits the very precision the spec had just paid for, before the save re-expanded it emptily to 16). A high-precision same-layout mode (I, I;16 where the target is L) is now tolerated in both the map-type coercion step and the strict-mode step, defined once in _HIGH_PRECISION_EQUIV and folded into _MAP_TYPE_TOLERATED from it. Surfaced by the scene exporters' new optimize-textures gate, which assesses the staged output the task just wrote and could therefore never converge on a height map. Two new tests: an I/I;16 Height plans zero ops, and — the invariant itself — optimize_map output under every shipped profile assesses recommended=False under that same profile. Full suite green.

  • 2026-08-14 — MapOptimizer.optimize_map/assess expose pot_mode, so a caller-resolved budget can request the never-grow snap. The "down" snap existed but was reachable only by activating a whole output profile with enforce_budget — a caller that resolves a DeliveryBudget itself and passes it as plain max_size/force_pot (a UI with its own clamp control, like the Map Converter's — whose per-axis "nearest" snap under enforce_budget is a known backlog item) was forced onto "nearest", where 96 rounds UP to 128 and the enforcement inflates the asset it exists to shrink. The parameter defaults to None = the existing derived behavior ("down" only when the POT rule came from an enforced profile budget), and an explicit value outranks the derivation on both twins — same precedence rule, and the same single-resolution reasoning, as every other advisory-tier argument. Two new tests pin the no-profile "down" path across assess/optimize_map and the explicit-beats-derived precedence; full suite 3106 green.

  • 2026-08-14 — Docs stop rotting silently, and the API map ships with the wheel (DocAudit, python -m pythontk --index). The README-accuracy pass earlier the same day found the examples correct — but only by manually executing them, which protects nothing going forward. core_utils/doc_audit.py mechanizes that sweep as a general primitive: extract fenced code blocks from markdown, then validate every attribute chain rooted at a known namespace and every keyword argument against the live package (ast walk + inspect.signature), skipping what an example legitimately fakes (locally defined names, unknown roots, non-introspectable callables). It deliberately audits binding, not behavior — the literal outputs the README claims are pinned by a companion test that asserts each value both computes and still appears in the document, so behavior drift and doc drift each fail their own assert. test/test_doc_audit.py gates docs/README.md plus the three new subpackage READMEs; the class is root-exported so downstream repos can gate their own docs.

    --index closes the other reachability gap: the generated API_INDEX.md lives at repo root and never ships, so a pip-installed agent had the introspection CLI but no map of what to ask it about. Rather than shipping a second copy of the file (an artifact that can drift), the flag computes the listing from the live package — both tiers, since __all__ deliberately excludes the wildcard-exposed bare aliases (filter_list et al., per _populate_all's contract): root rows from __all__, bare rows from the resolver's published METHOD_TO_MODULE. Every row resolves to module.qualname, which makes each line a valid python -m pythontk target — the index and the inspector compose. --json emits structured rows; a symbol that fails to resolve (optional dep) degrades to an unresolvable row instead of killing the listing. New test/test_main_cli.py covers both tiers, the feed-back-in property, and the bare-target requirement staying intact.

  • 2026-08-14 — README architecture: subpackage READMEs for the three packages a table row can't hold, plus an accuracy pass on the front door. core_utils/, file_utils/, and net_utils/ gain co-located README.mds (module map by cluster + the design arguments that previously lived only in docstrings: temp-artifact lifetime policies, the RPC two-halves/verbatim-copy contract, the three hand-off shapes, the USD zero-dep floor, workspace.mel as a shared format). docs/README.md stays the pitch: its package table now links the three, rows caught up with the tree (PlateEmitter/UvPack, Ktx2Encoder + atlas tooling, TempArtifacts/Workspace/USD, Weights, CancelScope), the optional-deps list gained xatlas, toktx, paramiko, keyring, and the deep infrastructure bullets collapsed to pointers. Every code example was runtime-verified against the installed package — all literal outputs matched; the one false claim found, "plugin discovery never executes plugin code," is corrected (AST enumerates, but modules are imported to resolve class objects). core_utils/__init__.py's cluster map caught up with cancel_scope/status_badge/step_toggle. Workspace docs sweep: clean.

  • 2026-08-13 — KTX2/Basis Universal delivery lands across the texture chain (Ktx2Encoder, save_image, MapOptimizer, optimize_glb_textures, the preview viewer). The GPU-memory half the web budget was missing: WebP shrinks the wire, but every image still decodes to raw RGBA on the GPU — measured on a delivered preview GLB, 9.5 MB on the wire held ~740 MB of RGBA+mips, and that, not download, is what limits a headset. KTX2/Basis stays block-compressed GPU-side: one transcoder turns either codec into ASTC on a standalone headset, BC7/DXT on desktop, ETC2 as the floor — so the ETC1S/UASTC choice is quality policy, never client compatibility.

    Encoding shells out to KTX-Software's toktx (the ffmpeg pattern; no pure-Python Basis encoder exists) via the new img_utils/ktx2_encoder.py: discovery walks PATH, the conventional install dirs, then the AppInstaller catalog, and ImgUtils.register_ktx2_encoder is the substitution seam, mirroring register_dds_codec — with the difference that the built-in wrapper self-resolves whenever the binary exists. save_image routes .ktx2 before every PIL concern (Basis is 8-bit LDR; the mode fixup happens against the staged PNG, with its own _CONTAINER_MODE_FALLBACKS rows measured at the stage). A float source is refused by name instead of getting a table row: the rows that exist reduce precision inside a bounded range, but PIL's F -> L clips to 0-255, which flattens the 0..1 data an EXR normally carries to black and white — so an EXR into .ktx2 now raises a ValueError naming the 8-bit-LDR constraint rather than dying three frames deeper on OSError: cannot write mode F as PNG. The new DELIVERY_FORMATS tier deliberately stays out of writable: panels persist format combos by index, so growing that tuple would re-point every saved selection — a UI offers KTX2 explicitly, gated on ktx2_available().

    The per-map codec is derived, not configured: MapOptimizer.resolve_compression gives ETC1S to exactly the maps MapRegistry.is_lossy_safe admits to lossy containers (unpacked sRGB — base color, emissive) and UASTC to everything else — normals, packed masks, linear data, unknowns — so the codec choice and the lossy gate cannot disagree. An explicit ETC1S on a data map is upgraded to UASTC aloud, mirroring resolve_quality's refusal semantics; the quality dial passes through only where ETC1S lands. The colorspace label rides the same resolution because the KTX2 DFD records the transfer function and a wrong label shifts every texel. assess reports the codec (predicted["compression"]) and surfaces refusals through the same helper — dry-run parity by construction.

    optimize_glb_textures(image_format="KTX2") is the packaged half: codec by glTF slot (normal / metallic-roughness / occlusion → UASTC+linear; base color / emissive → ETC1S+sRGB; an image shared across slots takes the stricter treatment), textures rebind through KHR_texture_basisu with no fallback kept — the extension goes in extensionsRequired — dimensions snap down to POT (the KHR extension wants multiple-of-4 and full mip pyramids, generated at encode time), the keep-original-when-larger rule is deliberately off (the win being bought is GPU residency, not bytes), and lightmaps stay on the lossless-WebP path (ETC1S would blotch a bake for the same reason lossy WebP did). A missing encoder raises upfront rather than silently shipping WebP. PreviewDeliverer(texture_format="KTX2") plumbs it into the WebXR push, and preview_viewer.html wires three.js KTX2Loader with the transcoder off the same unpkg CDN the page already rides — inert for WebP deliverables, required for KTX2 ones.

    ~35 new tests: the toktx argv contract per codec/label/quality, staged-mode table measurement, the derivation/refusal matrix, real-run vs. dry-run parity, GLB bindings/POT snap/lightmap carve-out/fail-before-touching, plus binary-gated real-encode integration tests that skip where toktx is absent. Full suite: 3077 tests, 0 failures.

  • 2026-08-13 — Polyline gained arc-length sampling, extracted from the private closure frames was hiding it in (point_at_arc, cumulative_lengths). point_at is index space and says so — t=0.5 is the middle by index, not by length — which is only the same thing when every segment is equal. frames already needed the arc-length answer and carried its own inline cumulative-length walk plus a linear scan to find the containing span. That sampler is now a public primitive: point_at_arc(points, t) matches the (points, t) -> point shape resample's existing interpolation parameter was designed to accept, so an arc-uniform distribution is resample(pts, n, interpolation=Polyline.point_at_arc) with no new API shape. frames drops its duplicate (its linear span scan also became a bisect), but shares the math through a private _point_at_cum(pts, cum, s) rather than calling the one-shot point_at_arc itself — it takes three samples per frame (the position plus two tangent probes), so a one-shot entry point would rebuild the whole cumulative-length table on every one of them, turning an O(n) setup into O(segments x n). Measured on a 200-point rail: 127ms at 200 segments, 604 table builds. Sharing the table brings that to 2.5ms and exactly one build, which a test now pins by counting the builds rather than by wall clock.

    Every measurement in the class now goes through one _segment_length, which reads points by index rather than zipping them, so DCC point types work alongside plain sequences — om.MPoint carries a fourth w component a naive zip would fold into the distance. That consolidation was the point: adding a second distance walk beside length's existing one would have left the class accepting MPoint in some methods and silently mis-measuring it in others, which is a worse trap than either behaviour on its own. length now delegates to cumulative_lengths. Degenerate inputs are pinned by test (empty, single point, all-coincident) since a zero-length polyline has no arc to walk, as is the w-component tolerance (via a 4-tuple, so the test stays DCC-free).

    mayatk's tube rig is the first consumer: it places spline drivers at 20%/80% of arc, which its chord-based math had been approximating.