diff --git a/.gitignore b/.gitignore index b214f81..229fd6e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ other dist -tmp +*tmp + .DS_Store *.pyc diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 39eb0f5..eab267e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -25,7 +25,7 @@ { "label": "Zip Project", "type": "shell", - "command": "bash ${workspaceFolder}/tasks/build.sh", + "command": "bash ${workspaceFolder}/scripts/create_zip_project.sh", "dependsOn": [ "Clean .pyc" ] diff --git a/README.md b/README.md index d57862d..11eff60 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,18 @@ # 1. NukeServerSocket README -A Nuke that plugin that will allow code execution inside Nuke from the local network. +A Nuke plugin that will allow code execution from the local network and more. -> This is primarily a companion plugin for: [Nuke Tools](https://marketplace.visualstudio.com/items?itemName=virgilsisoe.nuke-tools). +> This is primarily a companion plugin for [Nuke Tools](https://marketplace.visualstudio.com/items?itemName=virgilsisoe.nuke-tools). - [1. NukeServerSocket README](#1-nukeserversocket-readme) - - [1.1. Features](#11-features) - - [1.2. Installation](#12-installation) - - [1.3. Usage](#13-usage) - - [1.4. Connection](#14-connection) + - [1.1. Tools](#11-tools) + - [1.2. Features](#12-features) + - [1.3. Installation](#13-installation) + - [1.4. Usage](#14-usage) + - [1.4.1. Receive incoming request](#141-receive-incoming-request) + - [1.4.2. Receive/Send nodes](#142-receivesend-nodes) + - [1.4.2.1. Send](#1421-send) + - [1.4.2.2. Receive](#1422-receive) - [1.5. Settings](#15-settings) - [1.6. Extendibility](#16-extendibility) - [1.6.1. Code Sample](#161-code-sample) @@ -18,53 +22,70 @@ A Nuke that plugin that will allow code execution inside Nuke from the local net - [1.9. Test plugin locally](#19-test-plugin-locally) - [1.10. Overview](#110-overview) -## 1.1. Features -- Execute code from your local network by using Visual Studio Code. -- If used locally (same machine) no configuration required, just start the server. -- Connect from another computer by specify a custom address. -- Multiple computer can connect to the same Nuke instance. -- Easy expandable with any method of your choice (read more [Extendibility](#16-extendibility)) +## 1.1. Tools -## 1.2. Installation +Tools that are using NukeServerSocket: + +- [Nuke Tools](https://marketplace.visualstudio.com/items?itemName=virgilsisoe.nuke-tools) - Visual Studio Code extension. + +## 1.2. Features + +- Send Python or BlinkScript code to be executed inside Nuke from your local network. +- Multiple computers can connect to the same Nuke instance. +- Receive/Send nodes from another Nuke instance in your local network. +- Not bound to any application. (more on [Extendibility](#16-extendibility)) + +## 1.3. Installation Save the plugin in your _.nuke_ directory or in a custom directory and then `import NukeServerSocket` in your _menu.py_. **Remember**: If you use a custom plugin path, add the path in your init.py: `nuke.pluginAddPath('custom/path')`. > N.B. if your downloaded zip folder has a different name (NukeServerSocket-master, NukeServerSocket-0.0.2 etc._), then you **need to rename it to just NukeServerSocket**. -## 1.3. Usage +## 1.4. Usage -1. Open the _NukeServerSocket_ panel and start the server by clicking on **Connect**. - 1. If you receive a message: "_Server did not initiate. Error: The bound address is already in use_", just change the **port** entry to a different one and try again. It means that probably you have a connection listening on that port already. -2. When connected you could test the receiver with the **Test Server Receiver** otherwise you are done. -3. Send code from Visual Studio Code by using the companion extension. +### 1.4.1. Receive incoming request -> The plugin doesn't have to stay visible after the server has been initialized. +Open the _NukeServerSocket_ panel and with the mode on **Receiver**, start the server by clicking **Connect**. -## 1.4. Connection + > If you receive a message: "_Server did not initiate. Error: The bound address is already in use_", just change the **Port** to a random number between `49152` and `65535` and try again. It means that probably you have a connection listening on that port already. Also when connected, you could test the receiver with the **Test Receiver** button, otherwise you are done. -When used locally (same machine), no configuration is required as the server will listen on the **Local Host Address**. If the server **port** is already busy, just change it by typing a random number between `49152` and `65535` and try again. Visual Studio Code will pick Nuke's settings automatically. +When connected, NukeServerSocket will listen for incoming request on the IP Address and Port shown in the plugin. -When connecting from/to another computer, manual configuration will be required inside Visual Studio Code. -You will need the **Local IP address** and the **port** information from NukeServerSocket. +Now you can send code from Visual Studio Code with [Nuke Tools](https://marketplace.visualstudio.com/items?itemName=virgilsisoe.nuke-tools) or any other method you prefer. -The information inside Visual Studio Code must match the information inside NukeServerSocket. +### 1.4.2. Receive/Send nodes -> VMs: Sending from your local machine to a VM machine will likely require some configuration on your side like enabling some network settings etc. +#### 1.4.2.1. Send -If you still have some problems, please feel free to reach out for any questions. +When sending nodes, switch the mode from **Receiver** to **Sender** and be sure that there is another NukeServerSocket instance listening for incoming network request ([Receive incoming request](#141-receive-incoming-request)). Select the nodes you want to send a click **Send Selected Nodes**. + +> By default, the IP Address on the sender points to the local IP address, so if you want to send nodes between the same computer you should only specify the Port. When sending nodes to another computer you will also need to specify the IP Address of the NukeServerSocket computer you want the nodes to be sended. + +#### 1.4.2.2. Receive + +When receiving nodes just follow the steps for [Receive incoming request](#141-receive-incoming-request) for the receiving instance and the [Send](#1421-send) steps for the sending instances. ## 1.5. Settings -The plugin offers some minor settings for the internal script editor, like send output, format text and so on but they are pretty self explanatory. +The plugin offers some minor settings like output text to internal script editor, format text and so on. ## 1.6. Extendibility -At its core, the plugin is just a server socket that waits for an incoming request, performs the operations inside Nuke and returns the result. Nothing ties it to Visual Studio Code per se. +At its core, the plugin is just a server socket that awaits for an incoming request, performs the operations inside Nuke and returns the result. Nothing ties it to any application per se. + +The only requirement is that the code received should be inside a string. + +From the client point of view, the code can be sended either inside a _stringified_ associative array or inside a simple string, with the latter being valid only when sending Python code. + +The associative array should have the following keys: `text` and an optional `file`. -The only requirement is to send the code to be executed as a string. +- `text`: Must contain the code to be executed as a string. +- `file`: Could contain the file path of the script that is being executed. -Additionally the plugin accepts a _stringified Associative Array_ with the key **text** containing the code to be executed as a string, and an optional key **file** to show the name of the file that is being executed (this will only show if settings **Output to Script Editor** and **Clean & Format Text** are enabled). On the plugin side, the data is converted with `json.loads()` into a valid `dictionary` python type. +Although the associative array is optional when executing Python code, it is a requirement when executing BlinkScript. The key **file** must contain a valid file extension: `.cpp` or `.blink` in order for the plugin to know where to delegate the job. + +> When sending a stringified array, the plugin will try to deserialize it with `json.loads()`. ### 1.6.1. Code Sample @@ -104,31 +125,34 @@ print("Result :", data) # Hello World from py ```js // Node.js Send data example. -let s = new require('net').Socket(); +let socket = new require('net').Socket(); // connection host and port must match information inside Nuke plugin -s.connect(54321, '127.0.0.1', function () { + socket.connect(54321, '127.0.0.1', function () { const data = { - 'text': 'print("Hello World from node.js")', + "text": "print('Hello World from node.js')", }; // stringify the object before sending - s.write(JSON.stringify(data)); + socket.write(JSON.stringify(data)); }); // the returned data from NukeServerSocket -s.on('data', function (data) { + socket.on('data', function (data) { // data could be | | console.log(data.toString('utf8')); - s.destroy(); + socket.destroy(); }); ``` ### 1.6.2. Port & Host address -NukeServerSocket by default will listen on the local address so you just need to specify the local host address (eg.`127.0.0.1`) in your socket client code. -The port information is written automatically to _.nuke/NukeServerSocket.ini_ file each time the user changes it. This is used from the Visual Studio Code extension to pick automatically to which port connect. Otherwise it will required to be specified manually. +NukeServerSocket by default will listen on any host address. + +When connecting locally (same computer) you can just specify the local host address (eg.`127.0.0.1`) in your socket client code. When connecting from a different computer you also specify the exact IP Address (eg `192.168.1.10`). + +The port value is written to _.nuke/NukeServerSocket.ini_ inside the `server/port` field. Each time the user changes it, it gets update automatically. If used locally, this can be used from your extension to pick at which port to connect. This is pretty much all you need to start your own extension for your favorite text editor or any other method you prefer. @@ -136,8 +160,8 @@ If you still have some problems, please feel free to reach out for any questions ## 1.7. Known Issues -- Settings window doesn't display the tooltip text. This seems to be a Nuke bug as outside it works correctly. -- Plugin has been tested only on small amount of scripts so if you encounter problems/bugs will be great to receive a testable sample code. +- Settings window doesn't display the tooltip text. +- When changing workspace with an active open connection, Nuke will load a new plugin instance with the default UI state. This would look as if the previous connection has been closed, where in reality is still open and listening. The only way to force close all of the connections is to restart Nuke. ## 1.8. Compatibility @@ -161,7 +185,7 @@ While it should work the same on all platforms, it has been currently only teste While limited in some regards, the plugin can be tested outside Nuke environment. 1. Clone the github repo into your machine. -2. `pipenv install --ignore-pipfile` for normal installation or `pipenv install --ignore-pipfile --dev -e .` if you want to test the code with `pytest` (No tests are provided at the time of writing. 🤭) +2. `pipenv install --ignore-pipfile` for normal installation or `pipenv install --ignore-pipfile --dev -e .` if you want to test the code with `pytest` (No tests are provided at the time of writing). 3. Launch the app via terminal `python -m tests.run_app` or vscode task: `RunApp` The local plugin offers a simple emulation of the Nuke's internal Script Editor layout. It just basic enough to test some simple code. diff --git a/VERSION b/VERSION index 6812f81..6c6aa7c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.3 \ No newline at end of file +0.1.0 \ No newline at end of file diff --git a/src/_nuke.py b/src/_nuke.py index 187b4de..7d26680 100644 --- a/src/_nuke.py +++ b/src/_nuke.py @@ -1,5 +1,42 @@ +# coding: utf-8 +from __future__ import print_function + +import re +from textwrap import dedent + +from PySide2.QtGui import QClipboard + NUKE_VERSION_STRING = '13.0v1' env = { 'NukeVersionMajor': 13 } + + +def nodeCopy(s): + """internal implementation of nuke.nodeCopy for testing purpose.""" + copy_tmp = dedent(""" + set cut_paste_input [stack 0] + version 13.0 v1 + push $cut_paste_input + Blur { + size {{curve x-16 100 x25 1.8 x101 100}} + name Blur1 + selected true + xpos -150 + ypos -277 + } + Blur { + inputs 0 + name Blur2 + selected true + xpos -40 + ypos -301 + } + """).strip() + if re.match(r'^%.+%$', s): + clipboard = QClipboard() + clipboard.setText(copy_tmp) + else: + with open(s, 'w') as file: + file.write(copy_tmp) diff --git a/src/about.py b/src/about.py index b39ae54..610706b 100644 --- a/src/about.py +++ b/src/about.py @@ -78,7 +78,7 @@ def about_links(): ('Issues', github_web + '/issues'), ('Nukepedia', 'https://www.nukepedia.com/python/misc/nukeserversocket'), ('Logs', 'file:///%s/src/log' % _get_root()), - ('Vscode Ext.', 'https://marketplace.visualstudio.com/items?itemName=virgilsisoe.nuke-tools'), + ('VSCode Ext.', 'https://marketplace.visualstudio.com/items?itemName=virgilsisoe.nuke-tools'), ('Other', '') ) diff --git a/src/connection/__init__.py b/src/connection/__init__.py index 1629ef7..fb96971 100644 --- a/src/connection/__init__.py +++ b/src/connection/__init__.py @@ -1,3 +1,3 @@ -from .server import Server -from .socket import Socket -from .test_client import ClientTest +from .nss_server import Server +from .nss_socket import Socket +from .nss_client import TestClient, SendNodesClient diff --git a/src/connection/nss_client.py b/src/connection/nss_client.py new file mode 100644 index 0000000..e508f5c --- /dev/null +++ b/src/connection/nss_client.py @@ -0,0 +1,125 @@ +# coding: utf-8 +from __future__ import print_function, with_statement + +import json +import random +import logging + +from abc import abstractmethod, ABCMeta + +from PySide2.QtNetwork import QHostAddress, QTcpSocket + +from .. import nuke +from ..utils import AppSettings, validate_output + + +LOGGER = logging.getLogger('NukeServerSocket.client') + + +class NetworkAddresses(object): + """Convenient class with network addresses""" + + def __init__(self): + self.settings = AppSettings() + + @property + def port(self): # type: () -> int + """Get port data from the configuration.ini file""" + return int(self.settings.value('server/port', 54321)) + + @property + def hostname(self): # type: () -> str + """Get host address data from the configuration.ini file""" + return self.settings.value('server/send_to_address', '127.0.0.1') + + @property + def local_host(self): # type: () -> QHostAddress + """Get local host address QHostAddress object""" + # REVIEW: why not returning '127.0.0.1'? + return QHostAddress.LocalHost + + +class QBaseClient(object): + __metaclass__ = ABCMeta + + def __init__(self, hostname, port): # type: (str, int) -> None + + self.tcp_host = hostname + LOGGER.debug('client host: %s', self.tcp_host) + + self.tcp_port = port + LOGGER.debug('client port: %s', self.tcp_port) + + self.socket = QTcpSocket() + LOGGER.debug('creating socket: %s', self.socket) + + self.socket.readyRead.connect(self.read_data) + self.socket.connected.connect(self.on_connected) + self.socket.disconnected.connect(self.on_disconnect) + self.socket.error.connect(self.on_error) + + @abstractmethod + def on_connected(self): + # TODO: docstring not accurate anymore + """Method needs to return a string with the text to send write""" + + def write_data(self, data): + self.socket.write(validate_output(json.dumps(data))) + LOGGER.debug('message sent: %s', data) + + self.socket.flush() + self.socket.disconnectFromHost() + + def on_disconnect(self): + LOGGER.debug('Disconnected from host') + + def connect(self): + LOGGER.debug('Connecting to host: %s %s', self.tcp_host, self.tcp_port) + self.socket.connectToHost(self.tcp_host, self.tcp_port) + + def on_error(self, error): + LOGGER.error("QBaseClient Error: %s", error) + + def read_data(self): + LOGGER.debug('Reading data: %s', self.socket.readAll()) + + +class TestClient(QBaseClient): + """Test Socket by send a sample text to the local host port.""" + + def __init__(self, addresses=NetworkAddresses()): # type: (NetworkAddresses) -> None + QBaseClient.__init__(self, addresses.local_host, addresses.port) + + def on_connected(self): + LOGGER.debug('TestClient -> Connected to host') + r = random.randint(1, 50) + + output_text = { + "text": "from __future__ import print_function; print('Hello from Test Client', %s)" % r, + "file": "path/to/tmp_file.py" + } + + self.write_data(output_text) + + +class SendNodesClient(QBaseClient): + """Send nuke nodes using the Qt client socket.""" + + def __init__(self, addresses=NetworkAddresses()): # type: (NetworkAddresses, dict) -> None + QBaseClient.__init__(self, addresses.hostname, addresses.port) + self.transfer_data = self.transfer_file_content() + + def on_connected(self): + """When connected, send the content of the transfer file as data to the socket.""" + LOGGER.debug('SendNodesClient -> Connected to host') + self.write_data(self.transfer_data) + + def transfer_file_content(self): + settings = AppSettings() + transfer_file = settings.value('path/transfer_file') + + # this will also create the file if it doesn't exists already + nuke.nodeCopy(transfer_file) + + with open(transfer_file) as file: + return {"text": file.read(), "file": transfer_file} diff --git a/src/connection/server.py b/src/connection/nss_server.py similarity index 53% rename from src/connection/server.py rename to src/connection/nss_server.py index d7e0644..64f7de0 100644 --- a/src/connection/server.py +++ b/src/connection/nss_server.py @@ -6,18 +6,19 @@ from PySide2.QtCore import QObject from PySide2.QtNetwork import QTcpServer, QHostAddress -from .socket import Socket -from ..utils import SettingsState +from .nss_socket import Socket +from ..utils import AppSettings LOGGER = logging.getLogger('NukeServerSocket.server') class Server(QObject): - def __init__(self, status_widget): + + def __init__(self, log_widgets): QObject.__init__(self) - self.settings = SettingsState() + self.settings = AppSettings() - self.status_widget = status_widget + self.log_widgets = log_widgets self.tcp_port = int(self.settings.value('server/port', '54321')) LOGGER.debug('server tcp port: %s', self.tcp_port) @@ -30,20 +31,30 @@ def __init__(self, status_widget): self.socket = None + def close_server(self): + """Close server connection.""" + self.server.close() + self.log_widgets.set_status_text('Disconnected\n----') + def _create_connection(self): while self.server.hasPendingConnections(): self.socket = Socket( - self.server.nextPendingConnection(), self.status_widget) + self.server.nextPendingConnection(), self.log_widgets + ) LOGGER.debug('socket: %s', self.socket) def start_server(self): + """Start server connection. + + Raises: + ValueError: if connection cannot be made. + + """ if self.server.listen(QHostAddress.Any, self.tcp_port): - self.status_widget.set_status_text( - "Server listening to port: %s " % self.tcp_port) + self.log_widgets.set_status_text( + "Connected. Server listening to port: %s..." % self.tcp_port) return True - self.status_widget.set_status_text( - "Server did not initiate. Error: %s." % self.server.errorString() - ) - - return False + msg = "Server did not initiate. Error: %s." % self.server.errorString() + self.log_widgets.set_status_text(msg) + raise ValueError(msg) diff --git a/src/connection/socket.py b/src/connection/nss_socket.py similarity index 71% rename from src/connection/socket.py rename to src/connection/nss_socket.py index c63b36b..e303546 100644 --- a/src/connection/socket.py +++ b/src/connection/nss_socket.py @@ -6,15 +6,16 @@ from PySide2.QtCore import QObject -from ..utils import ScriptEditor, validate_output +from ..utils import validate_output +from ..script_editor import CodeEditor LOGGER = logging.getLogger('NukeServerSocket.socket') class Socket(QObject): - def __init__(self, socket, status_widget): + def __init__(self, socket, log_widgets): QObject.__init__(self) - self.status_widget = status_widget + self.log_widgets = log_widgets self.socket = socket self.socket.connected.connect(self.on_connected) @@ -23,24 +24,25 @@ def __init__(self, socket, status_widget): def on_connected(self): LOGGER.debug('Client connect event') - self.status_widget.set_status_text("Client Connected Event") + self.log_widgets.set_status_text("Client Connected Event") def on_disconnected(self): LOGGER.debug('-*- Client disconnect event -*-') - self.status_widget.set_status_text("Client socket closed.") + self.log_widgets.set_status_text("Client socket closed.") def _get_message(self): """Get the socket message. - If the message is a simple string then return it into a dictionary, + If the message is a simple string then add it to a dictionary, else return deserialized array. - Will raise a ValueError exception if can not convert the stringified array. - Returns: dict - dictionary data with the following keys: 'text': code to execute 'file': optional file name + + Raises: + ValueError: if fails to deserialize json data. """ msg = self.socket.readAll() @@ -73,14 +75,11 @@ def on_readyRead(self): LOGGER.warning("no text data to execute") return - script_editor = ScriptEditor() - script_editor.set_file(msg_data.get('file', '')) - script_editor.set_text(msg_text) - script_editor.execute() - - output_text = script_editor.set_status_output() + editor = CodeEditor(msg_data.get('file', '')) + editor.controller.set_input(msg_text) + editor.controller.execute() - script_editor.restore_state() + output_text = editor.controller.output() LOGGER.debug('Sending message back') self.socket.write(validate_output(output_text)) @@ -88,5 +87,5 @@ def on_readyRead(self): self.socket.close() LOGGER.debug('Closing socket') - self.status_widget.set_input_text(msg_text) - self.status_widget.set_output_text(output_text) + self.log_widgets.set_input_text(msg_text) + self.log_widgets.set_output_text(output_text) diff --git a/src/connection/test_client.py b/src/connection/test_client.py deleted file mode 100644 index d0ef955..0000000 --- a/src/connection/test_client.py +++ /dev/null @@ -1,59 +0,0 @@ -# coding: utf-8 -from __future__ import print_function, with_statement - -import json -import random -import logging - -from PySide2.QtNetwork import QTcpSocket - -from ..utils import SettingsState, validate_output - - -LOGGER = logging.getLogger('NukeServerSocket.client') - - -class ClientTest: - def __init__(self): - self.settings = SettingsState() - - self.tcp_host = "127.0.0.1" - LOGGER.debug('client host: %s', self.tcp_host) - - self.tcp_port = int(self.settings.value('server/port', '54321')) - LOGGER.debug('client port: %s', self.tcp_port) - - self.socket = QTcpSocket() - LOGGER.debug('creating socket: %s', self.socket) - - self.socket.readyRead.connect(self.read_data) - self.socket.connected.connect(self.on_connected) - self.socket.error.connect(self.on_error) - - def send_message(self): - LOGGER.debug('Sending message to host: %s %s', - self.tcp_host, self.tcp_port) - self.socket.connectToHost(self.tcp_host, self.tcp_port) - - def on_error(self, error): - LOGGER.error("Client Error: %s", error) - - def on_connected(self): - LOGGER.debug('Connected to host') - r = random.randint(1, 50) - - output_text = { - "text": "from __future__ import print_function; print('Hello from Test Client', %s)" % r, - "file": "path/to/tmp_file.py" - } - output_text = json.dumps(output_text) - - self.socket.write(validate_output(output_text)) - LOGGER.debug('message sent: %s', output_text) - - self.socket.flush() - self.socket.disconnectFromHost() - LOGGER.debug('Disconnected from host') - - def read_data(self): - LOGGER.debug('Reading data: %s', self.socket.readAll()) diff --git a/src/main.py b/src/main.py index 59f1c2d..5cbdb13 100644 --- a/src/main.py +++ b/src/main.py @@ -1,147 +1,121 @@ # coding: utf-8 from __future__ import print_function +import os import logging from PySide2.QtWidgets import ( - QErrorMessage, QMainWindow, - QPushButton, QStatusBar, QVBoxLayout, QWidget ) -from .utils import NSE, SettingsState -from .connection import Server, ClientTest +from .utils import AppSettings +from .script_editor import NukeScriptEditor +from .connection import Server, TestClient, SendNodesClient from .widgets import ( - TextWidgets, ServerStatus, ErrorDialog, ToolBar + LogWidgets, + ConnectionsWidget, + ErrorDialog, + ToolBar ) LOGGER = logging.getLogger('NukeServerSocket.main') LOGGER.debug('\nSTART APPLICATION') +_TMP_FOLDER = os.path.join( + os.path.abspath(os.path.dirname(__file__)), '.tmp' +) +if not os.path.exists(_TMP_FOLDER): + os.mkdir(_TMP_FOLDER) + class MainWindowWidget(QWidget): def __init__(self, parent): QWidget.__init__(self) - self.settings = SettingsState() + + self.settings = AppSettings() self.settings.verify_port_config() + self.settings.setValue( + 'path/transfer_file', + os.path.join(_TMP_FOLDER, 'transfer_nodes.tmp') + ) + + self.log_widgets = LogWidgets() - self.main_window = parent + self.connections = ConnectionsWidget(parent=self) - self.connect_btn = QPushButton("Connect") - self.connect_btn.setCheckable(True) - self.connect_btn.toggled.connect(self._validate_connection) + self.connect_btn = self.connections.buttons.connect_btn + self.connect_btn.toggled.connect(self._connection) - self.test_btn = QPushButton("Test Server Receiver") - self.test_btn.setToolTip('Set server by sending a simple message') - self.test_btn.setEnabled(False) - self.test_btn.clicked.connect(self._test_send) + self.send_btn = self.connections.buttons.send_btn + self.send_btn.clicked.connect(self._send_nodes) - # TODO: don't like the text widget ping pong between classes - self.text_widgets = TextWidgets() - self.server_status = ServerStatus() + self.test_btn = self.connections.buttons.test_btn + self.test_btn.clicked.connect(self._test_receiver) _layout = QVBoxLayout() - _layout.addWidget(self.server_status) - _layout.addWidget(self.connect_btn) - _layout.addWidget(self.test_btn) - _layout.addWidget(self.text_widgets) + _layout.addWidget(self.connections) + _layout.addWidget(self.log_widgets) self.setLayout(_layout) - self.server = None - self.tcp_test = None - - # Initialize NSE when plugin gets created from Nuke - NSE() + self._server = None + self._test_client = None + self._node_client = None - def _test_send(self): - """Test connection internally from Qt.""" - self.tcp_test = ClientTest() - self.tcp_test.send_message() + NukeScriptEditor.init_editor() - def _show_port_error(self): - """Error message when port is not in the correct range.""" - err_msg = QErrorMessage(self) - err_msg.setWindowTitle('NukeServerSocket') - err_msg.showMessage('Port should be between 49152 and 65535') - err_msg.show() + def _enable_connection_mod(self, state): # type: (bool) -> None + """Enable/disable connection widgets modification. - def _update_port(self): - """Update port on the ini file from the text entry field widget. + When connected the port and the sender mode will be disabled. - If port is changed manually on the .ini file, then app will pick the one - from the file, but it will show the old on inside the widget. + Args: + state (bool): state of the widget. """ - self.settings.setValue( - 'server/port', self.server_status.port_entry.text()) - - def _validate_connection(self, state): - if not self.server_status.is_valid_port(): - self._show_port_error() - return - - self._update_port() - - self.test_btn.setEnabled(state) - self.server_status.port_entry.setEnabled(not state) + self.connections.server_port.setEnabled(state) + self.connections.sender_mode.setEnabled(state) + + def _connection(self, state): # type: (bool) -> None + """When connect button is toggled start connection, otherwise close it.""" + + def _start_connection(): + """Setup connection to server.""" + self._server = Server(self.log_widgets) + + try: + status = self._server.start_server() + except ValueError as err: + LOGGER.error('server did not connect: %s', err) + self.connect_btn.disconnect() + self.connections.set_disconnected() + else: + LOGGER.debug('server is connected: %s', status) + self._enable_connection_mod(False) if state: - is_connected = self._setup_connection() - - if is_connected: - self._update_ui_connect() - else: - self._update_ui_problems() + _start_connection() else: - self._update_ui_disconnect() - - def _update_ui_connect(self): - """Update ui when connected.""" - _cs = 'Connected' - self.main_window.status_bar.showMessage(_cs) - self.text_widgets.set_status_text(_cs) - self.connect_btn.setText('Disconnect') - - def _update_ui_problems(self): - """Update ui when connection problem. + self._server.close_server() + self._enable_connection_mod(True) - This will call _validate_connection() back and execute the disconnect_event - """ - self.connect_btn.setChecked(False) - self.main_window.status_bar.showMessage( - 'The specified port might be already in use.') - - def _update_ui_disconnect(self): - """Update ui when disconnected.""" - _ds = 'Disconnected' - self.text_widgets.set_status_text(_ds + '\n----') - self.main_window.status_bar.showMessage(_ds) - self.connect_btn.setText('Connect') - self.server_status.set_idle() + def _send_nodes(self): + """Send the selected Nuke Nodes using the internal client.""" + self._node_client = SendNodesClient() + self._node_client.connect() - self.server.server.close() - - def _setup_connection(self): - """Setup connection to server. - - Returns: - str: status of the connection: True if successful False otherwise - """ - self.server = Server(self.text_widgets) - status = self.server.start_server() - self.server_status.update_status(status) - - LOGGER.debug('server is connected: %s', status) - - return status + def _test_receiver(self): + """Send a test message using the internal client.""" + self._test_client = TestClient() + self._test_client.connect() class MainWindow(QMainWindow): def __init__(self): QMainWindow.__init__(self) + self.setWindowTitle("NukeServerSocket") toolbar = ToolBar() diff --git a/src/script_editor/__init__.py b/src/script_editor/__init__.py new file mode 100644 index 0000000..8fe3ca2 --- /dev/null +++ b/src/script_editor/__init__.py @@ -0,0 +1,2 @@ +from .nuke_se import NukeScriptEditor +from .nuke_se_controllers import CodeEditor \ No newline at end of file diff --git a/src/script_editor/nuke_se.py b/src/script_editor/nuke_se.py new file mode 100644 index 0000000..6742d11 --- /dev/null +++ b/src/script_editor/nuke_se.py @@ -0,0 +1,131 @@ +# coding: utf-8 +from __future__ import print_function + +import logging + +from abc import abstractmethod, ABCMeta, abstractproperty + +from PySide2.QtCore import Qt +from PySide2.QtTest import QTest + +from PySide2.QtWidgets import ( + QPushButton, + QSplitter, + QPlainTextEdit, + QTextEdit, + QApplication, + QWidget +) + + +LOGGER = logging.getLogger('NukeServerSocket.get_script_editor') + + +class BaseScriptEditor(object): + """Abstract class for the BaseScriptEditor implementation. + + This could potentially be used for base interface when implementing other + application scripts editors like Maya. + """ + __metaclass__ = ABCMeta + + # TODO: input/output widget should be a method ? + + @abstractmethod + def execute(self): + pass + + @abstractproperty + def input_widget(self): + pass + + @abstractproperty + def output_widget(self): + pass + + +class NukeScriptEditor(BaseScriptEditor): + """Nuke Internal Script Editor widget. + + The class get initialized when The NukeServerSocket gets first created. Then + will save all of the Nuke internal script editor widgets inside class properties + so it can get called later without having to search each widget again. + + Note: This could break anytime if Foundry changes something. + """ + # TODO: Should invest some time to find a better way. + script_editor = QWidget + run_button = QPushButton + console = QSplitter + input_widget = QWidget + output_widget = QWidget + + @classmethod + def init_editor(cls): + """Initialize NukeScriptEditor properties""" + LOGGER.debug('Initialize getting Nuke Script Editor') + cls.script_editor = cls.get_script_editor() + cls.run_button = cls.get_run_button() + cls.console = cls.script_editor.findChild(QSplitter) + cls.output_widget = cls.console.findChild(QTextEdit) + cls.input_widget = cls.console.findChild(QPlainTextEdit) + + @classmethod + def get_script_editor(cls): # type: () -> QWidget + """Get script editor widget. + + Returns: + QWidget: Nuke Internal script editor widget + + Raises: + BaseException: if script editor was not found + """ + # .topLevelWidgets() is a smaller list but SE is not always there + for widget in QApplication.allWidgets(): + + # TODO: user should be able to decide which SE to use + if 'scripteditor.1' in widget.objectName(): + return widget + + # XXX: can the script editor not exists? + # TODO: don't like the traceback but probably will never be called anyway + raise BaseException( + 'NukeServerSocket: Script Editor panel not found!' + 'Please create one and reload the panel.' + ) + + @classmethod + def get_run_button(cls): # type: () -> QPushButton | None + """Get the run button from the script editor. + + Returns: + (QPushButton | None): Return the QPushButton otherwise None + """ + for button in cls.script_editor.findChildren(QPushButton): + # The only apparent identifier of the button is a tooltip. Risky + if 'Run' in button.toolTip(): + return button + + # XXX: can the button not be found? + return None + + @classmethod + def _execute_shortcut(cls): + """Simulate shortcut CTRL + Return for running script. + + This method is currently used as a fallback in case the execute button + couldn't be found. + """ + # XXX: could the user change the shortcut? if yes then what? + QTest.keyPress(cls.input_widget, Qt.Key_Return, Qt.ControlModifier) + QTest.keyRelease(cls.input_widget, Qt.Key_Return, Qt.ControlModifier) + + def execute(cls): + """Execute code inside Nuke Script Editor. + + Check if run_button exists otherwise simulate shortcut press. + """ + try: + cls.run_button.click() + except AttributeError: + cls._execute_shortcut() diff --git a/src/script_editor/nuke_se_controllers.py b/src/script_editor/nuke_se_controllers.py new file mode 100644 index 0000000..91d9826 --- /dev/null +++ b/src/script_editor/nuke_se_controllers.py @@ -0,0 +1,273 @@ +# coding: utf-8 +from __future__ import print_function + +import re +import os +import json +import logging + +from sys import getsizeof +from textwrap import dedent + +from ..utils import AppSettings, insert_time +from ..script_editor import NukeScriptEditor + + +LOGGER = logging.getLogger('NukeServerSocket.get_script_editor') + + +class ScriptEditorController(): + """Manipulate internal script editor.""" + + def __init__(self): + + self.script_editor = NukeScriptEditor() + + self.initial_input = None + self.initial_output = None + + self._save_state() + + def _save_state(self): + """Save initial state of the editor before any modification.""" + self.initial_input = self.script_editor.input_widget.toPlainText() + self.initial_output = self.script_editor.output_widget.toPlainText() + + def set_input(self, text): # type: (str) -> None + """Set text to the input editor. + + Arguments + (str) text - text to insert. + """ + self.script_editor.input_widget.setPlainText(text) + + def set_output(self, text): # type: (str) -> None + """Set text to the output editor. + + Arguments + (str) text - text to insert. + """ + self.script_editor.output_widget.setPlainText(text) + + def output(self): # type: () -> str + """Get output from the nuke internal script editor.""" + return self.script_editor.output_widget.document().toPlainText() + + def execute(self): + """Abstract method for executing code from script editor.""" + self.script_editor.execute() + + def restore_input(self): + """Restore input text.""" + self.script_editor.input_widget.setPlainText(self.initial_input) + + def restore_output(self): + """Restore output text.""" + self.script_editor.output_widget.setPlainText(self.initial_output) + + def restore_state(self): + """Restore the initial text data of the editor.""" + self.restore_input() + self.restore_output() + + def __del__(self): + """Restore widget text after deleting object""" + # TODO: this is kind of confusing + self.restore_state() + + +class _PyController(ScriptEditorController, object): + history = [] + + def __init__(self, file): + ScriptEditorController.__init__(self) + self.settings = AppSettings() + self._file = file + + def restore_input(self): + """Override input editor if setting is True.""" + if not self.settings.get_bool('options/override_input'): + super(_PyController, self).restore_input() + + def restore_output(self): + """Send text to script editor output if setting is True.""" + if self.settings.get_bool('options/output_console'): + self._output_to_console() + else: + super(_PyController, self).restore_output() + + @classmethod + def _clear_history(cls): + """Clear the history list.""" + cls.history = [] + + @classmethod + def _append_output(cls, output_text): # type: (str) -> None + """Append text to class list in order to have a history output. + + The list can have a maximum size of 1mb after that it deletes the last element. + Theoretically this is way to big, but I am also unsure about this method + in general so it works for now. + + Args: + output_text (str): text to append into the list + """ + cls.history.append(output_text) + list_size = [getsizeof(n) for n in cls.history] + + if sum(list_size) >= 1000000: + cls.history.pop(0) + + def _show_file(self): # type: () -> str + """Set the file that is being executed. + + File could be empty string, in that case will do nothing + + Returns: + (str) file - file path of the file that is being executed from NukeTools + """ + return self._file if self.settings.get_bool( + 'options/include_path') else os.path.basename(self._file) + + @staticmethod + def _clean_output(text): # type: (str) -> str + """Return last section (after #Result:) of the output. + + Args: + text (str): text do be cleaned + + Returns: + str: Cleaned text. If #Result is not present in text, then returns the untouched text. + """ + try: + return re.search(r'(?:.*Result:\s)(.+)', text, re.S).group(1) + except AttributeError: + return text + + def _format_output(self, text): # type: (str) -> str + """Format output to send to nuke internal console.""" + text = self._clean_output(text) + file = self._show_file() + + output = "[Nuke Tools] %s \n%s" % (file, text) + + if self.settings.get_bool('options/use_unicode', True): + # Arrow sign going down + unicode = u'\u21B4' + output = "[Nuke Tools] %s %s\n%s" % (file, unicode, text) + + return insert_time(output) + + def output(self): # type: () -> str + """Get a clean version of the output editor.""" + return self._clean_output(super(_PyController, self).output()) + + def _output_to_console(self): + """Output data to Nuke internal script editor console.""" + + if self.settings.get_bool('options/format_text', True): + + output_text = self._format_output( + super(_PyController, self).output() + ) + + if self.settings.get_bool('options/clear_output', True): + super(_PyController, self).set_output(output_text) + else: + self._append_output(output_text) + super(_PyController, self).set_output(''.join(self.history)) + return + + self._clear_history() + + +class _BlinkController(ScriptEditorController, object): + def __init__(self, file): + ScriptEditorController.__init__(self) + self._file = file + + def output(self): # type: () -> str + """Overriding parent method by returning a simple string when executing + a blinkscript. + """ + return 'Recompiling' + + def set_input(self, text): # type: (str) -> str + """Overriding parent method by wrapping the code inside a some python + commands. + """ + text = self._blink_wrapper(text) + super(_BlinkController, self).set_input(text) + + def _blink_wrapper(self, code): # type: (str) -> str + """Wrap the code from the client data into some nuke commands. + + The nuke commands will check for a blinkscript and create one if needed. + Then will execute the code from the blinkscript recompile button. + + Returns: + str: wrapped text code. + """ + kwargs = { + 'file': self._file, + 'filename': os.path.basename(self._file), + 'code': json.dumps(code) + } + + return dedent(""" + nodes = [n for n in nuke.allNodes() if "{filename}" == n.name()] + try: + node = nodes[0] + except IndexError: + node = nuke.createNode('BlinkScript', 'name {filename}') + finally: + knobs = node.knobs() + knobs['kernelSourceFile'].setValue('{file}') + knobs['kernelSource'].setText({code}) + knobs['recompile'].execute() + """).format(**kwargs).strip() + + +class _CopyNodesController(ScriptEditorController, object): + def __init__(self): + ScriptEditorController.__init__(self) + self.settings = AppSettings() + + def output(self): # type: () -> str + """Overriding parent method by returning a simple string when pasting nodes. + """ + return 'Nodes copied' + + def set_input(self, text): # type: (str) -> str + """Overriding parent method by executing the `nuke.nodePaste()` command. + + Method will create a file with the text data received to be used as an + argument for the `nuke.nodePaste('file')` method. + """ + transfer_file = self.settings.value('path/transfer_file') + with open(transfer_file, 'w') as file: + file.write(text) + + text = "nuke.nodePaste('%s')" % transfer_file + super(_CopyNodesController, self).set_input(text) + + +class CodeEditor(object): + """Abstract facade for the script editor controller.""" + + def __init__(self, file): # type: (str) -> None + _, file_ext = os.path.splitext(file) + + if file_ext in {'.cpp', '.blink'}: + self._controller = _BlinkController(file) + + elif file_ext == '.tmp': + self._controller = _CopyNodesController() + + else: + self._controller = _PyController(file) + + @property + def controller(self): + """The script editor controller class.""" + return self._controller diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 801fce1..1a1f83c 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -1,4 +1,2 @@ from .util import validate_output, get_ip, insert_time -from .settings import SettingsState -from .get_script_editor import ScriptEditor, NSE -from .paths import * +from .settings import AppSettings diff --git a/src/utils/env.py b/src/utils/env.py index a70a65a..7172ead 100644 --- a/src/utils/env.py +++ b/src/utils/env.py @@ -3,7 +3,20 @@ import re import json -from .paths import get_root + +from os.path import ( + dirname, abspath +) + + +def get_src(): + """Return src absolute path.""" + return dirname(dirname(abspath(__file__))) + + +def get_root(): + """Return package root absolute path.""" + return dirname(get_src()) def get_env(): diff --git a/src/utils/get_script_editor.py b/src/utils/get_script_editor.py deleted file mode 100644 index 6cfe765..0000000 --- a/src/utils/get_script_editor.py +++ /dev/null @@ -1,250 +0,0 @@ -# coding: utf-8 -from __future__ import print_function - -import re -import os -import logging - -from sys import getsizeof - -from PySide2.QtTest import QTest -from PySide2.QtCore import QObject, Qt - -from PySide2.QtWidgets import ( - QPushButton, - QSplitter, - QTextEdit, - QPlainTextEdit, - QTextEdit, - QApplication, - QWidget -) - -from . import SettingsState, insert_time -from ..widgets import ErrorDialog - - -LOGGER = logging.getLogger('NukeServerSocket.get_script_editor') - - -def _clean_output(text): # type: (str) -> str - """Return last section (after #Result:) of the output. - - Arguments: - - str text: text do be cleaned - - Returns: - str: cleaned text - """ - clean_input = re.search(r'(?:.*Result:\s)(.+)', text, re.S) - if clean_input: - return clean_input.group(1) - return text - - -def _format_output(text, file, use_unicode=True): # type: (str, str, bool) -> str - """Format output to send to nuke internal console.""" - text = _clean_output(text) - - output = "[Nuke Tools] %s \n%s" % (file, text) - - if use_unicode: - # Arrow sign going down - unicode = u'\u21B4' - output = "[Nuke Tools] %s %s\n%s" % (file, unicode, text) - - output = insert_time(output) - - return output - - -class NSE(QObject): - """Nuke Internal Script Editor widget. - - Although a class is not necessary, Qt will complain in some instances that - internal C++ widget XXX is already deleted. This happens because of how python - handles the garbage collection. Saving a reference into a instance variable keeps it "alive". - - Don't like this method of executing code, this could break anytime if - Foundry changes something and it feels too hacky. Should invest some time to - find a better way. - """ - script_editor = QWidget - run_button = QPushButton - console = QSplitter - input_widget = QWidget - output_widget = QWidget - - def __init__(self, parent=None): - QObject.__init__(self, parent) - LOGGER.debug('init') - self.init_editor() - - @classmethod - def init_editor(cls): - """Initialize NSE properties""" - cls.script_editor = cls.get_script_editor() - cls.run_button = cls.get_run_button() - cls.console = cls.script_editor.findChild(QSplitter) - cls.output_widget = cls.console.findChild(QTextEdit) - cls.input_widget = cls.console.findChild(QPlainTextEdit) - - @classmethod - def get_script_editor(cls): # type: () -> QWidget - """Get script editor widget.""" - # .topLevelWidgets() is a smaller list but SE is not always there - for widget in QApplication.allWidgets(): - - # TODO: user should be able to decide which SE to use - if 'scripteditor' in widget.objectName(): - return widget - - # XXX: can the script editor not exists? - # TODO: don't like the traceback but probably will never be called anyway - raise BaseException( - 'NukeServerSocket: Script Editor panel not found!' - 'Please create one and reload the panel.' - ) - - @classmethod - def get_run_button(cls): # type: () -> QPushButton | None - """Get the run button from the script editor.""" - for button in cls.script_editor.findChildren(QPushButton): - # The only apparent identifier of the button is a tooltip. Risky - if 'Run' in button.toolTip(): - return button - - # XXX: can the button not be found? - return None - - @classmethod - def _execute_shortcut(cls): - """Simulate shortcut CTRL + Return for running script. - - This method is currently used as a fallback in case the execute button - couldn't be found. - """ - # XXX: could the user change the shortcut? if yes then what? - QTest.keyPress(cls.input_widget, Qt.Key_Return, Qt.ControlModifier) - QTest.keyRelease(cls.input_widget, Qt.Key_Return, Qt.ControlModifier) - - -class ScriptEditor(NSE): - """Manipulate Nuke internal script editor.""" - history = [] - - def __init__(self, parent=None): - - self.settings = SettingsState() - - self.initial_input = "" - self.initial_output = "" - - self._file = '' - - self._save_state() - - def refresh_parent(self): - """Call super for parent class.""" - NSE.__init__(self) - - def _save_state(self): - """Save initial state of the editor before any modification.""" - self.initial_input = self.input_widget.toPlainText() - self.initial_output = self.output_widget.toPlainText() - - def set_text(self, text): # type: (str) -> None - """Set text to the input editor. - - Arguments - (str) text - text to insert. - """ - self.input_widget.setPlainText(text) - - def set_file(self, file): # type: (str) -> None - """Set the file that is being executed. - - File could be empty string, in that case will do nothing - - Args: - (str) file - file path of the file that is being executed from NukeTools - """ - self._file = file if self.settings.get_bool( - 'options/include_path') else os.path.basename(file) - - def set_status_output(self): # type: () -> str - """Get a clean version of the output editor for status widget.""" - return _clean_output(self._get_output()) - - def _get_output(self): # type: () -> str - """Get output from the nuke internal script editor.""" - return self.output_widget.document().toPlainText() - - def execute(self): - """Abstract method for executing code inside NSE. - - Check if run_button exists otherwise simulate shortcut press. - """ - try: - self.run_button.click() - except AttributeError: - self._execute_shortcut() - - def _restore_input(self): - """Override input editor if setting is True.""" - if not self.settings.get_bool('options/override_input'): - self.input_widget.setPlainText(self.initial_input) - - @classmethod - def _append_output(cls, output_text): # type: (str) -> None - """Append text to class list in order to have a history output. - - The list can have a maximum size of 1mb after that it deletes the last element. - Theoretically this is way to big. But I am also unsure about this method - in general so it works for now. Need to think it better. - - :param output_text: text to append into the list - :type output_text: str - """ - cls.history.append(output_text) - list_size = [getsizeof(n) for n in cls.history] - - # HACK: not sure about this. give the list cap at 1 mb. way too generous? - if sum(list_size) >= 1000000: - cls.history.pop(0) - - @classmethod - def _clear_history(cls): - """Clear the history list.""" - cls.history = [] - - def _restore_output(self): - """Send text to script editor output if setting is True.""" - if self.settings.get_bool('options/output_console'): - self._output_to_console() - else: - self.output_widget.setPlainText(self.initial_output) - - def _output_to_console(self): - """Output data to Nuke internal script editor console.""" - output_text = self._get_output() - - if self.settings.get_bool('options/format_text', True): - use_unicode = self.settings.get_bool('options/use_unicode', True) - output_text = _format_output(output_text, self._file, use_unicode) - - if self.settings.get_bool('options/clear_output', True): - self._clear_history() - self.output_widget.setPlainText(output_text) - else: - self._append_output(output_text) - self.output_widget.setPlainText(''.join(self.history)) - return - - self._clear_history() - - def restore_state(self): - """Restore the initial state of the editor.""" - self._restore_input() - self._restore_output() diff --git a/src/utils/nuke_exec.py b/src/utils/nuke_exec.py deleted file mode 100644 index 632880b..0000000 --- a/src/utils/nuke_exec.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Testing module for nuke various way to execute code. currently non using it""" - - -def _pyscript_knob(msg): - # This seems to work fine but does not return anything - cmd = nuke.PyScript_Knob('exec', 'Execute', msg.data().decode('utf-8')) - cmd.execute() - - -def _execute_main(): - # This should be the method by needs more time to investigate - cmd = nuke.executeInMainThreadWithReturn() diff --git a/src/utils/paths.py b/src/utils/paths.py deleted file mode 100644 index 624fd19..0000000 --- a/src/utils/paths.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Utility module for getting various paths in package.""" - -from os.path import ( - dirname, abspath -) - - -def get_src(): - """Return src absolute path.""" - return dirname(dirname(abspath(__file__))) - - -def get_root(): - """Return package root absolute path.""" - return dirname(get_src()) diff --git a/src/utils/settings.py b/src/utils/settings.py index fae1ff7..63fe8f0 100644 --- a/src/utils/settings.py +++ b/src/utils/settings.py @@ -16,7 +16,7 @@ def config_file(): ) -class SettingsState(QSettings): +class AppSettings(QSettings): def __init__(self): QSettings.__init__(self, config_file(), QSettings.IniFormat) diff --git a/src/utils/util.py b/src/utils/util.py index b27c9c7..736ccc3 100644 --- a/src/utils/util.py +++ b/src/utils/util.py @@ -1,24 +1,32 @@ # coding: utf-8 from __future__ import print_function +import sys import socket import logging from PySide2.QtCore import QByteArray, QTime from PySide2.QtNetwork import QNetworkInterface -from .. import nuke - LOGGER = logging.getLogger('NukeServerSocket.util') -def insert_time(text): +def insert_time(text): # type: (str) -> str + """Insert textual time at the beginning of the string. + + Example: [17:49:25] Hello World + + Args: + text (str): str to insert the time. + + Returns: + str: string with inserted current timed at the beginning. + """ time = QTime().currentTime().toString() - text = '[%s] %s\n' % (time, text) - return text + return '[%s] %s\n' % (time, text) -def validate_output(data): +def validate_output(data): # type: (str) -> bytearray | QByteArray """Check for nuke version and return appropriate type of output data. Nuke11, 12 output type is 'unicode' @@ -31,10 +39,7 @@ def validate_output(data): PySide2.QtCore.QByteArray(PySide2.QtCore.QByteArray) PySide2.QtCore.QByteArray(int, typing.Char) """ - nuke_version = nuke.env['NukeVersionMajor'] - LOGGER.debug('Running Nuke: %s', nuke_version) - - if nuke_version == 13: + if sys.version_info > (3, 0): data = bytearray(data, 'utf-8') else: data = QByteArray(data.encode('utf-8')) @@ -44,14 +49,12 @@ def validate_output(data): def get_ip(): def _get_ip_qt(): - """This doesnt work on nuke 11 as QNetworkInterface doesnt not have .isglobal()""" - ips = [] - qt_network = QNetworkInterface() - for address in qt_network.allAddresses(): - if address.isGlobal(): - ips.append(address.toString()) - - return ips + """Doesn't work on Nuke 11 as QNetworkInterface doesn't not have .isglobal()""" + return [ + address.toString() + for address in QNetworkInterface().allAddresses() + if address.isGlobal() + ] def _get_ip_py(): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -64,11 +67,11 @@ def _get_ip_py(): s.close() return [ip] - # HACK: Nuke11 doesnt have Network.isGlobal() - if nuke.env['NukeVersionMajor'] == 11: - ip1 = [] - else: + # Nuke11 doesn't have Network.isGlobal() + try: ip1 = _get_ip_qt() + except AttributeError: + ip1 = [] ip2 = _get_ip_py() diff --git a/src/widgets/__init__.py b/src/widgets/__init__.py index c9f21b1..1c1751a 100644 --- a/src/widgets/__init__.py +++ b/src/widgets/__init__.py @@ -1,5 +1,5 @@ from .error_dialog import ErrorDialog from .toolbar import ToolBar -from .server_status import ServerStatus -from .text_widgets import TextWidgets -from .script_editor import FakeScriptEditor +from .connections_widget import ConnectionsWidget +from .log_widgets import LogWidgets +from .fake_script_editor import FakeScriptEditor diff --git a/src/widgets/connections_widget.py b/src/widgets/connections_widget.py new file mode 100644 index 0000000..a5ca066 --- /dev/null +++ b/src/widgets/connections_widget.py @@ -0,0 +1,271 @@ +# coding: utf-8 +from __future__ import print_function + +import logging + +from PySide2.QtCore import QObject, Qt, Signal +from PySide2.QtNetwork import QHostInfo + +from PySide2.QtWidgets import ( + QFormLayout, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QRadioButton, + QVBoxLayout, + QWidget, + QSpinBox +) + +from ..utils import AppSettings, get_ip + +LOGGER = logging.getLogger('NukeServerSocket.connections_widget') + + +class ConnectButton(QPushButton): + """Custom QPushButton class for quick Connect/Disconnect setup.""" + + def __init__(self, parent=None): + QPushButton.__init__(self, parent) + self.setText('Connect') + self.setCheckable(True) + + self.toggled.connect(self.toggle_state) + + def disconnect(self): + """Force disconnect and uncheck toggle state of button.""" + self.setText('Connect') + self.setChecked(False) + + def toggle_state(self, state): + """Toggle state of button. + + When a button is toggled (True) the button text will be changed to Disconnect, + otherwise when the button not toggled (False) will be changed to Connect. + + Args: + state (bool): state of the button + """ + if state: + self.setText('Disconnect') + else: + self.setText('Connect') + + +class ConnectionButtons(QObject): + """Button section of the connection widgets""" + + def __init__(self, parent=None): + QObject.__init__(self, parent) + + self.connect_btn = ConnectButton(parent) + self.connect_btn.toggled.connect(self._toggle_buttons_state) + + self.test_btn = QPushButton('Test Receiver') + self.test_btn.setEnabled(False) + + self.send_btn = QPushButton('Send Selected Nodes') + self.send_btn.setEnabled(False) + + def _toggle_buttons_state(self, state): + """Switch button state based on the connect button. + + If button is toggled (True) then enable test_btn but disabled send_btn + and vice versa. + + Args: + state (bool): state of the connect button + """ + # self._enable_send(state) + self._enable_test(state) + + def _enable_send(self, state): + """Enable or disable send_btn. + + The state will always be mutually exclusive with the connect_btn. + + Args: + state (bool): state of the connect button + """ + self.send_btn.setEnabled(not state) + + def _enable_test(self, state): + """Enable or disable test_btn. + + Args: + state (bool): state of the connect button + """ + self.test_btn.setEnabled(state) + + +class TcpPort(QSpinBox): + """Tcp port object""" + + def __init__(self, port_id): # type: (str) -> None + QSpinBox.__init__(self) + + self.port_id = port_id + self.settings = AppSettings() + + self.setRange(49512, 65535) + self.setMaximumHeight(100) + self.setToolTip('Server port for the socket to listen/send.') + self._setup_port() + + def _setup_port(self): + """Setup the port entry field widget.""" + port = self.settings.value(self.port_id, 54321) + + self.setValue(int(port)) + self.valueChanged.connect(self._write_port) + + def _write_port(self, port): # type: (int) -> None + """Write port id to configuration file is port is valid. + + The method will convert the port value into text before writing it. + """ + if 49152 <= port <= 65535: + self.settings.setValue(self.port_id, self.textFromValue(port)) + + +class ConnectionsWidget(QWidget): + + def __init__(self, parent=None): + QWidget.__init__(self, parent) + + self.settings = AppSettings() + + self._is_connected = QLabel() + self._is_connected.setObjectName('connection') + self.set_idle() + + self.server_port = TcpPort(port_id='server/port') + + self.buttons = ConnectionButtons(self) + + # when button is clicked, update server status label + self.buttons.connect_btn.toggled.connect(self.update_status_label) + + self.ip_entry = QLineEdit() + self.ip_entry.textChanged.connect(self._update_send_address) + self.ip_entry.setMaximumWidth(100) + + self.ip_address_label = QLabel() + + self.receiver_mode = QRadioButton('Receiver') + self.receiver_mode.toggled.connect(self._state_changed) + self.receiver_mode.setChecked(True) + self.receiver_mode.setLayoutDirection(Qt.RightToLeft) + + self.sender_mode = QRadioButton('Sender') + + self._layout = QVBoxLayout() + self._add_switch_layout() + self._add_form_layout() + self._add_grid_layout() + + self.setLayout(self._layout) + self._set_tooltips() + + def _set_tooltips(self): + """Setup the various tooltips so to clean the init method.""" + self._is_connected.setToolTip( + 'State of the server when listening for incoming requests.' + ) + self.ip_entry.setToolTip( + 'Local IP address for current host.\n' + 'When on Receiver mode this only serves as a lookup.\n' + 'When on Sender mode, field can be changed to match a different computer address.') + self.receiver_mode.setToolTip( + 'Receiver Mode.\n' + 'This puts the plugin into listening for incoming request mode.' + ) + self.sender_mode.setToolTip( + 'Sender Mode.\nThis puts the plugin into the sending nodes mode.' + ) + + def _update_send_address(self, text): + """Update settings for send port address if mode is on sender.""" + if not self.receiver_mode.isChecked(): + self.settings.setValue('server/send_to_address', text) + + def _state_changed(self, state): + """When mode changes, update the UI accordingly. """ + + def _update_ip_text(state): + """Update the ip widgets based on the mode.""" + if state: + ip_label_text = 'Local IP Address' + ip_entry_text = get_ip() + else: + ip_label_text = 'Send To IP Address' + ip_entry_text = self.settings.value( + 'server/send_to_address', get_ip() + ) + + self.ip_entry.setText(ip_entry_text) + self.ip_address_label.setText(ip_label_text) + + self.buttons.connect_btn.setEnabled(state) + self.buttons.send_btn.setEnabled(not state) + self.ip_entry.setReadOnly(state) + + _update_ip_text(state) + + def _add_switch_layout(self): + switch_layout = QHBoxLayout() + switch_layout.addWidget(self.receiver_mode) + switch_layout.addWidget(self.sender_mode) + + self._layout.addLayout(switch_layout) + + def _add_form_layout(self): + """Setup the form layout for the labels.""" + + _form_layout = QFormLayout() + _form_layout.setFormAlignment(Qt.AlignHCenter | Qt.AlignTop) + _form_layout.addRow(QLabel('Status'), self._is_connected) + _form_layout.addRow(self.ip_address_label, self.ip_entry) + # _form_layout.addRow( + # QLabel('Local Host Address'), QLabel(QHostInfo().localHostName())) + _form_layout.addRow(QLabel('Port'), self.server_port) + + self._layout.addLayout(_form_layout) + + def _add_grid_layout(self): + """Setup the grid layout for the buttons.""" + + _grid_layout = QGridLayout() + _grid_layout.addWidget(self.buttons.connect_btn, 0, 0, 1, 2) + _grid_layout.addWidget(self.buttons.test_btn, 1, 0) + _grid_layout.addWidget(self.buttons.send_btn, 1, 1) + + self._layout.addLayout(_grid_layout) + + def set_idle(self): + """Set idle status.""" + self._is_connected.setText('Idle') + self.setStyleSheet('QLabel#connection { color: orange;}') + + def set_disconnected(self): + """Set disconnected status.""" + self._is_connected.setText('Not Connected') + self.setStyleSheet('QLabel#connection { color: red;}') + + def set_connected(self): + """Set connected status.""" + self._is_connected.setText('Connected') + self.setStyleSheet('QLabel#connection { color: green;}') + + def update_status_label(self, status): # type (bool) -> None + """Update status label. + + Args: + status (bool): bool representation of the connection status + """ + if status is True: + self.set_connected() + elif status is False: + self.set_idle() diff --git a/src/widgets/error_dialog.py b/src/widgets/error_dialog.py index 85e1b23..8f3b5eb 100644 --- a/src/widgets/error_dialog.py +++ b/src/widgets/error_dialog.py @@ -82,4 +82,7 @@ def click_event(self, button): elif button.text() == 'Open logs': to_open = get_about_key('Logs') + elif button.text() == 'Cancel': + return + QDesktopServices.openUrl(to_open) diff --git a/src/widgets/script_editor.py b/src/widgets/fake_script_editor.py similarity index 85% rename from src/widgets/script_editor.py rename to src/widgets/fake_script_editor.py index 2ea04a4..e247e62 100644 --- a/src/widgets/script_editor.py +++ b/src/widgets/fake_script_editor.py @@ -1,4 +1,5 @@ # coding: utf-8 +"""Fake emulation of the internal nuke script editor layout/widgets.""" from __future__ import print_function import sys @@ -6,11 +7,10 @@ import subprocess -from PySide2.QtCore import Qt, QEvent +from PySide2.QtCore import Qt from PySide2.QtGui import QKeySequence, QKeyEvent from PySide2.QtWidgets import ( - QShortcut, QVBoxLayout, QWidget, QPlainTextEdit, @@ -35,9 +35,9 @@ def __init__(self): class FakeScriptEditor(QWidget): - def __init__(self): + def __init__(self, object_name='uk.co.thefoundry.scripteditor.1'): QWidget.__init__(self) - self.setObjectName('uk.co.thefoundry.scripteditor.1') + self.setObjectName(object_name) self.run_btn = QPushButton('Run') self.run_btn.setToolTip('Run the current') @@ -71,8 +71,9 @@ def eventFilter(self, obj, event): def run_code(self): code = self.input_console.toPlainText() - call = subprocess.check_output(['python', '-c', code]) - self.output_console.setPlainText(call) + if 'nuke.nodePaste' not in code: + code = subprocess.check_output(['python', '-c', code]) + self.output_console.setPlainText(code) class MainWindow(QMainWindow): diff --git a/src/widgets/text_widgets.py b/src/widgets/log_widgets.py similarity index 62% rename from src/widgets/text_widgets.py rename to src/widgets/log_widgets.py index eeb53f7..cfa220b 100644 --- a/src/widgets/text_widgets.py +++ b/src/widgets/log_widgets.py @@ -14,14 +14,14 @@ from ..utils import insert_time -class TextBox(QGroupBox): +class LogBox(QGroupBox): def __init__(self, title): QGroupBox.__init__(self, title) self.text_box = QPlainTextEdit() self.text_box.setReadOnly(True) - btn = QPushButton('Clear text') + btn = QPushButton('Clear log') btn.clicked.connect(self.text_box.clear) _layout = QVBoxLayout() @@ -31,12 +31,12 @@ def __init__(self, title): self.setLayout(_layout) -class TextWidgets(QWidget): +class LogWidgets(QWidget): def __init__(self): QWidget.__init__(self) - self.status_text = TextBox('Status') - self.input_text = TextBox('Input') - self.output_text = TextBox('Output') + self.status_text = LogBox('Status') + self.input_text = LogBox('Input') + self.output_text = LogBox('Output') _layout = QVBoxLayout() _layout.addWidget(self.status_text) @@ -45,15 +45,17 @@ def __init__(self): self.setLayout(_layout) + @staticmethod + def _write_log(widget, text): + widget.text_box.insertPlainText(insert_time(text)) + widget.text_box.ensureCursorVisible() + def set_status_text(self, text): - self.status_text.text_box.insertPlainText(insert_time(text)) - self.status_text.text_box.ensureCursorVisible() + self._write_log(self.status_text, text) def set_input_text(self, text): - self.input_text.text_box.insertPlainText(insert_time(text)) - self.input_text.text_box.ensureCursorVisible() + self._write_log(self.input_text, text) def set_output_text(self, text): text = str(text).strip() - self.output_text.text_box.insertPlainText(insert_time(text)) - self.output_text.text_box.ensureCursorVisible() + self._write_log(self.output_text, text) diff --git a/src/widgets/server_status.py b/src/widgets/server_status.py deleted file mode 100644 index 36056a5..0000000 --- a/src/widgets/server_status.py +++ /dev/null @@ -1,150 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import print_function - - -import logging - -from PySide2.QtCore import QRegExp, Qt -from PySide2.QtNetwork import QHostInfo -from PySide2.QtGui import QRegExpValidator - -from PySide2.QtWidgets import ( - QFormLayout, - QLabel, - QWidget, - QLineEdit -) - -from ..utils import SettingsState, get_ip - -LOGGER = logging.getLogger('NukeServerSocket.server_status') - - -def _set_style_sheet(func): - """Wrapper that adds styleSheet strings to main styleSheet""" - def inner_wrapper(*args, **kwargs): - self = args[0] - - style = self.styleSheet() - style += func(*args, **kwargs) - self.setStyleSheet(style) - - return inner_wrapper - - -class ServerStatus(QWidget): - def __init__(self): - QWidget.__init__(self) - self.settings = SettingsState() - self.port_config_id = 'server/port' - - self._is_connected = QLabel() - self._is_connected.setObjectName('connection') - self.set_idle() - - self._server_port = QLineEdit() - self._server_port.setMaximumWidth(100) - self._server_port.setToolTip('Server port for vscode to listen') - self._server_port.setObjectName('port') - self._set_server_port() - - _layout = QFormLayout() - _layout.setFormAlignment(Qt.AlignHCenter | Qt.AlignTop) - _layout.addRow(QLabel('Status'), self._is_connected) - _layout.addRow(QLabel('Local Host Address'), - QLabel(QHostInfo().localHostName())) - _layout.addRow(QLabel('Local IP Address'), QLabel(get_ip())) - _layout.addRow(QLabel('Port'), self._server_port) - - self.setLayout(_layout) - - @property - def port_entry(self): # type: () -> QLineEdit - """The entry widget port Qt object.""" - return self._server_port - - def is_valid_port(self): # type: (str) -> bool - """Public method that checks if current port is in valid range. - - Returns: - (bool): True if is in valid range, False otherwise - """ - return self._check_port_range(self._server_port.text()) - - def _set_server_port(self): - """Setup the port entry field widget.""" - port = self.settings.value(self.port_config_id) - - self._server_port.setText(port) - self._server_port.setValidator(QRegExpValidator(QRegExp('\d{5}'))) - self._server_port.textChanged.connect(self._update_port) - - @staticmethod - def _check_port_range(port): # type: (str) -> bool - """Check if port range is valid. - - Args: - port (str): port to check. - - Returns: - (bool): True if is in valid range, False otherwise - """ - port = 0 if not port else port - return 49152 <= int(port) <= 65535 - - @_set_style_sheet - def _update_port(self, port): # type: (str) -> str - """Update file configuration value. - - If port is out of range widget will be colored with red. - - Returns: - (str): Qt styleSheet to be added to main styleSheet. - """ - if self._check_port_range(port): - style = 'QLineEdit#port {background-color: none;}' - self.settings.setValue(self.port_config_id, port) - else: - style = 'QLineEdit#port {background-color: rgba(255, 0, 0, 150);}' - return style - - @_set_style_sheet - def set_idle(self): # type () -> str - """Set idle status. - - Returns: - (str): Qt styleSheet to be added to main styleSheet. - """ - self._is_connected.setText('Idle') - return 'QLabel#connection { color: orange;}' - - @_set_style_sheet - def set_disconnected(self): # type () -> str - """Set disconnected status. - - Returns: - (str): Qt styleSheet to be added to main styleSheet. - """ - self._is_connected.setText('Not Connected') - return 'QLabel#connection { color: red;}' - - @_set_style_sheet - def set_connected(self): # type () -> str - """Set connected status. - - Returns: - (str): Qt styleSheet to be added to main styleSheet. - """ - self._is_connected.setText('Connected') - return 'QLabel#connection { color: green;}' - - def update_status(self, status): # type (bool) -> None - """Update status switch. - - Args: - status (bool): bool representation of the connection status - """ - if status is True: - self.set_connected() - elif status is False: - self.set_disconnected() diff --git a/src/widgets/settings_widget.py b/src/widgets/settings_widget.py index d55ca44..10bcbc9 100644 --- a/src/widgets/settings_widget.py +++ b/src/widgets/settings_widget.py @@ -13,9 +13,9 @@ QWidget ) -from ..utils import SettingsState +from ..utils import AppSettings -LOGGER = logging.getLogger('NukeServerSocket.server_status') +LOGGER = logging.getLogger('NukeServerSocket.settings_widget') class QHLine(QFrame): @@ -33,7 +33,7 @@ class SettingsWidget(QWidget): def __init__(self): # TODO: major refactoring needed here QWidget.__init__(self) - self.settings = SettingsState() + self.settings = AppSettings() # setup checkboxes self._output_console = self._create_checkbox( diff --git a/tests/run_app.py b/tests/run_app.py index 555f367..a76a126 100644 --- a/tests/run_app.py +++ b/tests/run_app.py @@ -8,6 +8,7 @@ from PySide2.QtWidgets import ( QApplication, QMainWindow, + QPushButton, QVBoxLayout, QWidget, QStatusBar @@ -25,16 +26,37 @@ } +class SecondFakeScriptEditor(QWidget): + def __init__(self, se1): + QWidget.__init__(self) + self.se1 = se1 + self.se2 = FakeScriptEditor('uk.co.thefoundry.scripteditor.2') + + delete_btn = QPushButton('Delete Editor') + delete_btn.clicked.connect(self.delete_widget) + + _layout = QVBoxLayout() + _layout.addWidget(delete_btn) + _layout.addWidget(self.se2) + + self.setLayout(_layout) + + def delete_widget(self): + print('delete editor.1') + self.se1.deleteLater() + + class TestMainwindowWidgets(QWidget): def __init__(self, parent): QWidget.__init__(self) - self.script_editor = FakeScriptEditor() + self.script_editor = FakeScriptEditor('uk.co.thefoundry.scripteditor.1') self.main_app = MainWindowWidget(parent) _layout = QVBoxLayout() _layout.addWidget(self.main_app) _layout.addWidget(self.script_editor) + # _layout.addWidget(SecondFakeScriptEditor(self.script_editor)) self.setLayout(_layout) self.main_app.connect_btn.click()