Metadata-Version: 2.4
Name: mayakit
Version: 0.3.0
Summary: A toolkit for Maya pipeline work. Its scenedoc module turns a Maya scene into a stable, versioned data document.
Author: narutozb
License-Expression: MIT
Project-URL: Homepage, https://github.com/narutozb/mayakit
Project-URL: Repository, https://github.com/narutozb/mayakit
Project-URL: Issues, https://github.com/narutozb/mayakit/issues
Project-URL: Changelog, https://github.com/narutozb/mayakit/blob/main/CHANGELOG.md
Keywords: maya,autodesk,vfx,pipeline,3d,scene,dcc,asset-management
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: End Users/Desktop
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"
Dynamic: license-file

# 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](https://img.shields.io/badge/python-3.9%2B-blue)](https://github.com/narutozb/mayakit)
[![Maya](https://img.shields.io/badge/maya-2023%E2%80%932027-brightgreen)](https://github.com/narutozb/mayakit)
[![License](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE)

📖 **中文文档：[README.zh-CN.md](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):

```bash
"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:

```bash
"C:/Program Files/Autodesk/Maya2026/bin/mayapy.exe" -m pip install --target D:/maya_site mayakit
```

From source, for development:

```bash
"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 |

```python
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

```bash
python -m mayakit demo
```

#### Scan the scene open in Maya

```python
from mayakit.scenedoc import scan_current_scene, dumps

doc = scan_current_scene()
print(dumps(doc))
```

#### Call one collector with arguments

```python
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:

```bash
python -m mayakit query pose --param roots=|root --param time=0 --param space=world
```

#### Read a saved document back

```python
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

```bash
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

```bash
"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?

```bash
"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)

```bash
python -m pytest tests -q
```

---

### The document

```jsonc
{
  "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:

```python
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](examples/custom_collector.py):

```python
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:

```python
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](src/mayakit/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`.

```bash
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
  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 pattern `pose` already uses).
- **Incremental scanning** (collecting only what changed).
- **Writing back to Maya.** This is read-only, deliberately.
- **Recursing into references** — `include_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](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](src/mayakit/__about__.py). Change it there and
nowhere else — [tests/test_packaging.py](tests/test_packaging.py) fails if a
hard-coded copy reappears.

```bash
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:

```bash
python -m twine upload --repository testpypi dist/*
```

Then the real thing:

```bash
python -m twine upload dist/*
```

See [CHANGELOG.md](CHANGELOG.md) for what changed.

---

## License

MIT — see [LICENSE](LICENSE).
