-
2026-09-27 --
ProcessExit.register/unregister:hard_exit's ownatexit. AsessionTempArtifactsstore 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_exitskipsatexit, which is where asessionstore deletes itself, and uitk's runner, mayatk's chunk driver, tentacle's slot runs and every DCC bridge template leave throughhard_exit. So each of those exits leaked its whole sandbox. Second,shutil.rmtreestops 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)runsfuncathard_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 survivesimportlib.reload, which builds a second class.cleanup,releaseandsweep_stalenow 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 kepthard_exitrather than runningatexitfrom it, becauseatexitalso holds the Qt and Maya teardownhard_exitexists to skip. Tests:test_process_exit.py::TestHardExitRunsReleases(+6) andtest_temp_artifacts.py(+7: a hard-exiting child's store goes; a held file costs only itself undercleanup,releaseandsweep_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.tomldeclares the package's layer order ([tool.m3trik.layers]). Parts top first, kernel last, checked bym3trik/scripts/check_layers.pyagainst a frozen baseline of today's exceptions (CODE_STANDARD section 0). No code change. -
2026-09-27 -- A child process that activates
TestSandboxnests its root inside its parent's, by name, instead of working it out fromTEMP(core_utils/test_sandbox.py).TestSandbox.temp()passed its root to child processes only throughTMPDIR/TEMP/TMP. A fresh interpreter finds its temp dir by probing those with a throwaway write, and on any failure exceptFileExistsErrorit falls past all three to the user's real temp dir, with no warning. Measured: a root that had gone, anOSErroron 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 inPYTHONTK_TEST_TEMP_ROOT, and a child'stemp()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_sandboxstarts 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_trashandFileUtils.trash_nameanswer before anything moves: whethermove_to_trashwould 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_trashmakes the same checks as the move itself: on Windows the drive, the policy and the bin's own settings; on macOS a writable~/.Trashon 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 answersFalsehere too, so a prompt under test says what really happens, andreal_trash()restores both calls. Tests:test_file.py(+2),test_test_sandbox.py(extended). All were red before the change. -
2026-09-27 --
FileDependencies.walkandFileDependencies.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.BINas FAT spells it, so NTFS's$Recycle.Bingot through. blendertk's walks pruned nothing, nor did mayatk's Resolve Missing Textures index.find_filesand 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=Trueswaps 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/ZomSaveunder 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 tombcs, which exists only on Windows, so a test forcing the win32 branch on Linux CI raisedLookupErrorin every check; it now falls back toascii(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_proxiesrenumbered the nodes behind every proxy it removed but not the node indices the earlier shadow pass had written intoextras.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/).ArticulationModelposes 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.ArticulationAnalysisproposes 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),ArticulationConformancegenerates the golden cases the C# and JS ports are held to.SceneRecords.ARTICULATIONdeclares thearticulationrecord;MeshConvert.apply_glb_articulationbinds it as rootextras.articulation_webinsidefbx_to_glb. The preview gainsviewer.grab(the mouse ahead of the orbit, a controller's grip or trigger, a tracked hand's pinch;press/drag/release/stepon plain values for tests),viewer.nodeResolver, a panel'saddSlider, and the auto-activatedarticulated_rigscript (itsArticulationModelport exported by name, three.js-free)._ShadowRigsMixin._shadow_nodes_namedis_nodes_named, shared with the new pass, andshadow_rig.jsresolves nodes throughviewer.nodeResolver. -
2026-09-27 --
TestSandbox.activate()keeps a test run out of the machine's Recycle Bin:TestSandbox.trash(),real_trash()andtrashedare 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 ontrashedand answersNone, as a volume with no trash would, soFileDependencies.set_asideputs the file in_supersededinside the sandboxed temp root. A test of the trash itself runs insidereal_trash(). It is part ofactivate(), 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_pathandScriptTemplate.child_pathare new,python_args_via_envhands the child its TEMP/TMP (and TMPDIR, which a Python child'stempfilereads first andTestSandboxsets) 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.filesave 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_pathswaps each path component the code page cannot hold for itsGetShortPathNameWname 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'sACP), not this process's. Blender's manifest declares UTF-8, so inside BlendermbcsandGetACP()are 65001 (measured) while the mayapy it launches reads cp1252: anmbcscheck 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_asto.ma,import_sceneback, andsend_tothrough a freshmaya.exe -batchand back all PASS; reverted in-process,save_asandsend_toFAIL. Tests:test_app_launcher.py::TestAnsiSafePath(+7),test_bridge.py(+1),test_script_run.py(+1, real mayapy; it leaves throughProcessExit.hard_exitand asserts its TEMP holds no crash-save -- withos._exitit 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_envandAppLauncher.PYTHON_ARGV_VARare new, andScriptRunner's default launch, the script deliverers,ExecutionMonitor's sidecars andPackageManager._run_pipall go through it;ScriptLaunchSpec.launch_argsis 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_artifactlaunchedmayapy <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.blendbake, blendertk's.mapull and itssave_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 inPYTHONTK_ARGV. The shim removes the variable before the target runs, then runs exactlypython <script> ...(runpy.run_path, the script's folder assys.path[0]) orpython -m <module> .... Not the entry's other option (cwdplus a bare file name): a caller'scwdis its own, and maya.exe's-commandMEL has no working-directory hook, while the environment serves every interpreter and the MEL alike. That is whyScriptRunnerand the deliverers put the script path in the child env even whenlaunch_argsis custom: a launch that must not name it can read it there. AlsoAppLauncher.runnow decodes captured output witherrors="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 privatePackageManager._PIP_SHIM/_PIP_ARGV_VARare 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-mforms, 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), andconftest.find_mayapy, now shared. -
2026-09-27 -- A re-bake's superseded files go to the Recycle Bin, never deleted:
FileUtils.move_to_trashandFileDependencies.set_asideare new, andFileDependencies.remove_supersededmoves files there instead of callingos.remove(file_utils/_trash.pynew,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_trashis stdlib-only. On Windows it callsSHFileOperationWwithFOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI. On Linux it uses the freedesktop trash: the home trash, else the volume's.Trash/$uidor.Trash-$uid, writing the.trashinforecord 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, returningNonewith the file untouched: a network, removable, optical or RAM drive; a volume mounted into a folder; theNoRecycleFilespolicy; a bin set toNukeOnDeleteor smaller than the file (MaxCapacity); a path ofMAX_PATHor more. A SUBST drive is read on its host volume. After the call, the bin's own$Irecord is looked up so the result can be the$Rpath, 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_asidemoves the file into a_supersededfolder beside it (SUPERSEDED_DIR), under a free<stem>_<k>name.find_files, the default walk ofresolveandrelocate, 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);TestRemoveSupersededis 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(onLIGHTMAP_WRITERSand the hierarchy baseline'sscene),SceneRecords.with_stamps/stamp_unsaved, andSceneStoreBase.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, notpath_keys, becauseLIGHTMAP_DIRSandAUDIO_FILE_MAPare 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 ahiddenkey 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 (andset_inforefuses it), so its flag is a store-level.hiddenlist 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.domainmarker, 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'sdescriptionsidecar key (set_description: blank clears, built-ins skipped, likeset_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(PresetStoreHiddenTest7,PresetStoreDescriptionTest2),test_preset_library.py(HiddenAndDescriptionTest3,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 countedN failedanywhere 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 owntestgate now does.