github m3trik/pythontk v0.15.0
pythontk v0.15.0

3 hours ago
  • 2026-10-13 -- Two stores the DCC packages carried as hand-kept twins sink here: new SceneNotes (file_utils/scene_notes.py) and BakeCache (file_utils/bake_cache.py); FileDependencies gains reference_bytes, scan_references, open_decompressed and the REFERENCE_READERS table. SceneNotes is the Reference Manager's Notes store -- one <project>/<data rule>/scene_notes.json keyed project-relative, a legacy <scene>.metadata.json note imported on first read, and a new import_notes for notes a host kept elsewhere -- moved whole from mayatk and blendertk, which held identical 169-line copies with no drift guard. BakeCache is the project bake store both bridges kept: one never-aged slot per source in Workspace.cache_dir("bridge"), the <stem>_<hash8> slot key, the source's content key (source_key, byte-identical to the keys already written, so no existing bake reads as stale), current / write / rehome / source_of, and purge by reference. How a record is read from a bake (read_info) and how one is produced (write's and rehome's callables) are injected by the host, so neither DCC branches on the other. The purge's scan is FileDependencies.scan_references over reference_bytes (a .ma header up to its first createNode, a .blend decompressed -- gzip, zstd through compression.zstd or the optional zstandard -- anything else whole), walking FileDependencies.walk; a file it cannot read stops the answer (None), never a partial set a purge would delete by. Hardened before release (review 2026-10-13): a notes file that exists but cannot be read -- a sharing lock, corrupt JSON, a document that is not an object -- read as an empty store, so the next set wrote one note over every other scene's (and the failed read was cached by mtime); now set raises OSError and writes nothing, get reads "" and logs, remove and move log and write nothing, and a failed read is never cached. move writes the new note before it drops the old, so a failed write leaves the note where it was. A legacy sidecar is tidied only when it parsed as an object: a JSON list crashed .items(), and an unreadable one was removed. A corrupt zstd frame raised the decoder's own ZstdError -- an Exception, not an OSError -- out of reference_bytes, scan_references and BakeCache.purge; it reads as unreadable now (None, and the purge refuses), and open_decompressed's zstd stream raises OSError for it, as gzip's does. BakeCache builds one CachedArtifact per cache folder instead of one per slot / current / write call.
    • Test(s): new test_scene_notes.py (14), test_bake_cache.py (28), test_file_dependencies.py TestReferenceBytes + TestScanReferences (11) -- all 53 red before the move; surface_snapshot.json updated (+BakeCache, +SceneNotes). Review fixes: test_scene_notes.py new TestUnreadableStore (6: corrupt / locked / non-object store, remove, the uncached failed read, move order) and test_a_non_object_sidecar_is_left_alone, test_an_unreadable_sidecar_is_never_removed (7 red before the fix; the remove pin was green); test_file_dependencies.py new test_a_corrupt_zstd_file_is_unreadable_not_an_error (stub compression.zstd and zstandard decoders, red); test_bake_cache.py new test_a_corrupt_zstd_scene_stops_the_purge_without_raising, test_the_store_is_built_once_per_cache_folder (red).
  • 2026-10-13 -- A file set aside with no trash goes to a temp store kept 30 days, never a _superseded folder left beside the maps (file_utils/file_dependencies.py FileDependencies.set_aside, new SUPERSEDED_STORE, SUPERSEDED_DAYS; SUPERSEDED_DIR stays walk-skipped for projects that hold one). A re-bake on a network share or a removable drive (a volume with no Recycle Bin) filled the project with _superseded/ folders. The file is moved under its own name into a fresh TempArtifacts("superseded") folder (copied, then the original removed, across volumes; a held-open file stays where it was) and is still one restore away. Maintainer's decision 2026-10-13, reversing the _superseded/ folder decided 2026-09-27: with no trash a superseded file goes to the 30-day temp store, and it is swept for sure -- by the store's first allocation in a process and by the daily TempArtifacts.sweep_all, the 30 days being pythontk's own guarantee, not the OS temp cleaner's. The file keeps its modification time, so its age counts from its last write. Only a cross-device rename (EXDEV, Windows' ERROR_NOT_SAME_DEVICE) falls back to a copy -- any other refusal (held open, denied) raises with nothing copied -- and a copy cut short is removed with its folder. A batch sets aside through one sweep of the store, not one per file (sweep_interval).
    • Test(s): test_file_dependencies.py test_with_no_trash_it_goes_to_the_temp_store_under_its_name (replaces the beside-itself pin; red: the folder beside the map), TestRemoveSuperseded._aside reads the store. 38/38. Review fixes: new test_another_volume_is_copied_then_the_original_removed, test_a_rename_refused_for_another_reason_is_not_copied, test_a_failed_copy_leaves_no_partial_file_behind, test_a_batch_sweeps_the_store_once_not_per_file (the last three red).
  • 2026-10-13 -- Every hand-off run gets its own scratch folder, a detached one too (core_utils/handoff/app_handoff.py HandoffBridge._scratch_dir). A detached run (a send whose app reads the files after we return) reused ONE fixed <prefix>/<name> folder per bridge, so a second send -- or a second host -- rewrote the files a launched Toolbag or Painter was still reading. It now takes a unique root per run like a scoped one; the store's age sweep bounds the generations.
    • Test(s): test_bridge.py test_detached_run_gets_its_own_folder_and_never_deletes (red: the second send got the first's folder). 109/109.
  • 2026-10-13 -- PresetStore.path finds a listed file whose name the sanitizer would rewrite (core_utils/presets/store.py PresetStore.path). list() reports each file's own stem, so a shipped glTF 2.0.yaml listed as glTF 2.0, but path()/source()/load() looked for glTF 2_0.yaml and read it as missing. An existing file named exactly name (no path separator, and in the store: on Windows a drive-relative D:evil joined onto the store named D:evil.yaml in that drive's current folder) now wins; any other name maps to its sanitized stem as before.
    • Test(s): test_preset_store.py test_a_listed_file_whose_name_sanitizes_differently_still_loads (red: source() None), test_a_drive_relative_name_never_leaves_the_store (Windows; red).
  • 2026-10-13 -- FileDependencies.search_dirs puts each file's own folder ahead of any folder holding a same-named twin (file_utils/file_dependencies.py FileDependencies.search_dirs, new _unshadowed). A consumer that joins names onto the list binds the first folder holding the name, and the order was by reads alone: on a production room the texture root (105 readers) held a 512 px lightmap atlas from an earlier bake under the name (case aside) a current 2048 px one carried in its own folder (46 readers), so the FBX embedded and the GLB shipped the stale map through the current atlas rects -- blotchy, contact shadows gone (a bright strip at the base of every cabinet), the room 2x the level of the cabinets (Arnold-reference p90 error 77-88%; 7-17% with the right map). A folder that holds a file under a resolved dependency's name -- tested by the consumer's own join, so a case-folding file system folds case as it will -- without being that dependency's folder now follows its folder (a stable topological sort over the read order); two folders that shadow each other keep the read order and the names no order serves are warned. Each folder is listed once and only a name it holds is stated, not one probe per dependency per folder. The same scene carried a stray second carrier (data_export1, an older manifest naming a stale *_LightMap.exr), and FbxMedia.dependencies -- which keeps the FIRST spelling of a name, compared without case -- read carriers in file order, so a stray written ahead of the scene's own would have named the stale file for the embed; it now reads the scene's own data_export first, the GLB applier's precedence (fbx_media.py _FbxMediaInternal._channel_payloads).
    • Test(s): test_file_dependencies.py new test_a_folder_holding_a_stale_twin_follows_the_maps_own_folder (red: the stale twin bound), test_twins_that_shadow_each_other_keep_the_read_order_and_warn (red: no warning), test_each_folder_is_listed_once_and_only_hits_are_stated (red: a probe per dependency per folder); test_fbx_media.py new test_the_scenes_own_carrier_names_a_file_before_any_other (red: the stray's spelling won), build_carrier_fbx(others=) writes further carriers ahead of the scene's own.
  • 2026-10-13 -- FbxMedia.merge_takes plays each take as it plays alone, between frames too (file_utils/mesh_convert/fbx_file.py _FbxFileInternal._union_curves, _interleave_curves, new _keep_own_tangents, _follows_on; fbx_media.py FbxMedia.merge_takes docstring). Maya writes spline, clamped and auto keys with zero slopes and its importer solves them from the neighbouring keys; merged, a take's last key and the next take's first gained each other as neighbours, so every edge segment changed shape (fresh mayapy, a Maya-written Shots Only file: up to 1.19 units off the take alone between frames, at a gap and at a seam). Each computed key whose neighbours the merge changed is now written as a user tangent carrying the slopes it plays in its own take (the left one in the previous key's NextLeftSlope; broken where the sides differ): measured in the same fresh mayapy, every take within 1e-14 of itself alone over 420 quarter-frame samples.
    • Test(s): test_fbx_media.py test_each_take_plays_its_own_tangents_beside_a_boundary.
  • 2026-10-13 -- One temp root: every TempArtifacts store with no dir owns its own folder, <root>/<prefix>/, and a store's sweep can no longer reach another's entries (file_utils/temp_artifacts.py new TempArtifacts.root, ROOT_ENV_VAR, STORE_MARKER, sweep_all, scoped, keep, managed, sweep_interval=, max_age_days=None; core_utils/test_sandbox.py). Stores sat flat in the system temp dir as <prefix>_<tag> and swept by prefix match, which is not nesting-safe: a scratch store <name> swept its own cache's <name>_cache_* entries by the scratch's 7 days (reproduced: a cache entry declared 30 days old went at 10), and blender_to_mtk / blender_to_mtk_tex, glb_pipeline / glb_pipeline_media, ptk_usdz / ptk_usdz_layer overlapped the same way -- a cache's own max_age_days was never authoritative. Now the root is $PYTHONTK_TEMP_ROOT, else <tempfile.gettempdir()>/ptk, created on demand and spelled for the ANSI code page (AppLauncher.ansi_safe_path), so every payload minted there opens in Maya under a TEMP its code page cannot hold. Each store folder records the age its owner declared (.ptk_store.json), and TempArtifacts.sweep_all() -- run on its own at a process's first allocation when the root's last one is a day old -- reclaims a prefix that never allocates again by that age (an unreadable declaration reads as 30 days; a folder with none, and anything loose in the root, is never touched; None never ages). The declaration is the LONGEST age any store of the folder declared (None the longest), so a store spelled with a shorter default cannot age out a longer-lived one. TempArtifacts.contains(path) (new) answers whether a path is scratch it holds -- under the root, or for the legacy window the system temp dir -- resolved on both sides: the one spelling of mayatk's and blendertk's _in_temp and their Substance engine's _is_temp_path (check_temp_artifacts.py drops the two _in_temp allowlist entries). A store's first allocation no longer scans the system temp dir: its own folder only, plus, for one release, a legacy sweep of its prefix's stale flat <temp>/<prefix>_* leftovers off one listing per process (removed in 0.16.0; a test fails once that window closes). Tags were the allocation clock alone, and Python 3.11's ~15 ms clock gave two processes one path (reproduced: one shared dir_path); an unnamed tag now carries the clock, the pid and a random part (<clock:016x><pid:x><rand:04x>; a session tag stays <clock>-<pid>), and dir_path() creates a minted folder exclusively, minting again over one that exists. keep(path) disowns the one result a scoped run must leave (the stage_dir = None + finally: rmtree idiom); TempArtifacts.scoped(prefix) spells the scoped policy (the default stays "detached", documented as right only for a payload another process reads after you return). sweep_interval= lets a store built per call over a folder it does not own skip a rescan (atomic_write). A store with an explicit dir keeps the flat <dir>/<prefix>_<tag> layout. TestSandbox.temp() sets PYTHONTK_TEMP_ROOT to its root (and PYTHONTK_CACHE_ROOT to a folder in it), so a store in a Python child lands in the sandbox however that child's tempfile resolves; a test that steers stores into its own folder patches PYTHONTK_TEMP_ROOT, not tempfile.tempdir. Its top-level root sits flat in the temp root -- a root of roots, not a store folder: each nesting level is paid again by every path a suite builds inside it, and mayatk's scene-data records ran past Windows' 260 one level deeper (measured) -- and for one release it also reclaims the flat roots the old layout left in the system temp dir.
    • Test(s): test_temp_artifacts.py new TestRoot (3), TestStoreFolders (6; red: ca_cache entry swept at 10 days by the 7-day scratch, glb_pipeline_media swept by glb_pipeline), TestLegacyFlatSweep (3), TestUniqueTags (3; red: one path for two pids in one tick, one shared folder), TestKeepAndScoped (3), TestSweepAll (4); test_the_marker_keeps_the_longest_age_any_alias_declared (red), test_contains_reads_the_root_and_the_legacy_temp_dir; test_default_dir_is_the_stores_own_folder_under_the_root replaces test_default_dir_is_system_temp; test_test_sandbox.py test_the_temp_dir_is_one_throwaway_root_that_children_inherit (the root is the temp root), new test_the_user_cache_is_inside_the_root_too, test_a_top_level_root_sits_flat_in_the_temp_root, the restore snapshot covers both new variables; test_execution_monitor.py, test_script_run.py, test_bridge.py steer stores through PYTHONTK_TEMP_ROOT. Suites: pythontk 7356 (7317 passed, 1 failed -- test_preview_viewer_live's copy-report label, a load-timing flake that passes alone -- 38 skipped); uitk 5522 of 5531 (the 2 failures were tests another session rewrote mid-run; the 9 affected modules re-run 706/706); mayatk's 39 modules that use these primitives 3763/3763 (mtk_cfg config store flat in its sandbox, flat-path assertions in test_blender_bridge, test_scene_exporter, test_reference_manager and the sidecar assertion in test_emissive_groups updated); blendertk's 23 such suites 2723/2723 (test_reference_manager, test_emissive_groups likewise).
  • 2026-10-13 -- CachedArtifact: the slot model is public, a slot can be a directory, scratch always lives under the temp root, and a project-resident cache never ages (file_utils/temp_artifacts.py new CachedArtifact.slot, peek, touch, promote, cache_dir, directory=, max_age_days=None; core_utils/engines/textures/map_optimizer.py MapOptimizer._encode_cache, _cached, _promote, _remember_kept). mayatk rebuilt the cache by hand from its private naming to answer "where is this key's bake" and "mark it in use" (_bake_slots, _bake_slot, _touch_bake), and MapOptimizer hand-rolled a directory-slot cache of its own (promote, read, touch, a .kept marker written into the slot). slot(key) names a key's path without creating it, peek(key) answers only for a complete entry (a non-empty file, a directory that arrived by one rename), touch(key, sidecars=) restarts its age, and get now touches on every hit -- a hit wrote nothing, so a bake in daily use was swept at its age. directory=True makes each slot a folder (produce fills an empty one); promote(key, produced) adopts what a batch produced itself, replacing a slot whole, across volumes by a copy beside the slot first. Scratch was created in dir, beside the slots; it is always under the temp root now, and only the promoted result lands in dir, so CachedArtifact(..., dir=ws.cache_dir("bake"), max_age_days=None) is a project cache a saved scene can reference. The cache store keeps its <name>_cache prefix, so TempArtifacts(f"{name}_cache").path(name=key, extension=ext) -- mayatk's _bake_slot spelling -- still names the same slot. MapOptimizer's encode cache is a directory-slot CachedArtifact (map_optimizer, slots unchanged in name): an encode's scratch folder or a folder holding only the "kept" marker is promoted whole.
    • Test(s): test_temp_artifacts.py new TestCachedArtifactSlots (10, incl. test_the_spelling_mayatk_rebuilt_by_hand_still_names_the_slot, test_scratch_is_always_in_the_temp_root_never_the_cache_dir, test_a_project_resident_cache_is_never_age_swept, test_a_directory_slot_is_produced_whole_then_reused); test_map_optimizer.py new test_the_cache_is_the_shared_directory_slot_store, TestStageMaps steers its cache through PYTHONTK_TEMP_ROOT.
  • 2026-10-13 -- UserConfig.cache_dir(*parts, create=False): the durable per-user cache root for regenerable caches that belong to no project; AppInstaller installs location="cache" there, and "temp" is its retired spelling (core_utils/user_config.py new cache_dir, CACHE_ROOT_ENV_VAR, xdg_home("cache"); core_utils/app_installer.py). Resolved as the config root is: $PYTHONTK_CACHE_ROOT, else %LOCALAPPDATA%/pythontk/cache, ~/Library/Caches/pythontk, $XDG_CACHE_HOME/pythontk (~/.cache/pythontk). location="temp" installed a downloaded tool into <system temp>/pythontk_tools, which the OS and every temp cleanup remove; "temp" now warns (Deprecation.values, remove in 0.16.0) and resolves to "cache".
    • Test(s): test_user_config.py new test_the_cache_root_is_the_oss_own_cache_dir, test_the_cache_env_var_moves_it_and_parts_name_a_folder, test_xdg_home_takes_only_an_absolute_value covers cache; test_app_installer.py new test_resolve_location_cache_is_the_per_user_cache, test_resolve_location_temp_is_a_retired_spelling_of_cache (replace test_resolve_location_temp).
  • 2026-10-13 -- Workspace.for_scene: one scene-to-project rule for both DCCs; and the permanent-file tree they share, resolved through rules (file_utils/workspace.py new Workspace.for_scene, cache_dir, movie_dir, images_dir, export_dir, backup_dir, relative, absolute). mayatk's scene_project_root (the scene's own project, else the active workspace) and blendertk's (pin-aware) were two algorithms for one question. for_scene(scene, session_root): the scene's nearest marked ancestor, else the session root when it holds the scene, else the scene's own folder; an unsaved scene has the session root's workspace, or None -- never a temp fallback. The accessors use Maya's own rule names, so a Maya project stays a normal Maya project and a Blender project gets the same tree: cache_dir(kind) (rule <kind>Cache, else cache/<kind>), movie_dir (movie/movies), images_dir, export_dir (FBX export, FBX, else data), backup_dir(scene) (Maya's <scene dir>/incrementalSave/<scene file>/); source_images_dir stays the one default for both (sourceimages, an existing textures/ matched). Asking writes nothing; create=True makes the folder. relative(path) spells a project file root-relative (POSIX) and anything outside absolute, never a ../ chain; absolute(stored) reads it back.
    • Test(s): test_workspace.py new WorkspaceForSceneTest (8), WorkspaceProjectStructureTest (6).
  • 2026-10-13 -- Nothing is written into the process's current directory, the drive root or a deliverable folder unasked (img_utils/_channels.py extract_channels; core_utils/engines/textures/map_factory/_converters.py convert_bump_to_normal, convert_normal_map_format, convert_spec_gloss_to_pbr; map_factory/processor.py save_map; core_utils/logging_mixin/logger_ext.py add_file_handler; vid_utils/sequence_exporter.py SequenceExporter.export; core_utils/handoff/script_run.py ScriptRunner.run_script_to_artifact; core_utils/engines/textures/region_masks.py RegionMaskPacker.write new write_manifest=; net_utils/preview/_page_outputs.py). An in-memory image saved by extract_channels with no output_dir went to the cwd, and so did a PIL bump map's bump_map_Normal_OpenGL.png: both raise ValueError now. convert_normal_map_format of a bare file name wrote /<name> -- the drive root (reproduced: C:\mat_Normal_OpenGL.png); it writes beside the source. convert_spec_gloss_to_pbr resolved output_dir and then wrote beside the specular map; it honours it. A dry run created the output folder; it creates nothing. add_file_handler() with no file wrote logfile.log into the cwd; it writes <logger name>.log in the per-user cache's logs folder (UserConfig.cache_dir, resolved in the kernel: a temp store there was an upward core_utils -> file_utils import). A child ScriptRunner.run_script_to_artifact launches with no cwd inherited the caller's, so a DCC's droppings (mayaDiskCache_*) landed wherever the caller was; it runs in a scratch folder of the run's own store, removed with the script (an explicit cwd wins). The encodes' PNG capture staged as <output>/<name>_png_tmp inside the deliverable folder; it is a scoped temp store (keep_frames=True still keeps the frames there, as an output), made only when an encode needs that capture. RegionMaskPacker.write returns the manifest and writes the <mask>.json sidecar only on request (write_manifest=True or manifest_path=): no production code read it -- both DCCs publish the manifest through their scene records. The preview's page outputs (a still, a recording) asked whether the deliverable's folder was writable by creating a throwaway temp file in it; the still is now written there and falls back to the serve root on an OSError, and a recording is encoded in the serve root and delivered beside the deliverable (FileUtils.deliver_file), staying in the root where the folder takes no file.
    • Test(s): test_img.py new ExtractChannelsCwdTest; test_map_factory.py new test_a_bump_image_with_no_path_names_no_folder_to_write, test_a_relative_normal_map_converts_beside_itself_not_at_the_drive_root, test_spec_gloss_writes_into_the_output_dir_it_was_given, test_a_dry_run_creates_no_output_folder; test_logging_mixin.py new test_a_file_handler_with_no_name_never_writes_into_the_cwd; test_sequence_exporter.py new test_intermediate_frames_never_land_in_the_deliverable_folder, test_no_scratch_folder_without_an_intermediate_capture (red); test_script_run.py new TestWorkingDirectory (2; red: the caller's cwd); test_region_masks.py new test_write_writes_only_the_mask_by_default; test_preview_server.py new test_placing_a_still_writes_no_probe_into_the_users_folder, test_an_unwritable_deliverable_folder_falls_back_to_the_serve_root simulates the refused write; test_preview_playblast.py new test_a_folder_that_takes_no_file_leaves_the_movie_in_the_serve_root. All red before the fix.
  • 2026-10-13 -- AudioUtils: a transcode caches in the source's project (cache/audio) or the per-user cache, never beside the source, and a failed conversion can no longer poison the cache (audio_utils/_audio_utils.py new AudioUtils.cache_dir_for; ensure_playable_path, resolve_playable_path, build_audio_map). The default cache was <source dir>/_audio_cache/, a folder written into the user's audio folder. cache_dir_for(source) is the one place it is decided: a source in a marked workspace caches in Workspace.cache_dir("audio") (the scene that plays it references the WAV, so it travels with the project), any other in UserConfig.cache_dir("audio"). ffmpeg wrote straight to the cache name and a cached file was returned unchecked, so a failed or killed run left a partial <stem>_<md5>.wav the next call returned (reproduced). The transcode now goes through FileUtils.atomic_write (-f wav into the .part), and a cached file is reused only when its RIFF size matches the file -- checked before ffmpeg is looked up, so a cached transcode plays on a machine without it.
    • Test(s): test_audio_utils.py new TestPlayableCache (6; red: no cache_dir_for, the partial served as the cache, ffmpeg required for a cache hit).
  • 2026-10-13 -- FileUtils.atomic_write_text is atomic_write with a text writer, so a temp a killed write left is swept; ImgUtils.cv2_temp points OpenCV at the temp root (file_utils/_file_utils.py; img_utils/_codecs.py; m3trik/scripts/check_temp_artifacts.py drops its allowlist entry). The hand-rolled .<uuid>.tmp beside the target was never removed by anything once a process died mid-write; it is an atomic_write temp now, swept once stale by a later write into the folder, at most once per folder every ten minutes in a process (a batch of sidecars no longer rescans per file). atomic_write's temp is .atomic_<12 hex>.part: a dot name (hidden, skipped by every listing that skips dot files, as the old temp was) and short -- the temp spends the target folder's path budget, and a 43-character store tag put a 258-character record's temp past Windows' 260 (measured). cv2_temp swapped TEMP/TMP for the short spelling of the system temp dir through a second copy of the GetShortPathNameW call; it names an ASCII spelling of a folder of the temp root (TempArtifacts("cv2_temp")) through the one short-name helper (AppLauncher.ansi_safe_path), still scoped and shared by overlapping blocks, so what a crashed codec leaves is swept. FileUtils.copy_file removed the target and then copied onto its name, so a failed copy left nothing (or a truncated file); it copies through atomic_write, and the three hand-spelled atomic_write(dst, copyfile) sites call it (FileUtils.deliver_file, CachedArtifact's cross-volume promotion, Fbx2Gltf's GLB delivery: data and metadata now, as copy2).
    • Test(s): test_atomic_write.py new test_a_stale_temp_a_killed_write_left_is_swept_by_a_later_write, test_the_temp_is_hidden_and_no_longer_than_a_short_name (red); test_img.py test_overlapping_blocks_share_one_swap pins the target under the root (red); test_file_transfer.py new test_a_failed_copy_leaves_the_existing_file_whole (red).

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.