diff --git a/CHANGELOG.md b/CHANGELOG.md index f971c33208..5acb161bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,16 @@ # Changelog -## [3.9.0-nightly.8](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.0](https://github.com/pypeclub/OpenPype/tree/3.9.0) (2022-03-14) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...HEAD) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...3.9.0) **Deprecated:** - AssetCreator: Remove the tool [\#2845](https://github.com/pypeclub/OpenPype/pull/2845) -- Houdini: Remove unused code [\#2779](https://github.com/pypeclub/OpenPype/pull/2779) **🚀 Enhancements** +- General: Subset name filtering in ExtractReview outpus [\#2872](https://github.com/pypeclub/OpenPype/pull/2872) - NewPublisher: Descriptions and Icons in creator dialog [\#2867](https://github.com/pypeclub/OpenPype/pull/2867) - NewPublisher: Changing task on publishing instance [\#2863](https://github.com/pypeclub/OpenPype/pull/2863) - TrayPublisher: Choose project widget is more clear [\#2859](https://github.com/pypeclub/OpenPype/pull/2859) @@ -22,11 +22,12 @@ - global: letter box calculated on output as last process [\#2812](https://github.com/pypeclub/OpenPype/pull/2812) - Nuke: adding Reformat to baking mov plugin [\#2811](https://github.com/pypeclub/OpenPype/pull/2811) - Manager: Update all to latest button [\#2805](https://github.com/pypeclub/OpenPype/pull/2805) -- General: Set context environments for non host applications [\#2803](https://github.com/pypeclub/OpenPype/pull/2803) **🐛 Bug fixes** +- General: Missing time function [\#2877](https://github.com/pypeclub/OpenPype/pull/2877) - Deadline: Fix plugin name for tile assemble [\#2868](https://github.com/pypeclub/OpenPype/pull/2868) +- Nuke: gizmo precollect fix [\#2866](https://github.com/pypeclub/OpenPype/pull/2866) - General: Fix hardlink for windows [\#2864](https://github.com/pypeclub/OpenPype/pull/2864) - General: ffmpeg was crashing on slate merge [\#2860](https://github.com/pypeclub/OpenPype/pull/2860) - WebPublisher: Video file was published with one too many frame [\#2858](https://github.com/pypeclub/OpenPype/pull/2858) @@ -35,6 +36,7 @@ - Nuke: slate resolution to input video resolution [\#2853](https://github.com/pypeclub/OpenPype/pull/2853) - WebPublisher: Fix username stored in DB [\#2852](https://github.com/pypeclub/OpenPype/pull/2852) - WebPublisher: Fix wrong number of frames for video file [\#2851](https://github.com/pypeclub/OpenPype/pull/2851) +- Nuke: Fix family test in validate\_write\_legacy to work with stillImage [\#2847](https://github.com/pypeclub/OpenPype/pull/2847) - Nuke: fix multiple baking profile farm publishing [\#2842](https://github.com/pypeclub/OpenPype/pull/2842) - Blender: Fixed parameters for FBX export of the camera [\#2840](https://github.com/pypeclub/OpenPype/pull/2840) - Maya: Stop creation of reviews for Cryptomattes [\#2832](https://github.com/pypeclub/OpenPype/pull/2832) @@ -47,23 +49,18 @@ - Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819) - Nuke: Use AVALON\_APP to get value for "app" key [\#2818](https://github.com/pypeclub/OpenPype/pull/2818) - StandalonePublisher: use dynamic groups in subset names [\#2816](https://github.com/pypeclub/OpenPype/pull/2816) -- Settings UI: Search case sensitivity [\#2810](https://github.com/pypeclub/OpenPype/pull/2810) -- Flame Babypublisher optimalization [\#2806](https://github.com/pypeclub/OpenPype/pull/2806) **🔀 Refactored code** +- Refactor: move webserver tool to openpype [\#2876](https://github.com/pypeclub/OpenPype/pull/2876) - General: Move create logic from avalon to OpenPype [\#2854](https://github.com/pypeclub/OpenPype/pull/2854) - General: Add vendors from avalon [\#2848](https://github.com/pypeclub/OpenPype/pull/2848) +- General: Basic event system [\#2846](https://github.com/pypeclub/OpenPype/pull/2846) - General: Move change context functions [\#2839](https://github.com/pypeclub/OpenPype/pull/2839) - Tools: Don't use avalon tools code [\#2829](https://github.com/pypeclub/OpenPype/pull/2829) - Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823) - General: Extract template formatting from anatomy [\#2766](https://github.com/pypeclub/OpenPype/pull/2766) -**Merged pull requests:** - -- Nuke: gizmo precollect fix [\#2866](https://github.com/pypeclub/OpenPype/pull/2866) -- Nuke: Fix family test in validate\_write\_legacy to work with stillImage [\#2847](https://github.com/pypeclub/OpenPype/pull/2847) - ## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.2-nightly.3...3.8.2) diff --git a/openpype/__init__.py b/openpype/__init__.py index 755036168d..0df1b7270f 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -5,18 +5,13 @@ import platform import functools import logging -from openpype.pipeline import ( - LegacyCreator, - register_loader_plugins_path, - deregister_loader_plugins_path, -) - from .settings import get_project_settings from .lib import ( Anatomy, filter_pyblish_plugins, set_plugin_attributes_from_settings, - change_timer_to_current_context + change_timer_to_current_context, + register_event_callback, ) pyblish = avalon = _original_discover = None @@ -80,6 +75,10 @@ def install(): """Install Pype to Avalon.""" from pyblish.lib import MessageHandler from openpype.modules import load_modules + from openpype.pipeline import ( + LegacyCreator, + register_loader_plugins_path, + ) from avalon import pipeline # Make sure modules are loaded @@ -133,16 +132,18 @@ def install(): avalon.discover = patched_discover pipeline.discover = patched_discover - avalon.on("taskChanged", _on_task_change) + register_event_callback("taskChanged", _on_task_change) -def _on_task_change(*args): +def _on_task_change(): change_timer_to_current_context() @import_wrapper def uninstall(): """Uninstall Pype from Avalon.""" + from openpype.pipeline import deregister_loader_plugins_path + log.info("Deregistering global plug-ins..") pyblish.deregister_plugin_path(PUBLISH_PATH) pyblish.deregister_discovery_filter(filter_pyblish_plugins) diff --git a/openpype/hosts/aftereffects/api/launch_logic.py b/openpype/hosts/aftereffects/api/launch_logic.py index 97f14c9332..c549268978 100644 --- a/openpype/hosts/aftereffects/api/launch_logic.py +++ b/openpype/hosts/aftereffects/api/launch_logic.py @@ -15,7 +15,7 @@ from Qt import QtCore from openpype.tools.utils import host_tools from avalon import api -from avalon.tools.webserver.app import WebServerTool +from openpype.tools.adobe_webserver.app import WebServerTool from .ws_stub import AfterEffectsServerStub diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 2dc41bd8b9..8961599149 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -15,6 +15,7 @@ from openpype.pipeline import ( deregister_loader_plugins_path, ) import openpype.hosts.aftereffects +from openpype.lib import register_event_callback from .launch_logic import get_stub @@ -78,7 +79,7 @@ def install(): "instanceToggled", on_pyblish_instance_toggled ) - avalon.api.on("application.launched", application_launch) + register_event_callback("application.launched", application_launch) def uninstall(): diff --git a/openpype/hosts/aftereffects/api/ws_stub.py b/openpype/hosts/aftereffects/api/ws_stub.py index 5a0600e92e..b0893310c1 100644 --- a/openpype/hosts/aftereffects/api/ws_stub.py +++ b/openpype/hosts/aftereffects/api/ws_stub.py @@ -8,7 +8,7 @@ import logging import attr from wsrpc_aiohttp import WebSocketAsync -from avalon.tools.webserver.app import WebServerTool +from openpype.tools.adobe_webserver.app import WebServerTool @attr.s diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index f3a5c941eb..64fb135d89 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -20,6 +20,10 @@ from openpype.pipeline import ( deregister_loader_plugins_path, ) from openpype.api import Logger +from openpype.lib import ( + register_event_callback, + emit_event +) import openpype.hosts.blender HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.blender.__file__)) @@ -55,8 +59,9 @@ def install(): lib.append_user_scripts() - avalon.api.on("new", on_new) - avalon.api.on("open", on_open) + register_event_callback("new", on_new) + register_event_callback("open", on_open) + _register_callbacks() _register_events() @@ -118,22 +123,22 @@ def set_start_end_frames(): scene.render.resolution_y = resolution_y -def on_new(arg1, arg2): +def on_new(): set_start_end_frames() -def on_open(arg1, arg2): +def on_open(): set_start_end_frames() @bpy.app.handlers.persistent def _on_save_pre(*args): - avalon.api.emit("before_save", args) + emit_event("before.save") @bpy.app.handlers.persistent def _on_save_post(*args): - avalon.api.emit("save", args) + emit_event("save") @bpy.app.handlers.persistent @@ -141,9 +146,9 @@ def _on_load_post(*args): # Detect new file or opening an existing file if bpy.data.filepath: # Likely this was an open operation since it has a filepath - avalon.api.emit("open", args) + emit_event("open") else: - avalon.api.emit("new", args) + emit_event("new") ops.OpenFileCacher.post_load() @@ -174,7 +179,7 @@ def _register_callbacks(): log.info("Installed event handler _on_load_post...") -def _on_task_changed(*args): +def _on_task_changed(): """Callback for when the task in the context is changed.""" # TODO (jasper): Blender has no concept of projects or workspace. @@ -191,7 +196,7 @@ def _on_task_changed(*args): def _register_events(): """Install callbacks for specific events.""" - avalon.api.on("taskChanged", _on_task_changed) + register_event_callback("taskChanged", _on_task_changed) log.info("Installed event callback for 'taskChanged'...") diff --git a/openpype/hosts/harmony/api/pipeline.py b/openpype/hosts/harmony/api/pipeline.py index be183902a7..b9d2e78bce 100644 --- a/openpype/hosts/harmony/api/pipeline.py +++ b/openpype/hosts/harmony/api/pipeline.py @@ -9,6 +9,7 @@ import avalon.api from avalon.pipeline import AVALON_CONTAINER_ID from openpype import lib +from openpype.lib import register_event_callback from openpype.pipeline import ( LegacyCreator, register_loader_plugins_path, @@ -134,7 +135,7 @@ def check_inventory(): harmony.send({"function": "PypeHarmony.message", "args": msg}) -def application_launch(): +def application_launch(event): """Event that is executed after Harmony is launched.""" # FIXME: This is breaking server <-> client communication. # It is now moved so it it manually called. @@ -192,7 +193,7 @@ def install(): "instanceToggled", on_pyblish_instance_toggled ) - avalon.api.on("application.launched", application_launch) + register_event_callback("application.launched", application_launch) def uninstall(): diff --git a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py index 85237094e4..35b123f97d 100644 --- a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py +++ b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py @@ -5,6 +5,7 @@ from pathlib import Path import attr from avalon import api +from openpype.lib import get_formatted_current_time import openpype.lib.abstract_collect_render import openpype.hosts.harmony.api as harmony from openpype.lib.abstract_collect_render import RenderInstance @@ -138,7 +139,7 @@ class CollectFarmRender(openpype.lib.abstract_collect_render. render_instance = HarmonyRenderInstance( version=version, - time=api.time(), + time=get_formatted_current_time(), source=context.data["currentFile"], label=node.split("/")[1], subset=subset_name, diff --git a/openpype/hosts/hiero/api/events.py b/openpype/hosts/hiero/api/events.py index 7563503593..9439199933 100644 --- a/openpype/hosts/hiero/api/events.py +++ b/openpype/hosts/hiero/api/events.py @@ -1,12 +1,12 @@ import os import hiero.core.events -import avalon.api as avalon from openpype.api import Logger from .lib import ( sync_avalon_data_to_workfile, launch_workfiles_app, selection_changed_timeline, - before_project_save + before_project_save, + register_event_callback ) from .tags import add_tags_to_workfile from .menu import update_menu_task_label @@ -126,5 +126,5 @@ def register_events(): """ # if task changed then change notext of hiero - avalon.on("taskChanged", update_menu_task_label) + register_event_callback("taskChanged", update_menu_task_label) log.info("Installed event callback for 'taskChanged'..") diff --git a/openpype/hosts/hiero/api/menu.py b/openpype/hosts/hiero/api/menu.py index 306bef87ca..de20b86f30 100644 --- a/openpype/hosts/hiero/api/menu.py +++ b/openpype/hosts/hiero/api/menu.py @@ -14,7 +14,7 @@ self = sys.modules[__name__] self._change_context_menu = None -def update_menu_task_label(*args): +def update_menu_task_label(): """Update the task label in Avalon menu to current session""" object_name = self._change_context_menu diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 66c1c84308..7d4e58efb7 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -19,7 +19,9 @@ import openpype.hosts.houdini from openpype.hosts.houdini.api import lib from openpype.lib import ( - any_outdated + register_event_callback, + emit_event, + any_outdated, ) from .lib import get_asset_fps @@ -55,11 +57,11 @@ def install(): avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) log.info("Installing callbacks ... ") - # avalon.on("init", on_init) - avalon.api.before("save", before_save) - avalon.api.on("save", on_save) - avalon.api.on("open", on_open) - avalon.api.on("new", on_new) + # register_event_callback("init", on_init) + register_event_callback("before.save", before_save) + register_event_callback("save", on_save) + register_event_callback("open", on_open) + register_event_callback("new", on_new) pyblish.api.register_callback( "instanceToggled", on_pyblish_instance_toggled @@ -105,13 +107,13 @@ def _register_callbacks(): def on_file_event_callback(event): if event == hou.hipFileEventType.AfterLoad: - avalon.api.emit("open", [event]) + emit_event("open") elif event == hou.hipFileEventType.AfterSave: - avalon.api.emit("save", [event]) + emit_event("save") elif event == hou.hipFileEventType.BeforeSave: - avalon.api.emit("before_save", [event]) + emit_event("before.save") elif event == hou.hipFileEventType.AfterClear: - avalon.api.emit("new", [event]) + emit_event("new") def get_main_window(): @@ -233,11 +235,11 @@ def ls(): yield data -def before_save(*args): +def before_save(): return lib.validate_fps() -def on_save(*args): +def on_save(): log.info("Running callback on save..") @@ -246,7 +248,7 @@ def on_save(*args): lib.set_id(node, new_id, overwrite=False) -def on_open(*args): +def on_open(): if not hou.isUIAvailable(): log.debug("Batch mode detected, ignoring `on_open` callbacks..") @@ -283,7 +285,7 @@ def on_open(*args): dialog.show() -def on_new(_): +def on_new(): """Set project resolution and fps when create a new file""" if hou.hipFile.isLoadingHipFile(): diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 23e21894bd..ae8b36f9d3 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -14,7 +14,11 @@ from avalon.pipeline import AVALON_CONTAINER_ID import openpype.hosts.maya from openpype.tools.utils import host_tools -from openpype.lib import any_outdated +from openpype.lib import ( + any_outdated, + register_event_callback, + emit_event +) from openpype.lib.path_tools import HostDirmap from openpype.pipeline import ( LegacyCreator, @@ -59,7 +63,7 @@ def install(): log.info(PUBLISH_PATH) log.info("Installing callbacks ... ") - avalon.api.on("init", on_init) + register_event_callback("init", on_init) # Callbacks below are not required for headless mode, the `init` however # is important to load referenced Alembics correctly at rendertime. @@ -73,12 +77,12 @@ def install(): menu.install() - avalon.api.on("save", on_save) - avalon.api.on("open", on_open) - avalon.api.on("new", on_new) - avalon.api.before("save", on_before_save) - avalon.api.on("taskChanged", on_task_changed) - avalon.api.on("before.workfile.save", before_workfile_save) + register_event_callback("save", on_save) + register_event_callback("open", on_open) + register_event_callback("new", on_new) + register_event_callback("before.save", on_before_save) + register_event_callback("taskChanged", on_task_changed) + register_event_callback("workfile.save.before", before_workfile_save) def _set_project(): @@ -141,7 +145,7 @@ def _register_callbacks(): def _on_maya_initialized(*args): - avalon.api.emit("init", args) + emit_event("init") if cmds.about(batch=True): log.warning("Running batch mode ...") @@ -152,15 +156,15 @@ def _on_maya_initialized(*args): def _on_scene_new(*args): - avalon.api.emit("new", args) + emit_event("new") def _on_scene_save(*args): - avalon.api.emit("save", args) + emit_event("save") def _on_scene_open(*args): - avalon.api.emit("open", args) + emit_event("open") def _before_scene_save(return_code, client_data): @@ -170,7 +174,10 @@ def _before_scene_save(return_code, client_data): # in order to block the operation. OpenMaya.MScriptUtil.setBool(return_code, True) - avalon.api.emit("before_save", [return_code, client_data]) + emit_event( + "before.save", + {"return_code": return_code} + ) def uninstall(): @@ -347,7 +354,7 @@ def containerise(name, return container -def on_init(_): +def on_init(): log.info("Running callback on init..") def safe_deferred(fn): @@ -388,12 +395,12 @@ def on_init(_): safe_deferred(override_toolbox_ui) -def on_before_save(return_code, _): +def on_before_save(): """Run validation for scene's FPS prior to saving""" return lib.validate_fps() -def on_save(_): +def on_save(): """Automatically add IDs to new nodes Any transform of a mesh, without an existing ID, is given one @@ -411,7 +418,7 @@ def on_save(_): lib.set_id(node, new_id, overwrite=False) -def on_open(_): +def on_open(): """On scene open let's assume the containers have changed.""" from Qt import QtWidgets @@ -459,7 +466,7 @@ def on_open(_): dialog.show() -def on_new(_): +def on_new(): """Set project resolution and fps when create a new file""" log.info("Running callback on new..") with lib.suspended_refresh(): @@ -475,7 +482,7 @@ def on_new(_): lib.set_context_settings() -def on_task_changed(*args): +def on_task_changed(): """Wrapped function of app initialize and maya's on task changed""" # Run menu.update_menu_task_label() @@ -513,7 +520,7 @@ def on_task_changed(*args): def before_workfile_save(event): - workdir_path = event.workdir_path + workdir_path = event["workdir_path"] if workdir_path: copy_workspace_mel(workdir_path) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 12cbd00257..84379bc145 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -181,7 +181,7 @@ class ReferenceLoader(Loader): loader=self.__class__.__name__ ) loaded_containers.append(container) - self._organize_containers([ref_node], container) + self._organize_containers(nodes, container) c += 1 namespace = None @@ -250,6 +250,8 @@ class ReferenceLoader(Loader): self.log.warning("Ignoring file read error:\n%s", exc) + self._organize_containers(content, container["objectName"]) + # Reapply alembic settings. if representation["name"] == "abc" and alembic_data: alembic_nodes = cmds.ls( @@ -287,7 +289,6 @@ class ReferenceLoader(Loader): to remove from scene. """ - from maya import cmds node = container["objectName"] @@ -321,6 +322,7 @@ class ReferenceLoader(Loader): @staticmethod def _organize_containers(nodes, container): # type: (list, str) -> None + """Put containers in loaded data to correct hierarchy.""" for node in nodes: id_attr = "{}.id".format(node) if not cmds.attributeQuery("id", node=node, exists=True): diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 66cf95a643..04a25f6493 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -121,18 +121,10 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): if family == "rig": self._post_process_rig(name, namespace, context, options) else: - if "translate" in options: cmds.setAttr(group_name + ".t", *options["translate"]) - return new_nodes - def load(self, context, name=None, namespace=None, options=None): - container = super(ReferenceLoader, self).load( - context, name, namespace, options) - # clean containers if present to AVALON_CONTAINERS - self._organize_containers(self[:], container[0]) - def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index d99e81573b..a525b562f3 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -50,6 +50,7 @@ import maya.app.renderSetup.model.renderSetup as renderSetup import pyblish.api from avalon import api +from openpype.lib import get_formatted_current_time from openpype.hosts.maya.api.lib_renderproducts import get as get_layer_render_products # noqa: E501 from openpype.hosts.maya.api import lib @@ -328,7 +329,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin): "family": "renderlayer", "families": ["renderlayer"], "asset": asset, - "time": api.time(), + "time": get_formatted_current_time(), "author": context.data["user"], # Add source to allow tracing back to the scene from # which was submitted originally diff --git a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py index c1e5d388af..327fc836dc 100644 --- a/openpype/hosts/maya/plugins/publish/collect_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/collect_vrayscene.py @@ -7,6 +7,7 @@ from maya import cmds import pyblish.api from avalon import api +from openpype.lib import get_formatted_current_time from openpype.hosts.maya.api import lib @@ -117,7 +118,7 @@ class CollectVrayScene(pyblish.api.InstancePlugin): "family": "vrayscene_layer", "families": ["vrayscene_layer"], "asset": api.Session["AVALON_ASSET"], - "time": api.time(), + "time": get_formatted_current_time(), "author": context.data["user"], # Add source to allow tracing back to the scene from # which was submitted originally diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py index 5cc7b52090..389995d30c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -58,16 +58,11 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): else: members = instance[:] - loaded_containers = None - if set(self.add_for_families).intersection( - set(instance.data.get("families")), - set(instance.data.get("family").lower())): - loaded_containers = self._add_loaded_containers(members) - selection = members - if loaded_containers: - self.log.info(loaded_containers) - selection += loaded_containers + if set(self.add_for_families).intersection( + set(instance.data.get("families", []))) or \ + instance.data.get("family") in self.add_for_families: + selection += self._get_loaded_containers(members) # Perform extraction self.log.info("Performing extraction ...") @@ -97,15 +92,15 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) @staticmethod - def _add_loaded_containers(members): + def _get_loaded_containers(members): # type: (list) -> list - refs_to_include = [ - cmds.referenceQuery(ref, referenceNode=True) - for ref in members - if cmds.referenceQuery(ref, isNodeReferenced=True) - ] + refs_to_include = { + cmds.referenceQuery(node, referenceNode=True) + for node in members + if cmds.referenceQuery(node, isNodeReferenced=True) + } - refs_to_include = set(refs_to_include) + members_with_refs = refs_to_include.union(members) obj_sets = cmds.ls("*.id", long=True, type="objectSet", recursive=True, objectsOnly=True) @@ -121,7 +116,7 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): continue set_content = set(cmds.sets(obj_set, query=True)) - if set_content.intersection(refs_to_include): + if set_content.intersection(members_with_refs): loaded_containers.append(obj_set) return loaded_containers diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index 7011b3bed1..cecd129eac 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -14,6 +14,7 @@ from openpype.api import ( BuildWorkfile, get_current_project_settings ) +from openpype.lib import register_event_callback from openpype.pipeline import ( LegacyCreator, register_loader_plugins_path, @@ -107,8 +108,8 @@ def install(): avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) # Register Avalon event for workfiles loading. - avalon.api.on("workio.open_file", check_inventory_versions) - avalon.api.on("taskChanged", change_context_label) + register_event_callback("workio.open_file", check_inventory_versions) + register_event_callback("taskChanged", change_context_label) pyblish.api.register_callback( "instanceToggled", on_pyblish_instance_toggled) @@ -231,7 +232,7 @@ def _uninstall_menu(): menu.removeItem(item.name()) -def change_context_label(*args): +def change_context_label(): menubar = nuke.menu("Nuke") menu = menubar.findItem(MENU_LABEL) diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py index 112cd8fe3f..0021905cb5 100644 --- a/openpype/hosts/photoshop/api/launch_logic.py +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -14,7 +14,7 @@ from openpype.api import Logger from openpype.tools.utils import host_tools from avalon import api -from avalon.tools.webserver.app import WebServerTool +from openpype.tools.adobe_webserver.app import WebServerTool from .ws_stub import PhotoshopServerStub diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index a7bd64585d..85155f45d6 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -6,6 +6,7 @@ import avalon.api from avalon import pipeline, io from openpype.api import Logger +from openpype.lib import register_event_callback from openpype.pipeline import ( LegacyCreator, register_loader_plugins_path, @@ -79,7 +80,7 @@ def install(): "instanceToggled", on_pyblish_instance_toggled ) - avalon.api.on("application.launched", on_application_launch) + register_event_callback("application.launched", on_application_launch) def uninstall(): diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index d4406d17b9..64d89f5420 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -2,12 +2,11 @@ Stub handling connection from server to client. Used anywhere solution is calling client methods. """ -import sys import json import attr from wsrpc_aiohttp import WebSocketAsync -from avalon.tools.webserver.app import WebServerTool +from openpype.tools.adobe_webserver.app import WebServerTool @attr.s diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index e9c5f4c73e..65cb9aa2f3 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -21,7 +21,7 @@ from aiohttp_json_rpc.protocol import ( ) from aiohttp_json_rpc.exceptions import RpcError -from avalon import api +from openpype.lib import emit_event from openpype.hosts.tvpaint.tvpaint_plugin import get_plugin_files_path log = logging.getLogger(__name__) @@ -754,7 +754,7 @@ class BaseCommunicator: self._on_client_connect() - api.emit("application.launched") + emit_event("application.launched") def _on_client_connect(self): self._initial_textfile_write() @@ -938,5 +938,5 @@ class QtCommunicator(BaseCommunicator): def _exit(self, *args, **kwargs): super()._exit(*args, **kwargs) - api.emit("application.exit") + emit_event("application.exit") self.qt_app.exit(self.exit_code) diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index 6a26446226..46981851f4 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -14,6 +14,7 @@ from avalon.pipeline import AVALON_CONTAINER_ID from openpype.hosts import tvpaint from openpype.api import get_current_project_settings +from openpype.lib import register_event_callback from openpype.pipeline import ( LegacyCreator, register_loader_plugins_path, @@ -89,8 +90,8 @@ def install(): if on_instance_toggle not in registered_callbacks: pyblish.api.register_callback("instanceToggled", on_instance_toggle) - avalon.api.on("application.launched", initial_launch) - avalon.api.on("application.exit", application_exit) + register_event_callback("application.launched", initial_launch) + register_event_callback("application.exit", application_exit) def uninstall(): diff --git a/openpype/hosts/webpublisher/api/__init__.py b/openpype/hosts/webpublisher/api/__init__.py index 4542ddbba4..dbeb628073 100644 --- a/openpype/hosts/webpublisher/api/__init__.py +++ b/openpype/hosts/webpublisher/api/__init__.py @@ -14,10 +14,6 @@ PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -def application_launch(): - pass - - def install(): print("Installing Pype config...") @@ -25,7 +21,6 @@ def install(): log.info(PUBLISH_PATH) io.install() - avalon.on("application.launched", application_launch) def uninstall(): diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 34b217f690..47c69265b0 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -16,6 +16,11 @@ sys.path.insert(0, python_version_dir) site.addsitedir(python_version_dir) +from .events import ( + emit_event, + register_event_callback +) + from .vendor_bin_utils import ( find_executable, get_vendor_bin_path, @@ -24,6 +29,7 @@ from .vendor_bin_utils import ( ffprobe_streams, is_oiio_supported ) + from .env_tools import ( env_value_to_bool, get_paths_from_environ, @@ -63,7 +69,10 @@ from .anatomy import ( Anatomy ) -from .config import get_datetime_data +from .config import ( + get_datetime_data, + get_formatted_current_time +) from .python_module_tools import ( import_filepath, @@ -194,6 +203,9 @@ from .openpype_version import ( terminal = Terminal __all__ = [ + "emit_event", + "register_event_callback", + "find_executable", "get_openpype_execute_args", "get_pype_execute_args", @@ -309,6 +321,7 @@ __all__ = [ "Anatomy", "get_datetime_data", + "get_formatted_current_time", "PypeLogger", "get_default_components", diff --git a/openpype/lib/abstract_collect_render.py b/openpype/lib/abstract_collect_render.py index 3839aad45d..7c768e280c 100644 --- a/openpype/lib/abstract_collect_render.py +++ b/openpype/lib/abstract_collect_render.py @@ -26,7 +26,7 @@ class RenderInstance(object): # metadata version = attr.ib() # instance version - time = attr.ib() # time of instance creation (avalon.api.time()) + time = attr.ib() # time of instance creation (get_formatted_current_time) source = attr.ib() # path to source scene file label = attr.ib() # label to show in GUI subset = attr.ib() # subset name diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index bd3fcba950..d7f17d8eed 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -15,6 +15,7 @@ from openpype.settings import ( ) from .anatomy import Anatomy from .profiles_filtering import filter_profiles +from .events import emit_event # avalon module is not imported at the top # - may not be in path at the time of pype.lib initialization @@ -779,7 +780,6 @@ def update_current_task(task=None, asset=None, app=None, template_key=None): """ import avalon.api - from avalon.pipeline import emit changes = compute_session_changes( avalon.api.Session, @@ -799,7 +799,7 @@ def update_current_task(task=None, asset=None, app=None, template_key=None): os.environ[key] = value # Emit session change - emit("taskChanged", changes.copy()) + emit_event("taskChanged", changes.copy()) return changes diff --git a/openpype/lib/config.py b/openpype/lib/config.py index ba394cfd56..57e8efa57d 100644 --- a/openpype/lib/config.py +++ b/openpype/lib/config.py @@ -74,3 +74,9 @@ def get_datetime_data(datetime_obj=None): "S": str(int(seconds)), "SS": str(seconds), } + + +def get_formatted_current_time(): + return datetime.datetime.now().strftime( + "%Y%m%dT%H%M%SZ" + ) diff --git a/openpype/lib/events.py b/openpype/lib/events.py new file mode 100644 index 0000000000..7bec6ee30d --- /dev/null +++ b/openpype/lib/events.py @@ -0,0 +1,268 @@ +"""Events holding data about specific event.""" +import os +import re +import inspect +import logging +import weakref +from uuid import uuid4 +try: + from weakref import WeakMethod +except Exception: + from openpype.lib.python_2_comp import WeakMethod + + +class EventCallback(object): + """Callback registered to a topic. + + The callback function is registered to a topic. Topic is a string which + may contain '*' that will be handled as "any characters". + + # Examples: + - "workfile.save" Callback will be triggered if the event topic is + exactly "workfile.save" . + - "workfile.*" Callback will be triggered an event topic starts with + "workfile." so "workfile.save" and "workfile.open" + will trigger the callback. + - "*" Callback will listen to all events. + + Callback can be function or method. In both cases it should expect one + or none arguments. When 1 argument is expected then the processed 'Event' + object is passed in. + + The registered callbacks don't keep function in memory so it is not + possible to store lambda function as callback. + + Args: + topic(str): Topic which will be listened. + func(func): Callback to a topic. + + Raises: + TypeError: When passed function is not a callable object. + """ + + def __init__(self, topic, func): + self._log = None + self._topic = topic + # Replace '*' with any character regex and escape rest of text + # - when callback is registered for '*' topic it will receive all + # events + # - it is possible to register to a partial topis 'my.event.*' + # - it will receive all matching event topics + # e.g. 'my.event.start' and 'my.event.end' + topic_regex_str = "^{}$".format( + ".+".join( + re.escape(part) + for part in topic.split("*") + ) + ) + topic_regex = re.compile(topic_regex_str) + self._topic_regex = topic_regex + + # Convert callback into references + # - deleted functions won't cause crashes + if inspect.ismethod(func): + func_ref = WeakMethod(func) + elif callable(func): + func_ref = weakref.ref(func) + else: + raise TypeError(( + "Registered callback is not callable. \"{}\"" + ).format(str(func))) + + # Collect additional data about function + # - name + # - path + # - if expect argument or not + func_name = func.__name__ + func_path = os.path.abspath(inspect.getfile(func)) + if hasattr(inspect, "signature"): + sig = inspect.signature(func) + expect_args = len(sig.parameters) > 0 + else: + expect_args = len(inspect.getargspec(func)[0]) > 0 + + self._func_ref = func_ref + self._func_name = func_name + self._func_path = func_path + self._expect_args = expect_args + self._ref_valid = func_ref is not None + self._enabled = True + + def __repr__(self): + return "< {} - {} > {}".format( + self.__class__.__name__, self._func_name, self._func_path + ) + + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + @property + def is_ref_valid(self): + return self._ref_valid + + def validate_ref(self): + if not self._ref_valid: + return + + callback = self._func_ref() + if not callback: + self._ref_valid = False + + @property + def enabled(self): + """Is callback enabled.""" + return self._enabled + + def set_enabled(self, enabled): + """Change if callback is enabled.""" + self._enabled = enabled + + def deregister(self): + """Calling this funcion will cause that callback will be removed.""" + # Fake reference + self._ref_valid = False + + def topic_matches(self, topic): + """Check if event topic matches callback's topic.""" + return self._topic_regex.match(topic) + + def process_event(self, event): + """Process event. + + Args: + event(Event): Event that was triggered. + """ + + # Skip if callback is not enabled or has invalid reference + if not self._ref_valid or not self._enabled: + return + + # Get reference + callback = self._func_ref() + # Check if reference is valid or callback's topic matches the event + if not callback: + # Change state if is invalid so the callback is removed + self._ref_valid = False + + elif self.topic_matches(event.topic): + # Try execute callback + try: + if self._expect_args: + callback(event) + else: + callback() + + except Exception: + self.log.warning( + "Failed to execute event callback {}".format( + str(repr(self)) + ), + exc_info=True + ) + + +# Inherit from 'object' for Python 2 hosts +class Event(object): + """Base event object. + + Can be used for any event because is not specific. Only required argument + is topic which defines why event is happening and may be used for + filtering. + + Arg: + topic (str): Identifier of event. + data (Any): Data specific for event. Dictionary is recommended. + source (str): Identifier of source. + """ + _data = {} + + def __init__(self, topic, data=None, source=None): + self._id = str(uuid4()) + self._topic = topic + if data is None: + data = {} + self._data = data + self._source = source + + def __getitem__(self, key): + return self._data[key] + + def get(self, key, *args, **kwargs): + return self._data.get(key, *args, **kwargs) + + @property + def id(self): + return self._id + + @property + def source(self): + return self._source + + @property + def data(self): + return self._data + + @property + def topic(self): + return self._topic + + def emit(self): + """Emit event and trigger callbacks.""" + StoredCallbacks.emit_event(self) + + +class StoredCallbacks: + _registered_callbacks = [] + + @classmethod + def add_callback(cls, topic, callback): + callback = EventCallback(topic, callback) + cls._registered_callbacks.append(callback) + return callback + + @classmethod + def emit_event(cls, event): + invalid_callbacks = [] + for callback in cls._registered_callbacks: + callback.process_event(event) + if not callback.is_ref_valid: + invalid_callbacks.append(callback) + + for callback in invalid_callbacks: + cls._registered_callbacks.remove(callback) + + +def register_event_callback(topic, callback): + """Add callback that will be executed on specific topic. + + Args: + topic(str): Topic on which will callback be triggered. + callback(function): Callback that will be triggered when a topic + is triggered. Callback should expect none or 1 argument where + `Event` object is passed. + + Returns: + EventCallback: Object wrapping the callback. It can be used to + enable/disable listening to a topic or remove the callback from + the topic completely. + """ + return StoredCallbacks.add_callback(topic, callback) + + +def emit_event(topic, data=None, source=None): + """Emit event with topic and data. + + Arg: + topic(str): Event's topic. + data(dict): Event's additional data. Optional. + source(str): Who emitted the topic. Optional. + + Returns: + Event: Object of event that was emitted. + """ + event = Event(topic, data, source) + event.emit() + return event diff --git a/openpype/pipeline/lib/__init__.py b/openpype/pipeline/lib/__init__.py index ed38889c66..f762c4205d 100644 --- a/openpype/pipeline/lib/__init__.py +++ b/openpype/pipeline/lib/__init__.py @@ -1,8 +1,3 @@ -from .events import ( - BaseEvent, - BeforeWorkfileSave -) - from .attribute_definitions import ( AbtractAttrDef, @@ -20,9 +15,6 @@ from .attribute_definitions import ( __all__ = ( - "BaseEvent", - "BeforeWorkfileSave", - "AbtractAttrDef", "UIDef", diff --git a/openpype/pipeline/lib/events.py b/openpype/pipeline/lib/events.py deleted file mode 100644 index 05dea20e8c..0000000000 --- a/openpype/pipeline/lib/events.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Events holding data about specific event.""" - - -# Inherit from 'object' for Python 2 hosts -class BaseEvent(object): - """Base event object. - - Can be used to anything because data are not much specific. Only required - argument is topic which defines why event is happening and may be used for - filtering. - - Arg: - topic (str): Identifier of event. - data (Any): Data specific for event. Dictionary is recommended. - """ - _data = {} - - def __init__(self, topic, data=None): - self._topic = topic - if data is None: - data = {} - self._data = data - - @property - def data(self): - return self._data - - @property - def topic(self): - return self._topic - - @classmethod - def emit(cls, *args, **kwargs): - """Create object of event and emit. - - Args: - Same args as '__init__' expects which may be class specific. - """ - from avalon import pipeline - - obj = cls(*args, **kwargs) - pipeline.emit(obj.topic, [obj]) - return obj - - -class BeforeWorkfileSave(BaseEvent): - """Before workfile changes event data.""" - def __init__(self, filename, workdir): - super(BeforeWorkfileSave, self).__init__("before.workfile.save") - self.filename = filename - self.workdir_path = workdir diff --git a/openpype/plugins/publish/collect_time.py b/openpype/plugins/publish/collect_time.py index e0adc7dfc3..7a005cc9cb 100644 --- a/openpype/plugins/publish/collect_time.py +++ b/openpype/plugins/publish/collect_time.py @@ -1,12 +1,12 @@ import pyblish.api -from avalon import api +from openpype.lib import get_formatted_current_time class CollectTime(pyblish.api.ContextPlugin): """Store global time at the time of publish""" label = "Collect Current Time" - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder - 0.499 def process(self, context): - context.data["time"] = api.time() + context.data["time"] = get_formatted_current_time() diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 0b139a73e4..b8599454ee 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -103,9 +103,10 @@ class ExtractReview(pyblish.api.InstancePlugin): self.log.debug("Matching profile: \"{}\"".format(json.dumps(profile))) + subset_name = instance.data.get("subset") instance_families = self.families_from_instance(instance) - filtered_outputs = self.filter_outputs_by_families( - profile, instance_families + filtered_outputs = self.filter_output_defs( + profile, subset_name, instance_families ) # Store `filename_suffix` to save arguments profile_outputs = [] @@ -1651,7 +1652,7 @@ class ExtractReview(pyblish.api.InstancePlugin): return True return False - def filter_outputs_by_families(self, profile, families): + def filter_output_defs(self, profile, subset_name, families): """Return outputs matching input instance families. Output definitions without families filter are marked as valid. @@ -1684,6 +1685,24 @@ class ExtractReview(pyblish.api.InstancePlugin): if not self.families_filter_validation(families, families_filters): continue + # Subsets name filters + subset_filters = [ + subset_filter + for subset_filter in output_filters.get("subsets", []) + # Skip empty strings + if subset_filter + ] + if subset_name and subset_filters: + match = False + for subset_filter in subset_filters: + compiled = re.compile(subset_filter) + if compiled.search(subset_name): + match = True + break + + if not match: + continue + filtered_outputs[filename_suffix] = output_def return filtered_outputs diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 9c44d9bc86..30a71b044a 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -87,7 +87,8 @@ "render", "review", "ftrack" - ] + ], + "subsets": [] }, "overscan_crop": "", "overscan_color": [ diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index 3eea7ccb30..12043d4205 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -291,6 +291,15 @@ "label": "Families", "type": "list", "object_type": "text" + }, + { + "type": "separator" + }, + { + "key": "subsets", + "label": "Subsets", + "type": "list", + "object_type": "text" } ] }, diff --git a/openpype/tools/adobe_webserver/app.py b/openpype/tools/adobe_webserver/app.py new file mode 100644 index 0000000000..b79d6c6c60 --- /dev/null +++ b/openpype/tools/adobe_webserver/app.py @@ -0,0 +1,237 @@ +"""This Webserver tool is python 3 specific. + +Don't import directly to avalon.tools or implementation of Python 2 hosts +would break. +""" +import os +import logging +import urllib +import threading +import asyncio +import socket + +from aiohttp import web + +from wsrpc_aiohttp import ( + WSRPCClient +) + +from avalon import api + +log = logging.getLogger(__name__) + + +class WebServerTool: + """ + Basic POC implementation of asychronic websocket RPC server. + Uses class in external_app_1.py to mimic implementation for single + external application. + 'test_client' folder contains two test implementations of client + """ + _instance = None + + def __init__(self): + WebServerTool._instance = self + + self.client = None + self.handlers = {} + self.on_stop_callbacks = [] + + port = None + host_name = "localhost" + websocket_url = os.getenv("WEBSOCKET_URL") + if websocket_url: + parsed = urllib.parse.urlparse(websocket_url) + port = parsed.port + host_name = parsed.netloc.split(":")[0] + if not port: + port = 8098 # fallback + + self.port = port + self.host_name = host_name + + self.app = web.Application() + + # add route with multiple methods for single "external app" + self.webserver_thread = WebServerThread(self, self.port) + + def add_route(self, *args, **kwargs): + self.app.router.add_route(*args, **kwargs) + + def add_static(self, *args, **kwargs): + self.app.router.add_static(*args, **kwargs) + + def start_server(self): + if self.webserver_thread and not self.webserver_thread.is_alive(): + self.webserver_thread.start() + + def stop_server(self): + self.stop() + + async def send_context_change(self, host): + """ + Calls running webserver to inform about context change + + Used when new PS/AE should be triggered, + but one already running, without + this publish would point to old context. + """ + client = WSRPCClient(os.getenv("WEBSOCKET_URL"), + loop=asyncio.get_event_loop()) + await client.connect() + + project = api.Session["AVALON_PROJECT"] + asset = api.Session["AVALON_ASSET"] + task = api.Session["AVALON_TASK"] + log.info("Sending context change to {}-{}-{}".format(project, + asset, + task)) + + await client.call('{}.set_context'.format(host), + project=project, asset=asset, task=task) + await client.close() + + def port_occupied(self, host_name, port): + """ + Check if 'url' is already occupied. + + This could mean, that app is already running and we are trying open it + again. In that case, use existing running webserver. + Check here is easier than capturing exception from thread. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + result = True + try: + sock.bind((host_name, port)) + result = False + except: + print("Port is in use") + + return result + + def call(self, func): + log.debug("websocket.call {}".format(func)) + future = asyncio.run_coroutine_threadsafe( + func, + self.webserver_thread.loop + ) + result = future.result() + return result + + @staticmethod + def get_instance(): + if WebServerTool._instance is None: + WebServerTool() + return WebServerTool._instance + + @property + def is_running(self): + if not self.webserver_thread: + return False + return self.webserver_thread.is_running + + def stop(self): + if not self.is_running: + return + try: + log.debug("Stopping websocket server") + self.webserver_thread.is_running = False + self.webserver_thread.stop() + except Exception: + log.warning( + "Error has happened during Killing websocket server", + exc_info=True + ) + + def thread_stopped(self): + for callback in self.on_stop_callbacks: + callback() + + +class WebServerThread(threading.Thread): + """ Listener for websocket rpc requests. + + It would be probably better to "attach" this to main thread (as for + example Harmony needs to run something on main thread), but currently + it creates separate thread and separate asyncio event loop + """ + def __init__(self, module, port): + super(WebServerThread, self).__init__() + + self.is_running = False + self.port = port + self.module = module + self.loop = None + self.runner = None + self.site = None + self.tasks = [] + + def run(self): + self.is_running = True + + try: + log.info("Starting web server") + self.loop = asyncio.new_event_loop() # create new loop for thread + asyncio.set_event_loop(self.loop) + + self.loop.run_until_complete(self.start_server()) + + websocket_url = "ws://localhost:{}/ws".format(self.port) + + log.debug( + "Running Websocket server on URL: \"{}\"".format(websocket_url) + ) + + asyncio.ensure_future(self.check_shutdown(), loop=self.loop) + self.loop.run_forever() + except Exception: + self.is_running = False + log.warning( + "Websocket Server service has failed", exc_info=True + ) + raise + finally: + self.loop.close() # optional + + self.is_running = False + self.module.thread_stopped() + log.info("Websocket server stopped") + + async def start_server(self): + """ Starts runner and TCPsite """ + self.runner = web.AppRunner(self.module.app) + await self.runner.setup() + self.site = web.TCPSite(self.runner, 'localhost', self.port) + await self.site.start() + + def stop(self): + """Sets is_running flag to false, 'check_shutdown' shuts server down""" + self.is_running = False + + async def check_shutdown(self): + """ Future that is running and checks if server should be running + periodically. + """ + while self.is_running: + while self.tasks: + task = self.tasks.pop(0) + log.debug("waiting for task {}".format(task)) + await task + log.debug("returned value {}".format(task.result)) + + await asyncio.sleep(0.5) + + log.debug("Starting shutdown") + await self.site.stop() + log.debug("Site stopped") + await self.runner.cleanup() + log.debug("Runner stopped") + tasks = [task for task in asyncio.all_tasks() if + task is not asyncio.current_task()] + list(map(lambda task: task.cancel(), tasks)) # cancel all the tasks + results = await asyncio.gather(*tasks, return_exceptions=True) + log.debug(f'Finished awaiting cancelled tasks, results: {results}...') + await self.loop.shutdown_asyncgens() + # to really make sure everything else has time to stop + await asyncio.sleep(0.07) + self.loop.stop() diff --git a/openpype/tools/adobe_webserver/readme.txt b/openpype/tools/adobe_webserver/readme.txt new file mode 100644 index 0000000000..06cf140fc4 --- /dev/null +++ b/openpype/tools/adobe_webserver/readme.txt @@ -0,0 +1,12 @@ +Adobe webserver +--------------- +Aiohttp (Asyncio) based websocket server used for communication with host +applications, currently only for Adobe (but could be used for any non python +DCC which has websocket client). + +This webserver is started in spawned Python process that opens DCC during +its launch, waits for connection from DCC and handles communication going +forward. Server is closed before Python process is killed. + +(Different from `openpype/modules/webserver` as that one is running in Tray, +this one is running in spawn Python process.) \ No newline at end of file diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py index aa743b05fe..d73a977ac6 100644 --- a/openpype/tools/loader/app.py +++ b/openpype/tools/loader/app.py @@ -1,9 +1,10 @@ import sys from Qt import QtWidgets, QtCore -from avalon import api, io, pipeline +from avalon import api, io from openpype import style +from openpype.lib import register_event_callback from openpype.tools.utils import ( lib, PlaceholderLineEdit @@ -25,17 +26,6 @@ module = sys.modules[__name__] module.window = None -# Register callback on task change -# - callback can't be defined in Window as it is weak reference callback -# so `WeakSet` will remove it immediately -def on_context_task_change(*args, **kwargs): - if module.window: - module.window.on_context_task_change(*args, **kwargs) - - -pipeline.on("taskChanged", on_context_task_change) - - class LoaderWindow(QtWidgets.QDialog): """Asset loader interface""" @@ -194,6 +184,8 @@ class LoaderWindow(QtWidgets.QDialog): self._first_show = True + register_event_callback("taskChanged", self.on_context_task_change) + def resizeEvent(self, event): super(LoaderWindow, self).resizeEvent(event) self._overlay_frame.resize(self.size()) diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index aece7bfb4f..63958ac57b 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -9,10 +9,9 @@ import datetime import Qt from Qt import QtWidgets, QtCore -from avalon import io, api, pipeline +from avalon import io, api from openpype import style -from openpype.pipeline.lib import BeforeWorkfileSave from openpype.tools.utils.lib import ( qt_app_context ) @@ -21,6 +20,7 @@ from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget from openpype.tools.utils.tasks_widget import TasksWidget from openpype.tools.utils.delegates import PrettyTimeDelegate from openpype.lib import ( + emit_event, Anatomy, get_workfile_doc, create_workfile_doc, @@ -823,7 +823,11 @@ class FilesWidget(QtWidgets.QWidget): return # Trigger before save event - BeforeWorkfileSave.emit(work_filename, self._workdir_path) + emit_event( + "workfile.save.before", + {"filename": work_filename, "workdir_path": self._workdir_path}, + source="workfiles.tool" + ) # Make sure workfiles root is updated # - this triggers 'workio.work_root(...)' which may change value of @@ -853,7 +857,11 @@ class FilesWidget(QtWidgets.QWidget): api.Session["AVALON_PROJECT"] ) # Trigger after save events - pipeline.emit("after.workfile.save", [filepath]) + emit_event( + "workfile.save.after", + {"filename": work_filename, "workdir_path": self._workdir_path}, + source="workfiles.tool" + ) self.workfile_created.emit(filepath) # Refresh files model diff --git a/openpype/version.py b/openpype/version.py index d4af8d760f..d2182ac7da 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.0-nightly.8" +__version__ = "3.9.0" diff --git a/pyproject.toml b/pyproject.toml index fe681266ca..681702560a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.0-nightly.8" # OpenPype +version = "3.9.0" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" diff --git a/repos/avalon-core b/repos/avalon-core index ffe9e910f1..7753d15507 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit ffe9e910f1f382e222d457d8e4a8426c41ed43ae +Subproject commit 7753d15507afadc143b7d49db8fcfaa6a29fed91 diff --git a/website/docs/artist_hosts_aftereffects.md b/website/docs/artist_hosts_aftereffects.md index f9ef40fe1a..a9660bd13c 100644 --- a/website/docs/artist_hosts_aftereffects.md +++ b/website/docs/artist_hosts_aftereffects.md @@ -15,7 +15,7 @@ sidebar_label: AfterEffects ## Setup -To install the extension, download, install [Anastasyi's Extension Manager](https://install.anastasiy.com/). Open Anastasyi's Extension Manager and select AfterEffects in menu. Then go to `{path to pype}/repos/avalon-core/avalon/aftereffects/extension.zxp`. +To install the extension, download, install [Anastasyi's Extension Manager](https://install.anastasiy.com/). Open Anastasyi's Extension Manager and select AfterEffects in menu. Then go to `{path to pype}hosts/aftereffects/api/extension.zxp`. Drag extension.zxp and drop it to Anastasyi's Extension Manager. The extension will install itself. diff --git a/website/docs/artist_hosts_photoshop.md b/website/docs/artist_hosts_photoshop.md index 16539bcf79..b2b5fd58da 100644 --- a/website/docs/artist_hosts_photoshop.md +++ b/website/docs/artist_hosts_photoshop.md @@ -14,7 +14,7 @@ sidebar_label: Photoshop ## Setup -To install the extension, download, install [Anastasyi's Extension Manager](https://install.anastasiy.com/). Open Anastasyi's Extension Manager and select Photoshop in menu. Then go to `{path to pype}/repos/avalon-core/avalon/photoshop/extension.zxp`. Drag extension.zxp and drop it to Anastasyi's Extension Manager. The extension will install itself. +To install the extension, download, install [Anastasyi's Extension Manager](https://install.anastasiy.com/). Open Anastasyi's Extension Manager and select Photoshop in menu. Then go to `{path to pype}hosts/photoshop/api/extension.zxp`. Drag extension.zxp and drop it to Anastasyi's Extension Manager. The extension will install itself. ## Usage