Skip to content

pythontk v0.8.92

Latest

Choose a tag to compare

@m3trik m3trik released this 19 Jul 02:36
  • 2026-07-18 — FileUtils.get_object_path: the stack-scan fallback no longer executes caller source files (TDD'd red-first). When an object's class couldn't be resolved to a file via sys.modules/__main__, the fallback walked inspect.stack() and ran spec.loader.exec_module on each frame's source file just to check whether it defines a class with the right name — re-running every side effect in those files. Found live: uitk's new test_resolve_path_unresolvable_object_returns_none calls resolve_path(object()), and exec'ing the stack's pytest/__main__.py launched a whole nested test session inside the test (~30s, then the nested SystemExit(1) — not an Exception, so it escaped the branch's except Exception — failed the outer test). The scan also keyed its cache by file path into sys.modules (never a hit, and namespace pollution on every miss). Frame globals already ARE each running module's namespace, so the fallback now just checks frame.f_globals — identity match on the object itself (new: also finds functions, which getmembers(…, isclass) never could) or a class of the same name — executing nothing; same ValueError contract when nothing matches. The regression pin (test_file.py::test_get_object_path_stack_scan_never_executes_caller_files) proves it in a subprocess: a driver script appends its __name__ to a marker on every execution — red showed ['__main__', 'goppath_driver'] (the file ran twice), green shows one line. An earlier regression test had already stubbed inspect.stack() empty to tiptoe around this branch; it now composes with the fix unchanged. uitk's registry_manager suite: 47/48 + 33s → 48/48 in 1.2s (the 30s was the nested session). Full suite green.

  • 2026-07-18 — NamedTupleContainer correctness pass: plain-tuple rows now support field access (fixes a live Metadata._batch_get latent bug), get() honors its documented None-on-no-match, filter/map preserve the subclass, and __getattr__ can no longer recurse on partially-initialized instances. Public surface unchanged. Twelve defects fixed, each pinned red-first (test_namedtuple_container.py +19 → 49, incl. two custom-signature_func pins — the path uitk's FileRegistry._record_signature rides): (1) plain tuples passed as named_tuples were stored raw, so dynamic field access and get() crashed — Metadata._batch_get builds exactly that shape, so every batch-metadata query was broken; rows now normalize to the tuple class whenever fields are known. (2) __getattr__ looked up self.fields through itself — a partially-initialized instance (copy/pickle reconstruction) hit RecursionError, so copy.copy of a container was broken outright; field lookup now reads __dict__ re-entrancy-safe. (3) get(return_field=…, **conditions) returned [] on no match while its docstring promised None — every ecosystem call site treats the result by truthiness (verified: switchboard _core, ui_handler, both loaders, slots), so the docs won; the one uitk test pinning [] updated. (4) extend([]) mis-dispatched into the single-object path (ValueError, or extender misfire with [[]]) — now a no-op like extend(None). (5) duplicate detection crashed with TypeError on unhashable field values (lists/dicts); signatures fall back to repr, and are computed once per row instead of twice. (6) cross-class namedtuple extends converted positionally — the same field set in a different order silently misaligned values; conversion realigns by name when field sets match (positional stays the fallback, and plain-tuple-rowed containers no longer inherit a latent tuple(*nt) crash). (7) a field-less container never adopted a schema from incoming named tuples, leaving dynamic access dead; the first named-tuple batch now supplies fields + tuple class. (8) modify() validated via hasattr, so tuple method names (count, index) passed validation and blew up as ValueError; validation now checks _fields and raises the documented AttributeError. (9) filter/map hardcoded the base class — uitk's FileContainer (or any subclass) got back a base container with its extra state dropped, carrying a freshly-regenerated tuple class besides; both now clone via copy.copy (new _clone), preserving subclass, attributes, and the actual tuple class. (10) modify/remove rejected negative indices; list semantics now apply (shared _normalize_index). (11) string fields ("name, age city") crashed at class creation or half-worked through substring matching; they now split like collections.namedtuple (new _normalize_fields, also covering metadata["fields"] — which drops the deferred circular-import from pythontk import make_iterable workaround). (12) to_csv/from_csv docstrings claimed kwargs went to csv.writer/reader (they go to open() — now stated), and from_csv gained the newline="" the csv module requires. Also: __repr__ and AttributeError messages use the subclass name; extend's blanket try/log/re-raise (double-reporting every error) removed; namedtuple-as-type annotations corrected to tuple. Registry regenerated — "No public API changes" (signature hints only). Full suite green (2119 tests, 0 failures, 7 skipped); uitk consumers verified (file_manager 31/31, switchboard + sources + ui_handler + runtime loader 203/203).

  • 2026-07-18 — logging_mixin quality pass: per-record worker threads removed from the default widget handler, the global logging.Logger class no longer mutated, stream-handler dedup, explicit tee/buffer levels pinned against set_log_level, styling kwargs unified across every level method, and tables render as one aligned block. Seven defects fixed (all TDD'd red-first). (1) DefaultTextLogHandler spawned a threading.Timer thread per log record — one OS thread per line, delivery order at the scheduler's mercy, and no GUI-thread safety gained in exchange (appends landed on an arbitrary worker thread — for Qt/Tk, worse than the emitting thread). Appends are now synchronous on the emitting thread, serialized by the handler lock like every stdlib handler; GUI marshaling is explicitly the registered custom handler's job (uitk's TextEditLogHandler already posts cross-thread records through a queued Qt signal). (2) patch() installed the log_timestamp property on logger.__class__ — the GLOBAL logging.Logger class — so assigning .log_timestamp on any foreign, unpatched logger (a data descriptor beats an instance attribute) ran our setter and silently replaced that logger's handler formatters with LevelAwareFormatter. Patched loggers are now reassigned to a cached per-base subclass carrying the property (_install_log_timestamp_property; one subclass per base class, TypeError fallback to the legacy in-place property for exotic layouts) — the property API is unchanged for patched loggers (smart_bake slots, uitk example) and logging.Logger stays clean. (3) add_stream_handler never deduplicated (file and text-widget handlers did), so repeat calls stacked duplicate console output; now deduped per target stream (exact-type check — FileHandler subclasses StreamHandler), it gained a stream= param, and setup_logging_redirect's stream branch collapsed into it (same formatter/level wiring, now deduped too). (4) set_log_level synced EVERY handler level, clobbering an explicitly-requested set_log_file(path, "ERROR") / enable_log_buffer(level=…) — an errors-only tee started receiving DEBUG floods the moment the logger was opened up. Explicit (non-NOTSET) tee/buffer levels are now pinned (_pinned_level) and skipped by the sync; default-level handlers track as before. (5) success/result/notice/progress raised TypeError on the preset=/color= styling kwargs their siblings accept — they bypassed _log_custom straight into stdlib Logger.log; all four now route through it, which also picked up a lazy gate: styling/HTML work is skipped entirely when the record's level is disabled (it previously ran before the enabled check). (6) log_table emitted one record per table line — per-record paragraph spacing plus a proportional font misaligned every table in Qt widget handlers; on a patched logger the whole table is now a single log_raw record (one monospace block), exactly the fix log_box/log_group already shipped, and its dead exploratory comment block is gone. (7) format_table measured cells with len() — an emoji/CJK cell (display width 2, len 1) misaligned every column after it; it now measures/pads/truncates via the module's own display-width primitives (_display_width/_pad/_truncate), byte-identical output for ASCII tables. Cleanups: dead _select_formatter/_formatter_selector deleted (no callers ecosystem-wide); the four copy-pasted HTML-strip regex sites collapsed into public LoggerExt.strip_html (registry +1); _log_raw now holds the handler lock around both its stream writes and its direct raw emits (serialized with concurrent normal emits, as Handler.handle would); _coerce_level gained a default= param and _set_level composes it; _log_box got the @staticmethod its siblings have; except (ImportError, Exception)except Exception; inline os imports hoisted; module docstring added. test_logging_mixin.py +10 (synchronous same-thread ordered delivery, global-class purity + foreign-logger immunity, timestamp-property behavior pin, stream dedup, tee/buffer level pinning ×2, styling kwargs on custom levels, disabled-level skip, single-record table, display-width alignment); the one legacy test that asserted duplicate stream stacking updated to the corrected contract. Full suite green (2119 tests, 0 failures).

  • 2026-07-18 — ModuleReloader correctness pass: real dependency ordering (topological sort over observed imports), dependencies_first/last positioning fixed, dependency packages expand to their submodules, and a broken module no longer aborts the reload (new ReloadReport, root: ptk.ReloadReport). Four defects fixed. (1) The global deepest-first sort clobbered caller-specified ordering: modules were sorted by dot-count across the whole run, so a shallow dependencies_first ref (e.g. tentacle's Blender reload passing ("pythontk", "blendertk", "uitk") — all depth 0) sorted after every deeper submodule of the target, the exact inverse of what the parameter promises. Ordering is now per-group — dependencies_first → target (+ submodules) → dependencies_last — and dependencies_last genuinely runs last (previously the depth sort slotted it before the target's root __init__; the ordering test had enshrined the artifact and now pins the corrected sequence). (2) Depth was a heuristic, not a dependency order: within each group, modules are now topologically sorted over their observed import edges — submodules bound by from . import x plus the __module__ of imported classes/functions (restricted to modules/classes/functions so arbitrary objects' __getattr__ is never triggered) — so from .provider import thing reloads provider before its consumer regardless of name or depth; cycles fall back to the old deepest-first order, which is what max_passes (unchanged, default 2) remains for. (3) Dependency refs never expanded: a package named in dependencies_first/last reloaded only its root __init__; when include_submodules is True the ref now expands like the target does (honoring predicate/exclude_modules, and import_missing=False still confines expansion to session-loaded modules) — tentacle's documented "reloads the monorepo packages in dependency order" now actually happens. An unresolvable dependency ref is skipped and recorded instead of raising mid-run. (4) One broken module aborted the whole reload: only ImportError was caught around importlib.reload, so a syntax error in a single edited file propagated and left the session half-reloaded — the worst outcome for a hot-reload tool. Any exception is now contained (always printed — previously it raised, so silence would be an observability regression), remaining modules still reload, and the failure lands on the returned ReloadReport — a List[ModuleType] subclass (fully backward compatible: existing callers len() it and iterate it) carrying .failed ((name, exception) pairs), .skipped, and .ok. Also hardened: submodule discovery with import_missing=True no longer crashes on an unimportable module (e.g. another DCC's slot package — caught, recorded as skipped), and pkgutil.walk_packages gets an onerror swallow for the same reason; fnmatch import hoisted to module scope. Self-critique refinements (the first TDD'd red-first): a module that failed pass 1 but recovered on pass 2 — the exact scenario max_passes exists for — landed in neither the success list nor .failed (success-append was gated on pass 1); it now appears in the success list on its first clean reload. Also: an identical failure no longer prints once per pass, KeyboardInterrupt/SystemExit re-raise via an explicit except clause instead of an isinstance branch, .skipped dedupes across passes, and the cross-block name-union is hoisted out of the dedup comprehension. test_module_reloader.py +5 (shallow-dep-first regression, provider-before-consumer topological pin, dependency-package expansion, failure containment incl. sibling-still-reloads, second-pass recovery accounting) and the ordering pin updated — 12 tests. Full suite green (2092 tests, 0 failures).

  • 2026-07-18 — Hierarchy-utils overhaul: new HierarchyPath (root: ptk.HierarchyPath) is the single home for delimited-path string primitives; analyzer move-detection made deterministic; HierarchyDiff gains a bridge from analyzer records. The package's core value — namespace cleaning, split/join, normalization — was locked behind _-private staticmethods, duplicated verbatim inside the package (HierarchyIndexer._clean_namespaceHierarchyMatching._clean_namespace), re-implemented downstream (mayatk hierarchy-sync's clean_hierarchy_path / get_clean_node_name_from_string), and poked as privates by mayatk's tree_utils — the textbook cost of keeping useful primitives private. New hierarchy_path.py::HierarchyPath owns them publicly (clean_namespace, split/join, strip_namespaces, normalize, leaf/root/parent/depth, tail, ends_with — pure, separator-parameterized, str.split-faithful on absolute paths); indexer/matching/analyzer delegate, and the old privates stay as deprecated shims (released mayatk builds call them; its tree code now uses the public names, and its three duplicated helpers now delegate upstream with call sites unchanged). Determinism fix (TDD'd red-first): detect_moved_items was first-missing-wins over set-hash iteration order — with two missing items contesting one extra, the winner was hash-order-dependent and could be the worse match; it now scores all pairs and assigns one-to-one, best similarity first with path-text tiebreaks, and analyze_hierarchy_differences emits sorted output for the same reason. Honest signatures: compare_path_sets / analyze_hierarchy_differences dropped their never-used path_separator params (no ecosystem caller passes them). multi_strategy_match swapped its if/elif chain for a strategy registry that also accepts callables (source_items, target_items) -> matches — custom strategies without editing the module — and exposes the previously hardcoded tail_components; get_path_components_index gained the namespace_separator its siblings already had. Dead code removed: unreachable stdlib-difflib ImportError fallbacks, the _exact_name_match fallback they guarded, and the unused _path_ends_with (its behavior lives on as the public HierarchyPath.ends_with). HierarchyDiff is now a dataclass (kwargs constructor, per-instance defaults) with a new modified field wired through every aggregate (is_valid/has_differences/total_issues/summary/dict/merge/clear; legacy JSON without the key loads cleanly) and — the composition piece — HierarchyDiff.from_differences(records): MISSING/EXTRA/MODIFIED route to their lists; a MOVED record classifies as renamed (same parent) vs reparented (parent changed — a renaming move counts as reparented), keeps its pairing provenance in fuzzy_matches, and consumes the MISSING/EXTRA entries it supersedes. The two diff models finally compose instead of competing. Root surface: HierarchyPath registered, the previously unexported DifferenceType added, and the whole hierarchy family joined __all__ (star-import parity). Tests: indexer and matching gained their first coverage; hierarchy tests 33 → 69, split one-file-per-module (test_hierarchy_path / _indexer / _matching new; test_hierarchy_utils back to analyzer-only) — path-primitive edges, index/strategy behavior, determinism pins, dataclass semantics, bridge routing, legacy-JSON load. Full suite green (2088 tests, 0 failures). Release note: publish pythontk before mayatk (its hierarchy-sync now imports HierarchyPath).

  • 2026-07-18 — Textures engine improvement pass: an ORM data-loss bug fixed, the map-type taxonomy opened for runtime registration (MapRegistry.register), and SSoT/DRY cleanups across the registry and handlers. (1) ORM AO-channel data loss (TDD'd red-first): ORMMapHandler lacked the existing-packed-map passthrough its MRAO/MaskMap siblings have, so an ORM already in the inventory was re-derived through the conversion registry — and because the AO lookup runs before the ORM unpack caches its channels, the repack silently replaced the AO channel with white (verified (255,·,·) vs the source's (100,·,·)); an existing ORM now passes through verbatim. (2) NEW MapRegistry.register(map_type, overwrite=False): the factory's plug-in story (custom handlers + conversions, examples/texture_factory_extensibility_example.py) was incomplete — a conversion whose source type wasn't built in could never fire through prepare_maps, because unresolvable files are dropped at inventory build and the registry had no registration API (nor cache invalidation: its derived views — sorted alias candidates, resolve memo, suffix-strip pattern, precedence rules, now also the map-types table and the longest-first alias list — cache forever). Registration invalidates all derived caches centrally (_invalidate_caches; also fixes get_precedence_rules writing its cache to the singleton instance, where it would have shadowed invalidation), and MapFactory.map_types / passthrough_maps / packed_grayscale_maps / map_fallbacks became live ClassProperty views over the registry instead of import-time snapshots, so a registered type is honored everywhere: filename resolution, base-name suffix stripping, inventory build, passthrough, mask scaling. The example now registers its Curvature type (previously its Curvature→AO conversion was unreachable dead code); register_handler is now idempotent (module re-registration no longer duplicates the handler in the pipeline); MapType joins MapRegistry on the lazy root (ptk.MapType) so registration callers stay off internal paths. (3) Godot preset taxonomy fix: WF.GODOT's description has always said "Uses ORM and OpenGL Normals" but the ORM map type's workflows omitted it, so the preset shipped orm_map=False — the Godot preset now actually packs ORM (the only preset-output change; verified by before/after diff of every preset). (4) SSoT/DRY: get_workflow_presets now derives packed-map flags from each map's config_key — the field documented as the SSoT for exactly this — instead of name inference plus a hardcoded MSAO special case (output byte-identical, per the same diff); the pack gate (pack/legacy convert) shared by four handlers is now WorkflowHandler.packing_enabled; the smoothness/roughness inversion-tracking block copy-pasted across MaskMap/MetallicSmoothness/MRAO handlers collapsed into TextureProcessor.resolve_smoothness_channel / resolve_roughness_channel (verbatim extractions); the MRAO/MSAO/Metallic_Smoothness passthroughs switched from membership to the truthiness convention the processor already documents; resolve_config no longer leaks the consumed "preset" key into the resolved config; MapType.mode/default_background annotations corrected to Optional (MSAO/MRAO ship mode=None by design). Tests: new test_map_registry_register.py (7 — resolution/suffix/live-view pickup, cache invalidation incl. a pre-registration cached miss, duplicate/overwrite/type guards, the root export, and the E2E chain: unregistered file ignored by prepare_maps → registered file passes through → custom conversion fires from the registered source) + test_handlers.py ORM passthrough regression (+1). Self-critique refinements (both reload-safety holes TDD-pinned): re-registering an identical MapType is a no-op instead of raising — module-level register() calls survive the reload cycles DCC tooling lives by, while a conflicting definition still raises without overwrite; register_handler's idempotence now matches by module+qualname and replaces in place, since a reload produces a new class object that identity-dedup would have duplicated; Mask's redundant self-alias dropped (the canonical name is always a resolution candidate). Full suite green (2087 tests, 0 failures); docs sweep clean.

  • 2026-07-18 — Shots engine quality pass: duplication removed across the model / planner / manifest layers; new behaviors.phase_durations primitive. No behavior change. The behavior-template phase walk (summing in/out block durations across attributes) existed twice — inline in behaviors.compute_duration and again in the engine's resolve_duration — and is now one public primitive, phase_durations(tmpl) -> (in_total, out_total), both callers rewired (also hardens compute_duration against a null duration in a template, which previously summed None into a float). ShotManifest._execute_plan's locked/skipped/patched branches each hand-rolled the same reposition + metadata/description diff (~30 duplicated lines); they now share _reposition_kwargs/_content_kwargs in a single unified branch (locked still position-only, patched still the only one merging objects + pinning), and the no-op _find_shot wrapper over store.shot_by_id is gone (no callers ecosystem-wide). range_resolver.resolve_ranges duplicated the use_default audio-vs-uniform sizing policy in its placement pass and its end-resolution pass — now one _step_duration closure. ShotStore: batch_update re-implemented the guarded listener loop _notify already owns (now delegates), active()'s two create-fresh branches collapsed to one, and is_gap_locked's wrong str type hints corrected to int (shot IDs). shot_detection: dead unreachable if not boundaries guard removed; manifest_model._resolve_columns: _find now composes _find_optional instead of repeating its scan; assess()'s audio branch dropped a dead if exists conditional (that branch only runs when exists is already true). test_shots_manifest_core.py +1 pins the new primitive (multi-attribute summation, empty template, None duration). Full suite green, registry regenerated (phase_durations indexed; is_gap_locked signature change recorded).

  • 2026-07-18 — ExecutionMonitor reliability pass: the long-execution dialog re-arms per invocation, Esc-cancel now lasts the whole execution, and indicator accepts a GIF path. Two contract bugs fixed (both TDD'd red-first): (1) execution_monitor's once-per-run dialog guard lived in the decorator-factory scope, so after the dialog appeared for one call, every later call of the same decorated function ran dialog-less for the life of the session — uitk's @Cancelable slots would prompt once and never again; the guard now re-arms at each invocation. (2) With allow_escape_cancel=True and no repeat interval, the monitor thread exited right after the first callback and took the Esc poll with it — the documented "press-and-hold Esc at any time" promise silently ended at the threshold; the thread now keeps the Esc watch alive until the function returns (the wait helper reports why it woke — finished / Esc-abort / timeout — so an abort still sends exactly one interrupt instead of re-interrupting cleanup while Esc is held). Also: the indicator: bool|str type its signature always declared is now real — a string is a path to an animated GIF (absolute, or relative to the package, e.g. "task_indicator.gif") shown via the previously-orphaned _gif_viewer.py, with fallback to the canvas spinner when the file is missing; the heartbeat writer's stop() joins the thread before deleting the heartbeat file (a still-running writer could recreate it right after cleanup); the twice-copy-pasted callback-result dispatch collapsed into _handle_callback_result, and the thrice-duplicated hidden-subprocess boilerplate into _hidden_startupinfo/_helper_script_path; dead _start_dialog_process (no callers workspace-wide) removed. Public API unchanged. test_execution_monitor.py +5 (dialog re-arm, Esc-after-callback, heartbeat join, GIF indicator argv, bad-GIF fallback) — 49 tests.

  • 2026-07-18 — VidUtils.compress_video: image-sequence inputs made first-class (start_number + audio muxing) and an output-option placement fix; new VidUtils.get_sequence_start_number. ffmpeg's image2 demuxer only auto-detects sequence start numbers in the 0–4 range, so encoding a sequence that begins at any other frame (e.g. a Maya playblast of frames 101–150) failed with "No such file or directory". compress_video now resolves the first frame on disk (new get_sequence_start_number, %d/%0Nd-aware) or takes an explicit start_number, emitted as an input option before -i. New audio_filepath/audio_offset mux an audio track into the output (explicit -map 0:v:0/1:a:0, AAC, -af apad + -shortest — bare -shortest truncated the video to a shorter audio track, verified 12→6 frames; positive offset delays via -itsoffset, negative skips in via -ss). Bug fixed while there: extra **ffmpeg_options were appended after the output path, where ffmpeg parses them as a second (invalid) output — so options like movflags never applied; they now precede the output path. Driven by the mayatk playblast-exporter overhaul (its MP4/MOV targets encode from a PNG capture with real scene frame numbers — publish pythontk first). Tests: test_vid.py::CompressVideoSequenceTest (+5), gated on a managed-install-aware FFMPEG_AVAILABLE (bare shutil.which under-reports on machines using the AppInstaller ffmpeg). Full suite 2036/0.

  • 2026-07-18 (later still) — Workspace vocabulary: RULE_NICE_NAMES + a wrong rule key fixed in the default template (cacheFilefileCache). RULE_NICE_NAMES is Maya's own Project Window display vocabulary (rule key → "nice name", in its Primary → Secondary display order) — data-only, driving blendertk's Workspace Editor rows/order so a shared project reads the same way in both DCCs and in Maya's native window; a new test pins that every DEFAULT_FILE_RULES key has a label. The template bug: it shipped cacheFile, but Maya's real file-cache rule key is fileCache (verified against Maya's default workspace.mel / its Project Window "File Cache" row) — Maya would have carried the misspelt key as an inert custom rule while its own rule kept the default. 22 tests, full suite green.

  • 2026-07-18 (later) — Workspace codec: rule removal (write_workspace_mel(remove=…) / Workspace.save(remove=…)). The merge-preserving writer deliberately never deletes on its own, so an editor UI had no way to drop a rule; remove names rules whose lines are dropped (all duplicates), with rules winning on overlap and everything unmanaged still preserved verbatim. First consumer: blendertk's new Workspace Editor panel (deleted table rows → set(old) - set(new)). test_workspace.py +2 (targeted removal leaves other rules + non-rule lines intact; rules-beat-remove) — 21 tests, full suite 2025/0.

  • 2026-07-18 — Shared project workspaces, zero-dep: file_utils/workspace.py (Workspace + parse_workspace_mel / write_workspace_mel). A workspace is the ecosystem's project concept — root directory + named file rules (scenescenes, sourceImagessourceimages, …) — and Maya's workspace.mel marker is its on-disk serialization: despite the extension it's a flat rule store Maya round-trips losslessly including rules it doesn't recognize, which makes it a legitimate shared format (one project folder serves Maya natively and Blender via blendertk) rather than a Maya-only file. Follows the file_utils/usd.py precedent — pure-Python format primitives upstream, DCC adapters downstream. The codec's writer is merge-preserving: managed rules update their existing lines in place (duplicates collapse), new rules append after the last rule line, everything it can't parse — comments, workspace -v variables, hand-written MEL — survives verbatim, and a content-identical result is never rewritten. The Workspace model adds semantic directory resolution (resolve_dir: rule → first existing conventional folder → default — a present rule is authoritative even when its folder doesn't exist yet), create (idempotent promotion: existing rules win, the DEFAULT_FILE_RULES template only fills gaps), and discovery: find (marked dirs always count; with scene_exts, unmarked dirs directly holding scene files count too — unless nested inside a marked project, so a project's scenes/ never lists as its own workspace) and find_containing (the walk-up mayatk's find_workspace_using_path does, now shared). New test/test_workspace.py (18) pins codec semantics (Maya-authored sample with spaced rule names, MEL escapes, later-duplicate-wins, CRLF equivalence), merge preservation, model resolution chains, and discovery/suppression; the live-Maya contract (Maya opens a codec-written project, resolves a foreign blenderScene rule, and preserves it across its own saveWorkspace rewrite) is locked downstream in mayatk/test/test_workspace_mel.py. Full suite green (2022 tests, 0 failures). Release note: publish pythontk before blendertk (its resolver imports ptk.Workspace).