diff --git a/openpype/host/__init__.py b/openpype/host/__init__.py new file mode 100644 index 0000000000..84a2fa930a --- /dev/null +++ b/openpype/host/__init__.py @@ -0,0 +1,13 @@ +from .host import ( + HostBase, + IWorkfileHost, + ILoadHost, + INewPublisher, +) + +__all__ = ( + "HostBase", + "IWorkfileHost", + "ILoadHost", + "INewPublisher", +) diff --git a/openpype/host/host.py b/openpype/host/host.py new file mode 100644 index 0000000000..48907e7ec7 --- /dev/null +++ b/openpype/host/host.py @@ -0,0 +1,524 @@ +import logging +import contextlib +from abc import ABCMeta, abstractproperty, abstractmethod +import six + +# NOTE can't import 'typing' because of issues in Maya 2020 +# - shiboken crashes on 'typing' module import + + +class MissingMethodsError(ValueError): + """Exception when host miss some required methods for specific workflow. + + Args: + host (HostBase): Host implementation where are missing methods. + missing_methods (list[str]): List of missing methods. + """ + + def __init__(self, host, missing_methods): + joined_missing = ", ".join( + ['"{}"'.format(item) for item in missing_methods] + ) + message = ( + "Host \"{}\" miss methods {}".format(host.name, joined_missing) + ) + super(MissingMethodsError, self).__init__(message) + + +@six.add_metaclass(ABCMeta) +class HostBase(object): + """Base of host implementation class. + + Host is pipeline implementation of DCC application. This class should help + to identify what must/should/can be implemented for specific functionality. + + Compared to 'avalon' concept: + What was before considered as functions in host implementation folder. The + host implementation should primarily care about adding ability of creation + (mark subsets to be published) and optionaly about referencing published + representations as containers. + + Host may need extend some functionality like working with workfiles + or loading. Not all host implementations may allow that for those purposes + can be logic extended with implementing functions for the purpose. There + are prepared interfaces to be able identify what must be implemented to + be able use that functionality. + - current statement is that it is not required to inherit from interfaces + but all of the methods are validated (only their existence!) + + # Installation of host before (avalon concept): + ```python + from openpype.pipeline import install_host + import openpype.hosts.maya.api as host + + install_host(host) + ``` + + # Installation of host now: + ```python + from openpype.pipeline import install_host + from openpype.hosts.maya.api import MayaHost + + host = MayaHost() + install_host(host) + ``` + + Todo: + - move content of 'install_host' as method of this class + - register host object + - install legacy_io + - install global plugin paths + - store registered plugin paths to this object + - handle current context (project, asset, task) + - this must be done in many separated steps + - have it's object of host tools instead of using globals + + This implementation will probably change over time when more + functionality and responsibility will be added. + """ + + _log = None + + def __init__(self): + """Initialization of host. + + Register DCC callbacks, host specific plugin paths, targets etc. + (Part of what 'install' did in 'avalon' concept.) + + Note: + At this moment global "installation" must happen before host + installation. Because of this current limitation it is recommended + to implement 'install' method which is triggered after global + 'install'. + """ + + pass + + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + @abstractproperty + def name(self): + """Host name.""" + + pass + + def get_current_context(self): + """Get current context information. + + This method should be used to get current context of host. Usage of + this method can be crutial for host implementations in DCCs where + can be opened multiple workfiles at one moment and change of context + can't be catched properly. + + Default implementation returns values from 'legacy_io.Session'. + + Returns: + dict: Context with 3 keys 'project_name', 'asset_name' and + 'task_name'. All of them can be 'None'. + """ + + from openpype.pipeline import legacy_io + + if legacy_io.is_installed(): + legacy_io.install() + + return { + "project_name": legacy_io.Session["AVALON_PROJECT"], + "asset_name": legacy_io.Session["AVALON_ASSET"], + "task_name": legacy_io.Session["AVALON_TASK"] + } + + def get_context_title(self): + """Context title shown for UI purposes. + + Should return current context title if possible. + + Note: + This method is used only for UI purposes so it is possible to + return some logical title for contextless cases. + Is not meant for "Context menu" label. + + Returns: + str: Context title. + None: Default title is used based on UI implementation. + """ + + # Use current context to fill the context title + current_context = self.get_current_context() + project_name = current_context["project_name"] + asset_name = current_context["asset_name"] + task_name = current_context["task_name"] + items = [] + if project_name: + items.append(project_name) + if asset_name: + items.append(asset_name) + if task_name: + items.append(task_name) + if items: + return "/".join(items) + return None + + @contextlib.contextmanager + def maintained_selection(self): + """Some functionlity will happen but selection should stay same. + + This is DCC specific. Some may not allow to implement this ability + that is reason why default implementation is empty context manager. + + Yields: + None: Yield when is ready to restore selected at the end. + """ + + try: + yield + finally: + pass + + +class ILoadHost: + """Implementation requirements to be able use reference of representations. + + The load plugins can do referencing even without implementation of methods + here, but switch and removement of containers would not be possible. + + Questions: + - Is list container dependency of host or load plugins? + - Should this be directly in HostBase? + - how to find out if referencing is available? + - do we need to know that? + """ + + @staticmethod + def get_missing_load_methods(host): + """Look for missing methods on "old type" host implementation. + + Method is used for validation of implemented functions related to + loading. Checks only existence of methods. + + Args: + Union[ModuleType, HostBase]: Object of host where to look for + required methods. + + Returns: + list[str]: Missing method implementations for loading workflow. + """ + + if isinstance(host, ILoadHost): + return [] + + required = ["ls"] + missing = [] + for name in required: + if not hasattr(host, name): + missing.append(name) + return missing + + @staticmethod + def validate_load_methods(host): + """Validate implemented methods of "old type" host for load workflow. + + Args: + Union[ModuleType, HostBase]: Object of host to validate. + + Raises: + MissingMethodsError: If there are missing methods on host + implementation. + """ + missing = ILoadHost.get_missing_load_methods(host) + if missing: + raise MissingMethodsError(host, missing) + + @abstractmethod + def get_containers(self): + """Retreive referenced containers from scene. + + This can be implemented in hosts where referencing can be used. + + Todo: + Rename function to something more self explanatory. + Suggestion: 'get_containers' + + Returns: + list[dict]: Information about loaded containers. + """ + + pass + + # --- Deprecated method names --- + def ls(self): + """Deprecated variant of 'get_containers'. + + Todo: + Remove when all usages are replaced. + """ + + return self.get_containers() + + +@six.add_metaclass(ABCMeta) +class IWorkfileHost: + """Implementation requirements to be able use workfile utils and tool.""" + + @staticmethod + def get_missing_workfile_methods(host): + """Look for missing methods on "old type" host implementation. + + Method is used for validation of implemented functions related to + workfiles. Checks only existence of methods. + + Args: + Union[ModuleType, HostBase]: Object of host where to look for + required methods. + + Returns: + list[str]: Missing method implementations for workfiles workflow. + """ + + if isinstance(host, IWorkfileHost): + return [] + + required = [ + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root", + ] + missing = [] + for name in required: + if not hasattr(host, name): + missing.append(name) + return missing + + @staticmethod + def validate_workfile_methods(host): + """Validate methods of "old type" host for workfiles workflow. + + Args: + Union[ModuleType, HostBase]: Object of host to validate. + + Raises: + MissingMethodsError: If there are missing methods on host + implementation. + """ + + missing = IWorkfileHost.get_missing_workfile_methods(host) + if missing: + raise MissingMethodsError(host, missing) + + @abstractmethod + def get_workfile_extensions(self): + """Extensions that can be used as save. + + Questions: + This could potentially use 'HostDefinition'. + """ + + return [] + + @abstractmethod + def save_workfile(self, dst_path=None): + """Save currently opened scene. + + Args: + dst_path (str): Where the current scene should be saved. Or use + current path if 'None' is passed. + """ + + pass + + @abstractmethod + def open_workfile(self, filepath): + """Open passed filepath in the host. + + Args: + filepath (str): Path to workfile. + """ + + pass + + @abstractmethod + def get_current_workfile(self): + """Retreive path to current opened file. + + Returns: + str: Path to file which is currently opened. + None: If nothing is opened. + """ + + return None + + def workfile_has_unsaved_changes(self): + """Currently opened scene is saved. + + Not all hosts can know if current scene is saved because the API of + DCC does not support it. + + Returns: + bool: True if scene is saved and False if has unsaved + modifications. + None: Can't tell if workfiles has modifications. + """ + + return None + + def work_root(self, session): + """Modify workdir per host. + + Default implementation keeps workdir untouched. + + Warnings: + We must handle this modification with more sofisticated way because + this can't be called out of DCC so opening of last workfile + (calculated before DCC is launched) is complicated. Also breaking + defined work template is not a good idea. + Only place where it's really used and can make sense is Maya. There + workspace.mel can modify subfolders where to look for maya files. + + Args: + session (dict): Session context data. + + Returns: + str: Path to new workdir. + """ + + return session["AVALON_WORKDIR"] + + # --- Deprecated method names --- + def file_extensions(self): + """Deprecated variant of 'get_workfile_extensions'. + + Todo: + Remove when all usages are replaced. + """ + return self.get_workfile_extensions() + + def save_file(self, dst_path=None): + """Deprecated variant of 'save_workfile'. + + Todo: + Remove when all usages are replaced. + """ + + self.save_workfile() + + def open_file(self, filepath): + """Deprecated variant of 'open_workfile'. + + Todo: + Remove when all usages are replaced. + """ + + return self.open_workfile(filepath) + + def current_file(self): + """Deprecated variant of 'get_current_workfile'. + + Todo: + Remove when all usages are replaced. + """ + + return self.get_current_workfile() + + def has_unsaved_changes(self): + """Deprecated variant of 'workfile_has_unsaved_changes'. + + Todo: + Remove when all usages are replaced. + """ + + return self.workfile_has_unsaved_changes() + + +class INewPublisher: + """Functions related to new creation system in new publisher. + + New publisher is not storing information only about each created instance + but also some global data. At this moment are data related only to context + publish plugins but that can extend in future. + """ + + @staticmethod + def get_missing_publish_methods(host): + """Look for missing methods on "old type" host implementation. + + Method is used for validation of implemented functions related to + new publish creation. Checks only existence of methods. + + Args: + Union[ModuleType, HostBase]: Host module where to look for + required methods. + + Returns: + list[str]: Missing method implementations for new publsher + workflow. + """ + + if isinstance(host, INewPublisher): + return [] + + required = [ + "get_context_data", + "update_context_data", + ] + missing = [] + for name in required: + if not hasattr(host, name): + missing.append(name) + return missing + + @staticmethod + def validate_publish_methods(host): + """Validate implemented methods of "old type" host. + + Args: + Union[ModuleType, HostBase]: Host module to validate. + + Raises: + MissingMethodsError: If there are missing methods on host + implementation. + """ + missing = INewPublisher.get_missing_publish_methods(host) + if missing: + raise MissingMethodsError(host, missing) + + @abstractmethod + def get_context_data(self): + """Get global data related to creation-publishing from workfile. + + These data are not related to any created instance but to whole + publishing context. Not saving/returning them will cause that each + reset of publishing resets all values to default ones. + + Context data can contain information about enabled/disabled publish + plugins or other values that can be filled by artist. + + Returns: + dict: Context data stored using 'update_context_data'. + """ + + pass + + @abstractmethod + def update_context_data(self, data, changes): + """Store global context data to workfile. + + Called when some values in context data has changed. + + Without storing the values in a way that 'get_context_data' would + return them will each reset of publishing cause loose of filled values + by artist. Best practice is to store values into workfile, if possible. + + Args: + data (dict): New data as are. + changes (dict): Only data that has been changed. Each value has + tuple with '(, )' value. + """ + + pass diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py index 5d76bf0f04..a6c5f50e1a 100644 --- a/openpype/hosts/maya/api/__init__.py +++ b/openpype/hosts/maya/api/__init__.py @@ -5,11 +5,11 @@ Anything that isn't defined here is INTERNAL and unreliable for external use. """ from .pipeline import ( - install, uninstall, ls, containerise, + MayaHost, ) from .plugin import ( Creator, @@ -40,11 +40,11 @@ from .lib import ( __all__ = [ - "install", "uninstall", "ls", "containerise", + "MayaHost", "Creator", "Loader", diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index d9276ddf4a..d08e8d1926 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -1,13 +1,15 @@ import os -import sys import errno import logging +import contextlib from maya import utils, cmds, OpenMaya import maya.api.OpenMaya as om import pyblish.api +from openpype.settings import get_project_settings +from openpype.host import HostBase, IWorkfileHost, ILoadHost import openpype.hosts.maya from openpype.tools.utils import host_tools from openpype.lib import ( @@ -28,6 +30,14 @@ from openpype.pipeline import ( ) from openpype.hosts.maya.lib import copy_workspace_mel from . import menu, lib +from .workio import ( + open_file, + save_file, + file_extensions, + has_unsaved_changes, + work_root, + current_file +) log = logging.getLogger("openpype.hosts.maya") @@ -40,49 +50,121 @@ INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") AVALON_CONTAINERS = ":AVALON_CONTAINERS" -self = sys.modules[__name__] -self._ignore_lock = False -self._events = {} +class MayaHost(HostBase, IWorkfileHost, ILoadHost): + name = "maya" -def install(): - from openpype.settings import get_project_settings + def __init__(self): + super(MayaHost, self).__init__() + self._op_events = {} - project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) - # process path mapping - dirmap_processor = MayaDirmap("maya", project_settings) - dirmap_processor.process_dirmap() + def install(self): + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + # process path mapping + dirmap_processor = MayaDirmap("maya", project_settings) + dirmap_processor.process_dirmap() - pyblish.api.register_plugin_path(PUBLISH_PATH) - pyblish.api.register_host("mayabatch") - pyblish.api.register_host("mayapy") - pyblish.api.register_host("maya") + pyblish.api.register_plugin_path(PUBLISH_PATH) + pyblish.api.register_host("mayabatch") + pyblish.api.register_host("mayapy") + pyblish.api.register_host("maya") - register_loader_plugin_path(LOAD_PATH) - register_creator_plugin_path(CREATE_PATH) - register_inventory_action_path(INVENTORY_PATH) - log.info(PUBLISH_PATH) + register_loader_plugin_path(LOAD_PATH) + register_creator_plugin_path(CREATE_PATH) + register_inventory_action_path(INVENTORY_PATH) + self.log.info(PUBLISH_PATH) - log.info("Installing callbacks ... ") - register_event_callback("init", on_init) + self.log.info("Installing callbacks ... ") + register_event_callback("init", on_init) - if lib.IS_HEADLESS: - log.info(("Running in headless mode, skipping Maya " - "save/open/new callback installation..")) + if lib.IS_HEADLESS: + self.log.info(( + "Running in headless mode, skipping Maya save/open/new" + " callback installation.." + )) - return + return - _set_project() - _register_callbacks() + _set_project() + self._register_callbacks() - menu.install() + menu.install() - 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) + 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 open_workfile(self, filepath): + return open_file(filepath) + + def save_workfile(self, filepath=None): + return save_file(filepath) + + def work_root(self, session): + return work_root(session) + + def get_current_workfile(self): + return current_file() + + def workfile_has_unsaved_changes(self): + return has_unsaved_changes() + + def get_workfile_extensions(self): + return file_extensions() + + def get_containers(self): + return ls() + + @contextlib.contextmanager + def maintained_selection(self): + with lib.maintained_selection(): + yield + + def _register_callbacks(self): + for handler, event in self._op_events.copy().items(): + if event is None: + continue + + try: + OpenMaya.MMessage.removeCallback(event) + self._op_events[handler] = None + except RuntimeError as exc: + self.log.info(exc) + + self._op_events[_on_scene_save] = OpenMaya.MSceneMessage.addCallback( + OpenMaya.MSceneMessage.kBeforeSave, _on_scene_save + ) + + self._op_events[_before_scene_save] = ( + OpenMaya.MSceneMessage.addCheckCallback( + OpenMaya.MSceneMessage.kBeforeSaveCheck, + _before_scene_save + ) + ) + + self._op_events[_on_scene_new] = OpenMaya.MSceneMessage.addCallback( + OpenMaya.MSceneMessage.kAfterNew, _on_scene_new + ) + + self._op_events[_on_maya_initialized] = ( + OpenMaya.MSceneMessage.addCallback( + OpenMaya.MSceneMessage.kMayaInitialized, + _on_maya_initialized + ) + ) + + self._op_events[_on_scene_open] = OpenMaya.MSceneMessage.addCallback( + OpenMaya.MSceneMessage.kAfterOpen, _on_scene_open + ) + + self.log.info("Installed event handler _on_scene_save..") + self.log.info("Installed event handler _before_scene_save..") + self.log.info("Installed event handler _on_scene_new..") + self.log.info("Installed event handler _on_maya_initialized..") + self.log.info("Installed event handler _on_scene_open..") def _set_project(): @@ -106,44 +188,6 @@ def _set_project(): cmds.workspace(workdir, openWorkspace=True) -def _register_callbacks(): - for handler, event in self._events.copy().items(): - if event is None: - continue - - try: - OpenMaya.MMessage.removeCallback(event) - self._events[handler] = None - except RuntimeError as e: - log.info(e) - - self._events[_on_scene_save] = OpenMaya.MSceneMessage.addCallback( - OpenMaya.MSceneMessage.kBeforeSave, _on_scene_save - ) - - self._events[_before_scene_save] = OpenMaya.MSceneMessage.addCheckCallback( - OpenMaya.MSceneMessage.kBeforeSaveCheck, _before_scene_save - ) - - self._events[_on_scene_new] = OpenMaya.MSceneMessage.addCallback( - OpenMaya.MSceneMessage.kAfterNew, _on_scene_new - ) - - self._events[_on_maya_initialized] = OpenMaya.MSceneMessage.addCallback( - OpenMaya.MSceneMessage.kMayaInitialized, _on_maya_initialized - ) - - self._events[_on_scene_open] = OpenMaya.MSceneMessage.addCallback( - OpenMaya.MSceneMessage.kAfterOpen, _on_scene_open - ) - - log.info("Installed event handler _on_scene_save..") - log.info("Installed event handler _before_scene_save..") - log.info("Installed event handler _on_scene_new..") - log.info("Installed event handler _on_maya_initialized..") - log.info("Installed event handler _on_scene_open..") - - def _on_maya_initialized(*args): emit_event("init") @@ -475,7 +519,6 @@ def on_task_changed(): workdir = legacy_io.Session["AVALON_WORKDIR"] if os.path.exists(workdir): log.info("Updating Maya workspace for task change to %s", workdir) - _set_project() # Set Maya fileDialog's start-dir to /scenes diff --git a/openpype/hosts/maya/startup/userSetup.py b/openpype/hosts/maya/startup/userSetup.py index a3ab483add..10e68c2ddb 100644 --- a/openpype/hosts/maya/startup/userSetup.py +++ b/openpype/hosts/maya/startup/userSetup.py @@ -1,10 +1,11 @@ import os from openpype.api import get_project_settings from openpype.pipeline import install_host -from openpype.hosts.maya import api +from openpype.hosts.maya.api import MayaHost from maya import cmds -install_host(api) +host = MayaHost() +install_host(host) print("starting OpenPype usersetup") diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index 047482f6ff..e719e46514 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -1,11 +1,9 @@ """Core pipeline functionality""" import os -import sys import json import types import logging -import inspect import platform import pyblish.api @@ -232,73 +230,10 @@ def register_host(host): required, or browse the source code. """ - signatures = { - "ls": [] - } - _validate_signature(host, signatures) _registered_host["_"] = host -def _validate_signature(module, signatures): - # Required signatures for each member - - missing = list() - invalid = list() - success = True - - for member in signatures: - if not hasattr(module, member): - missing.append(member) - success = False - - else: - attr = getattr(module, member) - if sys.version_info.major >= 3: - signature = inspect.getfullargspec(attr)[0] - else: - signature = inspect.getargspec(attr)[0] - required_signature = signatures[member] - - assert isinstance(signature, list) - assert isinstance(required_signature, list) - - if not all(member in signature - for member in required_signature): - invalid.append({ - "member": member, - "signature": ", ".join(signature), - "required": ", ".join(required_signature) - }) - success = False - - if not success: - report = list() - - if missing: - report.append( - "Incomplete interface for module: '%s'\n" - "Missing: %s" % (module, ", ".join( - "'%s'" % member for member in missing)) - ) - - if invalid: - report.append( - "'%s': One or more members were found, but didn't " - "have the right argument signature." % module.__name__ - ) - - for member in invalid: - report.append( - " Found: {member}({signature})".format(**member) - ) - report.append( - " Expected: {member}({required})".format(**member) - ) - - raise ValueError("\n".join(report)) - - def registered_host(): """Return currently registered host""" return _registered_host["_"] diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 7931ea400a..12cd9bbc68 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -6,6 +6,7 @@ import inspect from uuid import uuid4 from contextlib import contextmanager +from openpype.host import INewPublisher from openpype.pipeline import legacy_io from openpype.pipeline.mongodb import ( AvalonMongoDB, @@ -651,12 +652,6 @@ class CreateContext: discover_publish_plugins(bool): Discover publish plugins during reset phase. """ - # Methods required in host implementaion to be able create instances - # or change context data. - required_methods = ( - "get_context_data", - "update_context_data" - ) def __init__( self, host, dbcon=None, headless=False, reset=True, @@ -738,10 +733,10 @@ class CreateContext: Args: host(ModuleType): Host implementaion. """ - missing = set() - for attr_name in cls.required_methods: - if not hasattr(host, attr_name): - missing.add(attr_name) + + missing = set( + INewPublisher.get_missing_publish_methods(host) + ) return missing @property diff --git a/openpype/pipeline/legacy_io.py b/openpype/pipeline/legacy_io.py index 9359e3057b..bde2b24c2a 100644 --- a/openpype/pipeline/legacy_io.py +++ b/openpype/pipeline/legacy_io.py @@ -18,9 +18,13 @@ _database = database = None log = logging.getLogger(__name__) +def is_installed(): + return module._is_installed + + def install(): """Establish a persistent connection to the database""" - if module._is_installed: + if is_installed(): return session = session_data_from_environment(context_keys=True) @@ -55,7 +59,7 @@ def uninstall(): def requires_install(func): @functools.wraps(func) def decorated(*args, **kwargs): - if not module._is_installed: + if not is_installed(): install() return func(*args, **kwargs) return decorated diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index 0bb9c4a658..63fbe04c5c 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -6,6 +6,7 @@ from collections import defaultdict from Qt import QtCore, QtGui import qtawesome +from openpype.host import ILoadHost from openpype.client import ( get_asset_by_id, get_subset_by_id, @@ -193,7 +194,10 @@ class InventoryModel(TreeModel): host = registered_host() if not items: # for debugging or testing, injecting items from outside - items = host.ls() + if isinstance(host, ILoadHost): + items = host.get_containers() + else: + items = host.ls() self.clear() diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index 9dbbe25fda..ae23e4d089 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -6,7 +6,7 @@ use singleton approach with global functions (using helper anyway). import os import pyblish.api - +from openpype.host import IWorkfileHost, ILoadHost from openpype.pipeline import ( registered_host, legacy_io, @@ -49,12 +49,11 @@ class HostToolsHelper: def get_workfiles_tool(self, parent): """Create, cache and return workfiles tool window.""" if self._workfiles_tool is None: - from openpype.tools.workfiles.app import ( - Window, validate_host_requirements - ) + from openpype.tools.workfiles.app import Window + # Host validation host = registered_host() - validate_host_requirements(host) + IWorkfileHost.validate_workfile_methods(host) workfiles_window = Window(parent=parent) self._workfiles_tool = workfiles_window @@ -92,6 +91,9 @@ class HostToolsHelper: if self._loader_tool is None: from openpype.tools.loader import LoaderWindow + host = registered_host() + ILoadHost.validate_load_methods(host) + loader_window = LoaderWindow(parent=parent or self._parent) self._loader_tool = loader_window @@ -164,6 +166,9 @@ class HostToolsHelper: if self._scene_inventory_tool is None: from openpype.tools.sceneinventory import SceneInventoryWindow + host = registered_host() + ILoadHost.validate_load_methods(host) + scene_inventory_window = SceneInventoryWindow( parent=parent or self._parent ) diff --git a/openpype/tools/workfiles/__init__.py b/openpype/tools/workfiles/__init__.py index 5fbc71797d..205fd44838 100644 --- a/openpype/tools/workfiles/__init__.py +++ b/openpype/tools/workfiles/__init__.py @@ -1,12 +1,10 @@ from .window import Window from .app import ( show, - validate_host_requirements, ) __all__ = [ "Window", "show", - "validate_host_requirements", ] diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 352847ede8..2d0d551faf 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -1,6 +1,7 @@ import sys import logging +from openpype.host import IWorkfileHost from openpype.pipeline import ( registered_host, legacy_io, @@ -14,31 +15,6 @@ module = sys.modules[__name__] module.window = None -def validate_host_requirements(host): - if host is None: - raise RuntimeError("No registered host.") - - # Verify the host has implemented the api for Work Files - required = [ - "open_file", - "save_file", - "current_file", - "has_unsaved_changes", - "work_root", - "file_extensions", - ] - missing = [] - for name in required: - if not hasattr(host, name): - missing.append(name) - if missing: - raise RuntimeError( - "Host is missing required Work Files interfaces: " - "%s (host: %s)" % (", ".join(missing), host) - ) - return True - - def show(root=None, debug=False, parent=None, use_context=True, save=True): """Show Work Files GUI""" # todo: remove `root` argument to show() @@ -50,7 +26,7 @@ def show(root=None, debug=False, parent=None, use_context=True, save=True): pass host = registered_host() - validate_host_requirements(host) + IWorkfileHost.validate_workfile_methods(host) if debug: legacy_io.Session["AVALON_ASSET"] = "Mock" diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index c92e4fe904..34692b7102 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -6,6 +6,7 @@ import copy import Qt from Qt import QtWidgets, QtCore +from openpype.host import IWorkfileHost from openpype.client import get_asset_by_id from openpype.tools.utils import PlaceholderLineEdit from openpype.tools.utils.delegates import PrettyTimeDelegate @@ -125,7 +126,7 @@ class FilesWidget(QtWidgets.QWidget): filter_layout.addWidget(published_checkbox, 0) # Create the Files models - extensions = set(self.host.file_extensions()) + extensions = set(self._get_host_extensions()) views_widget = QtWidgets.QWidget(self) # --- Workarea view --- @@ -452,7 +453,12 @@ class FilesWidget(QtWidgets.QWidget): def open_file(self, filepath): host = self.host - if host.has_unsaved_changes(): + if isinstance(host, IWorkfileHost): + has_unsaved_changes = host.workfile_has_unsaved_changes() + else: + has_unsaved_changes = host.has_unsaved_changes() + + if has_unsaved_changes: result = self.save_changes_prompt() if result is None: # Cancel operation @@ -460,7 +466,10 @@ class FilesWidget(QtWidgets.QWidget): # Save first if has changes if result: - current_file = host.current_file() + if isinstance(host, IWorkfileHost): + current_file = host.get_current_workfile() + else: + current_file = host.current_file() if not current_file: # If the user requested to save the current scene # we can't actually automatically do so if the current @@ -471,7 +480,10 @@ class FilesWidget(QtWidgets.QWidget): return # Save current scene, continue to open file - host.save_file(current_file) + if isinstance(host, IWorkfileHost): + host.save_workfile(current_file) + else: + host.save_file(current_file) event_data_before = self._get_event_context_data() event_data_before["filepath"] = filepath @@ -482,7 +494,10 @@ class FilesWidget(QtWidgets.QWidget): source="workfiles.tool" ) self._enter_session() - host.open_file(filepath) + if isinstance(host, IWorkfileHost): + host.open_workfile(filepath) + else: + host.open_file(filepath) emit_event( "workfile.open.after", event_data_after, @@ -524,7 +539,7 @@ class FilesWidget(QtWidgets.QWidget): filepath = self._get_selected_filepath() extensions = [os.path.splitext(filepath)[1]] else: - extensions = self.host.file_extensions() + extensions = self._get_host_extensions() window = SaveAsDialog( parent=self, @@ -572,9 +587,14 @@ class FilesWidget(QtWidgets.QWidget): self.open_file(path) + def _get_host_extensions(self): + if isinstance(self.host, IWorkfileHost): + return self.host.get_workfile_extensions() + return self.host.file_extensions() + def on_browse_pressed(self): ext_filter = "Work File (*{0})".format( - " *".join(self.host.file_extensions()) + " *".join(self._get_host_extensions()) ) kwargs = { "caption": "Work Files", @@ -632,10 +652,16 @@ class FilesWidget(QtWidgets.QWidget): self._enter_session() if not self.published_enabled: - self.host.save_file(filepath) + if isinstance(self.host, IWorkfileHost): + self.host.save_workfile(filepath) + else: + self.host.save_file(filepath) else: shutil.copy(src_path, filepath) - self.host.open_file(filepath) + if isinstance(self.host, IWorkfileHost): + self.host.open_workfile(filepath) + else: + self.host.open_file(filepath) # Create extra folders create_workdir_extra_folders( diff --git a/website/docs/dev_host_implementation.md b/website/docs/dev_host_implementation.md new file mode 100644 index 0000000000..3702483ad1 --- /dev/null +++ b/website/docs/dev_host_implementation.md @@ -0,0 +1,89 @@ +--- +id: dev_host_implementation +title: Host implementation +sidebar_label: Host implementation +toc_max_heading_level: 4 +--- + +Host is an integration of DCC but in most of cases have logic that need to be handled before DCC is launched. Then based on abilities (or purpose) of DCC the integration can support different pipeline workflows. + +## Pipeline workflows +Workflows available in OpenPype are Workfiles, Load and Create-Publish. Each of them may require some functionality available in integration (e.g. call host API to achieve certain functionality). We'll go through them later. + +## How to implement and manage host +At this moment there is not fully unified way how host should be implemented but we're working on it. Host should have a "public face" code that can be used outside of DCC and in-DCC integration code. The main reason is that in-DCC code can have specific dependencies for python modules not available out of it's process. Hosts are located in `openpype/hosts/{host name}` folder. Current code (at many places) expect that the host name has equivalent folder there. So each subfolder should be named with the name of host it represents. + +### Recommended folder structure +```python +openpype/hosts/{host name} +│ +│ # Content of DCC integration - with in-DCC imports +├─ api +│ ├─ __init__.py +│ └─ [DCC integration files] +│ +│ # Plugins related to host - dynamically imported (can contain in-DCC imports) +├─ plugins +│ ├─ create +│ │ └─ [create plugin files] +│ ├─ load +│ │ └─ [load plugin files] +│ └─ publish +│ └─ [publish plugin files] +│ +│ # Launch hooks - used to modify how application is launched +├─ hooks +│ └─ [some pre/post launch hooks] +| +│ # Code initializing host integration in-DCC (DCC specific - example from Maya) +├─ startup +│ └─ userSetup.py +│ +│ # Public interface +├─ __init__.py +└─ [other public code] +``` + +### Launch Hooks +Launch hooks are not directly connected to host implementation, but they can be used to modify launch of process which may be crutial for the implementation. Launch hook are plugins called when DCC is launched. They are processed in sequence before and after launch. Pre launch hooks can change how process of DCC is launched, e.g. change subprocess flags, modify environments or modify launch arguments. If prelaunch hook crashes the application is not launched at all. Postlaunch hooks are triggered after launch of subprocess. They can be used to change statuses in your project tracker, start timer, etc. Crashed postlaunch hooks have no effect on rest of postlaunch hooks or launched process. They can be filtered by platform, host and application and order is defined by integer value. Hooks inside host are automatically loaded (one reason why folder name should match host name) or can be defined from modules. Hooks execution share same launch context where can be stored data used across multiple hooks (please be very specific in stored keys e.g. 'project' vs. 'project_name'). For more detailed information look into `openpype/lib/applications.py`. + +### Public interface +Public face is at this moment related to launching of the DCC. At this moment there there is only option to modify environment variables before launch by implementing function `add_implementation_envs` (must be available in `openpype/hosts/{host name}/__init__.py`). The function is called after pre launch hooks, as last step before subprocess launch, to be able set environment variables crutial for proper integration. It is also good place for functions that are used in prelaunch hooks and in-DCC integration. Future plans are to be able get workfiles extensions from here. Right now workfiles extensions are hardcoded in `openpype/pipeline/constants.py` under `HOST_WORKFILE_EXTENSIONS`, we would like to handle hosts as addons similar to OpenPype modules, and more improvements which are now hardcoded. + +### Integration +We've prepared base class `HostBase` in `openpype/host/host.py` to define minimum requirements and provide some default method implementations. The minimum requirement for a host is `name` attribute, this host would not be able to do much but is valid. To extend functionality we've prepared interfaces that helps to identify what is host capable of and if is possible to use certain tools with it. For those cases we defined interfaces for each workflow. `IWorkfileHost` interface add requirement to implement workfiles related methods which makes host usable in combination with Workfiles tool. `ILoadHost` interface add requirements to be able load, update, switch or remove referenced representations which should add support to use Loader and Scene Inventory tools. `INewPublisher` interface is required to be able use host with new OpenPype publish workflow. This is what must or can be implemented to allow certain functionality. `HostBase` will have more responsibility which will be taken from global variables in future. This process won't happen at once, but will be slow to keep backwards compatibility for some time. + +#### Example +```python +from openpype.host import HostBase, IWorkfileHost, ILoadHost + + +class MayaHost(HostBase, IWorkfileHost, ILoadHost): + def open_workfile(self, filepath): + ... + + def save_current_workfile(self, filepath=None): + ... + + def get_current_workfile(self): + ... + ... +``` + +### Install integration +We have prepared a host class, now where and how to initialize it's object? This part is DCC specific. In DCCs like Maya with embedded python and Qt we use advantage of being able to initialize object of the class directly in DCC process on start, the same happens in Nuke, Hiero and Houdini. In DCCs like Photoshop or Harmony there is launched OpenPype (python) process next to it which handles host initialization and communication with the DCC process (e.g. using sockects). Created object of host must be installed and registered to global scope of OpenPype. Which means that at this moment one process can handle only one host at a time. + +#### Install example (Maya startup file) +```python +from openpype.pipeline import install_host +from openpype.hosts.maya.api import MayaHost + + +host = MayaHost() +install_host(host) +``` + +Function `install_host` cares about installing global plugins, callbacks and register host. Host registration means that the object is kept in memory and is accessible using `get_registered_host()`. + +### Using UI tools +Most of functionality in DCCs is provided to artists by using UI tools. We're trying to keep UIs consistent so we use same set of tools in each host, all or most of them are Qt based. There is a `HostToolsHelper` in `openpype/tools/utils/host_tools.py` which unify showing of default tools, they can be showed almost at any point. Some of them are validating if host is capable of using them (Workfiles, Loader and Scene Inventory) which is related to [pipeline workflows](#pipeline-workflows). `HostToolsHelper` provides API to show tools but host integration must care about giving artists ability to show them. Most of DCCs have some extendable menu bar where is possible to add custom actions, which is preferred approach how to give ability to show the tools. diff --git a/website/sidebars.js b/website/sidebars.js index d4fec9fba2..0e578bd085 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -155,6 +155,7 @@ module.exports = { type: "category", label: "Hosts integrations", items: [ + "dev_host_implementation", "dev_publishing" ] }