mayakit
A toolkit for Maya pipeline work, organised as independent modules under one package.
| Module | What it does |
|---|---|
mayakit.scenedoc |
Turns a Maya scene into a stable, versioned data document (JSON) for other tools, frontends and backends to consume. |
The top level is deliberately thin — it holds the package identity and the
module list, nothing else. Each module owns its own public API, so adding a
second module can never collide with the first over a name like scan or
dumps.
This package covers the Maya side only. It contains no web service, database or UI — but the data it emits is shaped for something that will eventually go into a database and travel over an API.
📖 中文文档:README.zh-CN.md
Install
Zero runtime dependencies, deliberately: this installs into mayapy, where
every extra wheel is a version clash waiting to happen.
Into Maya (swap 2026 for your version):
"C:/Program Files/Autodesk/Maya2026/bin/mayapy.exe" -m pip install mayakit
Or somewhere else, and point PYTHONPATH at it, if you would rather not touch
the Maya installation:
"C:/Program Files/Autodesk/Maya2026/bin/mayapy.exe" -m pip install --target D:/maya_site mayakit
From source, for development:
"C:/Program Files/Autodesk/Maya2026/bin/mayapy.exe" -m pip install -e .
It also installs into a plain Python interpreter. Everything except actually
opening a .ma/.mb file — the schema, the collector logic, reading documents
back, the whole test suite — runs with no Maya at all.
mayakit.scenedoc
Everything below describes the scenedoc module.
Why not a class per Maya node type
The obvious design is an inheritance tree that mirrors Maya's concepts:
SceneBase → Scene → SceneFunctions, Property → Vector → DetailProperty, and
so on. It does not survive contact with a real pipeline:
| Problem | Consequence |
|---|---|
| The tree has to chase Maya's node types | Maya has a thousand of them, each growing attributes at runtime. The base classes are never finished. |
| Base classes both store data and query Maya | maya.cmds ends up imported at every level, so nothing can be imported or tested outside Maya. |
| The wire format is a bare dict with no schema | Backend models are aligned by hand; renaming one key breaks a consumer silently. |
A Maya scene is a document, but many of its properties are only decided at runtime. That is the tension, and the fix is to separate the runtime from the document.
Layers
┌──────────────────────────────────────────────────────┐
│ schema.py pure dataclasses, zero Maya imports │ ← the contract
│ SCHEMA_VERSION = "0.2.0" │
├──────────────────────────────────────────────────────┤
│ collectors/ pluggable units, one per kind of data │ ← the extension point
│ they only ever call SessionProtocol │
├──────────────────────────────────────────────────────┤
│ adapters/ MayaSession: the only layer that │ ← Maya is confined here
│ touches maya; FakeSession: a stand-in │
├──────────────────────────────────────────────────────┤
│ pipeline.py orchestration, error isolation, batch │
└──────────────────────────────────────────────────────┘
Dependencies point one way only. There is exactly one custom base class —
Collector — and it is not a tree of node types.
Extending means adding a collector file, not adding methods to a base class.
Two axes: scan and query
The same collector classes serve both:
| scan | query | |
|---|---|---|
| Question | "tell me everything about this scene" | "give me this, with these arguments" |
| Parameters | each collector's defaults | supplied by the caller |
| Result | SceneDocument |
QueryResult |
| Fits | batch ingestion, CI checks | interactive use, other tools calling in |
scan_current_scene() # everything
query("pose", roots=["|root"], time=0.0, space="world") # one thing, with args
No second registry, no second base class, no second set of payload types. A collector written for scanning is automatically queryable; a collector written for querying automatically appears in scans with its defaults.
Quick start
See the output shape without Maya
python -m mayakit demo
Scan the scene open in Maya
from mayakit.scenedoc import scan_current_scene, dumps
doc = scan_current_scene()
print(dumps(doc))
Call one collector with arguments
from mayakit.scenedoc import query
# a skeleton's pose at frame 0
r = query("pose", roots=["|root"], time=0.0)
for n in r.data.nodes:
print(n.long_name, n.translate)
# world space, with matrices
r = query("pose", roots=["|root"], time=24.0, space="world", include_matrix=True)
# the bind pose
r = query("bind_pose")
# just one subtree of the DAG
r = query("dag_nodes", roots=["|geo_grp"], node_types=["transform"])
Same thing from a shell:
python -m mayakit query pose --param roots=|root --param time=0 --param space=world
Read a saved document back
from mayakit.scenedoc import load
doc = load("out/shot_010.json")
doc.data["counters"].tris # a SceneCounters object, not a dict
doc.data["meshes"][0].uv_sets # a MeshInfo
doc.open_report.verdict # an OpenReport
Without this the JSON would be write-only — no diffing two scans, no offline QC, no feeding an archived scan into a script months later.
Compatibility runs both ways:
- a document from a newer mayakit → unknown payloads are kept as raw dicts, never dropped
- a document from an older mayakit → missing fields fall back to defaults
Discover what each collector accepts
python -m mayakit collectors --params
pose v1 requires: dag_nodes Transforms of a node set... [opt-in]
roots List[str] default=[]
include_descendants bool default=True
node_types List[str] default=['joint']
time Optional[float] default=None
space str default='local'
include_matrix bool default=False
The interface is self-describing: mayakit.scenedoc.available() returns the same
structure, so a frontend or backend can generate a form or validate a request
from it instead of hand-copying the parameter list.
Batch scan a directory, headless
"C:/Program Files/Autodesk/Maya2026/bin/mayapy.exe" -m mayakit scan D:/scenes -o D:/out
Triage: which files open, and what do they complain about?
"C:/Program Files/Autodesk/Maya2026/bin/mayapy.exe" -m mayakit check D:/scenes -o report.json
[1/5] .../broken_reference.ma
errors 0 error(s) 2 warning(s)
[2/5] .../garbage.ma
failed 0 error(s) 0 warning(s) RuntimeError: Unrecognized file.
[3/5] .../healthy.ma
ok 0 error(s) 0 warning(s)
checked 5 file(s): errors=2, failed=1, ok=1, warnings=1
Cheaper than a scan — no collectors run. Exit code is non-zero when anything failed to open, so it drops straight into CI.
Run the tests (no Maya needed)
python -m pytest tests -q
The document
{
"schema_version": "0.2.0",
"generated_at": "2026-08-22T03:39:49+00:00",
"generator": { "tool": "mayakit", "maya_version": "2026", "python_version": "3.11.9", "host": "..." },
// facts about the file on disk: true forever
"source": { "path": "...", "sha1": "...", "size_bytes": 44552,
"path_is_ascii": true, "exists": true },
// what this Maya build made of it: true only for this version, these plugins
"open_report": { "opened": true, "verdict": "errors", "error": null,
"error_count": 1, "warning_count": 2,
"missing_references": ["X:/gone/rig.ma"],
"unresolved_plugins": ["someStudioPlugin"],
"unknown_node_count": 1,
"diagnostics": [
{ "severity": "warning", "count": 8213, "source_line": 4821,
"message": "Unrecognized node type 'fooNode'; preserving..." }
] },
"data": {
"scene_settings": { "up_axis": "y", "linear_unit": "cm", "fps": 24.0, ... },
"dag_nodes": [ { "uuid": "...", "parent_uuid": "...", ... } ],
"meshes": [ { "uuid": "...", "tris": 192, "ngons": 0, ... } ],
"shading": { "materials": [...], "textures": [...], "unassigned_shape_uuids": [...] },
"cameras": [ ... ],
"skeleton": { "joints": [...], "skin_clusters": [...] },
"blend_shapes": [ ... ],
"bind_pose": { "skins": [...], "dag_poses": [...] },
"counters": { "tris": 1172, "ngons": 2, "joints": 3, ... }
},
"reports": [
{ "key": "meshes", "version": "1", "status": "ok", "duration_ms": 2, "error": null }
]
}
Key design decisions
1. Node identity is Maya's UUID, not the name
The UUID from cmds.ls(node, uuid=True) is stored inside the .ma/.mb file.
It survives renames, reparenting and re-saving. So every relationship in the
document — parent, skin influence, material assignment — is expressed as a UUID,
and names are carried only as human-readable labels.
For a backend this is the difference between a diff that shows real changes and one that claims the entire scene was rebuilt.
A trap worth knowing:
cmds.ls(uuid=True)without node arguments silently returns names, not UUIDs — and returns exactly as many of them as there are nodes, so a length check alone happily pairs every node with its own name.MayaSession._prime_uuid_cache()validates the shape of the results, not just the count.
2. The bind pose comes from skinCluster.bindPreMatrix, not dagPose
bindPreMatrix[i] is the inverse world matrix of influence i at bind
time, and it is what the deformation actually multiplies by. Invert it and you
have where that joint stood when the skin was bound. It cannot go stale — if it
were wrong, the mesh would already be deforming wrong.
dagPose nodes get deleted and can silently go out of date, but they can also
cover joints no skinCluster binds. So both are recorded: when the two
disagree, that is exactly the rig that is about to export wrong.
Verified: with the rig posed far away from its bind pose (root moved 30 units,
spine rotated 45°), bind_pose still returns the original bind positions
exactly.
3. opened == true does not mean the file is fine
The most underestimated part of this tool. Maya opens all of these without raising anything at all:
- a reference whose file does not exist
- a scene needing a plugin this machine does not have — its nodes degrade to
unknownand are permanently lost on the next save - broken texture paths
It writes them to the script editor as warnings and carries on. Code that only catches exceptions will report those files as healthy.
So open_scene() wraps the open in an output callback (MCommandMessage),
captures every warning and error, adds structured checks (do the references
resolve, unknownPlugin -q -list, ls -type unknown) and collapses the lot
into one verdict:
| verdict | meaning |
|---|---|
ok |
clean |
warnings |
opened, with warnings or unknown nodes |
errors |
opened, but with errors / missing references / unavailable plugins — the data is already incomplete |
failed |
would not open at all; error holds the exception |
not_attempted |
no open was tried (e.g. scanning the already-open scene) |
Diagnostics are de-duplicated and counted. Not for tidiness: a scene with a missing reference emits one warning per node in it — tens of thousands of them. And Maya's raw message looks like this:
file: D:/shots/sh010/anim.ma line 4821: Unrecognized node type 'fooNode'; ...
That line 4821 differs every time. Without stripping the prefix first, tens of
thousands of semantically identical warnings become tens of thousands of
distinct strings, each carrying its own full copy of the scene path. Stripped,
they collapse to a single row with count: 8213.
4. One failing collector never costs you the document
Scanning a few hundred production files will always hit a broken reference, a
missing plugin, a node from an older Maya. run_collectors() catches each
collector individually, records it in reports, and emits everything else.
Even a file that cannot be opened produces a document — with the full
on-disk facts and an open_error — because "this file is broken" is precisely
what the consumer needs to know.
5. The DAG is a flat list with parent pointers, not a nested tree
It maps directly onto a self-referencing foreign key, diffs cleanly, and cannot blow the recursion limit on a deep rig.
6. Parameters travel back with the result
QueryResult.params and CollectorReport.params carry the fully resolved
parameters, defaults included. A pose payload is uninterpretable without knowing
which frame and which space it was sampled in — and a backend cannot use it as a
cache key if it cannot see it.
7. A misspelled parameter is an error, not a silent no-op
>>> query("pose", tiem=0)
ParamError: unknown parameter(s) tiem; PoseParams accepts: roots,
include_descendants, node_types, time, space, include_matrix
Caller mistakes raise (fail fast); scene problems go into the
QueryResult.error envelope. Those are different kinds of thing and deserve
different handling. Everything raised inherits from MayakitError, so
except MayakitError catches all of it without swallowing a KeyError from
your own code.
8. The library does not write to its host's log
The package attaches a NullHandler and stays silent. Every log call is at
DEBUG level, because Maya configures the root logger — anything above DEBUG
from a library lands in the artist's script editor. When you need to look:
import logging
logging.basicConfig()
logging.getLogger("mayakit").setLevel(logging.DEBUG)
The Maya queries that MayaSession._safe swallows print full tracebacks at that
point.
Extending
A complete runnable example lives in examples/custom_collector.py:
from dataclasses import dataclass, field
from typing import List, Optional
from mayakit.scenedoc import Collector, register
@dataclass
class MyParams: # this is the interface definition
roots: List[str] = field(default_factory=list)
time: Optional[float] = None
strict: bool = True
@register
class MyCollector(Collector):
key = "my_thing" # top-level key in the document
version = "1" # bump when the payload shape changes
requires = ("dag_nodes", "meshes") # dependencies, sorted automatically
params_class = MyParams # having this makes it queryable
def collect(self, ctx):
params = ctx.params # already validated and coerced
for node in ctx.get("dag_nodes"): # reuse what others already collected
...
return result # return a dataclass
Four steps: subclass Collector → define params_class (optional) →
@register → make sure the module gets imported. Not a line of mayakit
changes, and the new collector immediately works both ways:
query("my_thing", roots=["|geo_grp"], time=0) # with arguments
scan_current_scene(ScanOptions(include=["my_thing"])) # as part of a full scan
params_class is an ordinary dataclass. The framework validates the parameter
names, coerces values against the annotations ("0" → 0.0, "true" → True,
"a,b" → ["a","b"]), and hands the resolved values back to the caller.
Note that the example never touches maya.cmds — it reads what the
dag_nodes collector already produced. That makes it nearly free in a batch run
and unit-testable against FakeSession.
When you genuinely need a new Maya query, add a semantic method to
MayaSession (and its counterpart to FakeSession), or use the escape hatch
ctx.session.cmds. The escape hatch costs you the ability to test that
collector outside Maya — a conscious trade, not an accident.
Overriding a builtin
registry.register() replaces on a duplicate key. Registering your own class
with key = "dag_nodes" swaps out the builtin entirely.
Why it tests without Maya
Collectors only ever talk to SessionProtocol
(adapters/protocol.py). FakeSession
implements the same interface over an in-memory scene graph, so:
- 153 tests run in a plain interpreter in under a second — no licence, no Maya;
- the only files that genuinely need a real Maya to verify are
maya_session.pyandstandalone.py.
python -m pytest tests -q # 153 passed
Verification
| Check | Result |
|---|---|
| Maya 2026 (Python 3.11.9) | all 10 collectors ok |
| Maya 2027 (Python 3.13.9) | same code, same scene, identical output |
| Batch scan over a directory containing a corrupt file | the bad file yields opened: false + open_error; the batch continues |
query("pose", time=0/24) |
correct samples, and current time is not moved (parked at frame 12, still 12 afterwards) |
query("pose", space="world") |
world matrix decomposition correct (chain tip after a 45° rotation matches exactly) |
query("bind_pose") |
bind positions recovered exactly with the rig posed away from them; influence UUIDs resolve |
check against 5 deliberately damaged scenes |
see below — all classified correctly |
| Wheel installed into a clean venv | CLI, pipeline and round-trip all work from outside the source tree |
Wheel installed into mayapy |
same, with no log noise in the script editor |
ast.parse(feature_version=(3,9)) |
every source file passes, so the requires-python floor is a checked claim |
| Unit tests (no Maya) | 153 passed |
What check found
Five scenes built to be broken on purpose (the test script creates them):
| File | opened | verdict | Detected |
|---|---|---|---|
healthy.ma |
✅ | ok |
— |
missing_texture.ma |
✅ | warnings |
1 warning |
broken_reference.ma |
✅ | errors |
missing_references: [_temp_ref.ma] |
unknown_plugin.ma |
✅ | errors |
unresolved_plugins: [someStudioPlugin], 1 unknown node, plus Maya's own "Errors have occurred while reading this scene that may result in data loss." |
garbage.ma |
❌ | failed |
RuntimeError: Unrecognized file. |
Note that the first four all have opened == true. That is why "can it be
opened" is not a sufficient question.
Notes for the backend / frontend
- Store
schema_version. Use it to drive migrations rather than guessing. - Key on
(scene_id, uuid), never onlong_name— that changes on every rename. - Give
countersits own table. A list page reads only that, instead of touching tens of thousands ofdag_nodesrows. - Store
reportstoo. "themeshescollector failed" and "this scene has no meshes" are completely different facts, and a consumer that only hasdatacannot tell them apart. source.sha1works as an idempotency key — skip a file already scanned, or use it to decide whether a re-scan is needed.- Store
sourceandopen_reportseparately.sourceis disk truth and stays true;open_reportis only true for that Maya version with those plugins. Merged into one table, "which files broke when we moved to Maya 2027" becomes a painful question. open_report.verdictis ready to be a status column; leavediagnosticsfor the detail view.- The package version and the schema version are independent. Consumers
should pin to
schema_version(mayakit.scenedoc.SCHEMA_VERSION), not to the pip release — the code can ship many times without the document changing. - Store
params. Pose data is meaningless detached from itstimeandspace, and those values are naturally part of a cache key. mayakit.scenedoc.available()can be served straight to a frontend to generate parameter forms or validate requests, instead of transcribing the parameter list on both sides.
Not included (on purpose)
- Animation curves (keyframes, tangents). Whole-curve storage costs far more
space than it is worth until there is a concrete need for it; the computed
result is better left in Maya. Note that single-frame sampling already
works —
query("pose", time=N). What is missing is the whole curve, not the frame. - Vertex-level data (per-point weights, positions) — involves real computation and probably does not belong in the same document.
- UV shell / overlap / non-manifold analysis — needs per-face iteration and
is expensive. When added it should be an opt-in collector
(
default_enabled = False, the patternposealready uses). - Incremental scanning (collecting only what changed).
- Writing back to Maya. This is read-only, deliberately.
- Recursing into references —
include_referencesrecords the reference list without descending into it.
Layout
src/mayakit/
├── __about__.py # the single definition of the version
├── __init__.py # umbrella: identity + module list, nothing else
├── cli.py # mounts each module's verbs onto one parser
└── scenedoc/ # ← the scene-document module
├── __init__.py # the module's public API
├── core/
│ ├── schema.py # the data contract (pure dataclasses, zero Maya)
│ ├── base.py # Collector base class + topological sort
│ ├── params.py # parameter validation, coercion, self-description
│ ├── deserialize.py # JSON → typed objects (reading documents back)
│ ├── errors.py # the MayakitError hierarchy
│ ├── diagnostics.py # Maya message prefix parsing (pure, testable)
│ ├── options.py # ScanOptions, including per-collector params
│ ├── registry.py # the collector registry
│ └── serialize.py # dataclasses → JSON
├── adapters/
│ ├── protocol.py # SessionProtocol — all a collector may call
│ ├── maya_session.py # the real Maya implementation
│ ├── output_capture.py # catches Maya's warnings/errors during an open
│ └── fake_session.py # the no-Maya stand-in + demo scene
├── collectors/ # 10 builtin collectors, incl. pose / bind_pose
├── pipeline.py # orchestration + error isolation (the scan axis)
├── query.py # parameterised single calls (the query axis)
├── standalone.py # headless batch scanning through mayapy
└── cli.py # this module's subcommands
Only scenedoc/adapters/ and scenedoc/standalone.py may import maya. An
import maya under core/ or collectors/ is a sign the design has been
broken — and that rule is enforced by an AST scan in
tests/test_layering.py, not left to discipline.
Adding a module
A module is a subpackage that declares __all__ and, if it has command line
verbs, a cli.py with VERBS and register_subcommands(subparsers). Add its
name to mayakit.MODULES and its verbs mount automatically. Two modules
claiming the same verb is a hard error rather than one silently shadowing the
other — checked before registration, because Python 3.9 and 3.10 (Maya 2023 and
2024) let argparse overwrite silently where 3.11+ raises.
__about__.py is the single definition of the version; pyproject.toml reads
it via dynamic = ["version"].
Releasing
The version lives in exactly one place, src/mayakit/__about__.py. Change it there and nowhere else — tests/test_packaging.py fails if a hard-coded copy reappears.
python -m pytest tests -q # must be green
python -m build # sdist + wheel
python -m twine check dist/* # metadata self-check
Try it on TestPyPI first:
python -m twine upload --repository testpypi dist/*
Then the real thing:
python -m twine upload dist/*
See CHANGELOG.md for what changed.
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mayakit-0.3.0.tar.gz.
File metadata
- Download URL: mayakit-0.3.0.tar.gz
- Upload date:
- Size: 97.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ab7e6d58da9242e61061aa8b12c3510b2a0847a165e91f6cdba8c574f24713e9
|
|
| MD5 |
6a9bf602825e87eeda98013800fd0b77
|
|
| BLAKE2b-256 |
4f9555813cee7dc40fdfa7332142bd287268d15761335a1f1a4c13fd5dff8caa
|
File details
Details for the file mayakit-0.3.0-py3-none-any.whl.
File metadata
- Download URL: mayakit-0.3.0-py3-none-any.whl
- Upload date:
- Size: 83.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
30f8c54546489f7671373de43b7ef68af07c88f062cd3778573dd0f95a73a53f
|
|
| MD5 |
15ec49b993c29e4a55bd183613267ecd
|
|
| BLAKE2b-256 |
ddb93e833605dd09fa186ae9fbc2c5c48b1249943151d884049de3da474a3367
|