Skip to main content

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.

Python Maya License

📖 中文文档: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 unknown and 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.py and standalone.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 on long_name — that changes on every rename.
  • Give counters its own table. A list page reads only that, instead of touching tens of thousands of dag_nodes rows.
  • Store reports too. "the meshes collector failed" and "this scene has no meshes" are completely different facts, and a consumer that only has data cannot tell them apart.
  • source.sha1 works as an idempotency key — skip a file already scanned, or use it to decide whether a re-scan is needed.
  • Store source and open_report separately. source is disk truth and stays true; open_report is 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.verdict is ready to be a status column; leave diagnostics for 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 its time and space, 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 worksquery("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 pattern pose already uses).
  • Incremental scanning (collecting only what changed).
  • Writing back to Maya. This is read-only, deliberately.
  • Recursing into referencesinclude_references records 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

mayakit-0.3.0.tar.gz (97.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mayakit-0.3.0-py3-none-any.whl (83.5 kB view details)

Uploaded Python 3

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

Hashes for mayakit-0.3.0.tar.gz
Algorithm Hash digest
SHA256 ab7e6d58da9242e61061aa8b12c3510b2a0847a165e91f6cdba8c574f24713e9
MD5 6a9bf602825e87eeda98013800fd0b77
BLAKE2b-256 4f9555813cee7dc40fdfa7332142bd287268d15761335a1f1a4c13fd5dff8caa

See more details on using hashes here.

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

Hashes for mayakit-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 30f8c54546489f7671373de43b7ef68af07c88f062cd3778573dd0f95a73a53f
MD5 15ec49b993c29e4a55bd183613267ecd
BLAKE2b-256 ddb93e833605dd09fa186ae9fbc2c5c48b1249943151d884049de3da474a3367

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page