maya-brew 0.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
maya_brew/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ import logging
2
+ import os
3
+
4
+ import maya.api._OpenMaya_py2 as OpenMaya2 # noqa
5
+ import maya.cmds as cmds # noqa: F401
6
+
7
+ PACKAGE_NAME = "MAYA_BREW"
8
+
9
+
10
+ def get_bool_env_variable(name: str, default=True):
11
+ value = os.getenv(name)
12
+ if value is None:
13
+ return default
14
+ return value.lower() in ("true", "1", "t", "yes", "y")
15
+
16
+
17
+ MAYA_BREW_AUTOINIT = get_bool_env_variable(f"{PACKAGE_NAME}_AUTOINIT", True)
18
+
19
+
20
+ def init():
21
+ old_handlers = logging.root.handlers[:]
22
+ old_log_level = logging.getLogger().level
23
+ import maya_brew.log
24
+ from maya_brew.maya_initializer.shared import is_interactive_maya
25
+
26
+ interactive = is_interactive_maya()
27
+ if interactive:
28
+ maya_brew.log.configure(use_colors=False)
29
+ else:
30
+ maya_brew.log.configure(
31
+ use_colors=get_bool_env_variable(f"{PACKAGE_NAME}_USE_LOG_COLORS", True)
32
+ )
33
+ logger = maya_brew.log.get_logger(__name__)
34
+ logger.info("Initializing maya...")
35
+ os.environ["MAYA_NO_WARNING_FOR_MISSING_DEFAULT_RENDERER"] = "1"
36
+ if is_interactive_maya():
37
+ from maya_brew.maya_initializer import interactive_init
38
+
39
+ interactive_init.initialize_maya()
40
+ else:
41
+ from maya_brew.maya_initializer import standalone_init
42
+
43
+ standalone_init.initialize_maya()
44
+ logging.root.handlers = old_handlers
45
+ logging.getLogger().setLevel(old_log_level)
46
+ logging.debug("maya initialized")
47
+
48
+
49
+ if MAYA_BREW_AUTOINIT:
50
+ init()
File without changes
@@ -0,0 +1,176 @@
1
+ import typing
2
+
3
+ from .. import OpenMaya2, cmds
4
+ from ..nodes import cast
5
+ from ..nodes.node_types import DagNode, Node
6
+
7
+ PlugInput = typing.Union[OpenMaya2.MPlug, str]
8
+ A = typing.TypeVar("A", bound="Attribute")
9
+
10
+
11
+ class Attribute:
12
+ _getter_type: str
13
+
14
+ def __new__(cls: type[A], plug_or_path: PlugInput) -> A:
15
+ if cls is not Attribute:
16
+ return typing.cast(A, super().__new__(cls))
17
+
18
+ if isinstance(plug_or_path, OpenMaya2.MPlug):
19
+ plug = plug_or_path
20
+ elif isinstance(plug_or_path, str):
21
+ plug = cls._plug_from_path(plug_or_path)
22
+ else:
23
+ raise ValueError("plug_or_path must be an MPlug or attribute path string.")
24
+
25
+ try:
26
+ mobj_attr = plug.attribute()
27
+ except (RuntimeError, ValueError) as e:
28
+ raise RuntimeError(
29
+ f"Failed to obtain MObject attribute for plug {plug}"
30
+ ) from e
31
+
32
+ api_type = getattr(mobj_attr, "apiTypeStr", None)
33
+ if not api_type:
34
+ raise RuntimeError(
35
+ f"Attribute object for plug {plug} has no apiTypeStr; cannot dispatch."
36
+ )
37
+
38
+ try:
39
+ subclass: type[Attribute] = _API_TYPE_SUBCLASS_MAP[api_type]
40
+ except KeyError:
41
+ raise NotImplementedError(
42
+ f"Unsupported attribute apiTypeStr '{api_type}'. "
43
+ f"Known types: {sorted(_API_TYPE_SUBCLASS_MAP)}"
44
+ )
45
+
46
+ instance = super().__new__(subclass)
47
+ setattr(instance, "_pre_init_plug", plug)
48
+ return typing.cast(A, instance)
49
+
50
+ @typing.overload
51
+ def __init__(self, plug_or_path: str): ...
52
+
53
+ @typing.overload
54
+ def __init__(self, plug_or_path: OpenMaya2.MPlug): ...
55
+
56
+ def __init__(self, plug_or_path: PlugInput):
57
+ # _pre_init_plug is injected by Attribute.__new__ ONLY when the user called
58
+ # Attribute(...) (base class factory dispatch). Direct subclass construction
59
+ # (e.g. FloatAttribute(...)) bypasses that path, so pre will be None and we
60
+ # must resolve plug_or_path here.
61
+ pre = getattr(self, "_pre_init_plug", None)
62
+ if pre is not None:
63
+ self.plug = pre
64
+ delattr(self, "_pre_init_plug")
65
+ return
66
+
67
+ if isinstance(plug_or_path, OpenMaya2.MPlug):
68
+ self.plug = plug_or_path
69
+ elif isinstance(plug_or_path, str):
70
+ self.plug = self._plug_from_path(plug_or_path)
71
+ else:
72
+ raise ValueError("plug_or_path must be an MPlug or attribute path string.")
73
+
74
+ @staticmethod
75
+ def _plug_from_path(path: str) -> OpenMaya2.MPlug:
76
+ """
77
+ Resolve a string path to an MPlug.
78
+ :param path: The full path to the attribute, e.g. '|grp|node|nodeShape.visibility'
79
+ :return: The MPlug for the attribute.
80
+ """
81
+ # Split path into node path and attribute name
82
+ if "." not in path:
83
+ raise ValueError(
84
+ "Attribute path must include a '.' separating node and attribute."
85
+ )
86
+ node_path, attr_name = path.rsplit(".", 1)
87
+ node_dag_path = cast.get_dag_path_from_string(node_path)
88
+ node = DagNode(node_dag_path.fullPathName())
89
+ plug = Attribute._get_plug_from_node(node, attr_name)
90
+ return plug
91
+
92
+ def __str__(self):
93
+ return self.name()
94
+
95
+ def get(self):
96
+ return self._get_plug_value(self.plug)
97
+
98
+ def set(self, value):
99
+ attr_name = self.plug.name()
100
+ cmds.setAttr(attr_name, value)
101
+
102
+ def node(self):
103
+ return self._get_node_from_plug(self.plug)
104
+
105
+ def name(self):
106
+ raise NotImplementedError
107
+
108
+ def connect(self, dest: "Attribute", force: bool = False, next_available=False):
109
+ """
110
+ Connect this attribute to another attribute.
111
+ :param dest: The destination attribute to connect to.
112
+ :param force: Whether to force the connection if the destination is already connected.
113
+ :param next_available: Whether to connect to the next available index if the destination is an array attribute.
114
+ """
115
+ cmds.connectAttr(
116
+ self.plug.name(),
117
+ dest.plug.name(),
118
+ force=force,
119
+ nextAvailable=next_available,
120
+ )
121
+
122
+ def disconnect(self, dest: "Attribute", next_available=False):
123
+ """
124
+ Disconnect this attribute from another attribute.
125
+ :param dest: The destination attribute to disconnect from.
126
+ :param next_available: Whether to disconnect from the next available index if the destination is an
127
+ array attribute.
128
+ """
129
+ cmds.disconnectAttr(
130
+ self.plug.name(), dest.plug.name(), nextAvailable=next_available
131
+ )
132
+
133
+ @classmethod
134
+ def _get_value(cls, node: DagNode, attr_name: str):
135
+ plug = cls._get_plug_from_node(node, attr_name)
136
+ return cls._get_plug_value(plug)
137
+
138
+ @staticmethod
139
+ def _get_plug_from_node(node: DagNode, attr_name: str) -> OpenMaya2.MPlug:
140
+ fn_dep = node.get_mfndependency_node()
141
+ return fn_dep.findPlug(attr_name, False)
142
+
143
+ @staticmethod
144
+ def _get_node_from_plug(plug: OpenMaya2.MPlug) -> Node:
145
+ mobj = plug.node()
146
+ if mobj.hasFn(OpenMaya2.MFn.kDagNode):
147
+ return DagNode(OpenMaya2.MFnDagNode(mobj).fullPathName())
148
+ else:
149
+ name = OpenMaya2.MFnDependencyNode(mobj).name()
150
+ return Node(name)
151
+
152
+ @classmethod
153
+ def _get_plug_value(cls, plug: OpenMaya2.MPlug):
154
+ return getattr(plug, cls._getter_type)()
155
+
156
+
157
+ class FloatAttribute(Attribute):
158
+ _getter_type = "asDouble"
159
+
160
+
161
+ class MessageAttribute(Attribute):
162
+ _getter_type = "kMessage"
163
+
164
+ @classmethod
165
+ def _get_plug_value(cls, plug: OpenMaya2.MPlug):
166
+ raise AttributeError("Message attributes do not hold data.")
167
+
168
+ @classmethod
169
+ def set(cls, value):
170
+ raise AttributeError("Message attributes are not settable.")
171
+
172
+
173
+ _API_TYPE_SUBCLASS_MAP = {
174
+ "kDoubleLinearAttribute": FloatAttribute,
175
+ "kMessageAttribute": MessageAttribute,
176
+ }
@@ -0,0 +1,4 @@
1
+ class MayaBrewException(Exception):
2
+ """Base class for all MayaBrew exceptions."""
3
+
4
+ pass
maya_brew/log.py ADDED
@@ -0,0 +1,338 @@
1
+ """
2
+ Utilities for setting up logging in modules
3
+ """
4
+
5
+ import datetime
6
+ import fnmatch
7
+ import logging
8
+ import os
9
+ import sys
10
+ import threading
11
+ import time
12
+ from typing import Callable, List, Optional, Union
13
+
14
+ import structlog
15
+
16
+ from . import PACKAGE_NAME, get_bool_env_variable
17
+
18
+ PROPAGATE = get_bool_env_variable(f"{PACKAGE_NAME}_LOG_PROPAGATE", False)
19
+
20
+
21
+ TELEMETRY_PROCESSORS: List[Callable] = []
22
+
23
+
24
+ def telemetry_runner(target_logger, method_name, event_dict):
25
+ """
26
+ Runs processors for telemetry events or events where logger is enabled for event level.
27
+ Drops telemetry events after processing
28
+ """
29
+ is_telemetry_event = event_dict.get("event_type") == "telemetry"
30
+ if is_telemetry_event:
31
+ run_telemetry_processors(target_logger, method_name, event_dict)
32
+ raise structlog.DropEvent
33
+ event_level = event_dict.get("level")
34
+ numeric_level = getattr(logging, event_level.upper(), None)
35
+ if target_logger.isEnabledFor(numeric_level):
36
+ run_telemetry_processors(target_logger, method_name, event_dict)
37
+ return event_dict
38
+
39
+
40
+ def run_telemetry_processors(target_logger, method_name, event_dict):
41
+ for processor in TELEMETRY_PROCESSORS:
42
+ processor(target_logger, method_name, event_dict)
43
+ return event_dict
44
+
45
+
46
+ def format_console_time(target_logger, method_name, event_dict):
47
+ event_dict["timestamp"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
48
+ return event_dict
49
+
50
+
51
+ def configure(use_colors=False):
52
+ # maya doesn't seem to play nice with colors, and once structlog is configured to use colors it seems to
53
+ # cause problems when reconfiguring without
54
+
55
+ if not structlog.is_configured():
56
+ logging.basicConfig(level=logging.INFO)
57
+ logging.Logger.propagate = bool(PROPAGATE)
58
+
59
+ structlog.configure(
60
+ processors=[
61
+ structlog.stdlib.add_log_level,
62
+ structlog.contextvars.merge_contextvars,
63
+ structlog.stdlib.PositionalArgumentsFormatter(),
64
+ structlog.processors.TimeStamper(fmt="iso", utc=False),
65
+ structlog.processors.CallsiteParameterAdder(
66
+ {
67
+ structlog.processors.CallsiteParameter.FILENAME,
68
+ structlog.processors.CallsiteParameter.PATHNAME,
69
+ structlog.processors.CallsiteParameter.MODULE,
70
+ structlog.processors.CallsiteParameter.FUNC_NAME,
71
+ structlog.processors.CallsiteParameter.LINENO,
72
+ }
73
+ ),
74
+ structlog.processors.StackInfoRenderer(),
75
+ structlog.processors.UnicodeDecoder(),
76
+ telemetry_runner,
77
+ format_console_time,
78
+ structlog.dev.ConsoleRenderer(colors=use_colors),
79
+ ],
80
+ context_class=dict,
81
+ logger_factory=structlog.stdlib.LoggerFactory(),
82
+ wrapper_class=structlog.stdlib.BoundLogger,
83
+ cache_logger_on_first_use=True,
84
+ )
85
+
86
+
87
+ class LogLevelContextManager:
88
+ def __init__(self, target_logger, level):
89
+ self.logger = target_logger
90
+ self.level = level
91
+ self.previous_level = target_logger.getEffectiveLevel()
92
+
93
+ def __enter__(self):
94
+ self.logger.setLevel(self.level)
95
+
96
+ def __call__(self, func):
97
+ """Allow class to be used as decorator"""
98
+
99
+ def decorator(*args, **kwargs):
100
+ with self:
101
+ return func(*args, **kwargs)
102
+
103
+ return decorator
104
+
105
+ def __exit__(self, exc_type, exc_val, exc_tb):
106
+ self.logger.setLevel(self.previous_level)
107
+
108
+
109
+ class LogAllLevelsContextManager:
110
+ def __init__(self, min_level=logging.CRITICAL):
111
+ self.min_level = min_level
112
+
113
+ def __enter__(self):
114
+ logging.disable(self.min_level)
115
+
116
+ def __call__(self, func):
117
+ """Allow class to be used as decorator"""
118
+
119
+ def decorator(*args, **kwargs):
120
+ with self:
121
+ return func(*args, **kwargs)
122
+
123
+ return decorator
124
+
125
+ def __exit__(self, exit_type, exit_value, exit_traceback):
126
+ logging.disable(logging.NOTSET)
127
+
128
+
129
+ class SuppressStdOutStdErr:
130
+ """
131
+ A context manager for doing a "deep suppression" of stdout and stderr in
132
+ Python, i.e. will suppress all print, even if the print originates in a
133
+ compiled C/Fortran sub-function.
134
+ This will not suppress raised exceptions, since exceptions are printed
135
+ to stderr just before a script exits, and after the context manager has
136
+ exited (at least, I think that is why it lets exceptions through).
137
+
138
+ """
139
+
140
+ def __init__(
141
+ self, filter_list: Optional[Union[List[str], str]] = None, enable=True
142
+ ) -> None:
143
+ self.enable = enable
144
+ if self.enable:
145
+ if filter_list is None:
146
+ self.filter_words = []
147
+ elif isinstance(filter_list, str):
148
+ self.filter_words = [filter_list]
149
+ else:
150
+ self.filter_words = filter_list
151
+
152
+ def __enter__(self):
153
+ if self.enable:
154
+ self._stdout = sys.stdout
155
+ self._stderr = sys.stderr
156
+ self._stdout_pipe = os.pipe()
157
+ self._stderr_pipe = os.pipe()
158
+ self._stdout_fd = os.dup(1)
159
+ self._stderr_fd = os.dup(2)
160
+ os.dup2(self._stdout_pipe[1], 1)
161
+ os.dup2(self._stderr_pipe[1], 2)
162
+ self._stdout_thread = self._start_thread(self._stdout_pipe[0], self._stdout)
163
+ self._stderr_thread = self._start_thread(self._stderr_pipe[0], self._stderr)
164
+ os.close(self._stdout_pipe[1])
165
+ os.close(self._stderr_pipe[1])
166
+ return self
167
+
168
+ def __exit__(self, exc_type, exc_value, traceback):
169
+ if self.enable:
170
+ os.dup2(self._stdout_fd, 1)
171
+ os.dup2(self._stderr_fd, 2)
172
+
173
+ os.close(self._stdout_fd)
174
+ os.close(self._stderr_fd)
175
+
176
+ if self._stdout_thread.is_alive():
177
+ self._stdout_thread.join()
178
+ if self._stderr_thread.is_alive():
179
+ self._stderr_thread.join()
180
+
181
+ def _should_filter(self, line) -> bool:
182
+ if not self.filter_words:
183
+ return True
184
+ line = line.strip()
185
+ for pattern in self.filter_words:
186
+ pattern = pattern
187
+ if "*" in pattern:
188
+ if fnmatch.fnmatch(line, pattern):
189
+ return True
190
+ else:
191
+ if pattern in line:
192
+ return True
193
+ return False
194
+
195
+ def _start_thread(self, read_fd, write_stream):
196
+ def _filter_output():
197
+ read_file = os.fdopen(read_fd)
198
+ try:
199
+ for line in read_file:
200
+ if not self._should_filter(line):
201
+ write_stream.write(line)
202
+ write_stream.flush()
203
+ finally:
204
+ read_file.close()
205
+
206
+ thread = threading.Thread(target=_filter_output)
207
+ thread.daemon = True
208
+ thread.start()
209
+ return thread
210
+
211
+
212
+ class ExecutionTimer:
213
+ def __init__(self):
214
+ self.elapsed_time = None
215
+
216
+ def __enter__(self):
217
+ self.start_time = time.time()
218
+ return self
219
+
220
+ def __exit__(self, type, value, traceback):
221
+ end_time = time.time()
222
+ self.elapsed_time = end_time - self.start_time
223
+
224
+
225
+ class SilenceContextManager(SuppressStdOutStdErr):
226
+ pass
227
+
228
+
229
+ class SingleLevelFilter(logging.Filter):
230
+ def __init__(self, passlevel, reject):
231
+ super(SingleLevelFilter, self).__init__()
232
+ self.passlevel = passlevel
233
+ self.reject = reject
234
+
235
+ def filter(self, record):
236
+ if self.reject:
237
+ return record.levelno != self.passlevel
238
+ else:
239
+ return record.levelno == self.passlevel
240
+
241
+
242
+ class CustomLogger(structlog.stdlib.BoundLogger):
243
+ timer: ExecutionTimer
244
+ info_handler: logging.StreamHandler
245
+
246
+ @staticmethod
247
+ def all_log_levels(min_level=logging.CRITICAL):
248
+ return LogAllLevelsContextManager(min_level)
249
+
250
+ @staticmethod
251
+ def silence(filter_list: Optional[List[str]] = None, enable=True):
252
+ return SilenceContextManager(filter_list=filter_list, enable=enable)
253
+
254
+ @staticmethod
255
+ def at_level(target_logger, level):
256
+ return LogLevelContextManager(target_logger, level)
257
+
258
+ @staticmethod
259
+ def info_to_stdout(target_logger=None):
260
+ h1 = logging.StreamHandler(sys.stdout)
261
+ f1 = SingleLevelFilter(logging.INFO, False)
262
+ h1.addFilter(f1)
263
+ target_logger.addHandler(h1)
264
+
265
+ @staticmethod
266
+ def send_telemetry(*args, **kwargs):
267
+ """Sends message to any telemetry handlers on logger"""
268
+ raise NotImplementedError
269
+
270
+ def setLevel(self, level: Union[int, str]) -> None:
271
+ super().setLevel(level) # type: ignore[arg-type]
272
+
273
+
274
+ class TelemetryHandler:
275
+ """Abstract class for logging handlers to send telemetry data"""
276
+
277
+ pass
278
+
279
+
280
+ def get_logger(name) -> CustomLogger:
281
+ """
282
+ Simple stream-logger that can also be used as a context manager.
283
+ Be aware of the hacky adding of methods to existing logger,
284
+ but overriding return type to get correct inspection in IDEs
285
+ """
286
+ logger = structlog.stdlib.get_logger(name) # noqa
287
+
288
+ # Hacks since current versions of pytest doesn't allow subclassing
289
+ # of logging.getLogger
290
+ setattr(logger, "all_log_levels", CustomLogger.all_log_levels)
291
+
292
+ def _silence(*args, **kwargs):
293
+ return CustomLogger.silence(*args, **kwargs) # noqa
294
+
295
+ setattr(logger, "silence", _silence)
296
+
297
+ setattr(logger, "timer", ExecutionTimer)
298
+
299
+ def _send_telemetry(*args, **kwargs):
300
+ kwargs["event_type"] = "telemetry"
301
+ logger.info(*args, **kwargs)
302
+
303
+ setattr(logger, "send_telemetry", _send_telemetry)
304
+
305
+ def _info_to_stdout():
306
+ return CustomLogger.info_to_stdout(logger)
307
+
308
+ setattr(logger, "info_to_stdout", _info_to_stdout)
309
+
310
+ def at_level(level):
311
+ return CustomLogger.at_level(logger, level)
312
+
313
+ setattr(logger, "at_level", at_level)
314
+ return logger # type: ignore[return-value]
315
+
316
+
317
+ def mock_telemetry_processor(logger, method_name, event_dict):
318
+ print(f"Sending to some telemetry service: {event_dict}")
319
+ return event_dict
320
+
321
+
322
+ configure()
323
+
324
+ if __name__ == "__main__":
325
+ TELEMETRY_PROCESSORS.append(mock_telemetry_processor)
326
+ configure(use_colors=True)
327
+ example_logger = get_logger(__name__)
328
+ example_logger.info("Logging configured")
329
+ example_logger.debug(
330
+ "This is a debug message and shouldn't be displayed as logging level is set to info"
331
+ )
332
+ example_logger.setLevel("DEBUG")
333
+ with example_logger.silence(filter_list=["filtered"]):
334
+ example_logger.info("this should be filtered out")
335
+ example_logger.info("this should not be silenced!")
336
+ example_logger.debug(
337
+ "This is a debug message and should be displayed as logging level is set to debug"
338
+ )
File without changes
@@ -0,0 +1,18 @@
1
+ from .. import log
2
+ from .interactive_utils import SuppressScriptEditorOutput
3
+
4
+ # note this logger is defined before SilenceMayaContextManager is added so will not
5
+ # silence script editor output
6
+ logger = log.get_logger(__name__)
7
+ # logger.setLevel("DEBUG")
8
+
9
+
10
+ class SilenceMayaContextManager(
11
+ SuppressScriptEditorOutput, log.LogAllLevelsContextManager
12
+ ):
13
+ pass
14
+
15
+
16
+ def initialize_maya():
17
+ log.SilenceContextManager = SilenceMayaContextManager
18
+ logger.debug("Initializing interactive maya...")
@@ -0,0 +1,26 @@
1
+ import maya.OpenMaya as OpenMaya1
2
+
3
+
4
+ class SuppressScriptEditorOutput:
5
+ """
6
+ Suppresses output to the scriptEditor
7
+ """
8
+
9
+ def __init__(self):
10
+ self.callback_id = OpenMaya1.MCommandMessage.addCommandOutputFilterCallback(
11
+ self.suppress
12
+ )
13
+
14
+ @staticmethod
15
+ def suppress(_, __, filter_output, ___):
16
+ """
17
+ This is the callback function that gets called when Maya wants to
18
+ print something suppressing the print
19
+ """
20
+ OpenMaya1.MScriptUtil.setBool(filter_output, True)
21
+
22
+ def __enter__(self):
23
+ pass
24
+
25
+ def __exit__(self, exc_type, exc_val, exc_tb):
26
+ OpenMaya1.MMessage.removeCallback(self.callback_id)
@@ -0,0 +1,10 @@
1
+ def is_interactive_maya():
2
+ """
3
+ Checks if maya is running in interactive mode
4
+ """
5
+ import maya.cmds as cmds
6
+
7
+ try:
8
+ return not cmds.about(batch=True) # noqa
9
+ except (AttributeError, ImportError):
10
+ return False
@@ -0,0 +1,27 @@
1
+ from ..log import get_logger
2
+
3
+ logger = get_logger(__name__)
4
+ # logger.setLevel("DEBUG")
5
+
6
+
7
+ def initialize_maya():
8
+ if cmds_about_exists():
9
+ return
10
+ with logger.silence(["This plugin does not support createPlatformOpenGLContext!"]):
11
+ import maya.standalone
12
+
13
+ maya.standalone.initialize(name="initialized_by_maya_brew")
14
+ logger.debug("initialized maya standalone")
15
+
16
+
17
+ def cmds_about_exists():
18
+ try:
19
+ from maya.cmds import about # noqa: F401
20
+
21
+ return True
22
+ except ImportError:
23
+ return False
24
+
25
+
26
+ if __name__ == "__main__":
27
+ initialize_maya()
File without changes
@@ -0,0 +1,65 @@
1
+ from .. import OpenMaya2, cmds, exceptions
2
+
3
+
4
+ class NodeCastingException(exceptions.MayaBrewException):
5
+ """Base class for node casting exceptions."""
6
+
7
+ pass
8
+
9
+
10
+ class InvalidDagPath(NodeCastingException, RuntimeError, ValueError, TypeError):
11
+ """Exception raised when a DAG path is invalid."""
12
+
13
+ pass
14
+
15
+
16
+ class NonExistingDagPath(NodeCastingException, RuntimeError, ValueError, TypeError):
17
+ """Exception raised when a DAG path is invalid."""
18
+
19
+ pass
20
+
21
+
22
+ class MultipleMatchingNodes(NodeCastingException):
23
+ """Exception raised when multiple nodes match a given name."""
24
+
25
+ pass
26
+
27
+
28
+ class NoMatchingNodes(NodeCastingException):
29
+ """Exception raised when no nodes match a given name."""
30
+
31
+ pass
32
+
33
+
34
+ def get_dag_path_from_string(node_path: str) -> OpenMaya2.MDagPath:
35
+ """
36
+ Convert a string representation of a node path to an OpenMaya2.MDagPath object.
37
+ """
38
+ selection_list = OpenMaya2.MSelectionList()
39
+ try:
40
+ selection_list.add(node_path)
41
+ dag_path = selection_list.getDagPath(0)
42
+ except RuntimeError as e:
43
+ if "Object does not exist" in str(e):
44
+ raise NonExistingDagPath(f"Dag Path does not exist: {node_path}") from e
45
+ raise
46
+ except TypeError as e:
47
+ if "item is not a DAG path" in str(e):
48
+ raise InvalidDagPath(f"{node_path} exists but is not a dag object") from e
49
+ raise
50
+ return dag_path
51
+
52
+
53
+ def get_long_name_from_maya_string(name: str) -> str:
54
+ """
55
+ Convert a unique maya.cmds node string to a long name.
56
+ """
57
+ matching_nodes = cmds.ls(name, long=True)
58
+ if not matching_nodes:
59
+ raise NoMatchingNodes(f"No objects matching '{name}' in scene")
60
+ num_nodes = len(matching_nodes)
61
+ if num_nodes > 1:
62
+ raise MultipleMatchingNodes(
63
+ f"Multiple objects matching '{name}' in scene. Found {num_nodes} nodes: {matching_nodes}"
64
+ )
65
+ return matching_nodes[0]
@@ -0,0 +1,87 @@
1
+ from typing import Any, Callable, Dict, Self
2
+
3
+ from .. import OpenMaya2, cmds
4
+ from ..log import get_logger
5
+ from ..nodes import cast
6
+
7
+ logger = get_logger(__name__)
8
+ logger.setLevel("DEBUG")
9
+
10
+
11
+ class Node:
12
+ _cmds_creator: Callable[..., str]
13
+ _cmds_creator_args: Dict[str, Any] = {}
14
+
15
+ def __init__(self, node_path: str):
16
+ self.node_path = node_path
17
+
18
+ def __str__(self):
19
+ return self.node_path
20
+
21
+ def __repr__(self):
22
+ return f'{type(self).__name__}("{str(self)}")'
23
+
24
+ def rename(self, new_name: str, *args, **kwargs) -> str:
25
+ """
26
+ Rename the current node. If new name is not unique maya will resolve a new unique name.
27
+ :param new_name: The new name of the node.
28
+ :return: The new name of the node.
29
+ """
30
+ actual_new_name = cmds.rename(str(self), new_name, *args, **kwargs)
31
+ self.node_path = actual_new_name
32
+ return actual_new_name
33
+
34
+ def _cmd_caller(self, func_name, *args, **kwargs):
35
+ return cmds.attr(func_name, self.node_path, *args, **kwargs)
36
+
37
+ @classmethod
38
+ def create(cls, name: str, **kwargs) -> Self:
39
+ """
40
+ Create a new transform node.
41
+ :param name: The name of the new transform node.
42
+ :return: The new transform node.
43
+ """
44
+ kwargs.update(cls._cmds_creator_args)
45
+ kwargs["name"] = name
46
+ cmds_value = cls._cmds_creator(**kwargs) # noqa: E1102
47
+ if cmds_value != name:
48
+ logger.debug(f"Name was not unique. New name: {cmds_value}.")
49
+ return cls(cmds_value)
50
+
51
+
52
+ class DagNode(Node):
53
+ def __init__(self, node_path: str):
54
+ super().__init__(node_path)
55
+ self.dag_path = cast.get_dag_path_from_string(node_path)
56
+
57
+ def __str__(self):
58
+ return self.get_full_path()
59
+
60
+ def get_depend_node(self) -> OpenMaya2.MObject:
61
+ """
62
+ Get the depend node of the current node.
63
+ :return: The depend node of the current node.
64
+ """
65
+ return self.dag_path.node()
66
+
67
+ def get_full_path(self) -> str:
68
+ """
69
+ Get the full path of the current node.
70
+ :return: The full path of the current node.
71
+ """
72
+ full_path = self.dag_path.fullPathName()
73
+ if not full_path:
74
+ logger.warning(f"Node has been deleted. Original path: {self.node_path}")
75
+ return full_path
76
+
77
+ def get_mfndependency_node(self) -> OpenMaya2.MFnDependencyNode:
78
+ """
79
+ Get the MFnDependencyNode of the current node.
80
+ :return: The MFnDependencyNode of the current node.
81
+ """
82
+ return OpenMaya2.MFnDependencyNode(self.get_depend_node())
83
+
84
+
85
+ class Transform(DagNode):
86
+ _cmds_creator = cmds.group
87
+ _cmds_creator_args = {"empty": True}
maya_brew/scene.py ADDED
@@ -0,0 +1,25 @@
1
+ import maya_brew
2
+
3
+ from .exceptions import MayaBrewException
4
+
5
+
6
+ class SceneException(MayaBrewException):
7
+ """Base class for scene-related exceptions."""
8
+
9
+ pass
10
+
11
+
12
+ class UnsavedChanges(SceneException, RuntimeError):
13
+ """Exception raised when there are unsaved changes in the scene."""
14
+
15
+ pass
16
+
17
+
18
+ def new_file(force=True):
19
+ try:
20
+ return maya_brew.cmds.file(newFile=True, force=force)
21
+ except RuntimeError as e:
22
+ message = str(e).strip()
23
+ if message == "Unsaved changes.":
24
+ raise UnsavedChanges(message) from e
25
+ raise SceneException(message) from e
maya_brew/utils.py ADDED
@@ -0,0 +1,26 @@
1
+ import importlib
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from .log import get_logger
6
+
7
+ logger = get_logger(__name__)
8
+ logger.setLevel("DEBUG")
9
+
10
+
11
+ def reload_mb():
12
+ """
13
+ Workaround to reload entire maya-brew library.
14
+ Removes all maya-brew modules from 'sys.modules' and imports it again
15
+ """
16
+
17
+ import maya_brew
18
+
19
+ mb_path = Path(maya_brew.__file__)
20
+ mb_modules = list()
21
+ for m in [p for p in sys.modules if p.startswith(mb_path.parent.name)]:
22
+ mb_modules.append(m)
23
+ sys.modules.pop(m)
24
+ for m in mb_modules:
25
+ logger.debug(f"re-importing {m}")
26
+ importlib.import_module(m)
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: maya-brew
3
+ Version: 0.0.4
4
+ Summary: Safe interaction with python in maya
5
+ Project-URL: Homepage, https://github.com/mortenbohne/maya-brew
6
+ Project-URL: Repository, https://github.com/mortenbohne/maya-brew
7
+ Project-URL: Documentation, https://github.com/mortenbohne/maya-brew#readme
8
+ Project-URL: Issues, https://github.com/mortenbohne/maya-brew/issues
9
+ Project-URL: Changelog, https://github.com/mortenbohne/maya-brew/releases
10
+ Author-email: Morten Bøhne <github@mortenbohne.dk>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: 3d,autodesk,maya,pipeline
14
+ Classifier: Development Status :: 2 - Pre-Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: colorama
22
+ Requires-Dist: structlog
23
+ Provides-Extra: dev
24
+ Requires-Dist: maya-stubs; extra == 'dev'
25
+ Requires-Dist: pytest; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # maya-brew
29
+
30
+ > **Status: Pre-Alpha** – Interfaces are experimental and may change without notice. Pin exact versions if using in
31
+ > automation.
32
+
33
+ What you need after a long day of wrestling with Maya Python.
34
+
35
+ [![Super-Linter](https://github.com/mortenbohne/maya-brew/actions/workflows/linter.yml/badge.svg)](https://github.com/marketplace/actions/super-linter)
36
+
37
+ ---
38
+
39
+ ## Overview
40
+
41
+ `maya-brew` aims to provide safer, more structured interaction patterns when scripting or tooling inside Autodesk Maya.
42
+
43
+ Goals:
44
+
45
+ - Reduce foot‑guns when using Python inside Maya
46
+ - Offer utilities that behave well with Maya’s quirks
47
+
48
+ ## Background
49
+
50
+ `maya-brew` draws inspiration from some of the ergonomic goals that motivated
51
+ [PyMEL](https://github.com/LumaPictures/pymel): making Maya’s Python interface feel more “Pythonic,” safer, and more
52
+ discoverable. PyMEL historically provided rich object wrappers around nodes, attributes, and other scene elements;
53
+ for many workflows this improved readability and reduced boilerplate.
54
+
55
+ However, over time several practical factors led to the decision to start a lightweight alternative instead of
56
+ extending PyMEL itself:
57
+
58
+ - Scope & Complexity: PyMEL’s dynamic wrapping and layered abstractions can introduce startup overhead, indirection, and
59
+ occasional debugging friction.
60
+ - Historical Churn: A long evolution, multiple contributors, and some legacy design choices make selective modernization
61
+ non‑trivial.
62
+ - Maintenance & Momentum: While still usable, PyMEL no longer appears to have the same pace of active development or
63
+ broad community push it once did.
64
+ - Adoption Fit: Many pipelines only need a thin set of safety/ergonomic helpers—not a full object layer over
65
+ `maya.cmds`.
66
+
67
+ ### Acknowledgment
68
+
69
+ PyMEL significantly influenced how many technical artists approach Maya scripting, and this project stands on that
70
+ conceptual foundation. `maya-brew` simply narrows the focus: take the useful ergonomic ideas, shed the heavy wrapping,
71
+ and keep to the canonical APIs.
@@ -0,0 +1,19 @@
1
+ maya_brew/__init__.py,sha256=OkRwh4MmWcBtsttw3kkR8SEZhlK7rNLoQQexzRLEmTI,1431
2
+ maya_brew/exceptions.py,sha256=se87z8Fo6phDroKCtNd4detMQuS6nabNeqjeqYW41_Y,96
3
+ maya_brew/log.py,sha256=bK8sIJjD8Hziy3_N2maz3HWU0OFPl9SO1SoqpZoRf_A,10735
4
+ maya_brew/scene.py,sha256=8gNR1bIr99cUiyH9PQUTiqcWKOEZzflRFOBsSMRe5qQ,598
5
+ maya_brew/utils.py,sha256=HeqXcMHHilT4B5bOobAH9ZHwtCuKe4nVu_3DiS7aFsw,618
6
+ maya_brew/attributes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ maya_brew/attributes/node_attribute.py,sha256=J8Vf_qWDDfzdIPWx1PLo82sb8bKKVac6U_FuWRusiCc,5997
8
+ maya_brew/maya_initializer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ maya_brew/maya_initializer/interactive_init.py,sha256=a6KCLDRUUuK9W9rzKzb2Lf0t6p-8yVmGkVshIffob5A,500
10
+ maya_brew/maya_initializer/interactive_utils.py,sha256=vpq7TVjAj0L1Wcbaq3IEqLZODcfeeytLC5bDzL05QxQ,692
11
+ maya_brew/maya_initializer/shared.py,sha256=Ot58zYLhHCOMR_AU9Rw65BwvKXioGRsD9_jSUqfRIcs,245
12
+ maya_brew/maya_initializer/standalone_init.py,sha256=iWV3eMSzeYO1uqT9oRYSyB6Tn-XlPOeTmI8l4LajUmw,594
13
+ maya_brew/nodes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ maya_brew/nodes/cast.py,sha256=fuEsSbkulwzQ2r12Pk7Ux6ays85u9e5SsTRAW8ejsQo,1906
15
+ maya_brew/nodes/node_types.py,sha256=NYZ1-Eqh5ks-org6xeXhWMQx-jroXspUMRx_Qk8RmvI,2707
16
+ maya_brew-0.0.4.dist-info/METADATA,sha256=iR0pNoE1OHXIHbZ4wuwowYVhE7WaHjED_2HeuQpUojM,3112
17
+ maya_brew-0.0.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
18
+ maya_brew-0.0.4.dist-info/licenses/LICENSE,sha256=M8rkKXCsDdVH3gxjabYl5pH9TzX9GrMmwlXyjlwjnkE,1070
19
+ maya_brew-0.0.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Morten Bøhne
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.