github m3trik/pythontk v0.12.0
pythontk v0.12.0

4 hours ago
  • 2026-09-27 -- FileUtils.get_dir_contents lists in one order on every file system: each directory's entries as NTFS sorts them, upper-cased then as written (file_utils/_file_utils.py). It returned the file system's own order -- sorted on NTFS, none in particular on ext4 -- so on Linux a listing, and whatever a caller keeps by position, changed with the disk: of two FBX presets with one name, the Scene Exporter keeps the last one listed. The threaded walk (num_threads) took results as they completed, in any order on any platform; it keeps walk order now. Windows results are unchanged. Found by CI's first Linux run (test_get_dir_contents_dirpath, test_get_dir_contents_inc_files). Test: test_file.py (a scandir that lists in reverse; serial and threaded). Its FileTest class now puts the committed test_files/file1.txt back (a partial run left it rewritten, which a release commit sweeps up) and makes the sub-directory its listing tests read, which they found only when test_create_directory had run first.

  • 2026-09-27 -- FileUtils.atomic_write_text gives the file the mode any write would: an existing file keeps its own, a new one gets 0o666 less the umask (file_utils/_file_utils.py). It wrote through tempfile.NamedTemporaryFile, which creates 0o600, and os.replace keeps the temp's mode -- so on POSIX every rewrite of a shared file left it readable by its owner alone: the scene-data sidecar beside a delivery (each exporter's own writer, before they shared this one, gave 0o644), presets, the naming-convention doc. Newlines are unchanged (text mode, \n as os.linesep). Tests: test_atomic_write.py (+3; the two mode cases run on the Linux leg).

  • 2026-09-27 -- AppLauncher.find_app on Windows answers the newest install its Program Files scan finds, and scan_for_executables ranks versions naturally (core_utils/app_launcher/_discovery.py). The scan (apps with no App Paths entry) returned glob's first hit, which NTFS yields by name -- the OLDEST install (Blender 3.6 over 5.1, Toolbag 4 over 5) -- and it runs at resolve_app_path's app_names stage, ahead of the newest-first scan_globs, so blendertk's find_blender (highest version before it delegated) and mayatk's Blender bridge inherited it. Each depth now ranks through scan_install_dirs (4.10 over 4.9); scan_for_executables' reverse string sort put 4.9 over 4.10. Tests: test_app_launcher.py::test_find_app_program_files_fallback_takes_the_newest_install, ::test_scan_for_executables_ranks_versions_naturally.

  • 2026-09-27 -- UserConfig.xdg_home(kind): the one reading of $XDG_CONFIG_HOME / $XDG_DATA_HOME, and user_config_root() ignores a relative one (core_utils/user_config.py). An absolute value, else the spec's default (~/.config / ~/.local/share): the XDG spec calls a relative value invalid and Qt ignores it, but used as given the root moved with the working directory (relcfg/uitk) -- and, with uitk's PresetManager.get_presets_root() now delegating here (it resolved through Qt before), every preset store moved with it. The same rule was hand-read elsewhere, and those readers use it now: AppLauncher's .desktop search dirs, unitytk's Unity Hub config dir, and mayatk's / blendertk's Substance installer (Documents via user-dirs.dirs) and Painter log path. Tests: test_user_config.py (test_a_relative_xdg_config_home_is_ignored, test_xdg_home_takes_only_an_absolute_value).

  • 2026-09-27 -- Metadata.set on Windows: a value or clear the property store takes drops the sidecar's parked copy of that key (file_utils/metadata.py). With enable_sidecar, a commit the store refused (a cloud placeholder) parks the value in the sidecar, which get overlays on every read, so once the store took writes again a later clear or new value was still shadowed. The sidecar keeps only what a store refused (MetadataInternal._sync_sidecar, shared with the POSIX path); a file with nothing parked gets no sidecar. Tests: test_metadata.py::TestMetadataSidecarWindows.

  • 2026-09-27 -- PresetStore.active holds the preset's file stem, so a rename or delete by either spelling moves or clears it (core_utils/presets/store.py). A name with punctuation is stored under a stem without it ("m3trik (laptop)" is m3trik _laptop_.json), but the pointer kept the name as typed while list(), panel combos and the Preset Editor use the stem, so rename / delete compared the two and missed: renaming the active m3trik (laptop) in the Preset Editor left .active naming a gone file, and tentacle's launch-time Macros.apply_saved_macros() hit a KeyError and fell back to the default hotkeys. The setter stores the stem, rename / delete compare stems, and a pointer an older release wrote (the name as typed) reads back as its stem. Tests: test_preset_store.py (4), test_preset_library.py::EditTest::test_renaming_the_active_preset_moves_the_pointer_whatever_its_name.

  • 2026-09-27 -- A hot reload re-runs bootstrap_package and Deprecation.attributes cleanly (core_utils/module_resolver.py, core_utils/deprecation.py). importlib.reload re-executes a module in the SAME globals. The bootstrap handle took the __all__ the previous run derived for a hand-declared one, so a name dropped from the include stayed advertised and from pkg import * raised on it; and every Deprecation.attributes re-run chained onto the hooks the previous run installed -- one more __getattr__/__dir__ layer per reload (two per reload_package), each still serving aliases the edited module no longer declares. Module hooks are now tagged with the __spec__ they were installed under (a reload binds a fresh one): a re-run chains past stale ones while two installers in one run still compose (_ModuleHook, shared with lazy_exports). Tests: test_module_resolver.py, test_deprecation.py.

  • 2026-09-27 -- FileUtils.canonical_module_path(filepath): the dotted module path a .py file has by its location (file_utils/_file_utils.py). Public now (was _canonical_module_path, used by get_classes_from_path): extapps' panel launcher needs the package a launcher.py run as __main__ belongs to, and had grown its own __init__.py walk-up for it. Tests: test_file.py (3, renamed).

  • 2026-09-26 -- PresetLibrary scans a scope: domains(inc=, exc=), entries(..., inc=, exc=) and PresetLibrary.in_scope(keys, inc, exc) (core_utils/presets/library.py). A pattern names a FOLDER under the root and takes every store under it -- matched shell-style, ignoring case, against a key and each of its parent folders, so "mayatk" is every mayatk store and "mayatk/scene_*" one tool's (a bare prefix such as "maya" matches nothing). Matching is IterUtils.filter_list(nested_as_unit=True) over the folder chain. An out-of-scope store's sidecars are never read. uitk's Preset Editor focuses on it. PresetLibrary.collection_named(name, exclude=) is the public form of the taken-name lookup (was _named), so a form checks a name by the library's own rule. Tests: test_a_pattern_names_a_folder_and_takes_its_stores_with_it, test_entries_are_scoped_the_same_way, red first; test_a_name_is_taken_ignoring_case_and_edge_spaces.

  • 2026-09-26 -- core_utils/app_launcher.py is a package, split by job behind the unchanged AppLauncher facade: app_launcher/ (_app_launcher.py the facade -- launch / run / spawn / write_batch_script; _discovery.py install and interpreter discovery; _environment.py the live process environment and the persisted PATH; _desktop.py Windows sessions and window lookup; _processes.py the process table and a spawned child's lifetime binding). A 1,660-line module with five jobs (CODE_STANDARD section 3's god-module trigger). Every member, public and private, still resolves on ptk.AppLauncher with its kind, signature and docstring, and cross-calls are still spelled AppLauncher.<name>, so patch.object(AppLauncher, ...) reaches them. The pythontk.core_utils.app_launcher path still resolves AppLauncher (lazy_exports; the Rizom bridge and the vendored Marmoset engines import the root name now), its loggers are pythontk.core_utils.app_launcher.*, and process_stream / x11 stay flat as primitives with users of their own.

  • 2026-09-26 -- PreviewServer is split by concept inside net_utils/preview/ (server.py keeps the facade -- lifecycle, manifest and viewer liveness, publish and its delivery dials, the browser; new routes.py the route names and the HTTP handler; private mixins _serve_root.py the page, viewer scripts and atomic writes, _sharing.py the guest listener and its tunnel, _page_outputs.py recordings and stills). server.py was 2,145 lines holding the HTTP layer and four jobs of the server. PreviewServer's members are unchanged and it stays at its path and root name; the route constants (VIEWER_CLOSED_PATH, SETTINGS_PATH, PLAYBLAST_PATH, PLAYBLAST_ACTIONS, SNAPSHOT_PATH) are still importable from server.py.

  • 2026-09-26 -- AppLauncher._posix_environ answers None where no libc loads, instead of raising (core_utils/app_launcher/). ctypes.CDLL(None) raises TypeError, not OSError, on a non-POSIX host, so desktop_env -- and FileUtils.open_explorer through it -- failed silently there. Found by an extapps test that simulates Linux on Windows.

  • 2026-09-26 -- HandoffScope: the Scope words (selected / all / visible) and their precedence, in one place (core_utils/handoff/handoff_scope.py, net_utils/preview/bridge.py). HandoffScope.resolve(scope, selected=..., all=..., visible=...) resolves a word through lookups the host supplies (a lookup, or a chain tried in order): an unknown word is selected, and a widening word whose lookups all answer None narrows to the selection, never to anything wider. Four resolvers had hand-kept that rule (mayatk's and blendertk's bridge-slots bases, their Lightmap Bakers) beside PreviewBridge.scope_objects, which now resolves through it too; HandoffScope.word maps a label's scene to all. Registered at the root.

  • 2026-09-26 -- core_utils/logging_mixin.py is a package, split by job: logging_mixin/ (_logging_mixin.py LoggingMixin, logger_ext.py LoggerExt with its formatters and handlers, table_mixin.py TableMixin, internal _text_layout.py TextLayout). A 2,038-line module with four jobs, whose layout helpers were LoggerExt staticmethods reading and writing LoggerExt._wcwidth_fn by name: the column math (display widths, wrapping, box and table geometry) is now one TextLayout instance that owns its state, held at LoggerExt._layout and shared by TableMixin, with an injectable width function (TextLayout(wcwidth=False) forces the heuristic without mutating shared state). Root names and the pythontk.core_utils.logging_mixin path are unchanged (lazy_exports resolves all seven former names, so uitk's and extapps' deep imports keep working); the private LoggerExt._char_width / _display_width / _pad / _truncate / _wrap_text / _split_lines / _wcwidth_fn are gone, and output is byte-identical (LayoutCharacterizationTest pins every box, divider, group and table, written before the split; test_text_layout.py).

  • 2026-09-26 -- The preset store and library are one subpackage: core_utils/presets/ (store.py, library.py). PresetLibrary is built on PresetStore and landed as one more flat sibling of it, the case CODE_STANDARD section 3's subpackage rule was written from. Root names (ptk.PresetStore, ptk.PresetLibrary, ptk.Codec...) are unchanged; no other package imported the old paths.

  • 2026-09-26 -- ptk.TiledPath: the one tile / frame token vocabulary of a path (file_utils/tiled_path.py; MapRegistry.split_tile_token, MapFactory.get_tile_paths). <UDIM>, <uvtile>, <u>_<v>, <f>, <frame> in one (stand-in, glob) table with the operations every caller asked privately: has_token, is_frame_sequence, scheme, wildcard, spell (a UDIM number in each tile vocabulary), tiles (expand to the set) and representative (collapse to one file). Four vocabularies had grown (mayatk six tokens, blendertk two, blendertk's exporter three, MapFactory two); the texture taxonomy now also splits and globs a <u>_<v> tile set, which it read as part of the map name.

  • 2026-09-26 -- HierarchyAnalyzer.detect_reparented / detect_fuzzy_renames / detect_suffix_flattening: the Hierarchy Sync pairing passes (core_utils/hierarchy_utils/hierarchy_analyzer.py). Plain missing/extra path lists in, (pairs, remaining_missing, remaining_extra) out, so the passes chain; a host vetoes a reparent pairing through a compatible(reference, current) predicate. mayatk and blendertk each carried the three passes (0.86-0.96 similar); both now call these.

  • 2026-09-26 -- ptk.SceneExporterBase and ptk.SceneDataSidecarBase: the Scene Exporter's DCC-free shell and the scene-data sidecar, written once (core_utils/engines/scene_export/scene_exporter.py, scene_data_sidecar.py). The exporter base holds the progress stream, the check-override consent and resume, the run config and the log-file plumbing (hooks: TASK_MANAGER_CLASS, _saved_scene_path, KEPT_EDITS_ADVICE); the sidecar base holds the v3 format, naming, legacy migration, manifest I/O, diff report and comparison (hooks: expand_to_descendants, drop_intermediate, build_clean_path_set, _close). mayatk and blendertk each carried the same 20 exporter and 24 sidecar methods (a few differing only in a comment or a host word); their classes now subclass these and keep only the scene I/O.

  • 2026-09-26 -- The scene-export engine: scene_records, export_profile and hierarchy_baseline move into core_utils/engines/scene_export/, and scene_records.py splits by concept (scene_records.py declarations, scene_store.py SceneStoreBase, export_snapshot.py ExportContext / ExportSnapshot, record_transfer.py TransferContext / RecordTransfer). The three meet the engines bar (model + planner, shared by both DCC exporters; shot_model already imported them), and the 2,273-line module held four jobs. Root names are unchanged; the old deep paths pythontk.core_utils.scene_records / .export_profile / .hierarchy_baseline warn and resolve until 0.13.0. The engine's loggers are pythontk.core_utils.engines.scene_export.*.

  • 2026-09-26 -- AppLauncher.companion_python(executable=None) / looks_like_python(path): the Python a host binary pairs with (core_utils/app_launcher/, core_utils/execution_monitor/_execution_monitor.py). A DCC's GUI binary runs -c as its own language, but the interpreter it ships (mayapy beside it, or Blender's under sys.prefix) shares its site-packages. ExecutionMonitor resolved this privately and uitk kept a product-name table; both now ask this one, which names no product.

  • 2026-09-26 -- StrUtils.natural_sort_key(text, ignore_case=False): the one natural-sort key (str_utils/_str_utils.py). Embedded integers rank by value (Cube2 < Cube10, 4.10 > 4.9). mayatk's and blendertk's AutoInstancer and AppLauncher.scan_install_dirs each carried a copy (_natural_key); all three call this now, and the private copies are gone. It marks numbers by split parity rather than str.isdigit, so a superscript run ("x²"), which the copies passed to int() and raised on, stays text. Tests: test_str.py (5).

  • 2026-09-26 -- ptk.InstancingStrategy / StrategyConfig / StrategyType: the auto-instancer's strategy decision tree (core_utils/engines/instancing/instancing_strategy.py). Identical in mayatk and blendertk except for the one scene read; the triangle count is now a hook (_get_triangle_count) each DCC binding overrides, and a caller that knows the count passes triangle_count= and needs no binding. Test: test_instancing_strategy.py (19), pinned against both DCC copies before the hoist.

  • 2026-09-26 -- ptk.CylinderSeams: band-based UV seam placement for cylinder / tube / turned meshes, moved down from mayatk (geo_utils/uv/cylinder_seams.py). The seamer was already a numpy-only core over plain arrays (points, faces, edge table, hard-edge ids) with one Maya-facing reader; the core lives here now and mayatk keeps the reader. Test: test_cylinder_seams.py (9, built meshes: capped / open / stepped / chamfered / torus), pinned against the mayatk copy.

  • 2026-09-26 -- HierarchyBaselineStore: the scene-stored hierarchy baseline's storage, shared by both DCC exporters (core_utils/engines/scene_export/hierarchy_baseline.py). read / inherited_from / is_unreadable / compare / write / adopt_sidecar were byte-identical in mayatk's and blendertk's HierarchyBaseline, and the multi-deliverable adoption fix of 2026-09-26 had to land twice. A DCC subclass now sets STORE (its SceneStoreBase) and SIDECAR and, where its exporter ships more than the selected paths, overrides _close (mayatk closes under ancestors).

  • 2026-09-26 -- The Shot Manifest and Shots panels' domain rules move into the shots engine (engines/shots/manifest/range_resolver.py, manifest_model.py, mapping/_mapping.py, new engines/shots/shot_report.py). RangeResolver gains parse_range_edit, previous_end, find_collisions, cascade_from, all_ranges_complete, step_index and gaps_from_regions; ManifestModel.describe_read_failure (+ LOW_DISK_BYTES), StepStatus.find_object, Mapping.seed_user_folder, and ShotReport (summary, moved, delta_summary) for the Shots footer. They were controller code copied into both DCC panels; now each rule has one owner and a test.

  • 2026-09-26 -- FileUtils.move_file(overwrite=True) no longer deletes the destination before the move lands (file_utils/_file_utils.py). It removed the old file and then moved, so a move that failed (a full disk, a folder the user cannot write) lost both; and a cross-volume move copied straight onto the destination, leaving it truncated on failure. A move onto an existing file, or across volumes, is now staged beside the destination and swapped in (a same-volume move onto a free name stays a plain rename); a refused swap puts the source back. The stage is a hidden per-call sibling (.<name>.<pid>.<id>.moving) that no TempArtifacts sweep matches: a moved file keeps its source's mtime, so staged as an atomic_write_* temp a week-old source read as stale to the sweep a concurrent atomic_write into the same folder runs, and a thread-pooled texture archive (MapOptimizer.optimize_maps) lost textures mid-move (test_file_transfer.py: a sibling sweep between stage and swap, 8 threads overwriting 32 week-old files). A failed cross-volume DIRECTORY move keeps its stage and logs where it is: the source may be half removed, so the stage can hold the only whole copy (test_an_interrupted_directory_move_keeps_the_whole_copy). mayatk's and blendertk's lightmap bakers carried a hand-written copy of this (_move_into_place) and now call it.

  • 2026-09-26 -- ImgUtils, MapFactory, MathUtils and StrUtils are split by job behind unchanged facades (img_utils/_codecs.py, _image_header.py, _channels.py, _filters.py, _atlas.py, _rasterize.py, _color_space.py; core_utils/engines/textures/map_factory/_texture_sets.py, _map_inventory.py, _converters.py, _channel_packing.py; math_utils/_clustering.py, _curve_fit.py; str_utils/_name_pattern.py, _search.py, _affix.py). CODE_STANDARD section 3's god-module trigger: _img_utils.py 4,879 -> 2,718 lines, _map_factory.py 3,927 -> 2,020, _math_utils.py 2,379 -> 1,878, _str_utils.py 2,513 -> 1,811. Every public method keeps its signature and docstring on the facade and delegates to an internal base of the same class (super().<name>(...)), so the flat ptk.<method> surface, cls-bound helpers, subclass overrides (extapps' ConverterSlots(ImgUtils)) and mock.patch.object(ImgUtils, ...) all behave as before; private helpers moved whole and still resolve as ImgUtils._<name> through the MRO. A layout test per facade pins that no public method lives only on a base. Left whole: logging_mixin.py (see the ledger: its split is a subpackage promotion plus an untangling of LoggerExt class state).

  • 2026-09-26 -- MapFactory.get_base_texture_name owns the base-name rule; ImgUtils.get_base_texture_name is a facade over it (core_utils/engines/textures/map_factory/_map_factory.py, img_utils/_img_utils.py). What counts as a map suffix is the texture engine's taxonomy, yet the one implementation sat in the generic *_utils layer and reached back up into MapRegistry, so the engine called down into ImgUtils which called up into the engine. The engine now holds it; ImgUtils keeps the public method (extapps' ConverterSlots inherits it, mayatk calls it) through a deferred import, and set_bit_depth's map-type -> mode lookup stays a deferred MapRegistry read. test_img.py pins that no img_utils module imports an engine at module level.

  • 2026-09-26 -- UV-layout primitives are one subpackage, geo_utils/uv/ (pack.py, budget.py, transfer.py); file_utils/uv_unwrap/ is a module (file_utils/uv_unwrap.py). Three uv_* siblings shared a concept prefix their neighbours did not (CODE_STANDARD section 3, trigger 4), and uv_unwrap/ was a package holding one module. Root names are unchanged (ptk.UvPack, ptk.UvBudget, ptk.UvTransfer, ptk.UvUnwrap, ...); no other package imported the old paths, so there is no alias.

  • 2026-09-26 -- ptk.ShotBoundaryConflict, ptk.GapRetime and ShotPlanner.UNBOUNDED (core_utils/engines/shots/shot_plan.py). blendertk reached the conflict class through the module path (11 imports) and took the private _INF sentinel; mayatk's _shot_plan shim imported _INF and _EPS the same way. The names are root-registered and the sentinel has a public spelling on the planner (the env_end of an envelope no shot bounds), so both DCC packages import from the root.

  • 2026-09-26 -- UserConfig.CONFIG_ROOT_ENV_VAR: the config-root env var's public spelling (core_utils/user_config.py). Seven files in uitk, mayatk and extapps imported the constant from the module path; they now read it off the root class. The module-level name stays for pythontk's own code.

  • 2026-09-26 -- AppLauncher.write_batch_script(path, lines, shell=None): the one writer of a script a shell runs (core_utils/app_launcher/). cmd gets the console OEM codepage and exact CRLF (a text-mode write of "\r\n" doubled every CR; a character cmd cannot spell raises instead of becoming ?); bash gets UTF-8, LF, a shebang and the executable bit. extapps' RealityScan and SuGaR runners each hand-wrote theirs. lines may be one pre-joined string (it was written a character per line). Test: test_app_launcher.py::test_a_pre_joined_block_may_be_one_string.

  • 2026-09-26 -- ptk.TooltipFormat: the rich-text tooltip DSL, moved down from uitk (str_utils/tooltip_format.py). fmt / kbd / hl / placeholder_preview / stored_items and the layout rules (wrap, display_ms) are pure string work; headless engine code (scene-exporter task definitions under a DCC's background mode) imported them from a uitk Qt module. The plain/rich decision is the Qt-free port of Qt.mightBeRichText, now public as TooltipFormat.is_rich. test_tooltip_format (69 tests, moved from uitk).

  • 2026-09-26 -- ptk.ShotSequencer: the shot sequencer's ripple-editing orchestration, written once (core_utils/engines/shots/shot_sequencer.py). mayatk and blendertk each carried a copy of the same ~50 timeline operations (define, expand, resize, slide, ripple, insert, delete, merge, split, pad, respace, apply a gap, move sequences between shots, fit to content) -- 36 methods at >=0.8 AST similarity, most differing only in which scene call they made. The DCC classes now subclass this one and supply narrow scene hooks (_apply_plan, _move_content_keys, _content_batch, _key_extent, ...; the table is in the module docstring). Used on its own it is a complete bounds-only sequencer. Tests: test_shot_sequencer.py (66), its bounds expectations recorded from mayatk's sequencer before the hoist.

  • 2026-09-26 -- ptk.InstanceGrouping: auto-instancing's signature-bucket merge and run summary (core_utils/engines/instancing/instance_grouping.py). Hand-copied verbatim in mayatk's and blendertk's AutoInstancer (_merge_similar_signatures, default_summary, format_summary); both delegate here now. Test: test_instance_grouping.py (12), expectations recorded from the DCC copies.

  • 2026-09-26 -- ShotDetection.cluster_spans: the one key-timing overlap grouping (core_utils/engines/shots/shot_detection.py). Strict overlap, touch, an epsilon seam and a frame gap are one sweep with a different join threshold, and it was written four times (cluster_segments_by_gap, mayatk AnimUtils._group_overlapping_keyframes / SegmentKeys._group_by_overlap, blendertk StaggerKeys._group_units) plus two interval merges. All six route through it; span= reads plain (start, end) tuples. Tests: test_shots_core.TestClusterSpans (9; one pins the exact-threshold seam: start - end <= gap, shot detection's form, splits a seam that float subtraction rounds one ULP above gap).

  • 2026-09-26 -- The CSV-mapping schema and resolve round-trip tests live with the engine (test/test_shots_manifest_core.py). Moved from mayatk's test_shot_manifest_mapping.py, which ran only under mayapy yet exercised only pythontk.core_utils.engines.shots.manifest.mapping.

  • 2026-09-26 -- SingletonMixin keeps each subclass's __init__ signature, name and docstring (core_utils/singleton_mixin.py). The once-only wrapper it installs was bare, so inspect.signature answered (*args, **kwargs) for every singleton class; it is functools.wraps-ed now. uitk's launch-code rendering asks a handler's signature whether its switchboard is optional. Test: test_singleton_mixin +1 (red first).

  • 2026-09-26 -- mesh_convert/ is split by job: MeshConvert is a facade over one private mixin per job, and the GLB family is one subpackage, glb/ (file_utils/mesh_convert/_mesh_convert.py -> _fbx2gltf.py, _sidecar.py, _lightmaps.py, _shadow_rigs.py, _animation.py, _visibility.py, _materials.py, _textures.py, _images.py; glb_reader/glb_pipeline/glb_clips/glb_fades/glb_key_reduction/glb_tangents -> glb/reader.py ... glb/tangents.py; new glb/edit.py). _mesh_convert.py was 9,889 lines doing nine jobs, and the six glb_* siblings imported the facade only to reach its private container members. The container is now GlbEdit (glb/edit.py), whose open/read/write/append_bin_views/compact_bin/drop_orphaned_accessors/release_animation_payload the siblings call directly. Every MeshConvert attribute resolves as before -- MeshConvert.GlbEdit, open_glb and the private buffer helpers are bound to GlbEdit's -- and ptk.GlbReader/ptk.GlbPipeline are unchanged at the root. Loggers follow their modules (pythontk.file_utils.mesh_convert.<module>): listen on pythontk.file_utils.mesh_convert for the whole family.

  • 2026-09-26 -- ptk.GlbFades is a root name, and its channel table is GlbFades.CHANNELS (file_utils/mesh_convert/glb/fades.py; glb_fades.py is a one-release stub). mayatk, blendertk and uitk deep-imported mesh_convert.glb_fades.CHANNELS to join their channel rows to the glTF half; they read ptk.GlbFades.CHANNELS now. The old path serves CHANNELS, GlbFades and PointerChannel with a DeprecationWarning until 0.13.0.

  • 2026-09-26 -- lazy_exports: one declaration publishes a subpackage's names, each loaded on first use (core_utils/module_resolver.py). The root's bootstrap_package scan imports every subpackage __init__, so an eager re-export there was paid by every import pythontk (the map-factory, mapping and behaviors packages now load nothing until a name is read), and uitk carried four hand-written PEP 562 loaders. CODE_STANDARD section 4 now makes lazy loading the default. It is reload-safe: importlib.reload re-runs the call in the same globals, and the re-run drops the names the last run cached (after ptk.reload_package("pythontk"), ptk.LoggingMixin was still the old class), chains past the last run's __getattr__ instead of stacking on it, and unions only a hand-declared __all__. A declared name whose submodule raises AttributeError while importing, or lacks the name, raises ImportError naming the submodule with the AttributeError as __cause__ -- from pkg import X said only "cannot import name 'X'", and hasattr read it as absent. Tests: test_module_resolver.py (LazyExportsTests).

  • 2026-09-26 -- Deprecation.attributes resolves targets past the module, onto a class (core_utils/deprecation.py). A retired module function usually lands as a method (pkg.launch -> pkg.slots.Slots.launch); the alias path can now say so. A target module that exists but fails inside its own import raises instead of reading as a missing attribute -- including when the missing dependency's name is a string prefix of the target's package (dep inside deppkg.mod), which a bare startswith walked past. Test: test_deprecation.py.

  • 2026-09-26 -- The app-handoff kit is one subpackage: core_utils/handoff/ (app_handoff, script_template, script_run, manifest (was handoff_manifest), manifest_plan). Five flat modules of one family, per CODE_STANDARD section 3. Every public name is unchanged at the root (ptk.HandoffBridge, ptk.ScriptTemplate, ptk.SEND_TO...); the two paths other packages imported, core_utils.app_handoff and core_utils.script_template, warn and forward until 0.13.0.

  • 2026-09-26 -- TRANSFORM_CHANNELS: the channel vocabulary in display order; MeshConvert.FBX2GLTF_VERSION (core_utils/engines/shots/shot_detection.py, file_utils/mesh_convert/_mesh_convert.py). STANDARD_TRANSFORM_ATTRS is now derived from it. uitk's sequencer no longer carries Maya's channel names; hosts pass these in. The pinned FBX2glTF version was reachable only through a private module path. That path keeps re-exporting it (and FBX2GLTF_PLATFORMS) for published extapps 0.2.2, which imports them there.

  • 2026-09-26 -- Saving over a file a sync client or virus scanner has just opened no longer fails (file_utils/_file_utils.py). Windows refuses to rename onto or away from a file another process holds for a moment after it changes ([WinError 5] / [WinError 32]); on a synced drive that is routinely the second quick rewrite of the same small file -- captured live as Access is denied: '.x.tmp' -> '.Unity.preset'. New FileUtils.replace_file(src, dst) is os.replace that retries those two codes for about a second (pip's budget for the same cause) and raises anything else at once. atomic_write_text, atomic_write (so every preset, sidecar and bundle write) and pythontk's other promotions (app_handoff, fbx_media, temp_artifacts, the preview server and playblast) go through it.

  • 2026-09-26 -- A workspace template's _-prefixed keys are annotations, never file rules (file_utils/workspace.py). WorkspaceTemplates.rules dropped only uitk's _meta block, so a hand-shared template's _comment became a rule written into workspace.mel. New WorkspaceTemplates.rules_from(data) is the one filter (every _ key dropped, the SchemaSpec convention); rules and blendertk's Workspace Editor both use it.

  • 2026-09-26 -- Presets can be locked, collected, backed up and shared: PresetLibrary, PresetStore metadata sidecars and PresetReadOnlyError (core_utils/presets/library.py, core_utils/presets/store.py). Each user preset now gets a sidecar .<name>.preset beside it holding an id (it survives renames), the name as typed (file names lose punctuation), a lock, a collection tag, tags and the author; the payload file is never touched, because tools splat it into keyword arguments and older installs read the same folders. PresetStore.save refuses a locked preset (PresetReadOnlyError, a PermissionError; force=True overrides), delete/rename return False for one, rename carries the sidecar, and a preset created under a stale sidecar's name starts with a fresh one; unique_name(base) gives the first free "<base> N". A store announces itself with a .domain marker (payload extension plus a host-independent built-in location) the first time it lists an existing folder. PresetLibrary works over every store under the root without importing a tool: scan, lock, tag, duplicate, named collections (at most one per preset; creating or renaming one refuses a name already in use, and ids carry a random suffix so installing someone else's same-named collection never reads as an update of yours -- the review warns and installs it beside), and one bundle format (a .zip) for backups and shared collections. plan_import classifies each preset as new / identical / update / conflict / removed before apply writes anything, and apply validates every action before writing and backs up first (the newest 10 automatic backups are kept); where the destination folder has no .domain marker yet (a fresh root, a tool never run there) a payload keeps its bundle member's extension, so a YAML store restores as YAML rather than as a *.json its own store never lists (test_a_restore_into_a_fresh_root_keeps_a_non_json_payload_format). Bundles are treated as untrusted: member paths that could leave the root, and collection ids that aren't slugs, are refused.

  • 2026-09-26 -- Linux is a supported platform: the suite runs on ubuntu-latest beside windows-latest (.github/workflows/tests.yml). Measured in WSL Ubuntu 24.04 on a case-sensitive filesystem: every module imports, and the 48 Linux-only failures were 24 Windows-shaped tests (ungated ctypes.windll patches, C:\ literals, .exe zip fixtures, a case-insensitive filesystem assumed) and 24 missing system packages -- none a product bug. The tests now assert each OS's own behavior (the Windows branches patch ctypes.windll in and run everywhere; the resolver fixtures are host-shaped, so Linux covers Maya's maya.bin -> mayapy). CI's Linux leg installs libopengl0 and runs under xvfb-run: a private X server, never a desktop. The legs are one matrix job, suite, behind the test check branch protection requires, which is green only when every leg is: a matrix reports one check per leg (<job> (<os>)), never the bare job name, so the required test would never have attached and every release PR would have sat BLOCKED. PyPI lists Operating System :: Microsoft :: Windows and :: POSIX :: Linux instead of OS Independent (pyproject.toml), and docs/README.md carries a Platform badge.

  • 2026-09-26 -- X11: a small, fully typed ctypes Xlib client -- window titles by PID, the focused window's PID, the pointer, compositor presence, key state (core_utils/x11.py). Works on X11 and XWayland (Maya is an xcb app under Wayland too); every query answers None where no X server exists (Windows, macOS, pure Wayland, SSH). Every function is typed before the handle is published and every call holds one lock: the old Linux Esc probe in ExecutionMonitor called XKeysymToKeycode with no argtypes, so ctypes passed the Display* as a 32-bit int and the host SEGFAULTED on its worker thread -- every uitk slot with a timeout (measured, exit -11) -- and a second polling thread could open the display untyped. Xlib's default error handler EXITS the process, so ours absorbs errors on this connection during each call (a window vanishing mid-query) and hands others' to the handler it replaced. A failed load or open is cached, not retried at the polling rate. ExecutionMonitor.is_escape_pressed, is_foreground_process (Esc in another app no longer cancels) and the spinner's cursor position now ask it. Tests: test_x11 (any OS against a recording libX11; a real server under Xvfb, with a raw-Xlib window fixture -- Tk 9 sets no _NET_WM_PID).

  • 2026-09-26 -- AppLauncher on Linux: process identity, windows, lifetime and discovery (core_utils/app_launcher/). get_running_processes matches the program's NAME from /proc (maya finds maya.bin) -- pgrep -f matched any command line containing it, so MayaConnection's "is this PID still a Maya?" guard matched tail maya.log. get_window_titles / wait_for_ready see X11 windows of the PID and its descendants (a Linux launcher is often a script that forks the real binary); they returned nothing / "ready" at once. spawn(bind_lifetime=True) binds on POSIX too: the child leads its own process group and a /bin/sh watcher in its own session kills that group within a second of the host going away, crash included (not PR_SET_PDEATHSIG: it needs a preexec_fn and fires when the spawning THREAD ends). scan_install_dirs takes {exe} (.exe on Windows only) and ~, skips {program_files} layouts off Windows and ranks versions naturally (Blender 4.10 over 4.9 -- a string sort had it backwards on every OS); location_env_vars suffixes take {exe}; find_app reads XDG .desktop entries, taking an entry's program only when the entry is that program's own: no launcher (flatpak, snap, env, Steam, Lutris, Heroic, gtk-launch, xdg-open, sh/bash, python*, wine*, java) and nothing after it but options and field codes -- Steam's Name=Blender entry (steam steam://rungameid/365670) had answered with Steam; a relative $XDG_DATA_HOME / $XDG_DATA_DIRS entry is ignored, per the spec (test_the_application_menu_never_answers_with_a_launcher, test_a_relative_xdg_data_dir_is_ignored). process_environ reads libc's live environ on Linux; desktop_env() (new) is that minus a host's loader overrides (LD_LIBRARY_PATH, PYTHONHOME, QT_PLUGIN_PATH, ...) for desktop helpers. append_to_path(user_scope=True) persists one marked line in ~/.profile (was a TODO), is_path_persisted finds it, and the process PATH compares with normcase. is_interactive_session is False on Linux without a display server.

  • 2026-09-26 -- NetUtils.listening_ports(): [(port, pid)] for every listening TCP socket -- netstat on Windows (read in the console's OEM codepage, errors replaced: under the ANSI codec a localized header -- French "État", cp850 0x90 -- raised UnicodeDecodeError; test_listening_ports_reads_a_localized_netstat), /proc/net/tcp{,6} plus socket inodes on Linux (net_utils/_net_utils.py). The owner check a launcher needs before trusting a port (MayaConnection parsed Windows netstat itself, which matched nothing on Linux). is_port_bindable binds as the server will on POSIX (SO_REUSEADDR), so a quick restart no longer reads the port as taken and moves the preview server off its URL (red first on Linux).

  • 2026-09-26 -- Linux fixes across the file, install and config utilities. AppInstaller.ensure picks a download by CPU as well as OS: a linux-arm64 key wins, and a Linux entry declared for another arch is refused rather than installed to fail "Exec format error" (ffmpeg and cloudflared gain arm64 builds; FBX2glTF has none); zip extraction keeps members' Unix permission bits, masked to 0o755 as the tar path's filter="data" masks (never setuid, never group/other-writable; test_extract_zip_never_grants_group_or_other_write). UvUnwrap no longer asks consent to download BFF where no build exists. FileUtils.reveal_in_file_manager resolves the path absolute first (a relative one raised ValueError building the file URI; a bare file name, dirname "", raised FileNotFoundError on every OS; test_reveal_in_file_manager_resolves_a_relative_path) and selects the file on Linux through freedesktop FileManager1 (D-Bus), else opens the folder; it and open_explorer start xdg-open under desktop_env. remap_file_paths tests "under base" with is_under (a separator boundary: /proj2 is not in /proj, on every OS). Metadata.set on POSIX: a mount that refuses xattrs (NFS, exFAT, FUSE) sends the value to the sidecar when enabled and raises otherwise -- it was printed and lost -- and get overlays the sidecar. The sidecar keeps only what the mount refused: a clear (None, refused too) or a value the xattrs take drops its parked copy, which had kept winning the read (test_a_cleared_key_leaves_the_sidecar_too, test_a_value_the_mount_takes_retires_the_parked_copy). UserConfig.expand reads ${VAR}, $VAR and %VAR% on every OS -- names with parentheses (%ProgramFiles(x86)%) and the %% escape as Windows' os.path.expandvars read them -- and gives ${TEMP} a value where the OS sets none (a Windows-written profile kept %VAR% literal on Linux); a quote is plain text (Windows' expandvars expanded nothing after an apostrophe; test_expand_reads_windows_names_and_the_percent_escape). MeshOps.available() probes that pymeshlab can WRITE a mesh (its IO plug-ins need libOpenGL.so.0; without it every op failed while available() said True). StrUtils.name_pattern_context no longer raises where getpass.getuser() has no account to name.

  • 2026-09-26 -- ImgUtils.get_image_size sizes OpenEXR and Radiance HDR from the header; ImgUtils.is_equirectangular / is_environment_map tell an environment map from a lightmap (img_utils/_img_utils.py). Pillow reads neither HDR format, so an EXR or .hdr had no size at all; the header parse walks EXR attributes to dataWindow (seeking past large ones) and reads a Radiance resolution line by axis, through the same _radiance_resolution walk validate_image_integrity uses -- so a header the integrity check refuses (a CRLF one) never sizes. is_equirectangular answers 2:1 (5% slack, for stitched panoramas like Maya's 4096x2004 skyDome.hdr) cached per file version, and returns None WITHOUT reading an online-only cloud placeholder -- a header read makes the sync client download the whole image. is_environment_map adds the naming convention's lightmap affix; it is what the Maya and Blender HDR managers now list through.

  • 2026-09-26 -- NamingConvention.matches / AffixRule.matches, NamingConvention.as_dict, NamingConvention.preset_store (core_utils/naming_convention.py). matches reads back exactly what apply writes (a delimited affix brings its own word boundary; a bare LM matches roomLM), through a trailing index and dotted sub-names (room_Lightmap_12, desk_Lightmap.LIGHT_A_areaLight), case-insensitive by default (production files still carry a pre-convention _LightMap). as_dict is the whole table in update's shape; preset_store keeps named snapshots of it under <user_config_root>/pythontk/naming_convention/, one store for every host.

  • 2026-09-26 -- NamingConvention.update no longer writes a stale table over another host's edit, nor a preset's _meta into the doc (core_utils/naming_convention.py). It merged into the process's CACHED table and wrote the whole of it back, so with Maya and Blender both open, one host's next set erased what the other had changed since. It now re-reads the doc first, and skips _-prefixed keys -- a preset_store file carries _meta beside its entries, and update(store.load(name)) stored it as a convention key.

  • 2026-09-26 -- TestSandbox.user_config: a test's view of the naming convention and preset stores is the shipped one, and its writes stay off the developer's doc (core_utils/test_sandbox.py). A context manager pointing $UITK_PRESETS_ROOT at a scoped temp dir, hiding $PYTHONTK_NAMING_CONVENTION, reloading the convention both ways; opt-in per test, since a suite asserting the default root must still see it. Replaces three hand-rolled copies (pythontk's and mayatk's convention tests, mayatk's naming-panel preset test). test_img's ImgTest no longer regenerates the TRACKED test/test_files/imgtk_test/ on every run -- its fixtures are generated into a TempArtifacts dir.

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.