From 464eb1b82a434b954c2bfd3b719bfae7dfd041c8 Mon Sep 17 00:00:00 2001 From: Justin Israel Date: Mon, 12 Sep 2016 16:18:38 +1200 Subject: [PATCH] #26 - Feature: Maya publishes ScriptEditor output to SublimeText console + Undo Support (#27) * #26 - First pass at having Maya publish ScriptEditor output to SublimeText console * #26 - Option to enable an undo chunk around the code that is sent to Maya * Update readme and bump version to 3.0.0 * Prevent startup errors when first connecting Maya output, when settings have not yet sync'd * Fix failures on large Maya output by buffering and breaking Maya output into small packets * Only print [MayaSublime ] prefix for Maya-initiated messages; dont line break --- Default.sublime-commands | 8 + LICENSE | 21 ++ MayaSublime.py | 369 +++++++++++++++++++++++++++++------ MayaSublime.sublime-settings | 15 +- README.md | 6 +- lib/pubScriptEditor.py | 100 ++++++++++ messages/3.0.0.md | 12 ++ 7 files changed, 472 insertions(+), 59 deletions(-) create mode 100644 LICENSE create mode 100644 lib/pubScriptEditor.py create mode 100644 messages/3.0.0.md diff --git a/Default.sublime-commands b/Default.sublime-commands index 26a0f23..c7af52d 100644 --- a/Default.sublime-commands +++ b/Default.sublime-commands @@ -3,6 +3,14 @@ "caption": "Maya: Send Selection to Maya", "command": "send_to_maya" }, + { + "caption": "Maya: Enable ScriptEditor Output", + "command": "enable_maya_output" + }, + { + "caption": "Maya: Disable ScriptEditor Output", + "command": "disable_maya_output" + }, { "caption": "Preferences: Maya Settings", "command": "open_file", "args": diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ab99edf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +(The MIT License) + +Copyright (c) 2016 Justin Israel + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the 'Software'), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY , +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MayaSublime.py b/MayaSublime.py index 6a2e535..ca99272 100644 --- a/MayaSublime.py +++ b/MayaSublime.py @@ -1,11 +1,16 @@ # ST2/ST3 compat from __future__ import print_function +import os import re import sys import time +import uuid +import errno +import socket import os.path import textwrap +import threading from telnetlib import Telnet @@ -13,47 +18,58 @@ if sublime.version() < '3000': - # we are on ST2 and Python 2.X + # we are on ST2 and Python 2.X _ST3 = False else: _ST3 = True -# Our default plugin settings +# Our default plugin state _settings = { - 'host' : '127.0.0.1', - 'mel_port' : 7001, - 'py_port' : 7002, - 'strip_sending_comments': True + + # State of plugin settings + 'host': '127.0.0.1', + 'mel_port': 7001, + 'py_port': 7002, + 'strip_comments': True, + 'no_collisions': True, + 'maya_output': False, + 'undo': False, + + # Internal state + '_t_reader': None, } +# A place to globally store a reference to our Thread +_ATTR_READER_THREAD = '_MayaSublime_Reader_Thread' -class send_to_mayaCommand(sublime_plugin.TextCommand): - # A template wrapper for sending Python source safely - # over the socket. - # Executes in a private namespace to avoid collisions - # with the main environment in Maya. - # Also handles catches and printing exceptions so that - # they are not masked. - PY_CMD_TEMPLATE = textwrap.dedent(''' - import traceback - import __main__ +def plugin_unloaded(): + """ + Hook called by ST3 when the plugin is unloaded + """ + # Clean up our thread + reader = _settings['_t_reader'] + if reader is not None: + reader.shutdown() + _settings['_t_reader'] = None - namespace = __main__.__dict__.get('_sublime_SendToMaya_plugin') - if not namespace: - namespace = __main__.__dict__.copy() - __main__.__dict__['_sublime_SendToMaya_plugin'] = namespace - try: - if {ns}: - namespace['__file__'] = {fp!r} - {xtype}({cmd!r}, namespace, namespace) - else: - {xtype}({cmd!r}) - except: - traceback.print_exc() - ''') +class enable_maya_output(sublime_plugin.ApplicationCommand): + + def run(self, *args): + _settings['maya_output'] = True + MayaReader.set_maya_output_enabled(True) + + +class disable_maya_output(sublime_plugin.ApplicationCommand): + + def run(self, *args): + _settings['maya_output'] = False + MayaReader.set_maya_output_enabled(False) + + +class send_to_mayaCommand(sublime_plugin.TextCommand): # Match single-line comments in MEL/Python RX_COMMENT = re.compile(r'^\s*(//|#)') @@ -79,11 +95,8 @@ def run(self, edit): # Apparently ST3 doesn't always sync up its latest # plugin settings? - if _ST3 and _settings['host']==None: + if _settings['host'] is None: sync_settings() - - host = _settings['host'] - port = _settings['py_port'] if lang=='python' else _settings['mel_port'] # Check the current selection size to determine # how we will send the source to be executed. @@ -149,47 +162,291 @@ def run(self, edit): # We need to wrap our source string into a template # so that it gets executed properly on the Maya side no_collide = _settings['no_collisions'] - opts = dict(xtype=execType, cmd=mCmd, fp=file_path, ns=no_collide) - mCmd = self.PY_CMD_TEMPLATE.format(**opts) + create_undo = _settings["undo"] + opts = dict( + xtype=execType, cmd=mCmd, fp=file_path, + ns=no_collide, undo=create_undo, + ) - c = None + mCmd = PY_CMD_TEMPLATE.format(**opts) - try: - c = Telnet(host, int(port), timeout=3) - if _ST3: - c.write(mCmd.encode(encoding='UTF-8')) - else: - c.write(mCmd) + if _settings["maya_output"]: + # In case maya was restarted, we can make sure the + # callback is always installed + MayaReader.set_maya_output_enabled(_settings["maya_output"]) - except Exception: - e = sys.exc_info()[1] - err = str(e) - sublime.error_message( - "Failed to communicate with Maya (%(host)s:%(port)s)):\n%(err)s" % locals() - ) - raise + _send_to_maya(mCmd, lang, wrap=False) - else: - time.sleep(.1) + +def _send_to_maya(cmd, lang='python', wrap=True, quiet=False): + """ + Send stringified Python code to Maya, to be executed. + """ + if _settings['host'] is None: + sync_settings() + + host = _settings['host'] + port = _settings['py_port'] if lang=='python' else _settings['mel_port'] + + if lang == 'python' and wrap: + no_collide = _settings['no_collisions'] + create_undo = _settings["undo"] + opts = dict(xtype='exec', cmd=cmd, fp='', ns=no_collide, undo=create_undo) + cmd = PY_CMD_TEMPLATE.format(**opts) + + c = None + + try: + c = Telnet(host, int(port), timeout=3) + c.write(_py_str(cmd)) + + except Exception: + e = sys.exc_info()[1] + err = str(e) + msg = "Failed to communicate with Maya (%(host)s:%(port)s)):\n%(err)s" % locals() + if quiet: + print(msg) + return False + + sublime.error_message(msg) + raise + + else: + time.sleep(.1) + + finally: + if c is not None: + c.close() + + return True + + +def _py_str(s): + """Encode a py3 string if needed""" + if _ST3: + return s.encode(encoding='UTF-8') + return s + + +class MayaReader(threading.Thread): + """ + A threaded reader that monitors for published ScriptEditor + output from Maya. + + Installs a ScriptEditor callback to Maya to produce messages. + """ + + # Max number of bytes to read from each packet. + BUFSIZE = 64 * 1024 # 64KB is max UDP packet size + + # Signal to stop a receiving MayaReader + STOP_MSG = _py_str('MayaSublime::MayaReader::{0}'.format(uuid.uuid4())) + + # Stringified ScriptEditor callback code to install in Maya + PY_MAYA_CALLBACK = open(os.path.join(os.path.dirname(__file__), + "lib/pubScriptEditor.py")).read() + + def __init__(self, host='127.0.0.1', port=0): + super(MayaReader, self).__init__() + + self.daemon = True + + self._running = threading.Event() + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind((host, port)) + + def port(self): + """Get the port number being used by the socket""" + _, port = self.sock.getsockname() + return port + + def is_running(self): + """Return true if the thread is running""" + return self._running.is_set() + + def shutdown(self): + """Stop the monitoring of Maya output""" + self._running.clear() + # Send shutdown message to local UDP + self.sock.sendto(self.STOP_MSG, self.sock.getsockname()) + + def run(self): + prefix = '[MayaSublime] ' + + print("{0}started on port {1}".format(prefix, self.port())) + + fails = 0 + self._running.set() + + while self._running.is_set(): + try: + msg, addr = self.sock.recvfrom(self.BUFSIZE) + + except Exception as e: + print("Failed while reading output from Maya:") + traceback.print_exc() + + # Prevent runaway failures from spinning + fails += 1 + if fails >= 10: + # After too many failures in a row + # wait a bit + fails = 0 + time.sleep(5) + + continue + + fails = 0 + + if msg == self.STOP_MSG: + break + + if _ST3: + msg = msg.decode() + + sys.stdout.write(msg) - finally: - if c is not None: - c.close() + print("{0}MayaReader stopped".format(prefix)) + + def _set_maya_callback_enabled(self, enable, quiet=False): + """ + Enable or disable the actual publishing of ScriptEditor output from Maya + """ + host, port = self.sock.getsockname() + cmd = "_MayaSublime_streamScriptEditor({0}, host={1!r}, port={2})".format(enable, host, port) + return _send_to_maya(cmd, quiet=quiet) + + @classmethod + def _st2_remove_reader(cls): + """ + A hack to work around SublimeText2 not having a + module level hook for when the plugin is loaded + and unloaded. + Need to store a reference to our thread that doesn't + get blown away when the plugin reloads, so that we + can clean it up. + """ + if _ST3: + return + + import __main__ + + reader = getattr(__main__, _ATTR_READER_THREAD, None) + if reader: + reader.shutdown() + setattr(__main__, _ATTR_READER_THREAD, None) + + @classmethod + def _st2_replace_reader(cls, reader): + """ + A hack to work around SublimeText2 not having a + module level hook for when the plugin is loaded + and unloaded. + Need to store a reference to our thread that doesn't + get blown away when the plugin reloads, so that we + can clean it up and replace it with another. + """ + if _ST3: + return + + cls._st2_remove_reader() + + import __main__ + setattr(__main__, _ATTR_READER_THREAD, reader) + + @classmethod + def install_maya_callback(cls): + """Send the callback logic to Maya""" + return _send_to_maya(cls.PY_MAYA_CALLBACK, quiet=True) + + @classmethod + def set_maya_output_enabled(cls, enable): + # Make sure the Maya filtering callback code + # is set up already + ok = cls.install_maya_callback() + quiet = not ok + + reader = _settings.get('_t_reader') + + # handle disabling the reader + if not enable: + if reader: + reader.shutdown() + reader._set_maya_callback_enabled(False, quiet) + return + + # handle enabling the reader + if reader and reader.is_alive(): + # The reader is already running + reader._set_maya_callback_enabled(True, quiet) + return + + # Start the reader + reader = cls() + reader.start() + _settings['_t_reader'] = reader + + cls._st2_replace_reader(reader) + reader._set_maya_callback_enabled(True, quiet) def settings_obj(): return sublime.load_settings("MayaSublime.sublime-settings") + def sync_settings(): so = settings_obj() + _settings['host'] = so.get('maya_hostname') _settings['py_port'] = so.get('python_command_port') _settings['mel_port'] = so.get('mel_command_port') _settings['strip_comments'] = so.get('strip_sending_comments') _settings['no_collisions'] = so.get('no_collisions') - + _settings['maya_output'] = so.get('receive_maya_output') + _settings['undo'] = so.get('create_undo') + + MayaReader._st2_remove_reader() + + if _settings['maya_output'] is not None: + MayaReader.set_maya_output_enabled(_settings["maya_output"]) + + +# A template wrapper for sending Python source safely +# over the socket. +# Executes in a private namespace to avoid collisions +# with the main environment in Maya. +# Also handles catches and printing exceptions so that +# they are not masked. +PY_CMD_TEMPLATE = textwrap.dedent(''' + import traceback + import __main__ + + import maya.cmds + + namespace = __main__.__dict__.get('_sublime_SendToMaya_plugin') + if not namespace: + namespace = __main__.__dict__.copy() + __main__.__dict__['_sublime_SendToMaya_plugin'] = namespace + + try: + if {undo}: + maya.cmds.undoInfo(openChunk=True, chunkName="MayaSublime Code") + + if {ns}: + namespace['__file__'] = {fp!r} + {xtype}({cmd!r}, namespace, namespace) + else: + {xtype}({cmd!r}) + except: + traceback.print_exc() + finally: + if {undo}: + maya.cmds.undoInfo(closeChunk=True) +''') +# Add callbacks for monitoring setting changes settings_obj().clear_on_change("MayaSublime.settings") settings_obj().add_on_change("MayaSublime.settings", sync_settings) -sync_settings() \ No newline at end of file +sync_settings() diff --git a/MayaSublime.sublime-settings b/MayaSublime.sublime-settings index 1b9cbcb..96351d2 100644 --- a/MayaSublime.sublime-settings +++ b/MayaSublime.sublime-settings @@ -27,5 +27,18 @@ // environment. (It is safer but if you are // mixing this plugin with code from the // Script Editor, set this to false.) - "no_collisions": true + "no_collisions": true, + + // If enabled, create an undo around the entire + // code that is sent to Maya and executed + "create_undo": false, + + // If enabled, MayaSublime will automatically hook + // up Maya's ScriptEditor to also publish its output to + // Sublime's console. A restart of SublimeText + // might be required after changing this value. + // Regardless of this setting, the output from Maya + // can be toggled on and off with the available + // commands in the Command Palette + "receive_maya_output": false } diff --git a/README.md b/README.md index 08a7cf2..3a2b3d4 100644 --- a/README.md +++ b/README.md @@ -9,19 +9,21 @@ Send selected MEL/Python code snippets or whole files to Maya via commandPort **Easy Install** -You can install this plugin directly from Sublimt Package Control: +You can install this plugin directly from Sublime Package Control: https://packagecontrol.io/packages/MayaSublime **Manual install** -1. clone this repo into the `SublimeText2 -> Preference -> Browse Packages` directory: +1. clone this repo into the `SublimeText2/3 -> Preference -> Browse Packages` directory: `git clone git://github.com/justinfx/MayaSublime.git` 2. Edit the `MayaSublime.sublime-settings` file, setting the port to match the commandPorts you have configured in Maya 3. Optionally edit the keymap file to change the default hotkey from `ctrl+return` to something else. +Note - Ideally you would make your custom changes to the user settings and not the default settings, so that they do not get overwritten when the plugin is updated. + ### Usage To send a snippet, simply select some code in a mel or python script, and hit `ctrl+return`, or right click and choose "Send To Maya". diff --git a/lib/pubScriptEditor.py b/lib/pubScriptEditor.py new file mode 100644 index 0000000..d76cd8f --- /dev/null +++ b/lib/pubScriptEditor.py @@ -0,0 +1,100 @@ +import errno +import socket +import maya.OpenMaya + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +if '_MayaSublime_ScriptEditorOutput_CID' not in globals(): + _MayaSublime_ScriptEditorOutput_CID = None + +if '_MayaSublime_SOCK' not in globals(): + _MayaSublime_SOCK = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + +def _MayaSublime_streamScriptEditor(enable, host="127.0.0.1", port=5123, quiet=False): + om = maya.OpenMaya + + global _MayaSublime_ScriptEditorOutput_CID + cid = _MayaSublime_ScriptEditorOutput_CID + + # Only print if we are really changing state + if enable and cid is None: + sys.stdout.write("[MayaSublime] Enable Streaming ScriptEditor " \ + "({0}:{1})\n".format(host, port)) + + elif not enable and cid is not None: + sys.stdout.write("[MayaSublime] Disable Streaming ScriptEditor\n") + + if cid is not None: + om.MMessage.removeCallback(cid) + _MayaSublime_ScriptEditorOutput_CID = None + + if not enable: + return + + buf = StringIO() + + def _streamToMayaSublime(msg, msgType, *args): + buf.seek(0) + buf.truncate() + + if msgType != om.MCommandMessage.kDisplay: + buf.write('[MayaSublime] ') + + if msgType == om.MCommandMessage.kWarning: + buf.write('# Warning: ') + buf.write(msg) + buf.write(' #\n') + + elif msgType == om.MCommandMessage.kError: + buf.write('// Error: ') + buf.write(msg) + buf.write(' //\n') + + elif msgType == om.MCommandMessage.kResult: + buf.write('# Result: ') + buf.write(msg) + buf.write(' #\n') + + else: + buf.write(msg) + + buf.seek(0) + + # Start with trying to send 8kb packets + bufsize = 8*1024 + + # Loop until the buffer is empty + while True: + + while bufsize > 0: + # Save our position in case we error + # and need to roll back + pos = buf.tell() + + part = buf.read(bufsize) + if not part: + # Buffer is empty. Nothing else to send + return + + try: + _MayaSublime_SOCK.sendto(part, (host, port)) + + except Exception as e: + if e.errno == errno.EMSGSIZE: + # We have hit a message size limit. + # Scale down and try the packet again + bufsize /= 2 + buf.seek(pos) + continue + # Some other error + raise + + # Message sent without error + break + + cid = om.MCommandMessage.addCommandOutputCallback(_streamToMayaSublime) + _MayaSublime_ScriptEditorOutput_CID = cid + diff --git a/messages/3.0.0.md b/messages/3.0.0.md new file mode 100644 index 0000000..8840333 --- /dev/null +++ b/messages/3.0.0.md @@ -0,0 +1,12 @@ +MayaSublime 3.0.0 change log: + +- All console output (ScriptEditor) from Maya can now be captured and streamed to the Sublime Console (shown with `ctrl+~`). To enable this feature, you can do it manually with the 2 new Command Palette commands: + + - "Maya: Enable ScriptEditor Output" + - "Maya: Disable ScriptEditor Output" + +- MayaSublime can automatically enable Maya output collection feature by setting the new MayaSublime user pref: + - `"receive_maya_output": true` + +- A new user pref can be enabled which tells MayaSublime to wrap all code that is sent to Maya within an undo block: + - `"create_undo": true`