github m3trik/pythontk v0.12.1
pythontk v0.12.1

4 hours ago
  • 2026-09-27 -- ProcessExit.register / unregister: hard_exit's own atexit. A session TempArtifacts store registers its cleanup there, and every store now removes what it can around a held entry (core_utils/process_exit.py, file_utils/temp_artifacts.py). After a week of runs the system temp dir held 791 test sandboxes: 572 MB in 16,760 files. There were two causes. First, hard_exit skips atexit, which is where a session store deletes itself, and uitk's runner, mayatk's chunk driver, tentacle's slot runs and every DCC bridge template leave through hard_exit. So each of those exits leaked its whole sandbox. Second, shutil.rmtree stops at the first entry it cannot delete. On Windows that is routine: a helper Maya spawns (Autodesk's ADPClientService) outlives it holding a file in Maya's temp dir, and one held file kept the whole tree. register(func, *args, **kwargs) runs func at hard_exit, last registered first; one that raises is reported and skipped, never blocking the exit. The list lives in the module namespace, so a registration survives importlib.reload, which builds a second class. cleanup, release and sweep_stale now delete everything that can go. A read-only entry is made writable and retried; a held one stays tracked for a later cleanup or the age sweep. We kept hard_exit rather than running atexit from it, because atexit also holds the Qt and Maya teardown hard_exit exists to skip. Tests: test_process_exit.py::TestHardExitRunsReleases (+6) and test_temp_artifacts.py (+7: a hard-exiting child's store goes; a held file costs only itself under cleanup, release and sweep_stale, plus one real open handle on Windows; a read-only file goes). 12 of the 13 were red before the change; the 13th, an ordinary exit, is the baseline.

  • 2026-09-27 — pyproject.toml declares the package's layer order ([tool.m3trik.layers]). Parts top first, kernel last, checked by m3trik/scripts/check_layers.py against a frozen baseline of today's exceptions (CODE_STANDARD section 0). No code change.

  • 2026-09-27 -- A child process that activates TestSandbox nests its root inside its parent's, by name, instead of working it out from TEMP (core_utils/test_sandbox.py). TestSandbox.temp() passed its root to child processes only through TMPDIR/TEMP/TMP. A fresh interpreter finds its temp dir by probing those with a throwaway write, and on any failure except FileExistsError it falls past all three to the user's real temp dir, with no warning. Measured: a root that had gone, an OSError on three probes in a row, or a refused create. A child that activates the sandbox, such as mayatk's chunk driver or tentacle's in-Maya dispatcher, then made its root in the real temp dir. That root is still a throwaway, but it sits outside the parent's, so a killed child's leftovers waited for the seven-day sweep. temp() now also names its root in PYTHONTK_TEST_TEMP_ROOT, and a child's temp() creates its root inside that one without probing. A child that must share the parent's root instead of nesting gets it pinned by whatever launches it (blendertk's runner does this). test_test_sandbox starts a child that refuses every probe write into the root: on the old code the child's root landed directly in ~\AppData\Local\Temp, and it goes red again when the parent withholds the new variable.

  • 2026-09-27 -- FileUtils.can_trash and FileUtils.trash_name answer before anything moves: whether move_to_trash would take a file to a trash that keeps it, and what the platform calls that trash (file_utils/_trash.py, _file_utils.py, core_utils/test_sandbox.py). A delete prompt must say before it asks whether a file goes to the Recycle Bin or is gone for good. The Reference Manager (both DCCs) now sends a scene to the trash, and on a drive with none it asks for a permanent delete instead. can_trash makes the same checks as the move itself: on Windows the drive, the policy and the bin's own settings; on macOS a writable ~/.Trash on the file's device; on Linux a trash the user can write. The move stays the authority, so a caller treats a later refusal as "still there". TestSandbox's trash guard answers False here too, so a prompt under test says what really happens, and real_trash() restores both calls. Tests: test_file.py (+2), test_test_sandbox.py (extended). All were red before the change.

  • 2026-09-27 -- FileDependencies.walk and FileDependencies.WALK_SKIP_DIRS: one pruning for every search that finds files by name (file_utils/file_dependencies.py). A walk skips sync-client caches (.dropbox.cache, .dropbox), the OS's trash and volume folders ($Recycle.Bin, System Volume Information, .Trash, .Trashes, .Trash-<uid>), version control, node_modules, __pycache__ and _superseded, comparing names without case. Each of those holds stale same-named copies that a walk would bind as the current file. mayatk's texture walk had its own copy of the list, spelled $RECYCLE.BIN as FAT spells it, so NTFS's $Recycle.Bin got through. blendertk's walks pruned nothing, nor did mayatk's Resolve Missing Textures index. find_files and both DCCs' texture walks now go through this one generator. Tests: test_file_dependencies.py::TestWalk (+2, red before the change).

  • 2026-09-27 -- AppLauncher.ansi_safe_path(path, ascii_only=False): ascii_only=True swaps EVERY non-ASCII component for its 8.3 name, for a path written INTO a file another program decodes as ANSI (core_utils/app_launcher/_environment.py). RizomUV 2020.1 reads the UTF-8 bytes of a Lua script's paths as ANSI, so an "é" the code page holds breaks there as surely as a Cyrillic letter: ZomLoad / ZomSave under a "José" folder hung to the timeout (measured through the real mayatk and blendertk bridges; the 8.3 form lands). Same rules as the default otherwise -- a component not yet on disk keeps its name, and with no short name the path comes back unchanged with one warning. Tests: TestAnsiSafePath (+2: an "é" folder and a Cyrillic one go ASCII, a plain path returns as is; no short name warns once) -- both red before (TypeError). The ANSI codec never names one this host lacks: with no Windows code page to read it fell back to mbcs, which exists only on Windows, so a test forcing the win32 branch on Linux CI raised LookupError in every check; it now falls back to ascii (TestAnsiCodecFallback, red before).

  • 2026-09-27 -- A GLB with render-effect curve proxies keeps its shadow planes on their own nodes (file_utils/mesh_convert/_fbx2gltf.py). strip_glb_curve_proxies renumbered the nodes behind every proxy it removed but not the node indices the earlier shadow pass had written into extras.shadow_web, so one keyed highlight ahead of a shadow rig left each plane following the node after its source. The indices a manifest binds by (SHADOW_WEB_NODE_FIELDS, ARTICULATION_WEB_NODE_FIELDS) now follow the renumber; an index to a stripped node reads as absent.

  • 2026-09-27 -- Articulated rigs: the joint model and grab solver every runtime ports, the geometry rules that propose a rig, and its web route (geo_utils/articulation/, file_utils/mesh_convert/_articulation.py, net_utils/preview/). ArticulationModel poses a rig of hinge / swivel / universal / ball / slide joints from one float per channel, reads a state back from local transforms (the Euler triple nearest the last state, or the one that keeps a joint's missing channels at 0), measures a runtime's unit off the rest pose (scale_of) and solves a grab by weighted damped least squares inside the limits -- a held link on a ball takes the hand's turn and the chain above places the ball's centre. ArticulationAnalysis proposes the joints from the parts' shells (a tube inside a tube is a slide with its travel, a small round shell a ball, a knob off the arm's plane a hinge about that plane's normal, a stub in a housing a swivel), ArticulationConformance generates the golden cases the C# and JS ports are held to. SceneRecords.ARTICULATION declares the articulation record; MeshConvert.apply_glb_articulation binds it as root extras.articulation_web inside fbx_to_glb. The preview gains viewer.grab (the mouse ahead of the orbit, a controller's grip or trigger, a tracked hand's pinch; press / drag / release / step on plain values for tests), viewer.nodeResolver, a panel's addSlider, and the auto-activated articulated_rig script (its ArticulationModel port exported by name, three.js-free). _ShadowRigsMixin._shadow_nodes_named is _nodes_named, shared with the new pass, and shadow_rig.js resolves nodes through viewer.nodeResolver.

  • 2026-09-27 -- TestSandbox.activate() keeps a test run out of the machine's Recycle Bin: TestSandbox.trash(), real_trash() and trashed are new (core_utils/test_sandbox.py). Superseded lightmaps now go to the trash (FileUtils.move_to_trash, below), so every suite that re-bakes sent its scratch maps into the developer's own bin. Measured the same day: a module fixture that was supposed to prevent it never ran under mayatk's suite driver, and one mayatk run left 11 maps there (9 from the bake tests, 1 from each run of the bridge's wire-back test, several agents' runs included). Under the sandbox the call now keeps its real refusals (a missing path, a folder), records the file on trashed and answers None, as a volume with no trash would, so FileDependencies.set_aside puts the file in _superseded inside the sandboxed temp root. A test of the trash itself runs inside real_trash(). It is part of activate(), so pythontk's runner, mayatk's suite driver and uitk's sandbox all get it; is_active() still asks only about the browser and the temp root. Test: test_test_sandbox.py (+1; red with the guard left out).

  • 2026-09-27 -- Maya opens hand-off payloads under a %TEMP% its ANSI code page cannot hold: AppLauncher.ansi_safe_path and ScriptTemplate.child_path are new, python_args_via_env hands the child its TEMP/TMP (and TMPDIR, which a Python child's tempfile reads first and TestSandbox sets) in that form, and the script deliverers hand templates their payload and output paths in it (core_utils/app_launcher/_environment.py, handoff/script_template.py, handoff/app_handoff.py). Measured on Maya 2025 with the system code page cp1252 and folders under "José Жук": cmds.file save said "An invalid path was specified" and open said "File not found", and Maya read TEMP as "???" and put its own temp files in the current directory. With the 8.3 short names of the same folders all three worked. ansi_safe_path swaps each path component the code page cannot hold for its GetShortPathNameW name and keeps every other component as written, so a payload's own file name, and a file not yet written, keep their names. Where there is no short name (8.3 names switched off on the volume) the path comes back unchanged and one warning names the remedy: point TEMP at a plain-ASCII folder. There is no fallback to a shared folder, because payloads are private. The check uses the SYSTEM code page (the registry's ACP), not this process's. Blender's manifest declares UTF-8, so inside Blender mbcs and GetACP() are 65001 (measured) while the mayapy it launches reads cp1252: an mbcs check passed every path Maya cannot open, and blendertk's test caught exactly that. No-op off Windows and for any path the code page holds. Proof: a real mayapy saves a cube under TEMP "José Жук". With the path fix reverted in-process it fails ("An invalid path was specified"); with only the TEMP part reverted, Maya's temp folder is the working directory. Production pass, Blender host with that TEMP: save_as to .ma, import_scene back, and send_to through a fresh maya.exe -batch and back all PASS; reverted in-process, save_as and send_to FAIL. Tests: test_app_launcher.py::TestAnsiSafePath (+7), test_bridge.py (+1), test_script_run.py (+1, real mayapy; it leaves through ProcessExit.hard_exit and asserts its TEMP holds no crash-save -- with os._exit it filed a recovered scene, a dump and a log there).

  • 2026-09-27 -- A Python child's command line travels in its environment, so mayapy runs a script under a %TEMP% with a non-ASCII letter: AppLauncher.python_args_via_env and AppLauncher.PYTHON_ARGV_VAR are new, and ScriptRunner's default launch, the script deliverers, ExecutionMonitor's sidecars and PackageManager._run_pip all go through it; ScriptLaunchSpec.launch_args is now optional (core_utils/app_launcher/_environment.py, _app_launcher.py, handoff/script_run.py, handoff/app_handoff.py, execution_monitor/_execution_monitor.py, package_manager.py). mayapy.exe reads its command line in the ANSI code page and decodes it as UTF-8. Measured on Maya 2025: "José" arrived as "Jos\udce9" and "Жук" as "???", while the environment and the working directory arrived intact (maya.exe keeps "é" but also turns "Жук" into "???"). run_script_to_artifact launched mayapy <script> with the script under %TEMP%, which holds the user's name, so for such a user the run died at once with "can't open file ... [Errno 22]" and produced nothing: mayatk's .blend bake, blendertk's .ma pull and its save_as. The external watchdog was worse. Inside Maya its python is mayapy, so the heartbeat path reached it as a different file; it read a LIVE heartbeat as missing and killed its owner once the timeout ran out (measured: a process whose heartbeat was touched every 0.1 s, killed). The pip route was fixed on 2026-09-24 with a private shim, and that shim is now the one primitive: python_args_via_env(argv, env=None) returns (["-c", shim], env), with argv as JSON in PYTHONTK_ARGV. The shim removes the variable before the target runs, then runs exactly python <script> ... (runpy.run_path, the script's folder as sys.path[0]) or python -m <module> .... Not the entry's other option (cwd plus a bare file name): a caller's cwd is its own, and maya.exe's -command MEL has no working-directory hook, while the environment serves every interpreter and the MEL alike. That is why ScriptRunner and the deliverers put the script path in the child env even when launch_args is custom: a launch that must not name it can read it there. Also AppLauncher.run now decodes captured output with errors="replace": mayapy writes cp1252 and Blender's Python decodes UTF-8, so the strict decode died in the reader thread and a failed hand-off reported an EMPTY output tail (measured through the bridge). The private PackageManager._PIP_SHIM / _PIP_ARGV_VAR are gone. Maya's own file I/O under such a path is the next entry's (ansi_safe_path). Tests: test_app_launcher.py::TestPythonArgsViaEnv (+5: script and -m forms, a real mayapy with a non-ASCII script and argument, undecodable output kept), test_script_run.py::TestNonAsciiScriptDir (+3; the mayapy run failed "can't open file" before), test_execution_monitor.py (+2; a mayapy watchdog killed its live owner before), test_bridge.py (+2), and conftest.find_mayapy, now shared.

  • 2026-09-27 -- A re-bake's superseded files go to the Recycle Bin, never deleted: FileUtils.move_to_trash and FileDependencies.set_aside are new, and FileDependencies.remove_superseded moves files there instead of calling os.remove (file_utils/_trash.py new, file_utils/_file_utils.py, file_utils/file_dependencies.py). A host sees only its own scene's references, so a lightmap re-bake deleted maps that another scene file still read: a Save As copy's source, a copy made in Explorer, or a Save Copy / Export All of an unsaved scene. The maintainer's decision (BACKLOG 2026-09-23, decided 2026-09-27) was to set them aside instead of deleting them. move_to_trash is stdlib-only. On Windows it calls SHFileOperationW with FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI. On Linux it uses the freedesktop trash: the home trash, else the volume's .Trash/$uid or .Trash-$uid, writing the .trashinfo record before the rename. On macOS it uses ~/.Trash. The Windows call alone was not enough, because it deletes a file PERMANENTLY, without a word, wherever the Recycle Bin will not keep it. So these cases are refused up front, returning None with the file untouched: a network, removable, optical or RAM drive; a volume mounted into a folder; the NoRecycleFiles policy; a bin set to NukeOnDelete or smaller than the file (MaxCapacity); a path of MAX_PATH or more. A SUBST drive is read on its host volume. After the call, the bin's own $I record is looked up so the result can be the $R path, or a warning that the file may have been deleted permanently. That record lands a moment after the call returns (measured: 6 of 8 quick calls in a row had none yet), so the lookup retries for up to about 1.5 s. Measured cost: about 200 ms for the first call, then 65-70 ms. A file held open raises (a sharing violation, PermissionError) and stays where it is. Where there is no trash, set_aside moves the file into a _superseded folder beside it (SUPERSEDED_DIR), under a free <stem>_<k> name. find_files, the default walk of resolve and relocate, never enters that folder, so a set-aside map is never bound again by its name. Tests: test_file.py::TestMoveToTrash (+9). It covers the real Recycle Bin, proven through the Shell's own view of the bin (Namespace(10)) and then purged, plus the refusals and SUBST mapping, and runs the freedesktop and macOS backends against a scratch trash on any host (also on Linux under WSL, where a real home-trash move was checked too). test_file_dependencies.py::TestSetAside (+4); TestRemoveSuperseded is pinned to a volume with no trash. All of them are red at HEAD, and a mutant that deletes, or that walks into _superseded, turns them red.

  • 2026-09-27 -- Writer stamps are declared, and a scene's first save stamps them: RecordSpec.stamps / stamp_keys (on LIGHTMAP_WRITERS and the hierarchy baseline's scene), SceneRecords.with_stamps / stamp_unsaved, and SceneStoreBase.writer_stamp_of / respell_for_write / path_records / restore_path_records (core_utils/engines/scene_export/scene_records.py, scene_store.py). A stamp of "" marks something made while the scene was unsaved, and it counts as the scene's own only while the scene stays unsaved. So, once saved, a lightmap baked before the first save was never retired, and a baseline recorded then was set aside as another scene's (BACKLOG 2026-09-23). Both DCC save hooks now call one bracket, respell_for_write(target, old_base, first_save). It re-spells the path records for the file being written, stamps "" on a first save, and returns the stored text so that a copy (an export, an autosave, a Save Copy, a failed save) can put the open scene's records back exactly. Re-spelling back instead could normalize an entry. The stamps are a declaration, not path_keys, because LIGHTMAP_DIRS and AUDIO_FILE_MAP are whole path mappings too and their values are folders and files, not writers. Tests: test_scene_records.py::TestWriterStamp (+4).

  • 2026-09-27 -- A preset can be hidden from its tool's dropdown -- a shipped built-in too -- and can say what it is for: PresetStore.set_hidden / is_hidden / description, PresetLibrary.set_hidden / set_description, PresetEntry.hidden / description; PresetLibrary.backup(keys=, name=) backs up some stores only (core_utils/presets/store.py, library.py). A hidden preset stays on disk and loads as before; uitk's preset selector leaves it out of its list, except the one a panel is on. Where the flag lives: a user preset's is a hidden key in its sidecar, so it moves with a rename and goes with a delete, and a new preset of that name starts visible -- older installs too, which keep a sidecar's unknown keys and delete it with its payload. A built-in has no sidecar (and set_info refuses it), so its flag is a store-level .hidden list beside .active: dot-prefixed with no payload extension, so no discovery glob or library scan reads it as a preset; written atomically through the store's writer; and hiding a built-in of a tool that never had a user folder writes the .domain marker, so the library still finds the store. The flag is the preset's, not the name's: a user copy saved over a hidden built-in shows until it is hidden itself, and deleting the copy brings the hidden built-in back. A hide is the user's own view: a collection export strips it, and an import that replaces a preset keeps the local flag (a backup carries it, and restores it -- over an unchanged preset too, whose hide alone then reads as an update, not identical). A description is a user preset's description sidecar key (set_description: blank clears, built-ins skipped, like set_tags); a built-in's is read-only, its shipped payload's _meta.description, read on first use (PresetEntry.description, cached), so a scan still reads only file names and sidecars. A collection member whose description alone changed now imports as an update, not identical. Tests: test_preset_store.py (PresetStoreHiddenTest 7, PresetStoreDescriptionTest 2), test_preset_library.py (HiddenAndDescriptionTest 3, test_a_changed_description_is_an_update_not_identical, test_hiding_is_the_artists_own_and_no_collection_carries_it, test_a_backup_can_cover_some_stores_and_keeps_what_you_hid, test_restoring_a_backup_in_place_restores_a_hide), red first.

  • 2026-09-27 -- The validate chain's uitk and tentacle jobs read pytest's final summary line and fail on errors as well as failures (.github/workflows/validate-chain.yml). They counted N failed anywhere in the log, so a collection or fixture error in a downstream suite (100 passed, 4 errors) read as green against a pythontk PR; they parse the way uitk's own test gate now does.

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.