├── .editorconfig ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── pyproject.toml ├── setup.sh ├── src ├── pymetanode.mod └── pymetanode │ └── scripts │ └── pymetanode │ ├── __init__.py │ ├── api.py │ ├── core.py │ ├── core_utils.py │ ├── pm_api.py │ ├── pm_utils.py │ └── utils.py └── tests ├── __init__.py ├── __main__.py ├── test_core.py └── test_pm_core.py /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.py] 2 | max_line_length = 120 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Byte-compiled / optimized / DLL files 4 | *.py[cod] 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # Installer logs 29 | pip-log.txt 30 | pip-delete-this-directory.txt 31 | 32 | # Unit test / coverage reports 33 | htmlcov/ 34 | .tox/ 35 | .coverage 36 | .coverage.* 37 | .cache 38 | nosetests.xml 39 | coverage.xml 40 | *,cover 41 | .hypothesis/ 42 | 43 | # Flask stuff: 44 | instance/ 45 | .webassets-cache 46 | 47 | # Sphinx documentation 48 | docs/_build/ 49 | 50 | # virtualenv 51 | .venv 52 | venv/ 53 | ENV/ 54 | 55 | # Project files 56 | .vscode/ 57 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: check-ast 6 | language_version: python3 7 | - id: check-yaml 8 | - id: check-case-conflict 9 | - id: check-executables-have-shebangs 10 | - id: check-merge-conflict 11 | - id: end-of-file-fixer 12 | - id: requirements-txt-fixer 13 | - id: trailing-whitespace 14 | - repo: https://github.com/ambv/black 15 | rev: 22.10.0 16 | hooks: 17 | - id: black 18 | language_version: python3 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Bohdon Sayre 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Maya Python Metadata Utils 2 | 3 | A simple solution for storing python objects as data on a node in Maya. 4 | 5 | Lots of metadata solutions involve wrapping the complex nature of Maya's node attribute types and quirks to allow easy 6 | storing and retrieving of data, and connecting nodes via message attributes. PyMetaNode aims to avoid (most of) maya's 7 | attributes altogether for simplicity and flexibility. This places the responsibility of controlling data structures 8 | completely on the tools, and treats the nodes purely as storage. 9 | 10 | There are several advantages of using Maya nodes for storing data vs. alternate methods, such as not requiring any other 11 | files other than the maya scene containing the node, and the ability to undo and redo changes in nodes, and therefore 12 | changes in data. 13 | 14 | ## Design Goals 15 | 16 | - Simple 17 | - A very concise and straightforward api. 18 | - No need for custom plugin nodes to store your custom data. 19 | - Fast 20 | - Uses Maya api for all critical operations. 21 | 22 | ## Features 23 | 24 | - Store basic python object types (any type supported by pythons `eval`). 25 | - Store references to other nodes inside metadata. 26 | - Store data for multiple meta classes on a single node. 27 | - Find any node with metadata. 28 | - Find nodes with metadata for a specific metaclass. 29 | 30 | ## Installation 31 | 32 | - Download the [latest release](https://github.com/bohdon/maya-pymetanode/releases/latest) 33 | - Unzip and copy the contents to: 34 | - Windows: `~/Documents/maya/modules/` 35 | - Mac: `~/Library/Preferences/Autodesk/maya/modules/` 36 | - Linux: `~/maya/modules/` 37 | 38 | > Note that you may need to create the `modules` folder if it does not exist. 39 | 40 | Once installed, the result should look like this: 41 | 42 | ``` 43 | .../modules/pymetanode/ 44 | .../modules/pymetanode.mod 45 | ``` 46 | 47 | ## Basic Usage 48 | 49 | ```python 50 | import pymel.core as pm 51 | import pymetanode as meta 52 | 53 | # create some example data 54 | my_data = {"myList": [1, 2, 3], "myTitle": "ABC"} 55 | # data must be associated with a metaclass 56 | my_meta_class = "MyMetaClass" 57 | # set meta data on the selected node 58 | node = pm.selected()[0] 59 | meta.set_metadata(node, my_meta_class, my_data) 60 | 61 | # retrieve the stored data for 'MyMetaClass' only 62 | # result: {"myList":[1,2,3], "myTitle":"ABC"} 63 | meta.get_metadata(node, my_meta_class) 64 | 65 | # retrieve all metadata on the node 66 | # result: {"MyMetaClass": {"myList":[1,2,3], "myTitle":"ABC"}} 67 | meta.get_metadata(node) 68 | 69 | # check if a node has meta data for a meta class 70 | # result: True 71 | meta.has_metaclass(node, "MyMetaClass") 72 | 73 | # check if a node has any meta data 74 | # result: True 75 | meta.is_meta_node(node) 76 | 77 | # find all nodes in the scene that have metadata for a class 78 | meta.find_meta_nodes("MyMetaClass") 79 | ``` 80 | 81 | ## How does it work 82 | 83 | The implementation is very simple: python data is serialized into a string that is stored on a Maya node, and 84 | deserialized using `ast.literal_eval` when retrieved. Each 'metaclass' type adds an attribute on the node that is used 85 | to perform fast searching for nodes by metaclass. Data goes through a basic encoding and decoding that allows node 86 | references and other potential future features. 87 | 88 | ## Running Tests 89 | 90 | - Add mayapy directory to `PATH` environment variable. 91 | - From the root directory of this repository, run `setup.sh test` 92 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | PROJECT_NAME="maya-pymetanode" 4 | PACKAGE_NAME="pymetanode" 5 | 6 | 7 | if [[ ! "$MAYA_MODULES_INSTALL_PATH" ]]; then 8 | if [[ "$(uname)" == "Darwin" ]]; then 9 | MAYA_MODULES_INSTALL_PATH="$HOME/Library/Preferences/Autodesk/maya/modules" 10 | elif [[ "$(expr substr $(uname -s) 1 5)" == "Linux" ]]; then 11 | MAYA_MODULES_INSTALL_PATH="/usr/autodesk/userconfig/maya/modules" 12 | elif [[ "$(expr substr $(uname -s) 1 5)" == "MINGW" ]]; then 13 | IS_WINDOWS=1 14 | MAYA_MODULES_INSTALL_PATH="$HOME/Documents/maya/modules" 15 | fi 16 | fi 17 | 18 | 19 | 20 | build() { 21 | echo "Building..." 22 | mkdir -p build 23 | cp -R src/$PACKAGE_NAME build/ 24 | cp src/$PACKAGE_NAME.mod build/ 25 | cp LICENSE build/$PACKAGE_NAME 26 | } 27 | 28 | release() { 29 | clean 30 | build 31 | echo "Making release archive..." 32 | VERSION=`git describe --tags --abbrev=0` 33 | cd build 34 | zip -rX ${PROJECT_NAME}_${VERSION}.zip * 35 | echo ${PROJECT_NAME}_${VERSION}.zip 36 | } 37 | 38 | clean() { 39 | echo "Cleaning..." 40 | rm -Rf build 41 | } 42 | 43 | dev() { 44 | uninstall 45 | clean 46 | echo "Installing for development..." 47 | link `pwd`/src/$PACKAGE_NAME.mod $MAYA_MODULES_INSTALL_PATH/$PACKAGE_NAME.mod 48 | link `pwd`/src/$PACKAGE_NAME $MAYA_MODULES_INSTALL_PATH/$PACKAGE_NAME 49 | } 50 | 51 | test() { 52 | if ! [[ "$1" ]]; then 53 | echo "usage: setup.sh test [MAYAVERSION] ..." 54 | return 55 | fi 56 | 57 | build 58 | echo "Running tests..." 59 | echo "Be sure to run 'setup.sh dev' first." 60 | 61 | for version in "$@" 62 | do 63 | # find mayapy 64 | mayapy="$PROGRAMFILES/Autodesk/Maya${version}/bin/mayapy.exe" 65 | # log maya version 66 | py_version=`"$mayapy" -V` 67 | printf "\n> Maya ${version} (${py_version})\n" 68 | # run tests 69 | "$mayapy" tests build/$PACKAGE_NAME 70 | done 71 | } 72 | 73 | install() { 74 | uninstall 75 | clean 76 | build 77 | echo "Installing..." 78 | cp -v build/$PACKAGE_NAME.mod $MAYA_MODULES_INSTALL_PATH/$PACKAGE_NAME.mod 79 | cp -Rv build/$PACKAGE_NAME $MAYA_MODULES_INSTALL_PATH/$PACKAGE_NAME 80 | } 81 | 82 | uninstall() { 83 | echo "Uninstalling..." 84 | rm -v $MAYA_MODULES_INSTALL_PATH/$PACKAGE_NAME.mod 85 | rm -Rv $MAYA_MODULES_INSTALL_PATH/$PACKAGE_NAME 86 | } 87 | 88 | 89 | ALL_COMMANDS="build, clean, dev, test, install, uninstall" 90 | 91 | 92 | 93 | # Template setup.sh utils 94 | # ----------------------- 95 | 96 | 97 | # simple cross-platform symlink util 98 | link() { 99 | # use mklink if on windows 100 | if [[ -n "$WINDIR" ]]; then 101 | # determine if the link is a directory 102 | # also convert '/' to '\' 103 | if [[ -d "$1" ]]; then 104 | cmd <<< "mklink /D \"`cygpath -w \"$2\"`\" \"`cygpath -w \"$1\"`\"" > /dev/null 105 | else 106 | cmd <<< "mklink \"`cygpath -w \"$2\"`\" \"`cygpath -w \"$1\"`\"" > /dev/null 107 | fi 108 | else 109 | ln -sf "$1" "$2" 110 | fi 111 | } 112 | 113 | # run command by name 114 | if [[ "$1" ]]; then 115 | cd $(dirname "$0") 116 | $1 "${@:2}" 117 | else 118 | echo -e "usage: setup.sh [COMMAND]\n $ALL_COMMANDS" 119 | fi 120 | -------------------------------------------------------------------------------- /src/pymetanode.mod: -------------------------------------------------------------------------------- 1 | + maya-pymetanode 1.0 ./pymetanode 2 | -------------------------------------------------------------------------------- /src/pymetanode/scripts/pymetanode/__init__.py: -------------------------------------------------------------------------------- 1 | from . import core 2 | from .core_utils import * 3 | 4 | try: 5 | from .pm_api import * 6 | from .pm_utils import * 7 | 8 | IS_PYMEL_AVAILABLE = True 9 | except ImportError: 10 | from .api import * 11 | from .utils import * 12 | 13 | IS_PYMEL_AVAILABLE = False 14 | 15 | __version__ = "v2.2.0" 16 | -------------------------------------------------------------------------------- /src/pymetanode/scripts/pymetanode/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | The core functions of pymetanode, using only the maya api and cmds. 3 | Provides utils for adding, settings, and removing metadata. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from typing import Any, Union 9 | 10 | import maya.OpenMaya as api 11 | 12 | from . import core 13 | from . import utils 14 | from .core import MetadataEncoder, MetadataController 15 | 16 | __all__ = [ 17 | "decode_metadata", 18 | "decode_metadata_value", 19 | "encode_metadata", 20 | "encode_metadata_value", 21 | "find_meta_nodes", 22 | "get_metadata", 23 | "has_metaclass", 24 | "is_meta_node", 25 | "remove_metadata", 26 | "set_all_metadata", 27 | "set_metadata", 28 | "update_metadata", 29 | ] 30 | 31 | 32 | def encode_metadata(data: Any) -> str: 33 | """ 34 | Return the given metadata encoded into a string. 35 | 36 | Args: 37 | data: The data to serialize. 38 | """ 39 | return MetadataEncoder().encode_metadata(data) 40 | 41 | 42 | def encode_metadata_value(value: Any) -> Any: 43 | """ 44 | Return a metadata value, possibly encoding it into an alternate format that supports string serialization. 45 | 46 | Handles special data types like Maya nodes. 47 | 48 | Args: 49 | value: Any python value to be encoded. 50 | 51 | Returns: 52 | The encoded value, or the unchanged value if no encoding was necessary. 53 | """ 54 | return MetadataEncoder().encode_metadata_value(value) 55 | 56 | 57 | def decode_metadata(data: str, ref_node: str = None) -> Any: 58 | """ 59 | Parse the given metadata and return it as a valid python object. 60 | 61 | Args: 62 | data: A string representing encoded metadata. 63 | ref_node: The name of the reference node that contains any nodes in the metadata. 64 | """ 65 | return MetadataEncoder().decode_metadata(data, ref_node) 66 | 67 | 68 | def decode_metadata_value(value: str, ref_node: str = None) -> Any: 69 | """ 70 | Parse string formatted metadata and return the resulting python object. 71 | 72 | Args: 73 | value: A str representing encoded metadata. 74 | ref_node: The name of the reference node that contains any nodes in the metadata. 75 | """ 76 | return MetadataEncoder().decode_metadata_value(value, ref_node) 77 | 78 | 79 | def is_meta_node(node: Union[api.MObject, str]) -> bool: 80 | """ 81 | Return True if the given node has any metadata. 82 | 83 | Args: 84 | node: An MObject, or string representing a node. 85 | """ 86 | return utils.has_attr(node, core.METADATA_ATTR) 87 | 88 | 89 | def has_metaclass(node: Union[api.MObject, str], class_name: str) -> bool: 90 | """ 91 | Return True if the given node has data for the given metaclass type 92 | 93 | Args: 94 | node: An MObject, or string representing a node. 95 | class_name: The metaclass name to check for. 96 | """ 97 | return utils.has_attr(node, core.METACLASS_ATTR_PREFIX + class_name) 98 | 99 | 100 | def find_meta_nodes(class_name: str = None, as_names=True) -> Union[list[str], list[api.MObject]]: 101 | """ 102 | Return a list of all meta nodes of the given class type. If no class is given, 103 | all nodes with metadata are returned. 104 | 105 | Args: 106 | class_name: The metaclass name to search for, or None to find all metadata nodes. 107 | as_names: Return a list of node names. If false, return a list of MObjects. 108 | 109 | Returns: 110 | A list of node names or MObjects that have metadata. 111 | """ 112 | objs = core.find_meta_nodes(class_name) 113 | 114 | if as_names: 115 | return [core.get_unique_node_name(api.MFnDependencyNode(obj)) for obj in objs] 116 | else: 117 | return objs 118 | 119 | 120 | def get_metadata(node: str, class_name: str = None) -> Union[dict, Any]: 121 | """ 122 | Return the metadata on a node. If `class_name` is given, return only data for that metaclass. 123 | 124 | Args: 125 | node: A string node name. 126 | class_name: The metaclass of the data to find and return. 127 | 128 | Returns: 129 | A dict if returning all metadata, or potentially any value if returning data for a specific class. 130 | """ 131 | return MetadataController.from_node(node).get_metadata(class_name) 132 | 133 | 134 | def set_metadata(node: str, class_name: str, data: Any, undoable=True, replace=False): 135 | """ 136 | Set the metadata for a metaclass type on a node. 137 | 138 | The class_name must be a valid attribute name. 139 | 140 | Args: 141 | node: The node on which to set data. 142 | class_name: The data's metaclass type name. 143 | data: The data to serialize and store on the node. 144 | undoable: Make the operation undoable by using cmds instead of the api. 145 | replace: Replace all metadata on the node with the new metadata. 146 | This uses set_all_metadata and can be much faster with large data sets, 147 | but will remove data for any other metaclass types. 148 | """ 149 | return MetadataController.from_node(node, undoable=undoable).set_metadata(class_name, data, replace) 150 | 151 | 152 | def set_all_metadata(node: Union[api.MObject, str], data: dict, undoable=True): 153 | """ 154 | Set all metadata on a node. This is faster because the existing data 155 | on the node is not retrieved first and then modified. 156 | 157 | The data must be of the form {"": } otherwise errors 158 | may occur when retrieving it later. 159 | 160 | New metaclass attributes will be added automatically, but existing metaclass 161 | attributes will not be removed. If old metaclass attributes on this node will 162 | no longer be applicable, they should be removed with `remove_metadata` first. 163 | 164 | Args: 165 | node: The node on which to set data. 166 | data: The data to serialize and store on the node. 167 | undoable: Make the operation undoable by using cmds instead of the api. 168 | """ 169 | return MetadataController.from_node(node, undoable=undoable).set_all_metadata(data) 170 | 171 | 172 | def update_metadata(node: str, class_name: str, data: dict): 173 | """ 174 | Update existing dict metadata on a node for a metaclass type. 175 | 176 | Args: 177 | node: A string node name. 178 | class_name: A string name of the metaclass type. 179 | data: A dict object containing metadata to add to the node. 180 | 181 | Raises: 182 | ValueError: The existing metadata on the node for the given metaclass was not a dict. 183 | """ 184 | return MetadataController.from_node(node).update_metadata(class_name, data) 185 | 186 | 187 | def remove_metadata(node: str, class_name: str = None, undoable=True) -> bool: 188 | """ 189 | Remove metadata from a node. If no `class_name` is given 190 | then all metadata is removed. 191 | 192 | Args: 193 | node: A string node name. 194 | class_name: A string name of the metaclass type. 195 | undoable: Make the operation undoable by using cmds instead of the api. 196 | 197 | Returns: 198 | True if node is fully clean of relevant metadata. 199 | """ 200 | if not is_meta_node(node): 201 | return True 202 | 203 | return MetadataController.from_node(node, undoable=undoable).remove_metadata(class_name) 204 | -------------------------------------------------------------------------------- /src/pymetanode/scripts/pymetanode/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | The core functionality of pymetanode for adding and removing metadata. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import ast 8 | import re 9 | from typing import Optional, Any, Union 10 | 11 | import maya.OpenMaya as api 12 | from maya import cmds 13 | 14 | from . import utils, core_utils 15 | 16 | METACLASS_ATTR_PREFIX = "pyMetaClass_" 17 | METADATA_ATTR = "pyMetaData" 18 | VALID_CLASS_ATTR = re.compile(r"^[_a-z0-9]*$", re.IGNORECASE) 19 | 20 | 21 | def find_meta_nodes(class_name: str = None) -> list[api.MObject]: 22 | """ 23 | Return a list of all meta nodes of the given class type. If no class is given, 24 | all nodes with metadata are returned. 25 | 26 | Args: 27 | class_name: The metaclass name to search for, or None to find all metadata nodes. 28 | 29 | Returns: 30 | A list of PyNodes or MObjects that have metadata. 31 | """ 32 | plug_name = f"{METACLASS_ATTR_PREFIX}{class_name}" if class_name else METADATA_ATTR 33 | return core_utils.get_m_objects_by_plug(plug_name) 34 | 35 | 36 | def get_metaclass_names(node_name: str) -> list[str]: 37 | """ 38 | Return all metaclass names that a node has metadata for. 39 | 40 | Args: 41 | node_name: A string node name. 42 | """ 43 | attrs: list[str] = cmds.listAttr(node_name) 44 | metaclass_attrs = [a for a in attrs if a.startswith(METACLASS_ATTR_PREFIX)] 45 | metaclass_names = [a[len(METACLASS_ATTR_PREFIX) :] for a in metaclass_attrs] 46 | return metaclass_names 47 | 48 | 49 | def get_unique_node_name(mfn_node: api.MFnDependencyNode) -> str: 50 | """ 51 | Return the unique name of a MFnDependencyNode. 52 | 53 | If the node is already unique, simply returns its name, otherwise returns the unique path to the node. 54 | 55 | Args: 56 | mfn_node: An MFnDependencyNode with a node. 57 | 58 | Returns: 59 | The unique name or path (str) of the node. E.g. 'node' or 'my|node' 60 | """ 61 | if not mfn_node.hasUniqueName(): 62 | node = mfn_node.object() 63 | if node.hasFn(api.MFn.kDagNode): 64 | mfn_dag = api.MFnDagNode(mfn_node.object()) 65 | dag_path = api.MDagPath() 66 | mfn_dag.getPath(dag_path) 67 | return dag_path.partialPathName() 68 | return mfn_node.name() 69 | 70 | 71 | def get_metadata_plug(mfn_node: api.MFnDependencyNode) -> Optional[api.MPlug]: 72 | """ 73 | Return the MPlug for the metadata attribute on a node. 74 | 75 | Args: 76 | mfn_node: An MFnDependencyNode with a node. 77 | 78 | Returns: 79 | The MPlug for the metadata attribute, or None if not found. 80 | """ 81 | try: 82 | return mfn_node.findPlug(METADATA_ATTR) 83 | except RuntimeError: 84 | pass 85 | 86 | 87 | def get_metaclass_plug(mfn_node: api.MFnDependencyNode, class_name: str) -> Optional[api.MPlug]: 88 | """ 89 | Return the MPlug for a metaclass attribute on a node. 90 | 91 | Args: 92 | mfn_node: An MFnDependencyNode with a node. 93 | class_name: The metaclass name for the plug to find. 94 | 95 | Returns: 96 | The MPlug for the metaclass attribute, or None if not found. 97 | """ 98 | attr_name = METACLASS_ATTR_PREFIX + class_name 99 | try: 100 | return mfn_node.findPlug(attr_name) 101 | except RuntimeError: 102 | pass 103 | 104 | 105 | def get_unique_plug_name(mfn_node: api.MFnDependencyNode, plug: api.MPlug) -> str: 106 | """ 107 | Return the unique name of a MPlug, including its nodes name. 108 | 109 | Args: 110 | mfn_node: An MFnDependencyNode with a node. 111 | Provided for performance reasons, since it is common to have both this 112 | and the MPlug when needing the plug name in this package. 113 | plug: A MPlug of a node. 114 | 115 | Returns: 116 | The unique name or path including attribute (str) of the plug. E.g. 'my|node.myPlug' 117 | """ 118 | return get_unique_node_name(mfn_node) + "." + plug.partialName() 119 | 120 | 121 | class MetadataEncoder(object): 122 | """ 123 | Base class for an encoder/decoder that processes data when 124 | storing and loading it from nodes. 125 | """ 126 | 127 | def encode_metadata(self, data: Any) -> str: 128 | """ 129 | Return the given metadata encoded into a string. 130 | 131 | Args: 132 | data: The data to serialize. 133 | """ 134 | return repr(self.encode_metadata_value(data)) 135 | 136 | def encode_metadata_value(self, value: Any) -> Any: 137 | """ 138 | Return a metadata value, possibly encoding it into an alternate format that supports string serialization. 139 | 140 | Handles special data types like Maya nodes. 141 | 142 | Args: 143 | value: Any python value to be encoded. 144 | 145 | Returns: 146 | The encoded value, or the unchanged value if no encoding was necessary. 147 | """ 148 | if isinstance(value, dict): 149 | result = {} 150 | for k, v in value.items(): 151 | result[k] = self.encode_metadata_value(v) 152 | return result 153 | elif isinstance(value, (list, tuple)): 154 | return value.__class__([self.encode_metadata_value(v) for v in value]) 155 | elif self.is_node(value): 156 | return self.get_node_id(value) 157 | else: 158 | return value 159 | 160 | def decode_metadata(self, data: str, ref_node: str = None) -> Any: 161 | """ 162 | Parse the given metadata and return it as a valid python object. 163 | 164 | Args: 165 | data: A string representing encoded metadata. 166 | ref_node: The name of the reference node that contains any nodes in the metadata. 167 | """ 168 | if not data: 169 | return {} 170 | 171 | try: 172 | data = ast.literal_eval(data.replace("\r", "")) 173 | except Exception as e: 174 | raise ValueError(f"Failed to decode meta data: {e}") 175 | return self.decode_metadata_value(data, ref_node) 176 | 177 | def decode_metadata_value(self, value: str, ref_node: str = None) -> Any: 178 | """ 179 | Parse string formatted metadata and return the resulting python object. 180 | 181 | Args: 182 | value: A str representing encoded metadata. 183 | ref_node: The name of the reference node that contains any nodes in the metadata. 184 | """ 185 | if isinstance(value, dict): 186 | result = {} 187 | for k, v in value.items(): 188 | result[k] = self.decode_metadata_value(v, ref_node) 189 | return result 190 | elif isinstance(value, (list, tuple)): 191 | return value.__class__([self.decode_metadata_value(v, ref_node) for v in value]) 192 | elif core_utils.is_node_id(value): 193 | return self.find_node_by_id(value, ref_node) 194 | else: 195 | return value 196 | 197 | def is_node(self, value: Any) -> bool: 198 | return utils.is_node(value) 199 | 200 | def get_node_id(self, value: Any) -> str: 201 | return utils.get_node_id(value) 202 | 203 | def find_node_by_id(self, node_id: str, ref_node: str = None) -> Optional[Any]: 204 | return utils.find_node_by_id(node_id, ref_node) 205 | 206 | 207 | class MetadataController(object): 208 | # the default encoder class to use if none is specified 209 | _default_encoder_cls = MetadataEncoder 210 | 211 | @classmethod 212 | def from_node(cls, node: Any, encoder: MetadataEncoder = None, undoable=True) -> MetadataController: 213 | """ 214 | Create a MetadataController from a node. 215 | Uses cls._get_mfn_node(node) to retrieve the mfn_node. 216 | """ 217 | mfn_node = cls._get_mfn_node(node) 218 | return cls(mfn_node, encoder, undoable=undoable) 219 | 220 | @classmethod 221 | def _get_mfn_node(cls, node: Any) -> api.MFnDependencyNode: 222 | return utils.get_mfn_node(node) 223 | 224 | def __init__(self, mfn_node: api.MFnDependencyNode, encoder: MetadataEncoder = None, undoable=True): 225 | """ 226 | Args: 227 | mfn_node: A MFnDependencyNode to operate on. 228 | encoder: A MetadataEncoder to use for reading/writing values. 229 | undoable: If true, make operations undoable by using cmds (slightly less performant) instead of the api. 230 | """ 231 | if encoder is None: 232 | encoder = self._default_encoder_cls() 233 | 234 | self.mfn_node = mfn_node 235 | self.encoder = encoder 236 | self.undoable = undoable 237 | 238 | def get_ref_node(self) -> Optional[str]: 239 | """ 240 | Return the name of the reference to use when encoding or decoding nodes. 241 | """ 242 | node_name = get_unique_node_name(self.mfn_node) 243 | if cmds.referenceQuery(node_name, isNodeReferenced=True): 244 | return cmds.referenceQuery(node_name, referenceNode=True) 245 | 246 | def get_metadata(self, class_name: str = None) -> Union[dict, Any]: 247 | """ 248 | Return the metadata on a node. If `class_name` is given, return only data for that metaclass. 249 | 250 | Args: 251 | class_name: The metaclass of the data to find and return. 252 | 253 | Returns: 254 | A dict if returning all metadata, or potentially any value if returning data for a specific class. 255 | """ 256 | try: 257 | plug = self.mfn_node.findPlug(METADATA_ATTR) 258 | datastr = plug.asString() 259 | except RuntimeError: 260 | return {} 261 | else: 262 | data = self.encoder.decode_metadata(datastr, self.get_ref_node()) 263 | 264 | if class_name is not None: 265 | return data.get(class_name, {}) 266 | 267 | return data 268 | 269 | def set_metadata(self, class_name: str, data: Any, replace=False): 270 | """ 271 | Set the metadata for a metaclass type on the node. 272 | 273 | The class_name must be a valid attribute name. 274 | 275 | Args: 276 | class_name: The data's metaclass type name. 277 | data: The data to serialize and store on the node. 278 | replace: Replace all metadata on the node with the new metadata. 279 | This uses set_all_metadata and can be much faster with large data sets, 280 | but will remove data for any other metaclass types. 281 | """ 282 | if replace: 283 | self.set_all_metadata({class_name: data}) 284 | return 285 | 286 | plug = self._get_or_create_metadata_plug() 287 | self._add_metaclass_attr(class_name) 288 | 289 | # update meta data 290 | full_data = self.encoder.decode_metadata(plug.asString(), self.get_ref_node()) 291 | full_data[class_name] = data 292 | new_value = self.encoder.encode_metadata(full_data) 293 | 294 | if self.undoable: 295 | plug_name = get_unique_plug_name(self.mfn_node, plug) 296 | cmds.setAttr(plug_name, new_value, type="string") 297 | else: 298 | plug.setString(new_value) 299 | 300 | def set_all_metadata(self, data: dict): 301 | """ 302 | Set all metadata on a node. This is faster because the existing data 303 | on the node is not retrieved first and then modified. 304 | 305 | The data must be of the form {"": } otherwise errors 306 | may occur when retrieving it later. 307 | 308 | New metaclass attributes will be added automatically, but existing metaclass 309 | attributes will not be removed. If old metaclass attributes on this node will 310 | no longer be applicable, they should be removed with `remove_metadata` first. 311 | 312 | Args: 313 | data: The data to serialize and store on the node. 314 | """ 315 | plug = self._get_or_create_metadata_plug() 316 | 317 | # add class attributes 318 | if data: 319 | for class_name in data.keys(): 320 | self._add_metaclass_attr(class_name) 321 | 322 | # set meta data 323 | new_value = self.encoder.encode_metadata(data) 324 | 325 | if self.undoable: 326 | plug_name = get_unique_plug_name(self.mfn_node, plug) 327 | cmds.setAttr(plug_name, new_value, type="string") 328 | else: 329 | plug.setString(new_value) 330 | 331 | def update_metadata(self, class_name: str, data: dict): 332 | """ 333 | Update existing dict metadata on a node for a metaclass type. 334 | 335 | Args: 336 | class_name: A string name of the metaclass type. 337 | data: A dict object containing metadata to add to the node. 338 | 339 | Raises: 340 | ValueError: The existing metadata on the node for the given metaclass was not a dict. 341 | """ 342 | full_data = self.get_metadata(class_name) 343 | 344 | if not isinstance(full_data, dict): 345 | raise ValueError( 346 | f"Expected dict metadata for '{class_name}', but got '{type(full_data)}' from node: '{get_unique_node_name(self.mfn_node)}'" 347 | ) 348 | 349 | full_data.update(data) 350 | self.set_metadata(class_name, full_data) 351 | 352 | def remove_metadata(self, class_name: str = None) -> bool: 353 | """ 354 | Remove metadata from a node. If no `class_name` is given then all metadata is removed. 355 | 356 | Args: 357 | class_name: A string name of the metaclass type. 358 | 359 | Returns: 360 | True if node is fully clean of relevant metadata. 361 | """ 362 | # make sure data attribute is unlocked 363 | data_plug = get_metadata_plug(self.mfn_node) 364 | if data_plug and data_plug.isLocked(): 365 | return False 366 | 367 | # this may become true if we find there are no 368 | # classes left after removing the target one 369 | remove_all_data = False 370 | 371 | if class_name: 372 | # attempt to remove class attribute 373 | if not self._remove_metaclass_attr(class_name): 374 | return False 375 | 376 | # remove just the data for this metaclass 377 | # TODO(bsayre): add a `partial_decode_metadata` for uses like this since we will only be modifying 378 | # the core dict object and not using any meta data values (like nodes) 379 | data = self.encoder.decode_metadata(data_plug.asString()) 380 | if class_name in data: 381 | del data[class_name] 382 | 383 | # set the new metadata 384 | new_value = self.encoder.encode_metadata(data) 385 | if self.undoable: 386 | plug_name = get_unique_plug_name(self.mfn_node, data_plug) 387 | cmds.setAttr(plug_name, new_value, type="string") 388 | else: 389 | data_plug.setString(new_value) 390 | 391 | if not data: 392 | # no data left, remove all metadata attributes 393 | remove_all_data = True 394 | 395 | else: 396 | # no class_name was given, remove everything 397 | remove_all_data = True 398 | 399 | if remove_all_data: 400 | node_name = get_unique_node_name(self.mfn_node) 401 | class_plugs = [get_metaclass_plug(self.mfn_node, c) for c in get_metaclass_names(node_name)] 402 | class_plugs = [c for c in class_plugs if c] 403 | 404 | # make sure all class attributes are unlocked 405 | for class_plug in class_plugs: 406 | if class_plug.isLocked(): 407 | return False 408 | 409 | # remove class attributes 410 | for classPlug in class_plugs: 411 | if self.undoable: 412 | plug_name = get_unique_plug_name(self.mfn_node, classPlug) 413 | cmds.deleteAttr(plug_name) 414 | else: 415 | self.mfn_node.removeAttribute(classPlug.attribute()) 416 | 417 | # remove data attribute 418 | if data_plug: 419 | if self.undoable: 420 | plug_name = get_unique_plug_name(self.mfn_node, data_plug) 421 | cmds.deleteAttr(plug_name) 422 | else: 423 | self.mfn_node.removeAttribute(data_plug.attribute()) 424 | 425 | return True 426 | 427 | def _get_or_create_metadata_plug(self) -> api.MPlug: 428 | """ 429 | Return the MPlug for the metadata attribute on a node, adding the attribute if it does not already exist. 430 | 431 | Returns: 432 | The MPlug for the metadata attribute. 433 | """ 434 | try: 435 | plug = self.mfn_node.findPlug(METADATA_ATTR) 436 | except RuntimeError: 437 | if self.undoable: 438 | name = get_unique_node_name(self.mfn_node) 439 | cmds.addAttr(name, longName=METADATA_ATTR, dataType="string") 440 | else: 441 | mfn_attr = api.MFnTypedAttribute() 442 | attr = mfn_attr.create(METADATA_ATTR, METADATA_ATTR, api.MFnData.kString) 443 | self.mfn_node.addAttribute(attr) 444 | plug = self.mfn_node.findPlug(METADATA_ATTR) 445 | 446 | return plug 447 | 448 | def _add_metaclass_attr(self, class_name: str) -> None: 449 | """ 450 | Add a metaclass attribute to a node. 451 | 452 | Does nothing if the attribute already exists. 453 | 454 | Args: 455 | class_name: The metadata class name. 456 | 457 | Raises: 458 | ValueError: The class_name was invalid. 459 | """ 460 | if not VALID_CLASS_ATTR.match(class_name): 461 | raise ValueError("Invalid metaclass name: " + class_name) 462 | 463 | class_attr = METACLASS_ATTR_PREFIX + class_name 464 | 465 | try: 466 | self.mfn_node.attribute(class_attr) 467 | except RuntimeError: 468 | if self.undoable: 469 | name = get_unique_node_name(self.mfn_node) 470 | cmds.addAttr(name, longName=class_attr, attributeType="short") 471 | else: 472 | mfn_attr = api.MFnNumericAttribute() 473 | attr = mfn_attr.create(class_attr, class_attr, api.MFnNumericData.kShort) 474 | self.mfn_node.addAttribute(attr) 475 | 476 | def _remove_metaclass_attr(self, class_name: str) -> bool: 477 | """ 478 | Remove a metaclass attribute from a node. 479 | 480 | Does nothing if the attribute does not exist. 481 | 482 | Args: 483 | class_name: The metadata class name. 484 | 485 | Returns: 486 | True if the attr was removed or didn't exist, False if it couldn't be removed. 487 | """ 488 | class_plug = get_metaclass_plug(self.mfn_node, class_name) 489 | if not class_plug: 490 | return True 491 | 492 | if class_plug.isLocked(): 493 | return False 494 | else: 495 | if self.undoable: 496 | plug_name = get_unique_plug_name(self.mfn_node, class_plug) 497 | cmds.deleteAttr(plug_name) 498 | else: 499 | self.mfn_node.removeAttribute(class_plug.attribute()) 500 | return True 501 | -------------------------------------------------------------------------------- /src/pymetanode/scripts/pymetanode/core_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utils for working with nodes and the maya api. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import logging 8 | import re 9 | 10 | import maya.OpenMaya as api 11 | from maya import cmds 12 | 13 | __all__ = [ 14 | "get_m_objects_by_plug", 15 | "get_mfn_node_id", 16 | "get_mfn_node_uuid", 17 | "has_attr_fast", 18 | "is_node_from_ref", 19 | "is_node_id", 20 | "is_uuid", 21 | "parse_node_id", 22 | ] 23 | 24 | LOG = logging.getLogger(__name__) 25 | 26 | # matches a node UUID, e.g. "ABC12345-AB12-AB12-AB12-ABCDEF123456" 27 | UUID_REGEX = re.compile(r"[A-F0-9]{8}-([A-F0-9]{4}-){3}[A-F0-9]{12}") 28 | 29 | # matches a node id, accepting also just a UUID, e.g. "myNode@ABC12345-AB12-AB12-AB12-ABCDEF123456" 30 | NODE_ID_REGEX = re.compile(rf"((?P[\w:]+)@)?(?P{UUID_REGEX.pattern})") 31 | 32 | 33 | def has_attr_fast(m_object: api.MObject, attr_name: str) -> bool: 34 | """ 35 | Return True if the given node has the given attribute. 36 | 37 | Uses the api for performance, and performs no validation or type-checking. 38 | 39 | Args: 40 | m_object: An MObject node. 41 | attr_name: The name of the attribute to find. 42 | """ 43 | try: 44 | api.MFnDependencyNode(m_object).attribute(attr_name) 45 | return True 46 | except RuntimeError: 47 | return False 48 | 49 | 50 | def get_m_objects_by_plug(plug_name: str) -> list[api.MObject]: 51 | """ 52 | Return all nodes in the scene that have a specific plug. 53 | 54 | Args: 55 | plug_name: A string name of a maya plug to search for on nodes 56 | 57 | Returns: 58 | A list of MObjects that have the plug. 59 | """ 60 | sel = api.MSelectionList() 61 | try: 62 | sel.add("*." + plug_name, True) 63 | except RuntimeError: 64 | pass 65 | 66 | count = sel.length() 67 | result = [api.MObject() for _ in range(count)] 68 | [sel.getDependNode(i, result[i]) for i in range(count)] 69 | return result 70 | 71 | 72 | def get_mfn_node_uuid(mfn_node: api.MFnDependencyNode): 73 | if mfn_node: 74 | return str(mfn_node.uuid().asString()) 75 | return "" 76 | 77 | 78 | def is_uuid(obj) -> bool: 79 | """ 80 | Returns true if an object is a valid UUID, e.g. "ABC12345-AB12-AB12-AB12-ABCDEF123456" 81 | """ 82 | return isinstance(obj, str) and UUID_REGEX.fullmatch(obj) 83 | 84 | 85 | def is_node_from_ref(node_name: str, ref_node: str): 86 | if not ref_node: 87 | raise ValueError("ref_node invalid") 88 | 89 | if cmds.referenceQuery(node_name, isNodeReferenced=True): 90 | return cmds.referenceQuery(node_name, referenceNode=True) == ref_node 91 | return False 92 | 93 | 94 | def is_node_id(obj): 95 | """ 96 | Return true if an object is a valid node id, e.g. "myNode@ABC12345-AB12-AB12-AB12-ABCDEF123456" 97 | """ 98 | return isinstance(obj, str) and NODE_ID_REGEX.fullmatch(obj) 99 | 100 | 101 | def get_mfn_node_id(mfn_node: api.MFnDependencyNode): 102 | if mfn_node: 103 | return f"{mfn_node.name()}@{mfn_node.uuid().asString()}" 104 | return "" 105 | 106 | 107 | def parse_node_id(node_id: str) -> (str, str): 108 | """ 109 | Parse a node id and return a tuple of (node_name, uuid) 110 | 111 | Args: 112 | node_id: A string representing the node, in the format of either a UUID, or name@UUID. 113 | """ 114 | match = NODE_ID_REGEX.fullmatch(node_id) 115 | if not match: 116 | raise ValueError("Not a valid node id: %s" % node_id) 117 | 118 | node_name = match.groupdict()["name"] 119 | uuid = match.groupdict()["uuid"] 120 | 121 | return node_name, uuid 122 | -------------------------------------------------------------------------------- /src/pymetanode/scripts/pymetanode/pm_api.py: -------------------------------------------------------------------------------- 1 | """ 2 | The core functions of pymetanode, with support for PyMel objects. 3 | Provides utils for adding, settings, and removing metadata. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from typing import Any, Union, Optional 9 | 10 | import maya.OpenMaya as api 11 | import pymel.core as pm 12 | 13 | from . import core 14 | from . import pm_utils 15 | from . import utils 16 | from .core import MetadataEncoder, MetadataController 17 | 18 | __all__ = [ 19 | "decode_metadata", 20 | "decode_metadata_value", 21 | "encode_metadata", 22 | "encode_metadata_value", 23 | "find_meta_nodes", 24 | "get_metadata", 25 | "has_metaclass", 26 | "is_meta_node", 27 | "remove_metadata", 28 | "set_all_metadata", 29 | "set_metadata", 30 | "update_metadata", 31 | ] 32 | 33 | 34 | class PyMelMetadataEncoder(MetadataEncoder): 35 | """ 36 | Metadata encoder/decoder that handles PyMel nodes 37 | """ 38 | 39 | def encode_metadata_value(self, value: Any) -> Any: 40 | if isinstance(value, pm.nt.DependNode): 41 | return utils.get_node_id(value) 42 | return super().encode_metadata_value(value) 43 | 44 | def is_node(self, value: Any) -> bool: 45 | return pm_utils.is_node(value) 46 | 47 | def get_node_id(self, value: Any) -> str: 48 | return pm_utils.get_node_id(value) 49 | 50 | def find_node_by_id(self, node_id: str, ref_node: str = None) -> Optional[Any]: 51 | # return PyNodes instead of node names 52 | return pm_utils.find_node_by_id(node_id, ref_node) 53 | 54 | 55 | class PyMelMetadataController(MetadataController): 56 | _default_encoder_cls = PyMelMetadataEncoder 57 | 58 | @classmethod 59 | def _get_mfn_node(cls, node: Any) -> api.MFnDependencyNode: 60 | return pm_utils.get_mfn_node(node) 61 | 62 | 63 | def encode_metadata(data: Any) -> str: 64 | """ 65 | Return the given metadata encoded into a string. 66 | 67 | Args: 68 | data: The data to serialize. 69 | """ 70 | return PyMelMetadataEncoder().encode_metadata(data) 71 | 72 | 73 | def encode_metadata_value(value: Any) -> Any: 74 | """ 75 | Return a metadata value, possibly encoding it into an alternate format that supports string serialization. 76 | 77 | Handles special data types like Maya nodes. 78 | 79 | Args: 80 | value: Any python value to be encoded. 81 | 82 | Returns: 83 | The encoded value, or the unchanged value if no encoding was necessary. 84 | """ 85 | return PyMelMetadataEncoder().encode_metadata_value(value) 86 | 87 | 88 | def decode_metadata(data: str, ref_node: str = None) -> Any: 89 | """ 90 | Parse the given metadata and return it as a valid python object. 91 | 92 | Args: 93 | data: A string representing encoded metadata. 94 | ref_node: The name of the reference node that contains any nodes in the metadata. 95 | """ 96 | return PyMelMetadataEncoder().decode_metadata(data, ref_node) 97 | 98 | 99 | def decode_metadata_value(value: str, ref_node: str = None) -> Any: 100 | """ 101 | Parse string formatted metadata and return the resulting python object. 102 | 103 | Args: 104 | value: A str representing encoded metadata. 105 | ref_node: The name of the reference node that contains any nodes in the metadata. 106 | """ 107 | return PyMelMetadataEncoder().decode_metadata_value(value, ref_node) 108 | 109 | 110 | def is_meta_node(node: Union[api.MObject, pm.nt.DependNode, str]) -> bool: 111 | """ 112 | Return True if the given node has any metadata. 113 | 114 | Args: 115 | node: An MObject, PyNode, or string representing a node. 116 | """ 117 | return pm_utils.has_attr(node, core.METADATA_ATTR) 118 | 119 | 120 | def has_metaclass(node: Union[api.MObject, pm.nt.DependNode, str], class_name: str) -> bool: 121 | """ 122 | Return True if the given node has data for the given metaclass type 123 | 124 | Args: 125 | node: An MObject, PyNode, or string representing a node. 126 | class_name: The metaclass name to check for. 127 | """ 128 | return pm_utils.has_attr(node, core.METACLASS_ATTR_PREFIX + class_name) 129 | 130 | 131 | def find_meta_nodes(class_name: str = None, as_py_nodes=True) -> Union[list[pm.PyNode], list[api.MObject]]: 132 | """ 133 | Return a list of all meta nodes of the given class type. If no class is given, 134 | all nodes with metadata are returned. 135 | 136 | Args: 137 | class_name: The metaclass name to search for, or None to find all metadata nodes. 138 | as_py_nodes: Return a list of PyNodes. If false, return a list of MObjects. 139 | 140 | Returns: 141 | A list of PyNodes or MObjects that have metadata. 142 | """ 143 | objs: list[api.MObject] = core.find_meta_nodes(class_name) 144 | 145 | if as_py_nodes: 146 | return [pm.PyNode(obj) for obj in objs] 147 | else: 148 | return objs 149 | 150 | 151 | def get_metadata(node: Union[pm.nt.DependNode, str], class_name: str = None) -> Union[dict, Any]: 152 | """ 153 | Return the metadata on a node. If `class_name` is given, return only data for that metaclass. 154 | 155 | Args: 156 | node: A PyNode or string node name. 157 | class_name: The metaclass of the data to find and return. 158 | 159 | Returns: 160 | A dict if returning all metadata, or potentially any value if returning data for a specific class. 161 | """ 162 | return PyMelMetadataController.from_node(node).get_metadata(class_name) 163 | 164 | 165 | def set_metadata(node: Union[pm.nt.DependNode, str], class_name: str, data: Any, undoable=True, replace=False): 166 | """ 167 | Set the metadata for a metaclass type on a node. 168 | 169 | The class_name must be a valid attribute name. 170 | 171 | Args: 172 | node: The node on which to set data. 173 | class_name: The data's metaclass type name. 174 | data: The data to serialize and store on the node. 175 | undoable: Make the operation undoable by using cmds instead of the api. 176 | replace: Replace all metadata on the node with the new metadata. 177 | This uses set_all_metadata and can be much faster with large data sets, 178 | but will remove data for any other metaclass types. 179 | """ 180 | return PyMelMetadataController.from_node(node, undoable=undoable).set_metadata(class_name, data, replace) 181 | 182 | 183 | def set_all_metadata(node: Union[pm.nt.DependNode, str], data: dict, undoable=True): 184 | """ 185 | Set all metadata on a node. This is faster because the existing data 186 | on the node is not retrieved first and then modified. 187 | 188 | The data must be of the form {"": } otherwise errors 189 | may occur when retrieving it later. 190 | 191 | New metaclass attributes will be added automatically, but existing metaclass 192 | attributes will not be removed. If old metaclass attributes on this node will 193 | no longer be applicable, they should be removed with `remove_metadata` first. 194 | 195 | Args: 196 | node: The node on which to set data. 197 | data: The data to serialize and store on the node. 198 | undoable: Make the operation undoable by using cmds instead of the api. 199 | """ 200 | return PyMelMetadataController.from_node(node, undoable=undoable).set_all_metadata(data) 201 | 202 | 203 | def update_metadata(node: Union[pm.nt.DependNode, str], class_name: str, data: dict): 204 | """ 205 | Update existing dict metadata on a node for a metaclass type. 206 | 207 | Args: 208 | node: A PyNode or string node name. 209 | class_name: A string name of the metaclass type. 210 | data: A dict object containing metadata to add to the node. 211 | 212 | Raises: 213 | ValueError: The existing metadata on the node for the given metaclass was not a dict. 214 | """ 215 | return PyMelMetadataController.from_node(node).update_metadata(class_name, data) 216 | 217 | 218 | def remove_metadata(node: Union[pm.nt.DependNode, str], class_name: str = None, undoable=True) -> bool: 219 | """ 220 | Remove metadata from a node. If no `class_name` is given 221 | then all metadata is removed. 222 | 223 | Args: 224 | node: A PyNode or string node name. 225 | class_name: A string name of the metaclass type. 226 | undoable: Make the operation undoable by using cmds instead of the api. 227 | 228 | Returns: 229 | True if node is fully clean of relevant metadata. 230 | """ 231 | if not is_meta_node(node): 232 | return True 233 | 234 | return PyMelMetadataController.from_node(node, undoable=undoable).remove_metadata(class_name) 235 | -------------------------------------------------------------------------------- /src/pymetanode/scripts/pymetanode/pm_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyMel wrappers for utils. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import logging 8 | from typing import Union, Optional 9 | 10 | import maya.OpenMaya as api 11 | import pymel.core as pm 12 | 13 | from . import core_utils, utils 14 | 15 | __all__ = [ 16 | "find_node_by_id", 17 | "find_node_by_name", 18 | "find_node_by_uuid", 19 | "get_m_object", 20 | "get_mfn_node", 21 | "get_node_id", 22 | "get_uuid", 23 | "has_attr", 24 | "is_node", 25 | ] 26 | 27 | LOG = logging.getLogger(__name__) 28 | 29 | 30 | def has_attr(node: Union[api.MObject, pm.nt.DependNode, str], attr_name: str) -> bool: 31 | """ 32 | Return True if the given node has the given attribute. 33 | 34 | Runs a fast version of has_attr if the node is an MObject, otherwise falls back to using `cmds.objExists`. 35 | 36 | Args: 37 | node: An MObject, PyNode, or string representing a node. 38 | attr_name: The name of the attribute to find. 39 | """ 40 | if isinstance(node, pm.nt.DependNode): 41 | return core_utils.has_attr_fast(node.__apimobject__(), attr_name) 42 | return utils.has_attr(node, attr_name) 43 | 44 | 45 | def get_m_object(node: Union[pm.nt.DependNode, str]) -> Optional[api.MObject]: 46 | """ 47 | Return the MObject for a node. 48 | 49 | Args: 50 | node: A PyNode or string node name. 51 | 52 | Returns: 53 | An MObject, or None if the node was not found. 54 | """ 55 | if isinstance(node, pm.nt.DependNode): 56 | return node.__apimobject__() 57 | return utils.get_m_object(node) 58 | 59 | 60 | def get_mfn_node(node: Union[api.MObject, pm.nt.DependNode, str]) -> api.MFnDependencyNode: 61 | """ 62 | Return an MFnDependencyNode for a node. 63 | 64 | Args: 65 | node: An MObject, PyNode, or string node name. 66 | """ 67 | if isinstance(node, pm.nt.DependNode): 68 | if node.exists(): 69 | return node.__apimfn__() 70 | return utils.get_mfn_node(node) 71 | 72 | 73 | def is_node(obj: Union[api.MObject, pm.nt.DependNode, str]) -> bool: 74 | """ 75 | Return True if an object represents a Maya node. 76 | 77 | Args: 78 | obj: A MObject, PyNode, uuid, node id, or string node name. 79 | """ 80 | if isinstance(obj, pm.nt.DependNode): 81 | return True 82 | return utils.is_node(obj) 83 | 84 | 85 | def get_uuid(node: Union[api.MObject, pm.nt.DependNode, str]) -> str: 86 | """ 87 | Return the UUID of a node. 88 | 89 | Args: 90 | node: A MObject, PyNode, or string node name. 91 | """ 92 | return core_utils.get_mfn_node_uuid(get_mfn_node(node)) 93 | 94 | 95 | def find_node_by_uuid(uuid: str, ref_node: str = None) -> Optional[pm.PyNode]: 96 | """ 97 | Find and return a node by its UUID. 98 | 99 | Args: 100 | uuid: A string UUID representing the node. 101 | ref_node: The name of the reference node that contains the node to find. 102 | 103 | Returns: 104 | A PyNode with the UUID from the given reference, or None if not found. 105 | """ 106 | nodes = pm.ls(uuid) 107 | if not nodes: 108 | return 109 | 110 | if ref_node: 111 | # return the first node that belongs to the given reference 112 | for node in nodes: 113 | node_name = str(node) 114 | if core_utils.is_node_from_ref(node_name, ref_node): 115 | return node 116 | else: 117 | # take the first result 118 | return nodes[0] 119 | 120 | 121 | def find_node_by_name(name: str, ref_node: str = None) -> Optional[pm.PyNode]: 122 | """ 123 | Find and return a node by its name. 124 | 125 | Args: 126 | name: A string representing the node name. 127 | ref_node: The name of the reference node that contains the node to find. 128 | 129 | Returns: 130 | A PyNode. 131 | """ 132 | nodes = pm.ls(name) 133 | if not nodes: 134 | return 135 | 136 | if ref_node: 137 | # return the first node that belongs to the given reference 138 | for node in nodes: 139 | node_name = str(node) 140 | if core_utils.is_node_from_ref(node_name, ref_node): 141 | return node 142 | else: 143 | # take the first result 144 | return nodes[0] 145 | 146 | 147 | def get_node_id(node: Union[api.MObject, pm.nt.DependNode, str]) -> str: 148 | """ 149 | Return a string representation of a node that includes both its name and UUID. 150 | 151 | Args: 152 | node: A MObject, PyNode, or string node name. 153 | """ 154 | return core_utils.get_mfn_node_id(get_mfn_node(node)) 155 | 156 | 157 | def find_node_by_id(node_id: str, ref_node: str = None) -> Optional[pm.PyNode]: 158 | """ 159 | Find and return a node by id. 160 | 161 | Args: 162 | node_id: A string representing the node, in the format of either a UUID, or name[UUID] . 163 | ref_node: The name of the reference node that contains the node to find. 164 | """ 165 | node_name, uuid = core_utils.parse_node_id(node_id) 166 | 167 | # try finding by UUID first 168 | node = find_node_by_uuid(uuid, ref_node) 169 | if node: 170 | return node 171 | 172 | # try finding by name as a fallback 173 | if node_name: 174 | node = find_node_by_name(node_name, ref_node) 175 | if node: 176 | return node 177 | 178 | LOG.error("Could not find node by UUID or name: %s", node_id) 179 | return None 180 | -------------------------------------------------------------------------------- /src/pymetanode/scripts/pymetanode/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utils for working with nodes and the maya api. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import logging 8 | from typing import Union, Optional 9 | 10 | import maya.OpenMaya as api 11 | from maya import cmds 12 | 13 | from . import core_utils 14 | 15 | __all__ = [ 16 | "find_node_by_id", 17 | "find_node_by_name", 18 | "find_node_by_uuid", 19 | "get_m_object", 20 | "get_mfn_node", 21 | "get_node_id", 22 | "get_uuid", 23 | "has_attr", 24 | "is_node", 25 | ] 26 | 27 | LOG = logging.getLogger(__name__) 28 | 29 | 30 | def has_attr(node: Union[api.MObject, str], attr_name: str) -> bool: 31 | """ 32 | Return True if the given node has the given attribute. 33 | 34 | Runs a fast version of has_attr if the node is an MObject, otherwise falls back to using `cmds.objExists`. 35 | 36 | Args: 37 | node: An MObject or string representing a node. 38 | attr_name: The name of the attribute to find. 39 | """ 40 | if isinstance(node, api.MObject): 41 | return core_utils.has_attr_fast(node, attr_name) 42 | else: 43 | return cmds.objExists(node + "." + attr_name) 44 | 45 | 46 | def get_m_object(node: str) -> Optional[api.MObject]: 47 | """ 48 | Return the MObject for a node. 49 | 50 | Args: 51 | node: A string node name. 52 | 53 | Returns: 54 | An MObject, or None if the node was not found. 55 | """ 56 | sel = api.MSelectionList() 57 | try: 58 | sel.add(node) 59 | except RuntimeError: 60 | # node does not exist or invalid arg 61 | return 62 | m_object = api.MObject() 63 | sel.getDependNode(0, m_object) 64 | return m_object 65 | 66 | 67 | def get_mfn_node(node: Union[api.MObject, str]) -> api.MFnDependencyNode: 68 | """ 69 | Return an MFnDependencyNode for a node. 70 | 71 | Args: 72 | node: An MObject, or string node name. 73 | """ 74 | if isinstance(node, api.MObject): 75 | return api.MFnDependencyNode(node) 76 | else: 77 | m_object = get_m_object(node) 78 | if m_object: 79 | return api.MFnDependencyNode(m_object) 80 | 81 | 82 | def is_node(obj: Union[api.MObject, str]) -> bool: 83 | """ 84 | Return True if an object represents a Maya node. 85 | 86 | Args: 87 | obj: A MObject, uuid, node id, or string node name. 88 | """ 89 | if isinstance(obj, api.MObject): 90 | return True 91 | elif isinstance(obj, str): 92 | return core_utils.is_node_id(obj) or core_utils.is_uuid(obj) or cmds.objExists(obj) 93 | return False 94 | 95 | 96 | def get_uuid(node: Union[api.MObject, str]) -> str: 97 | """ 98 | Return the UUID of a node. 99 | 100 | Args: 101 | node: A MObject or string node name. 102 | """ 103 | return core_utils.get_mfn_node_uuid(get_mfn_node(node)) 104 | 105 | 106 | def find_node_by_uuid(uuid: str, ref_node: str = None) -> Optional[str]: 107 | """ 108 | Find and return a node by its UUID. 109 | 110 | Args: 111 | uuid: A string UUID representing the node. 112 | ref_node: The name of the reference node that contains the node to find. 113 | 114 | Returns: 115 | The name of a node with the UUID from the given reference, or None if not found. 116 | """ 117 | node_names: list[str] = cmds.ls(uuid) 118 | if not node_names: 119 | return 120 | 121 | if ref_node: 122 | # return the first node that belongs to the given reference 123 | for node_name in node_names: 124 | if core_utils.is_node_from_ref(node_name, ref_node): 125 | return node_name 126 | else: 127 | # take the first result 128 | return node_names[0] 129 | 130 | 131 | def find_node_by_name(name: str, ref_node: str = None) -> Optional[str]: 132 | """ 133 | Find and return a node by its name, selecting the one from a specific reference if given. 134 | 135 | Args: 136 | name: A string representing the node name. 137 | ref_node: The name of the reference node that contains the node to find. 138 | 139 | Returns: 140 | A string node name. 141 | """ 142 | node_names: list[str] = cmds.ls(name) 143 | if not node_names: 144 | return 145 | 146 | if ref_node: 147 | # return the first node that belongs to the given reference 148 | for node_name in node_names: 149 | if core_utils.is_node_from_ref(node_name, ref_node): 150 | return node_name 151 | else: 152 | # take the first result 153 | return node_names[0] 154 | 155 | 156 | def get_node_id(node: Union[api.MObject, str]) -> str: 157 | """ 158 | Return a string representation of a node that includes both its name and UUID. 159 | 160 | Args: 161 | node: A MObject or string node name. 162 | """ 163 | return core_utils.get_mfn_node_id(get_mfn_node(node)) 164 | 165 | 166 | def find_node_by_id(node_id: str, ref_node: str = None) -> Optional[str]: 167 | """ 168 | Find and return a node by id. 169 | 170 | Args: 171 | node_id: A string representing the node, in the format of either a UUID, or name@UUID. 172 | ref_node: The name of the reference node that contains the node to find. 173 | """ 174 | node_name, uuid = core_utils.parse_node_id(node_id) 175 | 176 | # try finding by UUID first 177 | node_name = find_node_by_uuid(uuid, ref_node) 178 | if node_name: 179 | return node_name 180 | 181 | # try finding by name as a fallback 182 | if node_name: 183 | node_name = find_node_by_name(node_name, ref_node) 184 | if node_name: 185 | return node_name 186 | 187 | LOG.error("Could not find node by UUID or name: %s", node_id) 188 | return None 189 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bohdon/maya-pymetanode/d185d681f357a368021cfefbe6a1044bd4d9ed26/tests/__init__.py -------------------------------------------------------------------------------- /tests/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | 5 | import maya.standalone 6 | 7 | IS_PYMEL_AVAILABLE = False 8 | try: 9 | import pymel 10 | 11 | IS_PYMEL_AVAILABLE = True 12 | except ImportError: 13 | pass 14 | 15 | 16 | def update_sys_paths(): 17 | """ 18 | Update `sys.path` to contain any missing script paths found in MAYA_SCRIPT_PATH. 19 | This must be performed after maya standalone is initialized 20 | """ 21 | script_paths = os.environ["MAYA_SCRIPT_PATH"].split(":") 22 | for p in script_paths: 23 | if p not in sys.path: 24 | sys.path.append(p) 25 | 26 | 27 | def run_tests(): 28 | suite = unittest.TestSuite() 29 | 30 | # lazy loading to wait for maya env to be initialized 31 | if IS_PYMEL_AVAILABLE: 32 | import test_pm_core 33 | 34 | suite.addTests(unittest.TestLoader().loadTestsFromModule(test_pm_core)) 35 | else: 36 | import test_core 37 | 38 | suite.addTests(unittest.TestLoader().loadTestsFromModule(test_core)) 39 | 40 | unittest.TextTestRunner(verbosity=2).run(suite) 41 | 42 | 43 | def main(): 44 | maya.standalone.initialize() 45 | update_sys_paths() 46 | # insert the package to test at beginning of sys path in 47 | # case its already installed in maya modules path 48 | module_scripts = os.path.join(sys.argv[1], "scripts") 49 | sys.path.insert(0, os.path.abspath(module_scripts)) 50 | run_tests() 51 | 52 | 53 | main() 54 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from maya import cmds 4 | 5 | import pymetanode.api as meta 6 | 7 | 8 | class TestMetaData(unittest.TestCase): 9 | def setUp(self): 10 | self.node = cmds.group(empty=True) 11 | 12 | def tearDown(self): 13 | cmds.delete(self.node) 14 | 15 | def test_set_and_get_data(self): 16 | cmds.namespace(add="test_ns") 17 | node_a = cmds.group(name="node_a", empty=True) 18 | node_b = cmds.group(name="test_ns:node_b", empty=True) 19 | 20 | set_data = [ 21 | "myData", 22 | {"a": 1, "b": 2}, 23 | ("x", "y", "z"), 24 | [node_a, node_b], 25 | ] 26 | class_name = "myMetaClass" 27 | meta.set_metadata(self.node, class_name, set_data) 28 | self.assertEqual(meta.get_metadata(self.node, class_name), set_data) 29 | 30 | def test_multi_class_data(self): 31 | cls1 = "myMetaClass1" 32 | cls2 = "myMetaClass2" 33 | meta.set_metadata(self.node, cls1, None) 34 | meta.set_metadata(self.node, cls2, None) 35 | self.assertEqual(meta.get_metadata(self.node), {cls1: None, cls2: None}) 36 | 37 | def test_remove_data(self): 38 | meta.set_metadata(self.node, "myMetaClass", None) 39 | self.assertTrue(meta.is_meta_node(self.node)) 40 | result = meta.remove_metadata(self.node) 41 | self.assertTrue(result) 42 | self.assertFalse(meta.is_meta_node(self.node)) 43 | 44 | def test_remove_locked_data(self): 45 | meta.set_metadata(self.node, "myMetaClass", "myTestData") 46 | cmds.setAttr(self.node + "." + meta.core.METADATA_ATTR, edit=True, lock=True) 47 | result = meta.remove_metadata(self.node) 48 | self.assertFalse(result) 49 | data = meta.get_metadata(self.node) 50 | self.assertEqual(data, {"myMetaClass": "myTestData"}) 51 | 52 | def test_remove_class_data(self): 53 | meta.set_metadata(self.node, "myMetaClass", None) 54 | meta.set_metadata(self.node, "mySecondMetaClass", None) 55 | result = meta.remove_metadata(self.node, "myMetaClass") 56 | self.assertTrue(result) 57 | self.assertTrue(meta.is_meta_node(self.node)) 58 | self.assertFalse(meta.has_metaclass(self.node, "myMetaClass")) 59 | self.assertTrue(meta.has_metaclass(self.node, "mySecondMetaClass")) 60 | 61 | def test_remove_locked_class(self): 62 | meta.set_metadata(self.node, "myMetaClass", "myTestData") 63 | cmds.setAttr(self.node + "." + meta.core.METACLASS_ATTR_PREFIX + "myMetaClass", edit=True, lock=True) 64 | result = meta.remove_metadata(self.node) 65 | self.assertFalse(result) 66 | data = meta.get_metadata(self.node) 67 | self.assertEqual(data, {"myMetaClass": "myTestData"}) 68 | 69 | 70 | class TestNodeFinding(unittest.TestCase): 71 | def setUp(self): 72 | self.node_a = cmds.group(empty=True) 73 | meta.set_metadata(self.node_a, "ClassA", "A") 74 | self.node_b = cmds.group(empty=True) 75 | meta.set_metadata(self.node_b, "ClassB", "B") 76 | meta.set_metadata(self.node_b, "ClassD", "D") 77 | self.node_c = cmds.group(empty=True) 78 | meta.set_metadata(self.node_c, "ClassC", "C") 79 | meta.set_metadata(self.node_c, "ClassD", "D") 80 | 81 | def tearDown(self): 82 | cmds.delete([self.node_a, self.node_b, self.node_c]) 83 | 84 | def test_find_all(self): 85 | nodes = meta.find_meta_nodes() 86 | self.assertTrue(self.node_a in nodes) 87 | self.assertTrue(self.node_b in nodes) 88 | self.assertTrue(self.node_c in nodes) 89 | 90 | def test_find_class_a(self): 91 | nodes = meta.find_meta_nodes("ClassA") 92 | self.assertTrue(self.node_a in nodes) 93 | self.assertTrue(self.node_b not in nodes) 94 | self.assertTrue(self.node_c not in nodes) 95 | 96 | def test_find_class_d(self): 97 | nodes = meta.find_meta_nodes("ClassD") 98 | self.assertTrue(self.node_a not in nodes) 99 | self.assertTrue(self.node_b in nodes) 100 | self.assertTrue(self.node_c in nodes) 101 | -------------------------------------------------------------------------------- /tests/test_pm_core.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import pymel.core as pm 4 | 5 | import pymetanode as meta 6 | 7 | 8 | class TestMetaData(unittest.TestCase): 9 | def setUp(self): 10 | self.node = pm.group(empty=True) 11 | 12 | def tearDown(self): 13 | pm.delete(self.node) 14 | 15 | def test_set_and_get_data(self): 16 | pm.namespace(add="test_ns") 17 | node_a = pm.group(name="node_a", empty=True) 18 | node_b = pm.group(name="test_ns:node_b", empty=True) 19 | 20 | set_data = [ 21 | "myData", 22 | {"a": 1, "b": 2}, 23 | ("x", "y", "z"), 24 | [node_a, node_b], 25 | ] 26 | class_name = "myMetaClass" 27 | meta.set_metadata(self.node, class_name, set_data) 28 | self.assertEqual(meta.get_metadata(self.node, class_name), set_data) 29 | 30 | def test_multi_class_data(self): 31 | cls1 = "myMetaClass1" 32 | cls2 = "myMetaClass2" 33 | meta.set_metadata(self.node, cls1, None) 34 | meta.set_metadata(self.node, cls2, None) 35 | self.assertEqual(meta.get_metadata(self.node), {cls1: None, cls2: None}) 36 | 37 | def test_remove_data(self): 38 | meta.set_metadata(self.node, "myMetaClass", None) 39 | self.assertTrue(meta.is_meta_node(self.node)) 40 | result = meta.remove_metadata(self.node) 41 | self.assertTrue(result) 42 | self.assertFalse(meta.is_meta_node(self.node)) 43 | 44 | def test_remove_locked_data(self): 45 | meta.set_metadata(self.node, "myMetaClass", "myTestData") 46 | self.node.attr(meta.core.METADATA_ATTR).setLocked(True) 47 | result = meta.remove_metadata(self.node) 48 | self.assertFalse(result) 49 | data = meta.get_metadata(self.node) 50 | self.assertEqual(data, {"myMetaClass": "myTestData"}) 51 | 52 | def test_remove_class_data(self): 53 | meta.set_metadata(self.node, "myMetaClass", None) 54 | meta.set_metadata(self.node, "mySecondMetaClass", None) 55 | result = meta.remove_metadata(self.node, "myMetaClass") 56 | self.assertTrue(result) 57 | self.assertTrue(meta.is_meta_node(self.node)) 58 | self.assertFalse(meta.has_metaclass(self.node, "myMetaClass")) 59 | self.assertTrue(meta.has_metaclass(self.node, "mySecondMetaClass")) 60 | 61 | def test_remove_locked_class(self): 62 | meta.set_metadata(self.node, "myMetaClass", "myTestData") 63 | self.node.attr(meta.core.METACLASS_ATTR_PREFIX + "myMetaClass").setLocked(True) 64 | result = meta.remove_metadata(self.node) 65 | self.assertFalse(result) 66 | data = meta.get_metadata(self.node) 67 | self.assertEqual(data, {"myMetaClass": "myTestData"}) 68 | 69 | 70 | class TestNodeFinding(unittest.TestCase): 71 | def setUp(self): 72 | self.node_a = pm.group(empty=True) 73 | meta.set_metadata(self.node_a, "ClassA", "A") 74 | self.node_b = pm.group(empty=True) 75 | meta.set_metadata(self.node_b, "ClassB", "B") 76 | meta.set_metadata(self.node_b, "ClassD", "D") 77 | self.node_c = pm.group(empty=True) 78 | meta.set_metadata(self.node_c, "ClassC", "C") 79 | meta.set_metadata(self.node_c, "ClassD", "D") 80 | 81 | def tearDown(self): 82 | pm.delete([self.node_a, self.node_b, self.node_c]) 83 | 84 | def test_find_all(self): 85 | nodes = meta.find_meta_nodes() 86 | self.assertTrue(self.node_a in nodes) 87 | self.assertTrue(self.node_b in nodes) 88 | self.assertTrue(self.node_c in nodes) 89 | 90 | def test_find_class_a(self): 91 | nodes = meta.find_meta_nodes("ClassA") 92 | self.assertTrue(self.node_a in nodes) 93 | self.assertTrue(self.node_b not in nodes) 94 | self.assertTrue(self.node_c not in nodes) 95 | 96 | def test_find_class_d(self): 97 | nodes = meta.find_meta_nodes("ClassD") 98 | self.assertTrue(self.node_a not in nodes) 99 | self.assertTrue(self.node_b in nodes) 100 | self.assertTrue(self.node_c in nodes) 101 | --------------------------------------------------------------------------------