github m3trik/pythontk v0.20.0
pythontk v0.20.0

4 hours ago
  • 2026-11-03 -- Fixed (release CI): FileDependencies.walk lists every folder in name order, and ExportRun(fbx_media_selfcontained=) is removed (file_utils/file_dependencies.py, core_utils/engines/scene_export/export_profile.py). os.walk lists in the file system's own order (NTFS sorts, ext4 does not), so of two identical copies of a missing file a Linux run resolved to a different one than a Windows run; subfolders and files now come case-folded name first on every platform, for find_files and every DCC texture walk alike. fbx_media_selfcontained, retired 2026-10-05 for 0.15.0 (every FBX deliverable embeds its media, so nothing read it), reached the end of its 30-day window: a caller still passing it gets a TypeError naming it. Two test classes resolve their temp folder, since the results they compare name files resolved and a Windows runner's TEMP is spelled with 8.3 short names (RUNNER~1); one test's assigned lambda is a def (ruff E731).
    • Test(s): test_file_dependencies.py new test_the_walk_lists_in_name_order_whatever_the_file_system_gives (red first on the unsorted walk); test_export_profile.py test_the_self_contained_flag_is_removed (was the warning test); test_agent_instructions.py and test_operations.py TestBatchOperationHost pass under an 8.3 short TEMP (5 red first there); ruff check . clean.
  • 2026-11-03 -- migrate settles a carrier an import left beside the scene's own, and is_carrier knows Blender's numbering (core_utils/engines/scene_export/scene_store.py). A plain file import (Maya File > Import, Blender Append) never merges the other scene's carriers, so its data_export1 / data_export.001 was read and refreshed by nothing, yet every export shipped it and readers took its records for a referenced module's: a production assembly's carried a pre-rename lightmap_metadata and a version-1 handoff, and the deliverable verifier failed the file. SceneStoreBase.migrate (run on open and before every export commit) now removes such a copy when nobody takes anything from it: a merge would bring nothing in, and a reader none of its records (each is shadowed by the scene's own copy, under an old key, or in a version the verifier refuses; the handoff goes with them). One that holds more (shots, an undeclared channel, a current record the scene's own lacks, a keyed value) stays, named, the user's to merge_carriers / discard_carriers. A host names its copies through the new _leftover_carriers hook (default: none). is_carrier also matches Blender's data_export.001, so the verifier and the FBX and GLB passes read a Blender copy as they read Maya's.
    • Test(s): test_scene_records.py +9 (TestLeftoverCarriers, TestStore.test_a_carrier_is_named_as_every_host_numbers_a_copy), red first.
  • 2026-11-03 -- Fixed (review): AgentInstructions.sync removes only its own pack files and writes only what it can read, whole; the repo hook runs without a shell and survives a file it cannot read (core_utils/agent_instructions.py; every repo's .agents/session_hook.py and .claude/settings.json resynced). A pointer link could name any file: [principles](docs/principles.md) deleted a user's file when the pack moved, and an absolute or .. link one outside the repo. A moved pack is now removed only from .agents/packs or .claude/rules, under its own name; a link outside the codebase is no record; a packs= name must be a plain name. A settings.json, a vendored pack or its source that is not UTF-8 is refused as AGENTS.md is (a UTF-16 settings file lost its permissions); a rewritten file keeps its CRLF and byte-order mark; each file is written to a .part and renamed onto it; a session_hook.py sync did not write is refused (one the earlier template wrote is replaced). The hook is registered in exec form, "command": "python", "args": ["${CLAUDE_PROJECT_DIR}/.agents/session_hook.py"] (the expansion measured in Claude Code 2.1.317): PowerShell 5.1 rejected the || shell string, so a lone clone on Windows without Git Bash got no packs. The python3 fallback is gone: a virtual environment or python-is-python3 provides python, and without one AGENTS.md still links the packs. check reports the shell form as stale. The hook skips a pack or a CLAUDE.md above it that it cannot read, and never fails the session: one pack that was not UTF-8 cost every pack.
    • Test(s): test_agent_instructions.py new TestSyncLeavesWhatItDoesNotOwn (7) and TestHook +2 (test_a_pack_it_cannot_read_is_skipped_and_the_others_are_added, test_a_claude_md_above_it_cannot_read_costs_nothing), each red first.
  • 2026-11-03 -- Fixed (review): ApiSurface.read keeps no parse tree, and a file it could not read once is read again; a cache write leaves no .part; a default search survives an editable install that is no package (core_utils/api_surface/, core_utils/package_manager.py editable_packages). The process-wide AST memo kept every tree it parsed: 925 MB resident after reading the seven workspace packages, 275 MB now. Each file is parsed once per read, and a module whose files are all unchanged is not walked again, so a re-read of pythontk after a one-file edit takes 0.13 s (1.6 s before; 6.3 s with the per-read parse alone). A file a sync client or a scanner held during a read (PermissionError) was remembered as no module, in the process and on disk, until it changed; a read that meets one is now kept nowhere. A cache write whose rename Windows refused left its multi-MB .part: the rename waits out a reader's hold (WinError 5 or 32), the .part goes whatever happens, the sweep removes a .part older than 10 minutes and passes over an entry another process swept first, and a refresh (os.utime) it cannot make no longer turns a hit into a miss. A tree under a temp folder is kept in the process only (test sandboxes had left 551 entries in the user cache, now removed), and each Python version keeps its own entry. A default search (the api.search operation) passes over, and logs, a registered package it cannot locate, raising only for one it was given; editable_packages lists only names that import as packages, and reads a distribution with no name or a direct_url.json that is not an object.
    • Test(s): test_api_surface.py new in TestReadCache (6: a file it could not read, no tree outlives its read, a refused rename, the refresh, the sweep, an edit re-walks only what it reaches), new TestReadKeptOnDisk (2), new test_a_default_search_skips_a_registered_package_it_cannot_locate, test_editable_packages_reads_each_distribution_installed_editable +6 cases, test_a_tree_read_once_is_kept_nowhere holds _PARSED and _WALKED too; red first except the edit test, written with the module memo (it fails when a missing base file is not watched).
  • 2026-11-03 -- Fixed (review): code saves only what it saves itself; only code is interrupted on a cancel, and in Blender too; the code's own KeyboardInterrupt no longer ends the instance; the plan and apply servers work in one instance; a code operation is never served on the plan server (core_utils/operation_catalog/batch_operation_host.py, session_operation_host.py, operation_server.py, core_utils/python_console.py).
    • Code has no plan, so the save rule that saves "the scenes a live run of an operation that plans changed" no longer saves for it. A read-only snippet naming a scene rewrote the file, and in Blender a .blend was upgraded on every look. What code leaves unsaved is held: the next call that opens scenes is refused until code saves or discards it.
    • The cancel's interrupt watches only code. An operation with checkpoints stops at one, keeping its journal; interrupted, its changes were lost from the outcome and nothing was held, so the next call that opened scenes discarded them unasked.
    • An application that gave Python no SIGINT handler gets Python's default before the watch arms (background Blender, measured in 5.1: interrupt_main was dropped, so a cancelled loop ran on until the grace killed the instance). Measured after: a while True cancelled at 1 s stops at 3.1 s, and the same Blender keeps its names.
    • A KeyboardInterrupt the code raises itself is partial, and the instance and its names live on.
    • Held changes are marked after any live change, and the host's own flag says whether a file has them: an untitled scene a run built with scenes named was dropped before.
    • The key a call without scenes goes to is kept in the session's folder (current), so the plan server no longer planned against another Unity project than the apply server worked in.
    • A session folder name cut at 80 characters keeps a hash of the whole.
    • OperationServer serves a code entry on the apply server alone, however it is declared.
    • PythonConsole.run raises SyntaxError for code that cannot be parsed at all (NUL bytes).
    • The instance's output pipe is closed with the log.
    • Test(s): test_session_operation_host.py new test_the_codes_own_keyboard_interrupt_keeps_the_instance_and_its_names, test_code_saves_only_what_it_saves_itself_and_what_it_leaves_is_held, test_every_process_serving_the_session_works_in_its_current_instance, the plan-server case in test_code_is_served_where_changes_are_never_as_a_plan; test_python_console.py (NUL bytes). The Blender cancel was measured with a kept Blender.
  • 2026-11-03 -- API_INDEX.md, API_REGISTRY.md/.json and API_CHANGES.md are no longer committed: the API is searched live (api.search; python -m pythontk ops run api.search "query=<words>"); new ApiSurface.read(cache=False) reads a tree once and keeps it nowhere (core_utils/api_surface/api_surface.py; the api_surface, symbol_record, deprecation, module_resolver and __main__ docstrings; docs/README.md and the sub-package READMEs; CI's api-retirements job, publish.yml). m3trik's check_api_surface.py reads a release's source copied to temp through it; kept, each such read would leave megabytes per package in the per-user cache until the sweep.
    • Test(s): test_api_surface.py new test_a_tree_read_once_is_kept_nowhere.
  • 2026-11-03 -- Fixed: a kept instance held a scene the code had saved as unsaved; code that did not compile came back partial; a call naming a scene in a host's default project started a second instance of it (core_utils/operation_catalog/batch_operation_host.py new _mark, operation_host.py, session_operation_host.py _instance_for, _operation_catalog.py, core_utils/python_console.py). Three defects the Unity and Blender end-to-end runs over MCP found. (1) A code run marked what the instance holds as changed AFTER it ran, so a save the code made was undone by the mark: every later call that opened scenes was refused. A run that keeps no journal is now marked BEFORE it runs, so the code's own save clears the mark as the application's would; a run whose journal names a change is still marked after. (2) PythonConsole.run raises SyntaxError when the code does not parse (none of it ran), and a code operation that raises one is failed, not partial: a typo no longer holds the scene as unsaved. (3) A call without scenes now stays on the instance the session last worked in, else the host's default (instance([])), so a host whose default is a project (Unity's sandbox) keeps ONE instance for it whether a call names its scenes or not; scenes may name a folder a host opens whole (a Unity project).
    • Test(s): test_session_operation_host.py new test_code_that_saves_its_change_leaves_nothing_held, test_code_that_does_not_compile_fails_and_holds_nothing, test_its_default_project_is_one_instance_with_or_without_its_scenes (each red first); test_python_console.py (a syntax error raises SyntaxError).
  • 2026-11-03 -- New effect code: runs code it is given, may change anything, no plan and no undo (core_utils/operation_catalog/operation.py Operation.CODE, OperationEntry.EFFECTS, operation_provider.py, operation_server.py; every *.python.exec declares it). operations.check holds every change to a dry run and told a console to take a context, which would only echo its code; code is the one change with none (the check flags a code operation that takes a context instead), its tool says it runs code and is served on the change server alone. The C# contract's Effect gains Code with it (base architecture rev 215).
    • Test(s): test_operations.py new test_code_runs_with_no_plan_and_a_code_operation_that_plans_is_a_problem (red first).
  • 2026-11-03 -- Code is applied, never planned, and a cancel interrupts it; an application that serves a session itself is kept, one instance per project (core_utils/python_console.py, core_utils/operation_catalog/batch_operation_host.py new serve / instance strategies and INTERRUPT_AFTER, session_operation_host.py, _operation_catalog.py, operation_server.py). PythonConsole.run took a context only to echo its code back as a "plan", which told nobody anything and put a code tool on the read-only server; it takes none now, so every *.python.exec is an apply-only tool (ops run refuses it: no dry run) and the batch host counts a live run that keeps no journal as a possible change (the Blender save hook still fires). A cancelled call that reached no checkpoint was waited out, then its instance killed with every name the console held; 2 s after the cancel it is now interrupted as Ctrl+C would, the instance and its names kept (never while it saves; a native call is reached when it returns, the grace kill still the last resort). A runner whose application serves its own run folders (Unity's editor, in C#) could not be kept; serve now starts one serving a session folder, instance keys a session's instances by what a call's scenes name (Unity: the project), the last one named taking a call that names none, and app may take that key (the editor version a project names).
    • Test(s): test_python_console.py test_code_has_no_dry_run_so_its_operation_applies_only; test_session_operation_host.py new test_a_cancel_interrupts_running_code_and_keeps_the_instance_and_its_names, test_code_is_served_where_changes_are_never_as_a_plan, TestAnApplicationThatServesItself (2: one instance per project, the last one staying, each launched with its own executable; a catalog keeps it, one that cannot serve a session is refused) (red first).
  • 2026-11-03 -- ApiSurface.read keeps each package's read in the per-user cache, and reads of one package at once are one read (core_utils/api_surface/api_surface.py). Every process parsed every package again: an MCP session's first api_search waited 15.5 s (measured: the server's start-up read and the search parsed the same packages at once). A read is now kept on disk (UserConfig.cache_dir('api_surface'), the sidecar mapping plus each symbol's docstring, file and retirement date), keyed by the package's file stamps and the reader's own source, and a second asker waits for a read in progress. Measured over the seven packages: a new process's first search 0.6 to 0.9 s (16.7 s the first time ever); generate_api_registry.py --check 30.1 s, then 9.1 s, its output unchanged. Reads unread for 14 days are swept.
    • Test(s): test_api_surface.py new TestReadCache (3: a new process reads from the cache until the source changes, docstrings and retirement dates included; another reader's cache is not read; reads at once parse each file once; red first).
  • 2026-11-03 -- Fixed (review): a kept instance's lease ran out during a call longer than keep; new SessionOperationHost.claim takes a session for one runner alone; the plain Python's server no longer tells a client to pass scenes (core_utils/operation_catalog/session_operation_host.py, operation_server.py). The lease was renewed only around a call, so a test chunk longer than keep (900 s) lost its instance the moment it finished (measured with keep=3 and a 6 s call); the caller now renews it while it waits. Two test runs on one checkout shared one instance, against the rule that a reused instance has no other run using it; a runner that resets what its instance holds now claims it (an OS lock in the session's folder, let go by close or the process's end). A server serving only python.exec named python_ among the hosts its scene rules cover.
    • Test(s): test_session_operation_host.py new test_a_call_longer_than_its_keep_does_not_end_it, test_a_session_claimed_by_one_process_is_refused_to_another (red first), TestPlainPythonHost (instructions).
  • 2026-11-03 -- Fixed: a live run's change was neither saved nor guarded where the host's dirty flag misses edits made from code; SceneFiles may now have changed() (core_utils/operation_catalog/batch_operation_host.py). The save rule and the unsaved-changes refusal both read the host's own flag, and Blender's stays clear after a data-API edit (material.name = ...), so a blender.python.exec that changed an opened .blend saved nothing, and a session opening another file discarded the change unasked. After a live run that changed something, the instance now calls the adapter's changed() when it has one, before the flag is read.
    • Test(s): test_operations.py new test_a_host_whose_flag_misses_code_edits_is_told_so_the_change_is_saved (red first).
  • 2026-11-03 -- New: a session instance a runner keeps beyond itself, and folders it reloads in place, SessionOperationHost(keep=, reload=) (core_utils/operation_catalog/session_operation_host.py). A test runner wants the instance one run starts to serve the next, but a session instance was bound to the process that started it and restarted on any change to code it loaded, the package under test included. keep (seconds) launches it detached (AppLauncher.launch), with no owner watch and a temp dir of its own in the session's folder (a test sandbox's root goes with its run; a crash's dump stays out of the user's temp), and it ends by itself keep seconds after the last call (the JobQueue lease, renewed by every call and while it starts); close leaves it to that lease. reload names folders whose modules it imports afresh before each call and never restarts for; a change elsewhere still restarts it. Every safety property stands: launched by the tooling, alive while it holds instance.lock, killed by pid only while that lock is held. MCP sessions are unchanged (no keep, no reload).
    • Test(s): test_session_operation_host.py new TestKeptBeyondItsStarter (2: it outlives the process that started it and ends once idle; a folder it reloads is current at each call with no restart; red first).
  • 2026-11-03 -- Library functions are reached by search and code, never wrapped one by one: python.exec runs code for a function no application owns, in a kept plain Python registered as the python host (PureSceneFiles.operation_host, PureSceneFiles.HOST); a search covers every package installed editable (PackageManager.editable_packages); the server reads them while the client starts (OperationServer.warm); a kept instance watches every module it loaded from code it was handed (HandoffBridge.own_python_folders); removed: Operation.fixed / OperationEntry.fixed, OperationProvider.candidates (operations.candidates) and ModuleAttributeResolver.side_effect_free, all unreleased (operations.py + operations.json, pyproject.toml, core_utils/operation_catalog/, core_utils/api_surface/api_surface.py, core_utils/package_manager.py, core_utils/handoff/app_handoff.py, core_utils/module_resolver.py). With api.search and a host's python.exec reaching the whole API, a curated operation per library function only covered a handful of thousands, so the hosts declare their console and their view alone, and what only served curation went: fixed (a wrapper's pinned arguments) and the candidate scan that drafted wrappers. api.describe said a pythontk or uitk function runs "in any Python", but no tool ran plain Python: python.exec does, in a session instance like Maya's (its tool takes no scenes). A search named no uitk, tentacle, extapps or unitytk symbol (only packages registering operations or a host); it now covers the code installed editable too, measured 12.5 s cold for the seven packages here, so the server reads them in the background at start (0.26 s warm). A kept instance restarted when a module under its own import roots changed, but went stale on one its console imported from elsewhere (a uitk in a pythontk session); it now watches its whole handed path, never its own Python's install.
    • Test(s): test_session_operation_host.py new test_code_imported_from_outside_its_roots_is_watched_too, TestPlainPythonHost (2); test_api_surface.py new test_registered_adds_each_package_installed_editable_after_them, test_editable_packages_reads_each_distribution_installed_editable; test_operations.py new test_a_server_that_serves_search_reads_the_packages_while_the_client_starts; the fixed and candidate tests removed (red first for the new ones).
  • 2026-11-03 -- Removed: SceneExporterBase.confirm_check_override, its deprecation window closed (due in 0.14.0) (core_utils/engines/scene_export/scene_exporter.py). A run asks decide_check_failure the moment each check fails; the after-the-run consent outlived its window, so the registry's expiry gate failed.
    • Test(s): test_scene_exporter.py test_the_after_the_run_consent_is_gone replaces the alias's window test.
  • 2026-11-02 -- New: a host's calls kept in one instance per session over MCP, SessionOperationHost; PythonConsole; JobQueue.discard; images back over MCP (core_utils/operation_catalog/session_operation_host.py, new; core_utils/python_console.py, new; batch_operation_host.py, _operation_catalog.py, operation_server.py, file_utils/job_queue.py, __init__.py). Every host call launched a fresh mayapy or Blender (measured: 5-7 s and 3.4 s), so an agent paid a launch per read and nothing it built lived past one call. SessionOperationHost wraps a host's BatchOperationHost and keeps one instance per session name and host: the same run folders (request, entry, cancel, saving, and now a progress file), served in order from a JobQueue in the temp root, started by AppLauncher.spawn bound to the process that started it (and watching it, should the binding not take), shared by every runner given the session in any process. The instance holds an OS lock on a file in the session folder for its whole life: a held lock is what says it is up (a heartbeat lags while a host command holds the interpreter; a pid may name another process by now), and only then is it stopped by the pid it recorded; a dead instance's record is cleared before the next starts. Before each call it compares each module it loaded from its import roots with the file as it was when loaded, and ends when one changed; a fresh instance takes the call. Whenever another instance than a runner's last call's serves a call (a code change, a crash, a stop) its outcome says what was lost (session.restarted). Opening scenes over unsaved changes is refused, as an application asks before it discards them (SceneFiles.unsaved(), optional; PureSceneFiles has it). A cancel withdraws a call still waiting its turn, and cancels one waiting for the instance to start; grace and timeout stop the instance only for a call it runs, and a call queued past the timeout is withdrawn. run never raises; the instance's output reaches instance.log as it comes. OperationCatalog(session=) keeps each registered Python-strategy runner (close() ends the instances this process started); ops mcp names the session from PYTHONTK_OPS_SESSION or Claude Code's CLAUDE_CODE_SESSION_ID (set in each server's own environment), so its two servers share one instance per host, and ends them when the client disconnects. The server's instructions, tool descriptions and scenes schema say kept or fresh; a result naming an image under the temp root comes back as MCP image content (never one elsewhere: a person's texture is no capture). PythonConsole.run(code, ctx=None) runs code in a namespace that lasts: the last expression's value (the start of its repr when huge), what it printed (its end kept when long), a traceback from the snippet's own frames; a dry run lists the code. The hosts' python.exec operations declare it. BatchOperationHost shares its bootstrap script with the session, and the run-folder protocol both serve is one internal base. OperationCatalog.host builds one runner per host however many calls ask at once. JobQueue.discard forgets a finished job and withdraws a queued one, best effort. Measured over MCP, both servers on one session: Maya 4.5 s to start, then 73 ms a call; Blender 3.2 s, then 65 ms; what maya_python_exec built on ops-apply, the ops tools read.
    • Test(s): new test_session_operation_host.py (20: calls share one instance that keeps what they leave; runners of one session share it and only its starter ends it; scenes open first and a live plan saves them; a code change restarts it, with the issue; progress and cancel; a queued call cancelled is withdrawn and stops nothing; a dead instance is lost with its call and replaced; a failing start refused with why; a command runner cannot be kept; and, each red first against the first build (found by review): a failed start no longer fails the next, a call waiting when its instance dies runs in the next, an instance gone between calls is reported, another runner stops an instance hung in a native call, a module written before its first import is current, scenes are refused over unsaved changes, a cancel during the start ends the call; the catalog keeps registered runners; the server says kept or fresh; the session from the environment; image content only from the temp root), new test_python_console.py (7), test_job_queue.py (2: discard). test_operations.py's batch, server and run suites pass unchanged.
  • 2026-11-02 -- One engine reads the public API for every reader: ApiSurface, PackageSurface, ModuleSurface, ClassSurface (core_utils/api_surface/, new); HelpMixin, python -m pythontk, Operation, OperationProvider.candidates and the api.search / api.describe operations read through it; api.call, OperationProvider.surface and core_utils/operation_catalog/api_surface.py removed (never released) (help_mixin.py, symbol_record.py, __main__.py, operation_catalog/operation.py, operation_provider.py, _operation_catalog.py, operations.py + operations.json, __init__.py, test/surface_snapshot.json). One symbol was described five ways: the registry's static walk (in m3trik's generator), HelpMixin's live read, the operations' own AST walk (OperationProvider.surface), the CLI's index rows and four docstring-summary rules, each cutting a summary differently. ApiSurface is now the one reader. Its static half, read(package), is the registry's walk moved here verbatim (ast, nothing imported; private bases across files, retirements and their remove_in / since, ALL_CAPS constants), kept until a source file changes. The registry it writes is identical but for its summaries. retirements reads the sub-symbol Deprecation calls. Its live half, members / record / enrich / flags / signature_of / location / source_of / defined_in, is HelpMixin's reading moved out; the mixin now only renders, its public methods unchanged (about gains signature=). exports is the root namespace resolved live: --index and the surface snapshot read it. summary is the one rule: the first sentence of the first paragraph, wrapped lines joined, reST as Markdown, with an optional limit (the registry's one line). search and describe read the source: every public symbol (classes, instance methods, properties and constants too, since a host's code reaches them all), and describe gives the whole docstring and file:line. registered and host read the entry points, whose group names (PROVIDER_GROUP, HOST_GROUP) are defined here once. api.call goes: code run in the host replaces it. Every registry's summaries change once. A live read that cannot place a symbol says so as HelpMixin.where always has (ApiSurface.NO_LOCATION), and a search checks each module's package path once, not each symbol's (a warm search of every registered package: 1.4 s, now 0.3 s).
  • 2026-11-02 -- A package hidden by its repo folder is told to install in compat editable mode (core_utils/operation_catalog/operation_provider.py). The default editable finder loses to a same-named folder on sys.path, the working directory included, so the package imports as an empty namespace; import_problem names the fix: pip install --config-settings editable_mode=compat -e <repo>, or the folder on PYTHONPATH.
  • 2026-11-02 -- An operation declares the arguments it always passes, Operation(..., fixed={...}) / OperationEntry.fixed; a code hook is no argument; only an operation context gives a dry run; a named result reads as its name (core_utils/operation_catalog/operation.py, operation_host.py, operation_provider.py, operation_server.py). Populating mayatk's catalog from real library functions, end to end through the MCP servers in fresh mayapy and Blender instances, found four gaps. A read that also repairs (get_ngons(repair=), get_redundant_flat_keys(remove=)) exposed its change switch to the plan server: fixed holds it, out of the argument schema (a caller passing it is refused) and passed on every call, in this process or a fresh instance. A callable parameter (progress_callback) became an untyped argument no caller could fill: one with a default is left out, a required one refused. Operation.takes_context went by the name ctx alone, so FbxUtils.publish(ctx: ExportContext) read as plannable and would have been handed an OpContext: a ctx counts only unannotated (the convention) or annotated as an OpContext; another kind keeps its default and gives no dry run. A Blender datablock in a result read as its repr, and as a key with its memory address: a named object reads as its name. The candidate scan drafted set_tangent_info and stamp_bake_info as reads (a read ending now never outweighs a leading change verb), offered Painter and Toolbag plug-in code as Maya operations (a module under a folder that is no package is passed over) and drafts a read's change switch held off. The ops server tells a client that a fresh instance has nothing selected, a tool's description names what it always passes, and each tool carries the Outcome schema without its prose (the instructions describe it once): the workspace's 34 tools list in 70 KB, not 129 KB.
    • Test(s): test_operations.py new test_only_an_operation_context_gives_a_dry_run, test_a_code_hook_is_left_out_and_a_required_one_refused, test_a_fixed_argument_leaves_the_schema_and_always_reaches_the_call, test_a_fixed_argument_is_a_parameter_given_json, test_a_named_object_reads_as_its_name_as_a_value_and_a_key, test_a_tool_says_what_it_always_passes_and_carries_a_terse_outcome_schema; test_candidates_lists_what_could_be_declared extended (a change verb's read ending, a held-off switch, a plug-in folder), each red first.
  • 2026-11-02 -- Onboarding and upkeep are part of the tool: OperationProvider and the operations operations.init, operations.candidates, operations.catalog, operations.check; ModuleAttributeResolver.side_effect_free (core_utils/operation_catalog/operation_provider.py, new; _operation_catalog.py, core_utils/module_resolver.py, operations.py + operations.json, __init__.py). Adding a codebase was hand work (a declaration module, an entry point, a catalog) and finding what to declare was a one-off sweep; a workspace script kept the catalogs. OperationProvider owns one package's cycle, as operations so they run from the command line, over MCP or a script alike: init writes the declaration module, joins pyproject.toml's pythontk.operations table and writes the first catalog, each only where missing (a dry run lists them); candidates reads the package's source without importing it, imports only modules whose import runs nothing but definitions (the lazy loader's test, now public as ModuleAttributeResolver.side_effect_free), and lists each public function or static/class method Operation itself accepts as it stands (typed arguments, a docstring), not yet declared, with a suggested id (host= leads it) and a draft; a function that cannot plan is drafted only when its name says it reads (get_, ..._status), never as a read the plan server would run unasked; write_catalog rewrites a catalog (moved from OperationCatalog.write, with declarations, document, catalog_path, render; never released); check holds every registered package: a current catalog, a change that can plan, typed arguments, twins (maya.x, blender.x) that share their effect and their common arguments (a host may add its own), each an error issue, a stale catalog's fix the operations.catalog call; a catalog another language writes (a file, or a registered module path holding only its .json) is validated and held to the same rules, never called stale, its no-dry-run message in no one language's words; init edits only the package's own pyproject.toml (its project folder, or the one above a src/ layout). A package hidden by a same-named folder on sys.path (a repo folder reached through the workspace root) imported as an empty namespace, so its runner failed as a bare AttributeError; check, candidates, init and the host loader now name the folder and the fix. The ops catalog command is gone (the operations replace it), and a command whose run reports an error issue exits 1, as a linter does (instructions.check included). Measured on the workspace: mayatk 1,027 candidates (9 that plan, 190 reads by name), pythontk 1,096; check named blendertk as hidden by its repo folder in the shared .venv.
    • Test(s): test_operations.py new TestOperationProvider (7: a catalog planned then written once, a change that cannot plan and an untyped argument, twins, issues with their fix, a package hidden by its repo folder, candidates, ...) and TestProviderInit (3: plans then scaffolds once, joins an existing table, needs a package and its pyproject), each red first; the build tests on OperationProvider; test_upkeep_runs_as_operations, test_an_error_issue_fails_the_command.
  • 2026-11-02 -- AppLauncher.launch(hidden=True): a GUI the caller drives through its own channel starts on the private desktop; get_window_titles finds a hidden app's windows (core_utils/app_launcher/_app_launcher.py, _hidden.py, _desktop.py). A test pass that drives a GUI Maya over its command port put its window on the user's desktop, where it took their focus. launch(hidden=True) starts the app on the private desktop run(hidden=True) uses (Windows; a no-op elsewhere, as for run): it runs as usual -- measured: a GUI Maya 2025 boots, opens its port, renders a Viewport 2.0 playblast on the GPU and gets Qt activation, focus and key events there -- while the user's foreground never changes. It ends with the caller, however that ends, whatever detached says: an app no one can see could never be closed once its launcher is gone. get_window_titles (and wait_for_ready's window check, now the same lookup) also reads the private desktop, since EnumWindows sees only the caller's, and counts each window once (a caller on the private desktop sees it twice).
    • Test(s): test_app_launcher.py new TestLaunchHidden (4: its desktop, its end with a caller killed outright though detached, its window found by pid and never on the user's desktop, a no-op where it cannot hide), red first; TestRunHidden's child scripts moved to a shared base, whose user's desktop is the input desktop (a hidden test run's own is not); _INTERACTIVE holds on the private desktop (a fresh one may hold no window to enumerate yet: 5 window tests skipped there); test_wait_for_ready and test_get_window_titles launch the base interpreter, so the PID they watch owns the window (a venv python.exe is a launcher: both skipped themselves in every venv run). Hidden, through affected_tests.py: 85 passed, 10 skipped (POSIX only).
  • 2026-11-02 -- CLI.dispatch lets an option sit among a command's positionals, and takes prog; CLI.get_parser takes prog (core_utils/cli.py). argparse's subcommand parse gives a * positional nothing once an option interrupts it, then refuses the rest as unrecognized, under the program's usage line: python -m pythontk ops run ID --provider M NAME=VALUE failed so. The named command's own parser now parses intermixed (--help and an unknown command still get the program's usage), and prog names the program a usage line shows (python -m pythontk ops run, not __main__.py). Every CLI.dispatch table gains it (LookMatch, LookCapture, LookLoop, mayatk's LightLayerBaker).
    • Test(s): test_cli.py new test_an_option_may_sit_between_positionals (red first); test_operations.py new test_an_option_may_sit_between_the_id_and_its_arguments, test_a_catalog_file_is_a_provider, test_the_instructions_name_the_hosts_it_serves.
  • 2026-11-02 -- New: the operation catalog as an MCP server, OperationServer; agent instructions as a checked tree, AgentInstructions, with pythontk's own operations instructions.check / sync / measure / duplicates (core_utils/operation_catalog/operation_server.py, core_utils/agent_instructions.py, operations.py + operations.json, pyproject.toml). python -m pythontk ops mcp serves one tool per operation (its id with . as _; input schema = the argument schema, plus scenes for a host operation; result = the Outcome as structured content): reads and dry runs only, so a client may allow the server whole, while ops mcp --apply serves only the tools that make changes, under the same names, so a permission rule per server asks before each change. Progress reaches the client as MCP progress notifications, a cancelled request cancels the run's scope, in-process operations take turns. It needs the official SDK, the new pythontk[mcp] extra (mcp>=2.3,<3, Python 3.10+); nothing else imports it. AgentInstructions keeps the layout any agent reads and Claude Code loads as measured: AGENTS.md rules at every level, a CLAUDE.md stub beside each, shared packs vendored under a drift check (.agents/packs/, or .claude/rules/ for a pack with paths: frontmatter), a pointer block naming them, and a stdlib SessionStart hook a repo carries for a session rooted in it alone (a workspace root imports its packs instead); check reports each break as an issue whose fix is sync, measure models what a session loads, duplicates finds rules written twice. Also: Change.KINDS adds create and delete (OpContext.should_create / should_delete); an id is at most 34 characters (Operation.ID_MAX: served by a plugin, a tool is named mcp__plugin_<plugin>_<server>__<id>, measured, within Claude's 64) and no host-less id may start with a host's name; a committed catalog is found through a repo folder that shadows its package (an empty namespace import) and with no module at all (a catalog another language writes); argument descriptions are spelled as Markdown too.
    • Test(s): test_operations.py +14 (73: TestOperationServer (5: one tool-name clash is left out and reported, never fatal; a progress token of 0), TestOperationServerStdio (a JSON-RPC round trip, skipped without the SDK), the new kinds, the id rules, the catalog location, a summary read past e.g., a host refusing the dry run of a change with no ctx, a live run killed after the grace is partial, a cancel never cuts a save short, two live runs on one scene take turns, a scene saved elsewhere mid-run is not saved over); new test_agent_instructions.py (17: sync's plan and writes, the hook in a standalone repo and under a provider, path-scoped packs, check's codes, measure's launch and touched loads, duplicates; a CLAUDE.md that is not UTF-8 is refused and left as it was, a stub's other imports are kept, a decorator in a code block is no import, the hook skips only the packs a CLAUDE.md above imports, a pack's vendored copies are read once, pythontk's own catalog is current).
  • 2026-11-02 -- New: operations run in a FRESH instance of their application: BatchOperationHost, SceneFiles, PureSceneFiles, OpRequest (core_utils/operation_catalog/batch_operation_host.py, outcome.py, _operation_catalog.py, operation_host.py, operation.py). OpRequest is the batch protocol as a typed payload: a fresh run folder per call, request.json in (the op, its arguments, the dry run, the scenes to open), outcome.json out. BatchOperationHost is the runner a host library configures (which executable, how it runs a script, which packages it imports, its SceneFiles) and registers under pythontk.operation_hosts: it launches a NEW instance per call through ScriptRunner, so an operation never reaches a session a person has open, and inside it serve opens the scenes, runs the call through PureOperationHost, saves the scenes a finished live run changed (a dry run lists those saves with its plan; a run that does not finish saves nothing and says so) and writes the outcome. How the instance serves the folder is a strategy: a Python script calling serve by default, or command for an application that serves it itself (a C# server in a Unity project), with diagnose reading why an instance wrote no outcome. A live run holds its scenes, so two calls in one process changing one scene take turns, and it never saves over a scene file changed on disk since it opened it (another process saved it first). Cancelling, through the caller's scope or the ambient one, drops a cancel file the run polls at its checkpoints (the launch runs outside the ambient scope); a run still going grace seconds after the cancel is killed, but never while it saves its scenes, and a live run killed (by the grace or the timeout) is partial, naming any scene it was saving; an outcome written before an instance hung on exit still stands; progress comes back as ProgressRelay markers; an instance that dies without an outcome is reported (failed, or partial for a live change), never raised. OperationCatalog.run(..., scenes=) and ops run|apply --scene PATH pass the scenes (refused for an operation with no host, or a file that does not exist); OperationHost.run takes the OpRequest. An id an application runs starts with its host (maya.material.rename; Operation.id_error), so twin libraries' ids never collide. An operation's summary is its docstring's first sentence, wrapped lines joined and reST code spelled as Markdown.
    • Test(s): test_operations.py +15 (59): new TestBatchOperationHost (10, a fresh Python process standing in for the application: a read on an opened scene, a dry run's plan and its listed save, a live save, a failed run that saves nothing, a cancel at a checkpoint, a process that dies, scenes refused where none open, a missing executable, a newer request format, the request's schema), host-prefixed ids, scenes reaching the host, the summary; surface_snapshot.json +4 root names.
  • 2026-11-02 -- New: operations, the named actions every front end calls alike: Operation, OperationEntry, OperationCatalogDocument, OpContext, Outcome, Issue, Change, OpCall, OperationHost, PureOperationHost, OperationCatalog (core_utils/operation_catalog/; __main__.py). Operation declares one: an id (<area>.<action>, led by its host when it names one), the function it calls ("module:qualified.name"), its effect (read; state: what the running application holds, one undo step; files) and its host; the function's typed signature and Google docstring are its argument schema, and its arguments arrive as the types it declares (a path, an enum member, a SchemaSpec), so the library code it calls imports none of this. OperationEntry and OperationCatalogDocument are the published forms, typed SchemaSpec payloads every reader and writer shares, another language's included. OpContext is what a run is handed: should_change / should_write (the dry run: the same body lists its changes without making them), issues, progress and cancel (each a CancelScope checkpoint). Outcome and Issue (with Change and OpCall) are what a run hands back (status done / partial / cancelled / failed; an issue's fix is an operation call). OperationProvider writes and checks a provider's committed catalog (<module>.json beside its declaration module), OperationCatalog discovers providers through the pythontk.operations entry points without importing them (or one named by --provider: a declaration module, built live, or a catalog file another language's tool writes outside any package, read as it stands) (a provider that fails to load or ships a malformed document is a reported problem, never a crash), validates a call against its JSON Schema (an unknown argument is refused, not ignored), dry-runs anything that changes unless apply, and hands it to its host: this process (PureOperationHost, which keeps what an operation prints off stdout and contains a SystemExit) or the runner an application's library registers under pythontk.operation_hosts (OperationHost). python -m pythontk ops list|describe|search|run|apply|catalog is the command line.
    • Test(s): new test_operations.py (46), including each finding of the change's independent review (a SchemaSpec-typed argument made the catalog unwritable, red first; a failed live run that keeps no journal read failed; an annotation that does not resolve dropped every type; a malformed document killed discover; NaN defaults; print corrupting the JSON; the arguments' types); surface_snapshot.json +11 root names.
  • 2026-11-02 -- SchemaSpec.annotation_schema(tp): the JSON Schema of one plain annotation, by the rules json_schema reads a field with; a path type (pathlib.Path, os.PathLike) is a JSON string (core_utils/schema_spec.py). For readers that type values with annotations rather than a spec (an operation's signature). A path-typed field now publishes {"type": "string"} and a TYPED spec refuses a non-string there; no spec in the ecosystem has one.
    • Test(s): test_schema_spec.py new test_annotation_schema_reads_a_plain_annotation_by_the_same_rules.

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.