diff --git a/client/ayon_core/__init__.py b/client/ayon_core/__init__.py index ce5a28601c..6cde11c822 100644 --- a/client/ayon_core/__init__.py +++ b/client/ayon_core/__init__.py @@ -9,10 +9,6 @@ AYON_CORE_ROOT = os.path.dirname(os.path.abspath(__file__)) # ------------------------- PACKAGE_DIR = AYON_CORE_ROOT PLUGINS_DIR = os.path.join(AYON_CORE_ROOT, "plugins") -AYON_SERVER_ENABLED = True - -# Indicate if AYON entities should be used instead of OpenPype entities -USE_AYON_ENTITIES = True # ------------------------- @@ -23,6 +19,4 @@ __all__ = ( "AYON_CORE_ROOT", "PACKAGE_DIR", "PLUGINS_DIR", - "AYON_SERVER_ENABLED", - "USE_AYON_ENTITIES", ) diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index c7eccd7b6c..6a7ce8a3cb 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -9,11 +9,18 @@ from .interfaces import ( ) from .base import ( + ProcessPreparationError, + ProcessContext, AYONAddon, AddonsManager, load_addons, ) +from .utils import ( + ensure_addons_are_process_context_ready, + ensure_addons_are_process_ready, +) + __all__ = ( "click_wrap", @@ -24,7 +31,12 @@ __all__ = ( "ITrayService", "IHostAddon", + "ProcessPreparationError", + "ProcessContext", "AYONAddon", "AddonsManager", "load_addons", + + "ensure_addons_are_process_context_ready", + "ensure_addons_are_process_ready", ) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 08dd2d6bbd..982626ad9d 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -10,13 +10,18 @@ import threading import collections from uuid import uuid4 from abc import ABC, abstractmethod +from typing import Optional -import appdirs import ayon_api from semver import VersionInfo from ayon_core import AYON_CORE_ROOT -from ayon_core.lib import Logger, is_dev_mode_enabled +from ayon_core.lib import ( + Logger, + is_dev_mode_enabled, + get_launcher_storage_dir, + is_headless_mode_enabled, +) from ayon_core.settings import get_studio_settings from .interfaces import ( @@ -31,9 +36,6 @@ IGNORED_FILENAMES = { # Files ignored on addons import from "./ayon_core/modules" IGNORED_DEFAULT_FILENAMES = { "__init__.py", - "base.py", - "interfaces.py", - "click_wrap.py", } # When addon was moved from ayon-core codebase @@ -64,77 +66,65 @@ MOVED_ADDON_MILESTONE_VERSIONS = { } -# Inherit from `object` for Python 2 hosts -class _ModuleClass(object): - """Fake module class for storing AYON addons. +class ProcessPreparationError(Exception): + """Exception that can be used when process preparation failed. + + The message is shown to user (either as UI dialog or printed). If + different error is raised a "generic" error message is shown to user + with option to copy error message to clipboard. - Object of this class can be stored to `sys.modules` and used for storing - dynamically imported modules. """ + pass - def __init__(self, name): - # Call setattr on super class - super(_ModuleClass, self).__setattr__("name", name) - super(_ModuleClass, self).__setattr__("__name__", name) - # Where modules and interfaces are stored - super(_ModuleClass, self).__setattr__("__attributes__", dict()) - super(_ModuleClass, self).__setattr__("__defaults__", set()) +class ProcessContext: + """Hold context of process that is going to be started. - super(_ModuleClass, self).__setattr__("_log", None) + Right now the context is simple, having information about addon that wants + to trigger preparation and possibly project name for which it should + happen. - def __getattr__(self, attr_name): - if attr_name not in self.__attributes__: - if attr_name in ("__path__", "__file__"): - return None - raise AttributeError("'{}' has not attribute '{}'".format( - self.name, attr_name - )) - return self.__attributes__[attr_name] + Preparation for process can be required for ayon-core or any other addon. + It can be, change of environment variables, or request login to + a project management. - def __iter__(self): - for module in self.values(): - yield module + At the moment of creation is 'ProcessContext' only data holder, but that + might change in future if there will be need. - def __setattr__(self, attr_name, value): - if attr_name in self.__attributes__: - self.log.warning( - "Duplicated name \"{}\" in {}. Overriding.".format( - attr_name, self.name - ) - ) - self.__attributes__[attr_name] = value + Args: + addon_name (str): Addon name which triggered process. + addon_version (str): Addon version which triggered process. + project_name (Optional[str]): Project name. Can be filled in case + process is triggered for specific project. Some addons can have + different behavior based on project. Value is NOT autofilled. + headless (Optional[bool]): Is process running in headless mode. Value + is filled with value based on state set in AYON launcher. - def __setitem__(self, key, value): - self.__setattr__(key, value) + """ + def __init__( + self, + addon_name: str, + addon_version: str, + project_name: Optional[str] = None, + headless: Optional[bool] = None, + **kwargs, + ): + if headless is None: + headless = is_headless_mode_enabled() + self.addon_name: str = addon_name + self.addon_version: str = addon_version + self.project_name: Optional[str] = project_name + self.headless: bool = headless - def __getitem__(self, key): - return getattr(self, key) - - @property - def log(self): - if self._log is None: - super(_ModuleClass, self).__setattr__( - "_log", Logger.get_logger(self.name) - ) - return self._log - - def get(self, key, default=None): - return self.__attributes__.get(key, default) - - def keys(self): - return self.__attributes__.keys() - - def values(self): - return self.__attributes__.values() - - def items(self): - return self.__attributes__.items() + if kwargs: + unknown_keys = ", ".join([f'"{key}"' for key in kwargs.keys()]) + print(f"Unknown keys in ProcessContext: {unknown_keys}") class _LoadCache: addons_lock = threading.Lock() addons_loaded = False + addon_modules = [] def load_addons(force=False): @@ -248,7 +238,7 @@ def _handle_moved_addons(addon_name, milestone_version, log): return addon_dir -def _load_ayon_addons(openpype_modules, modules_key, log): +def _load_ayon_addons(log): """Load AYON addons based on information from server. This function should not trigger downloading of any addons but only use @@ -256,30 +246,18 @@ def _load_ayon_addons(openpype_modules, modules_key, log): development). Args: - openpype_modules (_ModuleClass): Module object where modules are - stored. - modules_key (str): Key under which will be modules imported in - `sys.modules`. log (logging.Logger): Logger object. - Returns: - List[str]: List of v3 addons to skip to load because v4 alternative is - imported. """ - - addons_to_skip_in_core = [] - + all_addon_modules = [] bundle_info = _get_ayon_bundle_data() addons_info = _get_ayon_addons_information(bundle_info) if not addons_info: - return addons_to_skip_in_core + return all_addon_modules addons_dir = os.environ.get("AYON_ADDONS_DIR") if not addons_dir: - addons_dir = os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - "addons" - ) + addons_dir = get_launcher_storage_dir("addons") dev_mode_enabled = is_dev_mode_enabled() dev_addons_info = {} @@ -298,7 +276,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): addon_version = addon_info["version"] # core addon does not have any addon object - if addon_name in ("openpype", "core"): + if addon_name == "core": continue dev_addon_info = dev_addons_info.get(addon_name, {}) @@ -337,7 +315,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): continue sys.path.insert(0, addon_dir) - imported_modules = [] + addon_modules = [] for name in os.listdir(addon_dir): # Ignore of files is implemented to be able to run code from code # where usually is more files than just the addon @@ -364,7 +342,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): inspect.isclass(attr) and issubclass(attr, AYONAddon) ): - imported_modules.append(mod) + addon_modules.append(mod) break except BaseException: @@ -373,50 +351,37 @@ def _load_ayon_addons(openpype_modules, modules_key, log): exc_info=True ) - if not imported_modules: + if not addon_modules: log.warning("Addon {} {} has no content to import".format( addon_name, addon_version )) continue - if len(imported_modules) > 1: + if len(addon_modules) > 1: log.warning(( - "Skipping addon '{}'." - " Multiple modules were found ({}) in dir {}." + "Multiple modules ({}) were found in addon '{}' in dir {}." ).format( + ", ".join([m.__name__ for m in addon_modules]), addon_name, - ", ".join([m.__name__ for m in imported_modules]), addon_dir, )) - continue + all_addon_modules.extend(addon_modules) - mod = imported_modules[0] - addon_alias = getattr(mod, "V3_ALIAS", None) - if not addon_alias: - addon_alias = addon_name - addons_to_skip_in_core.append(addon_alias) - new_import_str = "{}.{}".format(modules_key, addon_alias) - - sys.modules[new_import_str] = mod - setattr(openpype_modules, addon_alias, mod) - - return addons_to_skip_in_core + return all_addon_modules -def _load_addons_in_core( - ignore_addon_names, openpype_modules, modules_key, log -): +def _load_addons_in_core(log): # Add current directory at first place # - has small differences in import logic + addon_modules = [] modules_dir = os.path.join(AYON_CORE_ROOT, "modules") if not os.path.exists(modules_dir): log.warning( f"Could not find path when loading AYON addons \"{modules_dir}\"" ) - return + return addon_modules ignored_filenames = IGNORED_FILENAMES | IGNORED_DEFAULT_FILENAMES - for filename in os.listdir(modules_dir): # Ignore filenames if filename in ignored_filenames: @@ -425,9 +390,6 @@ def _load_addons_in_core( fullpath = os.path.join(modules_dir, filename) basename, ext = os.path.splitext(filename) - if basename in ignore_addon_names: - continue - # Validations if os.path.isdir(fullpath): # Check existence of init file @@ -446,69 +408,43 @@ def _load_addons_in_core( # - check manifest and content of manifest try: # Don't import dynamically current directory modules - new_import_str = f"{modules_key}.{basename}" - import_str = f"ayon_core.modules.{basename}" default_module = __import__(import_str, fromlist=("", )) - sys.modules[new_import_str] = default_module - setattr(openpype_modules, basename, default_module) + addon_modules.append(default_module) except Exception: log.error( f"Failed to import in-core addon '{basename}'.", exc_info=True ) + return addon_modules def _load_addons(): - # Key under which will be modules imported in `sys.modules` - modules_key = "openpype_modules" - - # Change `sys.modules` - sys.modules[modules_key] = openpype_modules = _ModuleClass(modules_key) - log = Logger.get_logger("AddonsLoader") - ignore_addon_names = _load_ayon_addons( - openpype_modules, modules_key, log - ) - _load_addons_in_core( - ignore_addon_names, openpype_modules, modules_key, log - ) + addon_modules = _load_ayon_addons(log) + # All addon in 'modules' folder are tray actions and should be moved + # to tray tool. + # TODO remove + addon_modules.extend(_load_addons_in_core(log)) - -_MARKING_ATTR = "_marking" -def mark_func(func): - """Mark function to be used in report. - - Args: - func (Callable): Function to mark. - - Returns: - Callable: Marked function. - """ - - setattr(func, _MARKING_ATTR, True) - return func - - -def is_func_marked(func): - return getattr(func, _MARKING_ATTR, False) + # Store modules to local cache + _LoadCache.addon_modules = addon_modules class AYONAddon(ABC): """Base class of AYON addon. Attributes: - id (UUID): Addon object id. enabled (bool): Is addon enabled. name (str): Addon name. Args: manager (AddonsManager): Manager object who discovered addon. settings (dict[str, Any]): AYON settings. - """ + """ enabled = True _id = None @@ -528,8 +464,8 @@ class AYONAddon(ABC): Returns: str: Object id. - """ + """ if self._id is None: self._id = uuid4() return self._id @@ -541,8 +477,8 @@ class AYONAddon(ABC): Returns: str: Addon name. - """ + """ pass @property @@ -573,18 +509,40 @@ class AYONAddon(ABC): Args: settings (dict[str, Any]): Settings. - """ + """ pass - @mark_func def connect_with_addons(self, enabled_addons): """Connect with other enabled addons. Args: enabled_addons (list[AYONAddon]): Addons that are enabled. - """ + """ + pass + + def ensure_is_process_ready( + self, process_context: ProcessContext + ): + """Make sure addon is prepared for a process. + + This method is called when some action makes sure that addon has set + necessary data. For example if user should be logged in + and filled credentials in environment variables this method should + ask user for credentials. + + Implementation of this method is optional. + + Note: + The logic can be similar to logic in tray, but tray does not require + to be logged in. + + Args: + process_context (ProcessContext): Context of child + process. + + """ pass def get_global_environments(self): @@ -594,8 +552,8 @@ class AYONAddon(ABC): Returns: dict[str, str]: Environment variables. - """ + """ return {} def modify_application_launch_arguments(self, application, env): @@ -607,8 +565,8 @@ class AYONAddon(ABC): Args: application (Application): Application that is launched. env (dict[str, str]): Current environment variables. - """ + """ pass def on_host_install(self, host, host_name, project_name): @@ -627,8 +585,8 @@ class AYONAddon(ABC): host_name (str): Name of host. project_name (str): Project name which is main part of host context. - """ + """ pass def cli(self, addon_click_group): @@ -655,31 +613,11 @@ class AYONAddon(ABC): Args: addon_click_group (click.Group): Group to which can be added commands. + """ - pass -class OpenPypeModule(AYONAddon): - """Base class of OpenPype module. - - Deprecated: - Use `AYONAddon` instead. - - Args: - manager (AddonsManager): Manager object who discovered addon. - settings (dict[str, Any]): Module settings (OpenPype settings). - """ - - # Disable by default - enabled = False - - -class OpenPypeAddOn(OpenPypeModule): - # Enable Addon by default - enabled = True - - class _AddonReportInfo: def __init__( self, class_name, name, version, report_value_by_label @@ -711,8 +649,8 @@ class AddonsManager: settings (Optional[dict[str, Any]]): AYON studio settings. initialize (Optional[bool]): Initialize addons on init. True by default. - """ + """ # Helper attributes for report _report_total_key = "Total" _log = None @@ -748,8 +686,8 @@ class AddonsManager: Returns: Union[AYONAddon, Any]: Addon found by name or `default`. - """ + """ return self._addons_by_name.get(addon_name, default) @property @@ -776,8 +714,8 @@ class AddonsManager: Returns: Union[AYONAddon, None]: Enabled addon found by name or None. - """ + """ addon = self.get(addon_name) if addon is not None and addon.enabled: return addon @@ -788,8 +726,8 @@ class AddonsManager: Returns: list[AYONAddon]: Initialized and enabled addons. - """ + """ return [ addon for addon in self._addons @@ -801,8 +739,6 @@ class AddonsManager: # Make sure modules are loaded load_addons() - import openpype_modules - self.log.debug("*** AYON addons initialization.") # Prepare settings for addons @@ -810,14 +746,12 @@ class AddonsManager: if settings is None: settings = get_studio_settings() - modules_settings = {} - report = {} time_start = time.time() prev_start_time = time_start addon_classes = [] - for module in openpype_modules: + for module in _LoadCache.addon_modules: # Go through globals in `ayon_core.modules` for name in dir(module): modules_item = getattr(module, name, None) @@ -826,8 +760,6 @@ class AddonsManager: if ( not inspect.isclass(modules_item) or modules_item is AYONAddon - or modules_item is OpenPypeModule - or modules_item is OpenPypeAddOn or not issubclass(modules_item, AYONAddon) ): continue @@ -853,33 +785,14 @@ class AddonsManager: addon_classes.append(modules_item) - aliased_names = [] for addon_cls in addon_classes: name = addon_cls.__name__ - if issubclass(addon_cls, OpenPypeModule): - # TODO change to warning - self.log.debug(( - "Addon '{}' is inherited from 'OpenPypeModule'." - " Please use 'AYONAddon'." - ).format(name)) - try: - # Try initialize module - if issubclass(addon_cls, OpenPypeModule): - addon = addon_cls(self, modules_settings) - else: - addon = addon_cls(self, settings) + addon = addon_cls(self, settings) # Store initialized object self._addons.append(addon) self._addons_by_id[addon.id] = addon self._addons_by_name[addon.name] = addon - # NOTE This will be removed with release 1.0.0 of ayon-core - # please use carefully. - # Gives option to use alias name for addon for cases when - # name in OpenPype was not the same as in AYON. - name_alias = getattr(addon, "openpype_alias", None) - if name_alias: - aliased_names.append((name_alias, addon)) now = time.time() report[addon.__class__.__name__] = now - prev_start_time @@ -898,17 +811,6 @@ class AddonsManager: f"[{enabled_str}] {addon.name} ({addon.version})" ) - for item in aliased_names: - name_alias, addon = item - if name_alias not in self._addons_by_name: - self._addons_by_name[name_alias] = addon - continue - self.log.warning( - "Alias name '{}' of addon '{}' is already assigned.".format( - name_alias, addon.name - ) - ) - if self._report is not None: report[self._report_total_key] = time.time() - time_start self._report["Initialization"] = report @@ -925,16 +827,7 @@ class AddonsManager: self.log.debug("Has {} enabled addons.".format(len(enabled_addons))) for addon in enabled_addons: try: - if not is_func_marked(addon.connect_with_addons): - addon.connect_with_addons(enabled_addons) - - elif hasattr(addon, "connect_with_modules"): - self.log.warning(( - "DEPRECATION WARNING: Addon '{}' still uses" - " 'connect_with_modules' method. Please switch to use" - " 'connect_with_addons' method." - ).format(addon.name)) - addon.connect_with_modules(enabled_addons) + addon.connect_with_addons(enabled_addons) except Exception: self.log.error( @@ -1283,56 +1176,3 @@ class AddonsManager: # Join rows with newline char and add new line at the end output = "\n".join(formatted_rows) + "\n" print(output) - - # DEPRECATED - Module compatibility - @property - def modules(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated property" - " 'modules' please use 'addons' instead." - ) - return self.addons - - @property - def modules_by_id(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated property" - " 'modules_by_id' please use 'addons_by_id' instead." - ) - return self.addons_by_id - - @property - def modules_by_name(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated property" - " 'modules_by_name' please use 'addons_by_name' instead." - ) - return self.addons_by_name - - def get_enabled_module(self, *args, **kwargs): - self.log.warning( - "DEPRECATION WARNING: Used deprecated method" - " 'get_enabled_module' please use 'get_enabled_addon' instead." - ) - return self.get_enabled_addon(*args, **kwargs) - - def initialize_modules(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated method" - " 'initialize_modules' please use 'initialize_addons' instead." - ) - self.initialize_addons() - - def get_enabled_modules(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated method" - " 'get_enabled_modules' please use 'get_enabled_addons' instead." - ) - return self.get_enabled_addons() - - def get_host_module(self, host_name): - self.log.warning( - "DEPRECATION WARNING: Used deprecated method" - " 'get_host_module' please use 'get_host_addon' instead." - ) - return self.get_host_addon(host_name) diff --git a/client/ayon_core/addon/ui/process_ready_error.py b/client/ayon_core/addon/ui/process_ready_error.py new file mode 100644 index 0000000000..78e2b9b9bb --- /dev/null +++ b/client/ayon_core/addon/ui/process_ready_error.py @@ -0,0 +1,132 @@ +import sys +import json +from typing import Optional + +from qtpy import QtWidgets, QtCore + +from ayon_core.style import load_stylesheet +from ayon_core.tools.utils import get_ayon_qt_app + + +class DetailDialog(QtWidgets.QDialog): + def __init__(self, detail, parent): + super().__init__(parent) + + self.setWindowTitle("Detail") + + detail_input = QtWidgets.QPlainTextEdit(self) + detail_input.setPlainText(detail) + detail_input.setReadOnly(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(detail_input, 1) + + def showEvent(self, event): + self.resize(600, 400) + super().showEvent(event) + + +class ErrorDialog(QtWidgets.QDialog): + def __init__( + self, + message: str, + detail: Optional[str], + parent: Optional[QtWidgets.QWidget] = None + ): + super().__init__(parent) + + self.setWindowTitle("Preparation failed") + self.setWindowFlags( + self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint + ) + + message_label = QtWidgets.QLabel(self) + + detail_wrapper = QtWidgets.QWidget(self) + + detail_label = QtWidgets.QLabel(detail_wrapper) + + detail_layout = QtWidgets.QVBoxLayout(detail_wrapper) + detail_layout.setContentsMargins(0, 0, 0, 0) + detail_layout.addWidget(detail_label) + + btns_wrapper = QtWidgets.QWidget(self) + + copy_detail_btn = QtWidgets.QPushButton("Copy detail", btns_wrapper) + show_detail_btn = QtWidgets.QPushButton("Show detail", btns_wrapper) + confirm_btn = QtWidgets.QPushButton("Close", btns_wrapper) + + btns_layout = QtWidgets.QHBoxLayout(btns_wrapper) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(copy_detail_btn, 0) + btns_layout.addWidget(show_detail_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(message_label, 0) + layout.addWidget(detail_wrapper, 1) + layout.addWidget(btns_wrapper, 0) + + copy_detail_btn.clicked.connect(self._on_copy_clicked) + show_detail_btn.clicked.connect(self._on_show_detail_clicked) + confirm_btn.clicked.connect(self._on_confirm_clicked) + + self._message_label = message_label + self._detail_wrapper = detail_wrapper + self._detail_label = detail_label + + self._copy_detail_btn = copy_detail_btn + self._show_detail_btn = show_detail_btn + self._confirm_btn = confirm_btn + + self._detail_dialog = None + + self._detail = detail + + self.set_message(message, detail) + + def showEvent(self, event): + self.setStyleSheet(load_stylesheet()) + self.resize(320, 140) + super().showEvent(event) + + def set_message(self, message, detail): + self._message_label.setText(message) + self._detail = detail + + for widget in ( + self._copy_detail_btn, + self._show_detail_btn, + ): + widget.setVisible(bool(detail)) + + def _on_copy_clicked(self): + if self._detail: + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(self._detail) + + def _on_show_detail_clicked(self): + if self._detail_dialog is None: + self._detail_dialog = DetailDialog(self._detail, self) + self._detail_dialog.show() + + def _on_confirm_clicked(self): + self.accept() + + +def main(): + json_path = sys.argv[-1] + with open(json_path, "r") as stream: + data = json.load(stream) + + message = data["message"] + detail = data["detail"] + app = get_ayon_qt_app() + dialog = ErrorDialog(message, detail) + dialog.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py new file mode 100644 index 0000000000..f983e37d3c --- /dev/null +++ b/client/ayon_core/addon/utils.py @@ -0,0 +1,201 @@ +import os +import sys +import contextlib +import tempfile +import json +import traceback +from io import StringIO +from typing import Optional + +from ayon_core.lib import run_ayon_launcher_process + +from .base import AddonsManager, ProcessContext, ProcessPreparationError + + +def _handle_error( + process_context: ProcessContext, + message: str, + detail: Optional[str], +): + """Handle error in process ready preparation. + + Shows UI to inform user about the error, or prints the message + to stdout if running in headless mode. + + Todos: + Make this functionality with the dialog as unified function, so it can + be used elsewhere. + + Args: + process_context (ProcessContext): The context in which the + error occurred. + message (str): The message to show. + detail (Optional[str]): The detail message to show (usually + traceback). + + """ + if process_context.headless: + if detail: + print(detail) + print(f"{10*'*'}\n{message}\n{10*'*'}") + return + + current_dir = os.path.dirname(os.path.abspath(__file__)) + script_path = os.path.join(current_dir, "ui", "process_ready_error.py") + with tempfile.NamedTemporaryFile("w", delete=False) as tmp: + tmp_path = tmp.name + json.dump( + {"message": message, "detail": detail}, + tmp.file + ) + + try: + run_ayon_launcher_process( + "--skip-bootstrap", + script_path, + tmp_path, + add_sys_paths=True, + creationflags=0, + ) + + finally: + os.remove(tmp_path) + + +def _start_tray(): + from ayon_core.tools.tray import make_sure_tray_is_running + + make_sure_tray_is_running() + + +def ensure_addons_are_process_context_ready( + process_context: ProcessContext, + addons_manager: Optional[AddonsManager] = None, + exit_on_failure: bool = True, +) -> bool: + """Ensure all enabled addons are ready to be used in the given context. + + Call this method only in AYON launcher process and as first thing + to avoid possible clashes with preparation. For example 'QApplication' + should not be created. + + Todos: + Run all preparations and allow to "ignore" failed preparations. + Right now single addon can block using certain actions. + + Args: + process_context (ProcessContext): The context in which the + addons should be prepared. + addons_manager (Optional[AddonsManager]): The addons + manager to use. If not provided, a new one will be created. + exit_on_failure (bool, optional): If True, the process will exit + if an error occurs. Defaults to True. + + Returns: + bool: True if all addons are ready, False otherwise. + + """ + if addons_manager is None: + addons_manager = AddonsManager() + + message = None + failed = False + use_detail = False + # Wrap the output in StringIO to capture it for details on fail + # - but in case stdout was invalid on start of process also store + # the tracebacks + tracebacks = [] + output = StringIO() + with contextlib.redirect_stdout(output): + with contextlib.redirect_stderr(output): + for addon in addons_manager.get_enabled_addons(): + addon_failed = True + try: + addon.ensure_is_process_ready(process_context) + addon_failed = False + except ProcessPreparationError as exc: + message = str(exc) + print(f"Addon preparation failed: '{addon.name}'") + print(message) + + except BaseException: + use_detail = True + message = "An unexpected error occurred." + formatted_traceback = "".join(traceback.format_exception( + *sys.exc_info() + )) + tracebacks.append(formatted_traceback) + print(f"Addon preparation failed: '{addon.name}'") + print(message) + # Print the traceback so it is in the stdout + print(formatted_traceback) + + if addon_failed: + failed = True + break + + output_str = output.getvalue() + # Print stdout/stderr to console as it was redirected + print(output_str) + if not failed: + if not process_context.headless: + _start_tray() + return True + + detail = None + if use_detail: + # In case stdout was not captured, use the tracebacks as detail + if not output_str: + output_str = "\n".join(tracebacks) + detail = output_str + + _handle_error(process_context, message, detail) + if exit_on_failure: + sys.exit(1) + return False + + +def ensure_addons_are_process_ready( + addon_name: str, + addon_version: str, + project_name: Optional[str] = None, + headless: Optional[bool] = None, + *, + addons_manager: Optional[AddonsManager] = None, + exit_on_failure: bool = True, + **kwargs, +) -> bool: + """Ensure all enabled addons are ready to be used in the given context. + + Call this method only in AYON launcher process and as first thing + to avoid possible clashes with preparation. For example 'QApplication' + should not be created. + + Args: + addon_name (str): Addon name which triggered process. + addon_version (str): Addon version which triggered process. + project_name (Optional[str]): Project name. Can be filled in case + process is triggered for specific project. Some addons can have + different behavior based on project. Value is NOT autofilled. + headless (Optional[bool]): Is process running in headless mode. Value + is filled with value based on state set in AYON launcher. + addons_manager (Optional[AddonsManager]): The addons + manager to use. If not provided, a new one will be created. + exit_on_failure (bool, optional): If True, the process will exit + if an error occurs. Defaults to True. + kwargs: The keyword arguments to pass to the ProcessContext. + + Returns: + bool: True if all addons are ready, False otherwise. + + """ + context: ProcessContext = ProcessContext( + addon_name, + addon_version, + project_name, + headless, + **kwargs + ) + return ensure_addons_are_process_context_ready( + context, addons_manager, exit_on_failure + ) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index db6674d88f..b80b243db2 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -21,21 +21,7 @@ from ayon_core.lib import ( -class AliasedGroup(click.Group): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._aliases = {} - - def set_alias(self, src_name, dst_name): - self._aliases[dst_name] = src_name - - def get_command(self, ctx, cmd_name): - if cmd_name in self._aliases: - cmd_name = self._aliases[cmd_name] - return super().get_command(ctx, cmd_name) - - -@click.group(cls=AliasedGroup, invoke_without_command=True) +@click.group(invoke_without_command=True) @click.pass_context @click.option("--use-staging", is_flag=True, expose_value=False, help="use staging variants") @@ -86,10 +72,6 @@ def addon(ctx): pass -# Add 'addon' as alias for module -main_cli.set_alias("addon", "module") - - @main_cli.command() @click.pass_context @click.argument("output_json_path") diff --git a/client/ayon_core/hooks/pre_global_host_data.py b/client/ayon_core/hooks/pre_global_host_data.py index e93b512742..12da6f12f8 100644 --- a/client/ayon_core/hooks/pre_global_host_data.py +++ b/client/ayon_core/hooks/pre_global_host_data.py @@ -94,4 +94,4 @@ class GlobalHostDataHook(PreLaunchHook): task_entity = get_task_by_name( project_name, folder_entity["id"], task_name ) - self.data["task_entity"] = task_entity \ No newline at end of file + self.data["task_entity"] = task_entity diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 63d1e84781..d803af23c7 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -7,11 +7,10 @@ from .local_settings import ( JSONSettingRegistry, AYONSecureRegistry, AYONSettingsRegistry, - OpenPypeSecureRegistry, - OpenPypeSettingsRegistry, + get_launcher_local_dir, + get_launcher_storage_dir, get_local_site_id, get_ayon_username, - get_openpype_username, ) from .ayon_connection import initialize_ayon_connection from .cache import ( @@ -57,13 +56,11 @@ from .env_tools import ( from .terminal import Terminal from .execute import ( get_ayon_launcher_args, - get_openpype_execute_args, get_linux_launcher_args, execute, run_subprocess, run_detached_process, run_ayon_launcher_process, - run_openpype_process, path_to_subprocess_arg, CREATE_NO_WINDOW ) @@ -109,6 +106,7 @@ from .transcoding import ( convert_ffprobe_fps_value, convert_ffprobe_fps_to_float, get_rescaled_command_arguments, + get_media_mime_type, get_image_info_metadata, ) @@ -130,6 +128,7 @@ from .ayon_info import ( is_in_ayon_launcher_process, is_running_from_build, is_using_ayon_console, + is_headless_mode_enabled, is_staging_enabled, is_dev_mode_enabled, is_in_tests, @@ -142,11 +141,10 @@ __all__ = [ "JSONSettingRegistry", "AYONSecureRegistry", "AYONSettingsRegistry", - "OpenPypeSecureRegistry", - "OpenPypeSettingsRegistry", + "get_launcher_local_dir", + "get_launcher_storage_dir", "get_local_site_id", "get_ayon_username", - "get_openpype_username", "initialize_ayon_connection", @@ -157,13 +155,11 @@ __all__ = [ "register_event_callback", "get_ayon_launcher_args", - "get_openpype_execute_args", "get_linux_launcher_args", "execute", "run_subprocess", "run_detached_process", "run_ayon_launcher_process", - "run_openpype_process", "path_to_subprocess_arg", "CREATE_NO_WINDOW", @@ -210,6 +206,7 @@ __all__ = [ "convert_ffprobe_fps_value", "convert_ffprobe_fps_to_float", "get_rescaled_command_arguments", + "get_media_mime_type", "get_image_info_metadata", "compile_list_of_regexes", @@ -241,6 +238,7 @@ __all__ = [ "is_in_ayon_launcher_process", "is_running_from_build", "is_using_ayon_console", + "is_headless_mode_enabled", "is_staging_enabled", "is_dev_mode_enabled", "is_in_tests", diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 360d47ea17..894b012d59 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -4,7 +4,7 @@ import collections import uuid import json import copy -from abc import ABCMeta, abstractmethod, abstractproperty +from abc import ABCMeta, abstractmethod import clique @@ -16,7 +16,7 @@ _attr_defs_by_type = {} def register_attr_def_class(cls): """Register attribute definition. - Currently are registered definitions used to deserialize data to objects. + Currently registered definitions are used to deserialize data to objects. Attrs: cls (AbstractAttrDef): Non-abstract class to be registered with unique @@ -60,7 +60,7 @@ def get_default_values(attribute_definitions): for which default values should be collected. Returns: - Dict[str, Any]: Default values for passet attribute definitions. + Dict[str, Any]: Default values for passed attribute definitions. """ output = {} @@ -75,13 +75,13 @@ def get_default_values(attribute_definitions): class AbstractAttrDefMeta(ABCMeta): - """Metaclass to validate existence of 'key' attribute. + """Metaclass to validate the existence of 'key' attribute. - Each object of `AbstractAttrDef` mus have defined 'key' attribute. + Each object of `AbstractAttrDef` must have defined 'key' attribute. """ - def __call__(self, *args, **kwargs): - obj = super(AbstractAttrDefMeta, self).__call__(*args, **kwargs) + def __call__(cls, *args, **kwargs): + obj = super(AbstractAttrDefMeta, cls).__call__(*args, **kwargs) init_class = getattr(obj, "__init__class__", None) if init_class is not AbstractAttrDef: raise TypeError("{} super was not called in __init__.".format( @@ -162,7 +162,8 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): def __ne__(self, other): return not self.__eq__(other) - @abstractproperty + @property + @abstractmethod def type(self): """Attribute definition type also used as identifier of class. @@ -215,7 +216,7 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): # ----------------------------------------- -# UI attribute definitoins won't hold value +# UI attribute definitions won't hold value # ----------------------------------------- class UIDef(AbstractAttrDef): @@ -245,7 +246,7 @@ class UILabelDef(UIDef): # --------------------------------------- -# Attribute defintioins should hold value +# Attribute definitions should hold value # --------------------------------------- class UnknownDef(AbstractAttrDef): @@ -311,7 +312,7 @@ class NumberDef(AbstractAttrDef): ): minimum = 0 if minimum is None else minimum maximum = 999999 if maximum is None else maximum - # Swap min/max when are passed in opposited order + # Swap min/max when are passed in opposite order if minimum > maximum: maximum, minimum = minimum, maximum @@ -364,10 +365,10 @@ class NumberDef(AbstractAttrDef): class TextDef(AbstractAttrDef): """Text definition. - Text can have multiline option so endline characters are allowed regex + Text can have multiline option so end-line characters are allowed regex validation can be applied placeholder for UI purposes and default value. - Regex validation is not part of attribute implemntentation. + Regex validation is not part of attribute implementation. Args: multiline(bool): Text has single or multiline support. @@ -577,7 +578,7 @@ class BoolDef(AbstractAttrDef): return self.default -class FileDefItem(object): +class FileDefItem: def __init__( self, directory, filenames, frames=None, template=None ): @@ -949,7 +950,8 @@ def deserialize_attr_def(attr_def_data): """Deserialize attribute definition from data. Args: - attr_def (Dict[str, Any]): Attribute definition data to deserialize. + attr_def_data (Dict[str, Any]): Attribute definition data to + deserialize. """ attr_type = attr_def_data.pop("type") diff --git a/client/ayon_core/lib/ayon_info.py b/client/ayon_core/lib/ayon_info.py index c4333fab95..7e194a824e 100644 --- a/client/ayon_core/lib/ayon_info.py +++ b/client/ayon_core/lib/ayon_info.py @@ -78,6 +78,10 @@ def is_using_ayon_console(): return "ayon_console" in executable_filename +def is_headless_mode_enabled(): + return os.getenv("AYON_HEADLESS_MODE") == "1" + + def is_staging_enabled(): return os.getenv("AYON_USE_STAGING") == "1" diff --git a/client/ayon_core/lib/events.py b/client/ayon_core/lib/events.py index 774790b80a..2601bc1cf4 100644 --- a/client/ayon_core/lib/events.py +++ b/client/ayon_core/lib/events.py @@ -8,7 +8,6 @@ import logging import weakref from uuid import uuid4 -from .python_2_comp import WeakMethod from .python_module_tools import is_func_signature_supported @@ -18,7 +17,7 @@ class MissingEventSystem(Exception): def _get_func_ref(func): if inspect.ismethod(func): - return WeakMethod(func) + return weakref.WeakMethod(func) return weakref.ref(func) @@ -123,7 +122,7 @@ class weakref_partial: ) -class EventCallback(object): +class EventCallback: """Callback registered to a topic. The callback function is registered to a topic. Topic is a string which @@ -380,8 +379,7 @@ class EventCallback(object): self._partial_func = None -# Inherit from 'object' for Python 2 hosts -class Event(object): +class Event: """Base event object. Can be used for any event because is not specific. Only required argument @@ -488,7 +486,7 @@ class Event(object): return obj -class EventSystem(object): +class EventSystem: """Encapsulate event handling into an object. System wraps registered callbacks and triggered events into single object, diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index e89c8f22ee..95696fd272 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -108,6 +108,20 @@ def run_subprocess(*args, **kwargs): | getattr(subprocess, "CREATE_NO_WINDOW", 0) ) + # Escape parentheses for bash + if ( + kwargs.get("shell") is True + and len(args) == 1 + and isinstance(args[0], str) + and os.getenv("SHELL") in ("/bin/bash", "/bin/sh") + ): + new_arg = ( + args[0] + .replace("(", "\\(") + .replace(")", "\\)") + ) + args = (new_arg, ) + # Get environents from kwarg or use current process environments if were # not passed. env = kwargs.get("env") or os.environ @@ -179,7 +193,7 @@ def clean_envs_for_ayon_process(env=None): return env -def run_ayon_launcher_process(*args, **kwargs): +def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): """Execute AYON process with passed arguments and wait. Wrapper for 'run_process' which prepends AYON executable arguments @@ -209,29 +223,18 @@ def run_ayon_launcher_process(*args, **kwargs): # - fill more if you find more env = clean_envs_for_ayon_process(os.environ) + if add_sys_paths: + new_pythonpath = list(sys.path) + lookup_set = set(new_pythonpath) + for path in (env.get("PYTHONPATH") or "").split(os.pathsep): + if path and path not in lookup_set: + new_pythonpath.append(path) + lookup_set.add(path) + env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) + return run_subprocess(args, env=env, **kwargs) -def run_openpype_process(*args, **kwargs): - """Execute AYON process with passed arguments and wait. - - Wrapper for 'run_process' which prepends AYON executable arguments - before passed arguments and define environments if are not passed. - - Values from 'os.environ' are used for environments if are not passed. - They are cleaned using 'clean_envs_for_ayon_process' function. - - Example: - >>> run_openpype_process("version") - - Args: - *args (tuple): AYON cli arguments. - **kwargs (dict): Keyword arguments for subprocess.Popen. - - """ - return run_ayon_launcher_process(*args, **kwargs) - - def run_detached_process(args, **kwargs): """Execute process with passed arguments as separated process. @@ -318,14 +321,12 @@ def path_to_subprocess_arg(path): def get_ayon_launcher_args(*args): - """Arguments to run ayon-launcher process. + """Arguments to run AYON launcher process. - Arguments for subprocess when need to spawn new pype process. Which may be - needed when new python process for pype scripts must be executed in build - pype. + Arguments for subprocess when need to spawn new AYON launcher process. Reasons: - Ayon-launcher started from code has different executable set to + AYON launcher started from code has different executable set to virtual env python and must have path to script as first argument which is not needed for built application. @@ -333,7 +334,8 @@ def get_ayon_launcher_args(*args): *args (str): Any arguments that will be added after executables. Returns: - list[str]: List of arguments to run ayon-launcher process. + list[str]: List of arguments to run AYON launcher process. + """ executable = os.environ["AYON_EXECUTABLE"] launch_args = [executable] @@ -391,21 +393,3 @@ def get_linux_launcher_args(*args): launch_args.extend(args) return launch_args - - -def get_openpype_execute_args(*args): - """Arguments to run pype command. - - Arguments for subprocess when need to spawn new pype process. Which may be - needed when new python process for pype scripts must be executed in build - pype. - - ## Why is this needed? - Pype executed from code has different executable set to virtual env python - and must have path to script as first argument which is not needed for - build pype. - - It is possible to pass any arguments that will be added after pype - executables. - """ - return get_ayon_launcher_args(*args) diff --git a/client/ayon_core/lib/file_transaction.py b/client/ayon_core/lib/file_transaction.py index 47b10dd994..a502403958 100644 --- a/client/ayon_core/lib/file_transaction.py +++ b/client/ayon_core/lib/file_transaction.py @@ -22,7 +22,7 @@ class DuplicateDestinationError(ValueError): """ -class FileTransaction(object): +class FileTransaction: """File transaction with rollback options. The file transaction is a three-step process. diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 54432265d9..690781151c 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -3,26 +3,11 @@ import os import json import platform +import configparser +import warnings from datetime import datetime from abc import ABC, abstractmethod - -# disable lru cache in Python 2 -try: - from functools import lru_cache -except ImportError: - def lru_cache(maxsize): - def max_size(func): - def wrapper(*args, **kwargs): - value = func(*args, **kwargs) - return value - return wrapper - return max_size - -# ConfigParser was renamed in python3 to configparser -try: - import configparser -except ImportError: - import ConfigParser as configparser +from functools import lru_cache import appdirs import ayon_api @@ -30,6 +15,87 @@ import ayon_api _PLACEHOLDER = object() +def _get_ayon_appdirs(*args): + return os.path.join( + appdirs.user_data_dir("AYON", "Ynput"), + *args + ) + + +def get_ayon_appdirs(*args): + """Local app data directory of AYON client. + + Deprecated: + Use 'get_launcher_local_dir' or 'get_launcher_storage_dir' based on + use-case. Deprecation added 24/08/09 (0.4.4-dev.1). + + Args: + *args (Iterable[str]): Subdirectories/files in local app data dir. + + Returns: + str: Path to directory/file in local app data dir. + + """ + warnings.warn( + ( + "Function 'get_ayon_appdirs' is deprecated. Should be replaced" + " with 'get_launcher_local_dir' or 'get_launcher_storage_dir'" + " based on use-case." + ), + DeprecationWarning + ) + return _get_ayon_appdirs(*args) + + +def get_launcher_storage_dir(*subdirs: str) -> str: + """Get storage directory for launcher. + + Storage directory is used for storing shims, addons, dependencies, etc. + + It is not recommended, but the location can be shared across + multiple machines. + + Note: + This function should be called at least once on bootstrap. + + Args: + *subdirs (str): Subdirectories relative to storage dir. + + Returns: + str: Path to storage directory. + + """ + storage_dir = os.getenv("AYON_LAUNCHER_STORAGE_DIR") + if not storage_dir: + storage_dir = _get_ayon_appdirs() + + return os.path.join(storage_dir, *subdirs) + + +def get_launcher_local_dir(*subdirs: str) -> str: + """Get local directory for launcher. + + Local directory is used for storing machine or user specific data. + + The location is user specific. + + Note: + This function should be called at least once on bootstrap. + + Args: + *subdirs (str): Subdirectories relative to local dir. + + Returns: + str: Path to local directory. + + """ + storage_dir = os.getenv("AYON_LAUNCHER_LOCAL_DIR") + if not storage_dir: + storage_dir = _get_ayon_appdirs() + + return os.path.join(storage_dir, *subdirs) + + class AYONSecureRegistry: """Store information using keyring. @@ -470,55 +536,17 @@ class JSONSettingRegistry(ASettingRegistry): class AYONSettingsRegistry(JSONSettingRegistry): """Class handling AYON general settings registry. - Attributes: - vendor (str): Name used for path construction. - product (str): Additional name used for path construction. - Args: name (Optional[str]): Name of the registry. """ def __init__(self, name=None): - self.vendor = "Ynput" - self.product = "AYON" if not name: name = "AYON_settings" - path = appdirs.user_data_dir(self.product, self.vendor) + path = get_launcher_storage_dir() super(AYONSettingsRegistry, self).__init__(name, path) -def _create_local_site_id(registry=None): - """Create a local site identifier.""" - from coolname import generate_slug - - if registry is None: - registry = AYONSettingsRegistry() - - new_id = generate_slug(3) - - print("Created local site id \"{}\"".format(new_id)) - - registry.set_item("localId", new_id) - - return new_id - - -def get_ayon_appdirs(*args): - """Local app data directory of AYON client. - - Args: - *args (Iterable[str]): Subdirectories/files in local app data dir. - - Returns: - str: Path to directory/file in local app data dir. - """ - - return os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - *args - ) - - def get_local_site_id(): """Get local site identifier. @@ -529,7 +557,7 @@ def get_local_site_id(): if site_id: return site_id - site_id_path = get_ayon_appdirs("site_id") + site_id_path = get_launcher_local_dir("site_id") if os.path.exists(site_id_path): with open(site_id_path, "r") as stream: site_id = stream.read() @@ -556,11 +584,3 @@ def get_ayon_username(): """ return ayon_api.get_user()["name"] - - -def get_openpype_username(): - return get_ayon_username() - - -OpenPypeSecureRegistry = AYONSecureRegistry -OpenPypeSettingsRegistry = AYONSettingsRegistry diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index 01a6985a25..dc88ec956b 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -38,7 +38,7 @@ class TemplateUnsolved(Exception): ) -class StringTemplate(object): +class StringTemplate: """String that can be formatted.""" def __init__(self, template): if not isinstance(template, str): @@ -410,7 +410,7 @@ class TemplatePartResult: self._invalid_types[key] = type(value) -class FormatObject(object): +class FormatObject: """Object that can be used for formatting. This is base that is valid for to be used in 'StringTemplate' value. @@ -460,6 +460,34 @@ class FormattingPart: return True return False + @staticmethod + def validate_key_is_matched(key): + """Validate that opening has closing at correct place. + Future-proof, only square brackets are currently used in keys. + + Example: + >>> is_matched("[]()()(((([])))") + False + >>> is_matched("[](){{{[]}}}") + True + + Returns: + bool: Openings and closing are valid. + + """ + mapping = dict(zip("({[", ")}]")) + opening = set(mapping.keys()) + closing = set(mapping.values()) + queue = [] + + for letter in key: + if letter in opening: + queue.append(mapping[letter]) + elif letter in closing: + if not queue or letter != queue.pop(): + return False + return not queue + def format(self, data, result): """Format the formattings string. @@ -472,6 +500,12 @@ class FormattingPart: result.add_output(result.realy_used_values[key]) return result + # ensure key is properly formed [({})] properly closed. + if not self.validate_key_is_matched(key): + result.add_missing_key(key) + result.add_output(self.template) + return result + # check if key expects subdictionary keys (e.g. project[name]) existence_check = key key_padding = list(KEY_PADDING_PATTERN.findall(existence_check)) diff --git a/client/ayon_core/lib/python_2_comp.py b/client/ayon_core/lib/python_2_comp.py index 091c51a6f6..900db59062 100644 --- a/client/ayon_core/lib/python_2_comp.py +++ b/client/ayon_core/lib/python_2_comp.py @@ -1,44 +1,17 @@ +# Deprecated file +# - the file container 'WeakMethod' implementation for Python 2 which is not +# needed anymore. +import warnings import weakref -WeakMethod = getattr(weakref, "WeakMethod", None) +WeakMethod = weakref.WeakMethod -if WeakMethod is None: - class _WeakCallable: - def __init__(self, obj, func): - self.im_self = obj - self.im_func = func - - def __call__(self, *args, **kws): - if self.im_self is None: - return self.im_func(*args, **kws) - else: - return self.im_func(self.im_self, *args, **kws) - - - class WeakMethod: - """ Wraps a function or, more importantly, a bound method in - a way that allows a bound method's object to be GCed, while - providing the same interface as a normal weak reference. """ - - def __init__(self, fn): - try: - self._obj = weakref.ref(fn.im_self) - self._meth = fn.im_func - except AttributeError: - # It's not a bound method - self._obj = None - self._meth = fn - - def __call__(self): - if self._dead(): - return None - return _WeakCallable(self._getobj(), self._meth) - - def _dead(self): - return self._obj is not None and self._obj() is None - - def _getobj(self): - if self._obj is None: - return None - return self._obj() +warnings.warn( + ( + "'ayon_core.lib.python_2_comp' is deprecated." + "Please use 'weakref.WeakMethod'." + ), + DeprecationWarning, + stacklevel=2 +) diff --git a/client/ayon_core/lib/python_module_tools.py b/client/ayon_core/lib/python_module_tools.py index cb6e4c14c4..d146e069a9 100644 --- a/client/ayon_core/lib/python_module_tools.py +++ b/client/ayon_core/lib/python_module_tools.py @@ -5,43 +5,30 @@ import importlib import inspect import logging -import six - log = logging.getLogger(__name__) def import_filepath(filepath, module_name=None): """Import python file as python module. - Python 2 and Python 3 compatibility. - Args: - filepath(str): Path to python file. - module_name(str): Name of loaded module. Only for Python 3. By default + filepath (str): Path to python file. + module_name (str): Name of loaded module. Only for Python 3. By default is filled with filename of filepath. + """ if module_name is None: module_name = os.path.splitext(os.path.basename(filepath))[0] - # Make sure it is not 'unicode' in Python 2 - module_name = str(module_name) - # Prepare module object where content of file will be parsed module = types.ModuleType(module_name) module.__file__ = filepath - if six.PY3: - # Use loader so module has full specs - module_loader = importlib.machinery.SourceFileLoader( - module_name, filepath - ) - module_loader.exec_module(module) - else: - # Execute module code and store content to module - with open(filepath) as _stream: - # Execute content and store it to module object - six.exec_(_stream.read(), module.__dict__) - + # Use loader so module has full specs + module_loader = importlib.machinery.SourceFileLoader( + module_name, filepath + ) + module_loader.exec_module(module) return module @@ -139,35 +126,31 @@ def classes_from_module(superclass, module): return classes -def _import_module_from_dirpath_py2(dirpath, module_name, dst_module_name): - """Import passed dirpath as python module using `imp`.""" +def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None): + """Import passed directory as a python module. + + Imported module can be assigned as a child attribute of already loaded + module from `sys.modules` if has support of `setattr`. That is not default + behavior of python modules so parent module must be a custom module with + that ability. + + It is not possible to reimport already cached module. If you need to + reimport module you have to remove it from caches manually. + + Args: + dirpath (str): Parent directory path of loaded folder. + folder_name (str): Folder name which should be imported inside passed + directory. + dst_module_name (str): Parent module name under which can be loaded + module added. + + """ + # Import passed dirpath as python module if dst_module_name: - full_module_name = "{}.{}".format(dst_module_name, module_name) + full_module_name = "{}.{}".format(dst_module_name, folder_name) dst_module = sys.modules[dst_module_name] else: - full_module_name = module_name - dst_module = None - - if full_module_name in sys.modules: - return sys.modules[full_module_name] - - import imp - - fp, pathname, description = imp.find_module(module_name, [dirpath]) - module = imp.load_module(full_module_name, fp, pathname, description) - if dst_module is not None: - setattr(dst_module, module_name, module) - - return module - - -def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name): - """Import passed dirpath as python module using Python 3 modules.""" - if dst_module_name: - full_module_name = "{}.{}".format(dst_module_name, module_name) - dst_module = sys.modules[dst_module_name] - else: - full_module_name = module_name + full_module_name = folder_name dst_module = None # Skip import if is already imported @@ -191,7 +174,7 @@ def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name): # Store module to destination module and `sys.modules` # WARNING this mus be done before module execution if dst_module is not None: - setattr(dst_module, module_name, module) + setattr(dst_module, folder_name, module) sys.modules[full_module_name] = module @@ -201,37 +184,6 @@ def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name): return module -def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None): - """Import passed directory as a python module. - - Python 2 and 3 compatible. - - Imported module can be assigned as a child attribute of already loaded - module from `sys.modules` if has support of `setattr`. That is not default - behavior of python modules so parent module must be a custom module with - that ability. - - It is not possible to reimport already cached module. If you need to - reimport module you have to remove it from caches manually. - - Args: - dirpath(str): Parent directory path of loaded folder. - folder_name(str): Folder name which should be imported inside passed - directory. - dst_module_name(str): Parent module name under which can be loaded - module added. - """ - if six.PY3: - module = _import_module_from_dirpath_py3( - dirpath, folder_name, dst_module_name - ) - else: - module = _import_module_from_dirpath_py2( - dirpath, folder_name, dst_module_name - ) - return module - - def is_func_signature_supported(func, *args, **kwargs): """Check if a function signature supports passed args and kwargs. @@ -275,25 +227,12 @@ def is_func_signature_supported(func, *args, **kwargs): Returns: bool: Function can pass in arguments. + """ - - if hasattr(inspect, "signature"): - # Python 3 using 'Signature' object where we try to bind arg - # or kwarg. Using signature is recommended approach based on - # documentation. - sig = inspect.signature(func) - try: - sig.bind(*args, **kwargs) - return True - except TypeError: - pass - - else: - # In Python 2 'signature' is not available so 'getcallargs' is used - # - 'getcallargs' is marked as deprecated since Python 3.0 - try: - inspect.getcallargs(func, *args, **kwargs) - return True - except TypeError: - pass + sig = inspect.signature(func) + try: + sig.bind(*args, **kwargs) + return True + except TypeError: + pass return False diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 905a2f03b1..9ff824f395 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -6,6 +6,7 @@ import collections import tempfile import subprocess import platform +from typing import Optional import xml.etree.ElementTree @@ -1583,3 +1584,87 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None): input_arg += ":ch={}".format(input_channels_str) return input_arg, channels_arg + + +def _get_media_mime_type_from_ftyp(content): + if content[8:10] == b"qt": + return "video/quicktime" + + if content[8:12] == b"isom": + return "video/mp4" + if content[8:12] in (b"M4V\x20", b"mp42"): + return "video/mp4v" + # ( + # b"avc1", b"iso2", b"isom", b"mmp4", b"mp41", b"mp71", + # b"msnv", b"ndas", b"ndsc", b"ndsh", b"ndsm", b"ndsp", b"ndss", + # b"ndxc", b"ndxh", b"ndxm", b"ndxp", b"ndxs" + # ) + return None + + +def get_media_mime_type(filepath: str) -> Optional[str]: + """Determine Mime-Type of a file. + + Args: + filepath (str): Path to file. + + Returns: + Optional[str]: Mime type or None if is unknown mime type. + + """ + if not filepath or not os.path.exists(filepath): + return None + + with open(filepath, "rb") as stream: + content = stream.read() + + content_len = len(content) + # Pre-validation (largest definition check) + # - hopefully there cannot be media defined in less than 12 bytes + if content_len < 12: + return None + + # FTYP + if content[4:8] == b"ftyp": + return _get_media_mime_type_from_ftyp(content) + + # BMP + if content[0:2] == b"BM": + return "image/bmp" + + # Tiff + if content[0:2] in (b"MM", b"II"): + return "tiff" + + # PNG + if content[0:4] == b"\211PNG": + return "image/png" + + # SVG + if b'xmlns="http://www.w3.org/2000/svg"' in content: + return "image/svg+xml" + + # JPEG, JFIF or Exif + if ( + content[0:4] == b"\xff\xd8\xff\xdb" + or content[6:10] in (b"JFIF", b"Exif") + ): + return "image/jpeg" + + # Webp + if content[0:4] == b"RIFF" and content[8:12] == b"WEBP": + return "image/webp" + + # Gif + if content[0:6] in (b"GIF87a", b"GIF89a"): + return "gif" + + # Adobe PhotoShop file (8B > Adobe, PS > PhotoShop) + if content[0:4] == b"8BPS": + return "image/vnd.adobe.photoshop" + + # Windows ICO > this might be wild guess as multiple files can start + # with this header + if content[0:4] == b"\x00\x00\x01\x00": + return "image/x-icon" + return None diff --git a/client/ayon_core/modules/__init__.py b/client/ayon_core/modules/__init__.py index f4e381f4a0..e69de29bb2 100644 --- a/client/ayon_core/modules/__init__.py +++ b/client/ayon_core/modules/__init__.py @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -from . import click_wrap -from .interfaces import ( - IPluginPaths, - ITrayAddon, - ITrayModule, - ITrayAction, - ITrayService, - IHostAddon, -) - -from .base import ( - AYONAddon, - OpenPypeModule, - OpenPypeAddOn, - - load_modules, - - ModulesManager, -) - - -__all__ = ( - "click_wrap", - - "IPluginPaths", - "ITrayAddon", - "ITrayModule", - "ITrayAction", - "ITrayService", - "IHostAddon", - - "AYONAddon", - "OpenPypeModule", - "OpenPypeAddOn", - - "load_modules", - - "ModulesManager", -) diff --git a/client/ayon_core/modules/base.py b/client/ayon_core/modules/base.py deleted file mode 100644 index df412d141e..0000000000 --- a/client/ayon_core/modules/base.py +++ /dev/null @@ -1,25 +0,0 @@ -# Backwards compatibility support -# - TODO should be removed before release 1.0.0 -from ayon_core.addon import ( - AYONAddon, - AddonsManager, - load_addons, -) -from ayon_core.addon.base import ( - OpenPypeModule, - OpenPypeAddOn, -) - -ModulesManager = AddonsManager -load_modules = load_addons - - -__all__ = ( - "AYONAddon", - "AddonsManager", - "load_addons", - "OpenPypeModule", - "OpenPypeAddOn", - "ModulesManager", - "load_modules", -) diff --git a/client/ayon_core/modules/click_wrap.py b/client/ayon_core/modules/click_wrap.py deleted file mode 100644 index 8f68de187a..0000000000 --- a/client/ayon_core/modules/click_wrap.py +++ /dev/null @@ -1 +0,0 @@ -from ayon_core.addon.click_wrap import * diff --git a/client/ayon_core/modules/interfaces.py b/client/ayon_core/modules/interfaces.py deleted file mode 100644 index 4b114b7a0e..0000000000 --- a/client/ayon_core/modules/interfaces.py +++ /dev/null @@ -1,21 +0,0 @@ -from ayon_core.addon.interfaces import ( - IPluginPaths, - ITrayAddon, - ITrayAction, - ITrayService, - IHostAddon, -) - -ITrayModule = ITrayAddon -ILaunchHookPaths = object - - -__all__ = ( - "IPluginPaths", - "ITrayAddon", - "ITrayAction", - "ITrayService", - "IHostAddon", - "ITrayModule", - "ILaunchHookPaths", -) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 8fd00ee6b6..e0b7d4acae 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -55,7 +55,6 @@ from .publish import ( PublishXmlValidationError, KnownPublishError, AYONPyblishPluginMixin, - OpenPypePyblishPluginMixin, OptionalPyblishPluginMixin, ) @@ -77,7 +76,6 @@ from .actions import ( from .context_tools import ( install_ayon_plugins, - install_openpype_plugins, install_host, uninstall_host, is_installed, @@ -168,7 +166,6 @@ __all__ = ( "PublishXmlValidationError", "KnownPublishError", "AYONPyblishPluginMixin", - "OpenPypePyblishPluginMixin", "OptionalPyblishPluginMixin", # --- Actions --- @@ -187,7 +184,6 @@ __all__ = ( # --- Process context --- "install_ayon_plugins", - "install_openpype_plugins", "install_host", "uninstall_host", "is_installed", diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 8b72405048..5c14cf20e6 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -234,16 +234,6 @@ def install_ayon_plugins(project_name=None, host_name=None): register_inventory_action_path(path) -def install_openpype_plugins(project_name=None, host_name=None): - """Install AYON core plugins and make sure the core is initialized. - - Deprecated: - Use `install_ayon_plugins` instead. - - """ - install_ayon_plugins(project_name, host_name) - - def uninstall_host(): """Undo all of what `install()` did""" host = registered_host() diff --git a/client/ayon_core/pipeline/create/__init__.py b/client/ayon_core/pipeline/create/__init__.py index da9cafad5a..ced43528eb 100644 --- a/client/ayon_core/pipeline/create/__init__.py +++ b/client/ayon_core/pipeline/create/__init__.py @@ -4,21 +4,41 @@ from .constants import ( PRE_CREATE_THUMBNAIL_KEY, DEFAULT_VARIANT_VALUE, ) - +from .exceptions import ( + UnavailableSharedData, + ImmutableKeyError, + HostMissRequiredMethod, + ConvertorsOperationFailed, + ConvertorsFindFailed, + ConvertorsConversionFailed, + CreatorError, + CreatorsCreateFailed, + CreatorsCollectionFailed, + CreatorsSaveFailed, + CreatorsRemoveFailed, + CreatorsOperationFailed, + TaskNotSetError, + TemplateFillError, +) +from .structures import ( + CreatedInstance, + ConvertorItem, + AttributeValues, + CreatorAttributeValues, + PublishAttributeValues, + PublishAttributes, +) from .utils import ( get_last_versions_for_instances, get_next_versions_for_instances, ) from .product_name import ( - TaskNotSetError, get_product_name, get_product_name_template, ) from .creator_plugins import ( - CreatorError, - BaseCreator, Creator, AutoCreator, @@ -36,10 +56,7 @@ from .creator_plugins import ( cache_and_get_instances, ) -from .context import ( - CreatedInstance, - CreateContext -) +from .context import CreateContext from .legacy_create import ( LegacyCreator, @@ -53,10 +70,31 @@ __all__ = ( "PRE_CREATE_THUMBNAIL_KEY", "DEFAULT_VARIANT_VALUE", + "UnavailableSharedData", + "ImmutableKeyError", + "HostMissRequiredMethod", + "ConvertorsOperationFailed", + "ConvertorsFindFailed", + "ConvertorsConversionFailed", + "CreatorError", + "CreatorsCreateFailed", + "CreatorsCollectionFailed", + "CreatorsSaveFailed", + "CreatorsRemoveFailed", + "CreatorsOperationFailed", + "TaskNotSetError", + "TemplateFillError", + + "CreatedInstance", + "ConvertorItem", + "AttributeValues", + "CreatorAttributeValues", + "PublishAttributeValues", + "PublishAttributes", + "get_last_versions_for_instances", "get_next_versions_for_instances", - "TaskNotSetError", "get_product_name", "get_product_name_template", @@ -78,7 +116,6 @@ __all__ = ( "cache_and_get_instances", - "CreatedInstance", "CreateContext", "LegacyCreator", diff --git a/client/ayon_core/pipeline/create/changes.py b/client/ayon_core/pipeline/create/changes.py new file mode 100644 index 0000000000..c8b81cac48 --- /dev/null +++ b/client/ayon_core/pipeline/create/changes.py @@ -0,0 +1,313 @@ +import copy + +_EMPTY_VALUE = object() + + +class TrackChangesItem: + """Helper object to track changes in data. + + Has access to full old and new data and will create deep copy of them, + so it is not needed to create copy before passed in. + + Can work as a dictionary if old or new value is a dictionary. In + that case received object is another object of 'TrackChangesItem'. + + Goal is to be able to get old or new value as was or only changed values + or get information about removed/changed keys, and all of that on + any "dictionary level". + + ``` + # Example of possible usages + >>> old_value = { + ... "key_1": "value_1", + ... "key_2": { + ... "key_sub_1": 1, + ... "key_sub_2": { + ... "enabled": True + ... } + ... }, + ... "key_3": "value_2" + ... } + >>> new_value = { + ... "key_1": "value_1", + ... "key_2": { + ... "key_sub_2": { + ... "enabled": False + ... }, + ... "key_sub_3": 3 + ... }, + ... "key_3": "value_3" + ... } + + >>> changes = TrackChangesItem(old_value, new_value) + >>> changes.changed + True + + >>> changes["key_2"]["key_sub_1"].new_value is None + True + + >>> list(sorted(changes.changed_keys)) + ['key_2', 'key_3'] + + >>> changes["key_2"]["key_sub_2"]["enabled"].changed + True + + >>> changes["key_2"].removed_keys + {'key_sub_1'} + + >>> list(sorted(changes["key_2"].available_keys)) + ['key_sub_1', 'key_sub_2', 'key_sub_3'] + + >>> changes.new_value == new_value + True + + # Get only changed values + only_changed_new_values = { + key: changes[key].new_value + for key in changes.changed_keys + } + ``` + + Args: + old_value (Any): Old value. + new_value (Any): New value. + """ + + def __init__(self, old_value, new_value): + self._changed = old_value != new_value + # Resolve if value is '_EMPTY_VALUE' after comparison of the values + if old_value is _EMPTY_VALUE: + old_value = None + if new_value is _EMPTY_VALUE: + new_value = None + self._old_value = copy.deepcopy(old_value) + self._new_value = copy.deepcopy(new_value) + + self._old_is_dict = isinstance(old_value, dict) + self._new_is_dict = isinstance(new_value, dict) + + self._old_keys = None + self._new_keys = None + self._available_keys = None + self._removed_keys = None + + self._changed_keys = None + + self._sub_items = None + + def __getitem__(self, key): + """Getter looks into subitems if object is dictionary.""" + + if self._sub_items is None: + self._prepare_sub_items() + return self._sub_items[key] + + def __bool__(self): + """Boolean of object is if old and new value are the same.""" + + return self._changed + + def get(self, key, default=None): + """Try to get sub item.""" + + if self._sub_items is None: + self._prepare_sub_items() + return self._sub_items.get(key, default) + + @property + def old_value(self): + """Get copy of old value. + + Returns: + Any: Whatever old value was. + """ + + return copy.deepcopy(self._old_value) + + @property + def new_value(self): + """Get copy of new value. + + Returns: + Any: Whatever new value was. + """ + + return copy.deepcopy(self._new_value) + + @property + def changed(self): + """Value changed. + + Returns: + bool: If data changed. + """ + + return self._changed + + @property + def is_dict(self): + """Object can be used as dictionary. + + Returns: + bool: When can be used that way. + """ + + return self._old_is_dict or self._new_is_dict + + @property + def changes(self): + """Get changes in raw data. + + This method should be used only if 'is_dict' value is 'True'. + + Returns: + Dict[str, Tuple[Any, Any]]: Changes are by key in tuple + (, ). If 'is_dict' is 'False' then + output is always empty dictionary. + """ + + output = {} + if not self.is_dict: + return output + + old_value = self.old_value + new_value = self.new_value + for key in self.changed_keys: + _old = None + _new = None + if self._old_is_dict: + _old = old_value.get(key) + if self._new_is_dict: + _new = new_value.get(key) + output[key] = (_old, _new) + return output + + # Methods/properties that can be used when 'is_dict' is 'True' + @property + def old_keys(self): + """Keys from old value. + + Empty set is returned if old value is not a dict. + + Returns: + Set[str]: Keys from old value. + """ + + if self._old_keys is None: + self._prepare_keys() + return set(self._old_keys) + + @property + def new_keys(self): + """Keys from new value. + + Empty set is returned if old value is not a dict. + + Returns: + Set[str]: Keys from new value. + """ + + if self._new_keys is None: + self._prepare_keys() + return set(self._new_keys) + + @property + def changed_keys(self): + """Keys that has changed from old to new value. + + Empty set is returned if both old and new value are not a dict. + + Returns: + Set[str]: Keys of changed keys. + """ + + if self._changed_keys is None: + self._prepare_sub_items() + return set(self._changed_keys) + + @property + def available_keys(self): + """All keys that are available in old and new value. + + Empty set is returned if both old and new value are not a dict. + Output is Union of 'old_keys' and 'new_keys'. + + Returns: + Set[str]: All keys from old and new value. + """ + + if self._available_keys is None: + self._prepare_keys() + return set(self._available_keys) + + @property + def removed_keys(self): + """Key that are not available in new value but were in old value. + + Returns: + Set[str]: All removed keys. + """ + + if self._removed_keys is None: + self._prepare_sub_items() + return set(self._removed_keys) + + def _prepare_keys(self): + old_keys = set() + new_keys = set() + if self._old_is_dict and self._new_is_dict: + old_keys = set(self._old_value.keys()) + new_keys = set(self._new_value.keys()) + + elif self._old_is_dict: + old_keys = set(self._old_value.keys()) + + elif self._new_is_dict: + new_keys = set(self._new_value.keys()) + + self._old_keys = old_keys + self._new_keys = new_keys + self._available_keys = old_keys | new_keys + self._removed_keys = old_keys - new_keys + + def _prepare_sub_items(self): + sub_items = {} + changed_keys = set() + + old_keys = self.old_keys + new_keys = self.new_keys + new_value = self.new_value + old_value = self.old_value + if self._old_is_dict and self._new_is_dict: + for key in self.available_keys: + item = TrackChangesItem( + old_value.get(key), new_value.get(key) + ) + sub_items[key] = item + if item.changed or key not in old_keys or key not in new_keys: + changed_keys.add(key) + + elif self._old_is_dict: + old_keys = set(old_value.keys()) + available_keys = set(old_keys) + changed_keys = set(available_keys) + for key in available_keys: + # NOTE Use '_EMPTY_VALUE' because old value could be 'None' + # which would result in "unchanged" item + sub_items[key] = TrackChangesItem( + old_value.get(key), _EMPTY_VALUE + ) + + elif self._new_is_dict: + new_keys = set(new_value.keys()) + available_keys = set(new_keys) + changed_keys = set(available_keys) + for key in available_keys: + # NOTE Use '_EMPTY_VALUE' because new value could be 'None' + # which would result in "unchanged" item + sub_items[key] = TrackChangesItem( + _EMPTY_VALUE, new_value.get(key) + ) + + self._sub_items = sub_items + self._changed_keys = changed_keys diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 1c64d22733..7706860499 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -5,9 +5,9 @@ import logging import traceback import collections import inspect -from uuid import uuid4 from contextlib import contextmanager -from typing import Optional +import typing +from typing import Optional, Iterable, Dict import pyblish.logic import pyblish.api @@ -15,26 +15,44 @@ import ayon_api from ayon_core.settings import get_project_settings from ayon_core.lib import is_func_signature_supported -from ayon_core.lib.attribute_definitions import ( - UnknownDef, - serialize_attr_defs, - deserialize_attr_defs, - get_default_values, -) +from ayon_core.lib.attribute_definitions import get_default_values from ayon_core.host import IPublishHost, IWorkfileHost -from ayon_core.pipeline import ( - Anatomy, - AYON_INSTANCE_ID, - AVALON_INSTANCE_ID, -) +from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import DiscoverResult +from .exceptions import ( + CreatorError, + CreatorsCreateFailed, + CreatorsCollectionFailed, + CreatorsSaveFailed, + CreatorsRemoveFailed, + ConvertorsFindFailed, + ConvertorsConversionFailed, + UnavailableSharedData, + HostMissRequiredMethod, +) +from .changes import TrackChangesItem +from .structures import PublishAttributes, ConvertorItem, InstanceContextInfo from .creator_plugins import ( Creator, AutoCreator, discover_creator_plugins, discover_convertor_plugins, - CreatorError, +) +if typing.TYPE_CHECKING: + from .structures import CreatedInstance + +# Import of functions and classes that were moved to different file +# TODO Should be removed in future release - Added 24/08/28, 0.4.3-dev.1 +from .exceptions import ( + ImmutableKeyError, # noqa: F401 + CreatorsOperationFailed, # noqa: F401 + ConvertorsOperationFailed, # noqa: F401 +) +from .structures import ( + AttributeValues, # noqa: F401 + CreatorAttributeValues, # noqa: F401 + PublishAttributeValues, # noqa: F401 ) # Changes of instances and context are send as tuple of 2 information @@ -42,68 +60,6 @@ UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"]) _NOT_SET = object() -class UnavailableSharedData(Exception): - """Shared data are not available at the moment when are accessed.""" - pass - - -class ImmutableKeyError(TypeError): - """Accessed key is immutable so does not allow changes or removals.""" - - def __init__(self, key, msg=None): - self.immutable_key = key - if not msg: - msg = "Key \"{}\" is immutable and does not allow changes.".format( - key - ) - super(ImmutableKeyError, self).__init__(msg) - - -class HostMissRequiredMethod(Exception): - """Host does not have implemented required functions for creation.""" - - def __init__(self, host, missing_methods): - self.missing_methods = missing_methods - self.host = host - joined_methods = ", ".join( - ['"{}"'.format(name) for name in missing_methods] - ) - dirpath = os.path.dirname( - os.path.normpath(inspect.getsourcefile(host)) - ) - dirpath_parts = dirpath.split(os.path.sep) - host_name = dirpath_parts.pop(-1) - if host_name == "api": - host_name = dirpath_parts.pop(-1) - - msg = "Host \"{}\" does not have implemented method/s {}".format( - host_name, joined_methods - ) - super(HostMissRequiredMethod, self).__init__(msg) - - -class ConvertorsOperationFailed(Exception): - def __init__(self, msg, failed_info): - super(ConvertorsOperationFailed, self).__init__(msg) - self.failed_info = failed_info - - -class ConvertorsFindFailed(ConvertorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed to find incompatible products" - super(ConvertorsFindFailed, self).__init__( - msg, failed_info - ) - - -class ConvertorsConversionFailed(ConvertorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed to convert incompatible products" - super(ConvertorsConversionFailed, self).__init__( - msg, failed_info - ) - - def prepare_failed_convertor_operation_info(identifier, exc_info): exc_type, exc_value, exc_traceback = exc_info formatted_traceback = "".join(traceback.format_exception( @@ -117,59 +73,6 @@ def prepare_failed_convertor_operation_info(identifier, exc_info): } -class CreatorsOperationFailed(Exception): - """Raised when a creator process crashes in 'CreateContext'. - - The exception contains information about the creator and error. The data - are prepared using 'prepare_failed_creator_operation_info' and can be - serialized using json. - - Usage is for UI purposes which may not have access to exceptions directly - and would not have ability to catch exceptions 'per creator'. - - Args: - msg (str): General error message. - failed_info (list[dict[str, Any]]): List of failed creators with - exception message and optionally formatted traceback. - """ - - def __init__(self, msg, failed_info): - super(CreatorsOperationFailed, self).__init__(msg) - self.failed_info = failed_info - - -class CreatorsCollectionFailed(CreatorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed to collect instances" - super(CreatorsCollectionFailed, self).__init__( - msg, failed_info - ) - - -class CreatorsSaveFailed(CreatorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed update instance changes" - super(CreatorsSaveFailed, self).__init__( - msg, failed_info - ) - - -class CreatorsRemoveFailed(CreatorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed to remove instances" - super(CreatorsRemoveFailed, self).__init__( - msg, failed_info - ) - - -class CreatorsCreateFailed(CreatorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed to create instances" - super(CreatorsCreateFailed, self).__init__( - msg, failed_info - ) - - def prepare_failed_creator_operation_info( identifier, label, exc_info, add_traceback=True ): @@ -188,1173 +91,6 @@ def prepare_failed_creator_operation_info( } -_EMPTY_VALUE = object() - - -class TrackChangesItem(object): - """Helper object to track changes in data. - - Has access to full old and new data and will create deep copy of them, - so it is not needed to create copy before passed in. - - Can work as a dictionary if old or new value is a dictionary. In - that case received object is another object of 'TrackChangesItem'. - - Goal is to be able to get old or new value as was or only changed values - or get information about removed/changed keys, and all of that on - any "dictionary level". - - ``` - # Example of possible usages - >>> old_value = { - ... "key_1": "value_1", - ... "key_2": { - ... "key_sub_1": 1, - ... "key_sub_2": { - ... "enabled": True - ... } - ... }, - ... "key_3": "value_2" - ... } - >>> new_value = { - ... "key_1": "value_1", - ... "key_2": { - ... "key_sub_2": { - ... "enabled": False - ... }, - ... "key_sub_3": 3 - ... }, - ... "key_3": "value_3" - ... } - - >>> changes = TrackChangesItem(old_value, new_value) - >>> changes.changed - True - - >>> changes["key_2"]["key_sub_1"].new_value is None - True - - >>> list(sorted(changes.changed_keys)) - ['key_2', 'key_3'] - - >>> changes["key_2"]["key_sub_2"]["enabled"].changed - True - - >>> changes["key_2"].removed_keys - {'key_sub_1'} - - >>> list(sorted(changes["key_2"].available_keys)) - ['key_sub_1', 'key_sub_2', 'key_sub_3'] - - >>> changes.new_value == new_value - True - - # Get only changed values - only_changed_new_values = { - key: changes[key].new_value - for key in changes.changed_keys - } - ``` - - Args: - old_value (Any): Old value. - new_value (Any): New value. - """ - - def __init__(self, old_value, new_value): - self._changed = old_value != new_value - # Resolve if value is '_EMPTY_VALUE' after comparison of the values - if old_value is _EMPTY_VALUE: - old_value = None - if new_value is _EMPTY_VALUE: - new_value = None - self._old_value = copy.deepcopy(old_value) - self._new_value = copy.deepcopy(new_value) - - self._old_is_dict = isinstance(old_value, dict) - self._new_is_dict = isinstance(new_value, dict) - - self._old_keys = None - self._new_keys = None - self._available_keys = None - self._removed_keys = None - - self._changed_keys = None - - self._sub_items = None - - def __getitem__(self, key): - """Getter looks into subitems if object is dictionary.""" - - if self._sub_items is None: - self._prepare_sub_items() - return self._sub_items[key] - - def __bool__(self): - """Boolean of object is if old and new value are the same.""" - - return self._changed - - def get(self, key, default=None): - """Try to get sub item.""" - - if self._sub_items is None: - self._prepare_sub_items() - return self._sub_items.get(key, default) - - @property - def old_value(self): - """Get copy of old value. - - Returns: - Any: Whatever old value was. - """ - - return copy.deepcopy(self._old_value) - - @property - def new_value(self): - """Get copy of new value. - - Returns: - Any: Whatever new value was. - """ - - return copy.deepcopy(self._new_value) - - @property - def changed(self): - """Value changed. - - Returns: - bool: If data changed. - """ - - return self._changed - - @property - def is_dict(self): - """Object can be used as dictionary. - - Returns: - bool: When can be used that way. - """ - - return self._old_is_dict or self._new_is_dict - - @property - def changes(self): - """Get changes in raw data. - - This method should be used only if 'is_dict' value is 'True'. - - Returns: - Dict[str, Tuple[Any, Any]]: Changes are by key in tuple - (, ). If 'is_dict' is 'False' then - output is always empty dictionary. - """ - - output = {} - if not self.is_dict: - return output - - old_value = self.old_value - new_value = self.new_value - for key in self.changed_keys: - _old = None - _new = None - if self._old_is_dict: - _old = old_value.get(key) - if self._new_is_dict: - _new = new_value.get(key) - output[key] = (_old, _new) - return output - - # Methods/properties that can be used when 'is_dict' is 'True' - @property - def old_keys(self): - """Keys from old value. - - Empty set is returned if old value is not a dict. - - Returns: - Set[str]: Keys from old value. - """ - - if self._old_keys is None: - self._prepare_keys() - return set(self._old_keys) - - @property - def new_keys(self): - """Keys from new value. - - Empty set is returned if old value is not a dict. - - Returns: - Set[str]: Keys from new value. - """ - - if self._new_keys is None: - self._prepare_keys() - return set(self._new_keys) - - @property - def changed_keys(self): - """Keys that has changed from old to new value. - - Empty set is returned if both old and new value are not a dict. - - Returns: - Set[str]: Keys of changed keys. - """ - - if self._changed_keys is None: - self._prepare_sub_items() - return set(self._changed_keys) - - @property - def available_keys(self): - """All keys that are available in old and new value. - - Empty set is returned if both old and new value are not a dict. - Output is Union of 'old_keys' and 'new_keys'. - - Returns: - Set[str]: All keys from old and new value. - """ - - if self._available_keys is None: - self._prepare_keys() - return set(self._available_keys) - - @property - def removed_keys(self): - """Key that are not available in new value but were in old value. - - Returns: - Set[str]: All removed keys. - """ - - if self._removed_keys is None: - self._prepare_sub_items() - return set(self._removed_keys) - - def _prepare_keys(self): - old_keys = set() - new_keys = set() - if self._old_is_dict and self._new_is_dict: - old_keys = set(self._old_value.keys()) - new_keys = set(self._new_value.keys()) - - elif self._old_is_dict: - old_keys = set(self._old_value.keys()) - - elif self._new_is_dict: - new_keys = set(self._new_value.keys()) - - self._old_keys = old_keys - self._new_keys = new_keys - self._available_keys = old_keys | new_keys - self._removed_keys = old_keys - new_keys - - def _prepare_sub_items(self): - sub_items = {} - changed_keys = set() - - old_keys = self.old_keys - new_keys = self.new_keys - new_value = self.new_value - old_value = self.old_value - if self._old_is_dict and self._new_is_dict: - for key in self.available_keys: - item = TrackChangesItem( - old_value.get(key), new_value.get(key) - ) - sub_items[key] = item - if item.changed or key not in old_keys or key not in new_keys: - changed_keys.add(key) - - elif self._old_is_dict: - old_keys = set(old_value.keys()) - available_keys = set(old_keys) - changed_keys = set(available_keys) - for key in available_keys: - # NOTE Use '_EMPTY_VALUE' because old value could be 'None' - # which would result in "unchanged" item - sub_items[key] = TrackChangesItem( - old_value.get(key), _EMPTY_VALUE - ) - - elif self._new_is_dict: - new_keys = set(new_value.keys()) - available_keys = set(new_keys) - changed_keys = set(available_keys) - for key in available_keys: - # NOTE Use '_EMPTY_VALUE' because new value could be 'None' - # which would result in "unchanged" item - sub_items[key] = TrackChangesItem( - _EMPTY_VALUE, new_value.get(key) - ) - - self._sub_items = sub_items - self._changed_keys = changed_keys - - -class InstanceMember: - """Representation of instance member. - - TODO: - Implement and use! - """ - - def __init__(self, instance, name): - self.instance = instance - - instance.add_members(self) - - self.name = name - self._actions = [] - - def add_action(self, label, callback): - self._actions.append({ - "label": label, - "callback": callback - }) - - -class AttributeValues(object): - """Container which keep values of Attribute definitions. - - Goal is to have one object which hold values of attribute definitions for - single instance. - - Has dictionary like methods. Not all of them are allowed all the time. - - Args: - attr_defs(AbstractAttrDef): Definitions of value type and properties. - values(dict): Values after possible conversion. - origin_data(dict): Values loaded from host before conversion. - """ - - def __init__(self, attr_defs, values, origin_data=None): - if origin_data is None: - origin_data = copy.deepcopy(values) - self._origin_data = origin_data - - attr_defs_by_key = { - attr_def.key: attr_def - for attr_def in attr_defs - if attr_def.is_value_def - } - for key, value in values.items(): - if key not in attr_defs_by_key: - new_def = UnknownDef(key, label=key, default=value) - attr_defs.append(new_def) - attr_defs_by_key[key] = new_def - - self._attr_defs = attr_defs - self._attr_defs_by_key = attr_defs_by_key - - self._data = {} - for attr_def in attr_defs: - value = values.get(attr_def.key) - if value is not None: - self._data[attr_def.key] = value - - def __setitem__(self, key, value): - if key not in self._attr_defs_by_key: - raise KeyError("Key \"{}\" was not found.".format(key)) - - old_value = self._data.get(key) - if old_value == value: - return - self._data[key] = value - - def __getitem__(self, key): - if key not in self._attr_defs_by_key: - return self._data[key] - return self._data.get(key, self._attr_defs_by_key[key].default) - - def __contains__(self, key): - return key in self._attr_defs_by_key - - def get(self, key, default=None): - if key in self._attr_defs_by_key: - return self[key] - return default - - def keys(self): - return self._attr_defs_by_key.keys() - - def values(self): - for key in self._attr_defs_by_key.keys(): - yield self._data.get(key) - - def items(self): - for key in self._attr_defs_by_key.keys(): - yield key, self._data.get(key) - - def update(self, value): - for _key, _value in dict(value): - self[_key] = _value - - def pop(self, key, default=None): - value = self._data.pop(key, default) - # Remove attribute definition if is 'UnknownDef' - # - gives option to get rid of unknown values - attr_def = self._attr_defs_by_key.get(key) - if isinstance(attr_def, UnknownDef): - self._attr_defs_by_key.pop(key) - self._attr_defs.remove(attr_def) - return value - - def reset_values(self): - self._data = {} - - def mark_as_stored(self): - self._origin_data = copy.deepcopy(self._data) - - @property - def attr_defs(self): - """Pointer to attribute definitions. - - Returns: - List[AbstractAttrDef]: Attribute definitions. - """ - - return list(self._attr_defs) - - @property - def origin_data(self): - return copy.deepcopy(self._origin_data) - - def data_to_store(self): - """Create new dictionary with data to store. - - Returns: - Dict[str, Any]: Attribute values that should be stored. - """ - - output = {} - for key in self._data: - output[key] = self[key] - - for key, attr_def in self._attr_defs_by_key.items(): - if key not in output: - output[key] = attr_def.default - return output - - def get_serialized_attr_defs(self): - """Serialize attribute definitions to json serializable types. - - Returns: - List[Dict[str, Any]]: Serialized attribute definitions. - """ - - return serialize_attr_defs(self._attr_defs) - - -class CreatorAttributeValues(AttributeValues): - """Creator specific attribute values of an instance. - - Args: - instance (CreatedInstance): Instance for which are values hold. - """ - - def __init__(self, instance, *args, **kwargs): - self.instance = instance - super(CreatorAttributeValues, self).__init__(*args, **kwargs) - - -class PublishAttributeValues(AttributeValues): - """Publish plugin specific attribute values. - - Values are for single plugin which can be on `CreatedInstance` - or context values stored on `CreateContext`. - - Args: - publish_attributes(PublishAttributes): Wrapper for multiple publish - attributes is used as parent object. - """ - - def __init__(self, publish_attributes, *args, **kwargs): - self.publish_attributes = publish_attributes - super(PublishAttributeValues, self).__init__(*args, **kwargs) - - @property - def parent(self): - return self.publish_attributes.parent - - -class PublishAttributes: - """Wrapper for publish plugin attribute definitions. - - Cares about handling attribute definitions of multiple publish plugins. - Keep information about attribute definitions and their values. - - Args: - parent(CreatedInstance, CreateContext): Parent for which will be - data stored and from which are data loaded. - origin_data(dict): Loaded data by plugin class name. - attr_plugins(Union[List[pyblish.api.Plugin], None]): List of publish - plugins that may have defined attribute definitions. - """ - - def __init__(self, parent, origin_data, attr_plugins=None): - self.parent = parent - self._origin_data = copy.deepcopy(origin_data) - - attr_plugins = attr_plugins or [] - self.attr_plugins = attr_plugins - - self._data = copy.deepcopy(origin_data) - self._plugin_names_order = [] - self._missing_plugins = [] - - self.set_publish_plugins(attr_plugins) - - def __getitem__(self, key): - return self._data[key] - - def __contains__(self, key): - return key in self._data - - def keys(self): - return self._data.keys() - - def values(self): - return self._data.values() - - def items(self): - return self._data.items() - - def pop(self, key, default=None): - """Remove or reset value for plugin. - - Plugin values are reset to defaults if plugin is available but - data of plugin which was not found are removed. - - Args: - key(str): Plugin name. - default: Default value if plugin was not found. - """ - - if key not in self._data: - return default - - if key in self._missing_plugins: - self._missing_plugins.remove(key) - removed_item = self._data.pop(key) - return removed_item.data_to_store() - - value_item = self._data[key] - # Prepare value to return - output = value_item.data_to_store() - # Reset values - value_item.reset_values() - return output - - def plugin_names_order(self): - """Plugin names order by their 'order' attribute.""" - - for name in self._plugin_names_order: - yield name - - def mark_as_stored(self): - self._origin_data = copy.deepcopy(self.data_to_store()) - - def data_to_store(self): - """Convert attribute values to "data to store".""" - - output = {} - for key, attr_value in self._data.items(): - output[key] = attr_value.data_to_store() - return output - - @property - def origin_data(self): - return copy.deepcopy(self._origin_data) - - def set_publish_plugins(self, attr_plugins): - """Set publish plugins attribute definitions.""" - - self._plugin_names_order = [] - self._missing_plugins = [] - self.attr_plugins = attr_plugins or [] - - origin_data = self._origin_data - data = self._data - self._data = {} - added_keys = set() - for plugin in attr_plugins: - output = plugin.convert_attribute_values(data) - if output is not None: - data = output - attr_defs = plugin.get_attribute_defs() - if not attr_defs: - continue - - key = plugin.__name__ - added_keys.add(key) - self._plugin_names_order.append(key) - - value = data.get(key) or {} - orig_value = copy.deepcopy(origin_data.get(key) or {}) - self._data[key] = PublishAttributeValues( - self, attr_defs, value, orig_value - ) - - for key, value in data.items(): - if key not in added_keys: - self._missing_plugins.append(key) - self._data[key] = PublishAttributeValues( - self, [], value, value - ) - - def serialize_attributes(self): - return { - "attr_defs": { - plugin_name: attrs_value.get_serialized_attr_defs() - for plugin_name, attrs_value in self._data.items() - }, - "plugin_names_order": self._plugin_names_order, - "missing_plugins": self._missing_plugins - } - - def deserialize_attributes(self, data): - self._plugin_names_order = data["plugin_names_order"] - self._missing_plugins = data["missing_plugins"] - - attr_defs = deserialize_attr_defs(data["attr_defs"]) - - origin_data = self._origin_data - data = self._data - self._data = {} - - added_keys = set() - for plugin_name, attr_defs_data in attr_defs.items(): - attr_defs = deserialize_attr_defs(attr_defs_data) - value = data.get(plugin_name) or {} - orig_value = copy.deepcopy(origin_data.get(plugin_name) or {}) - self._data[plugin_name] = PublishAttributeValues( - self, attr_defs, value, orig_value - ) - - for key, value in data.items(): - if key not in added_keys: - self._missing_plugins.append(key) - self._data[key] = PublishAttributeValues( - self, [], value, value - ) - - -class CreatedInstance: - """Instance entity with data that will be stored to workfile. - - I think `data` must be required argument containing all minimum information - about instance like "folderPath" and "task" and all data used for filling - product name as creators may have custom data for product name filling. - - Notes: - Object have 2 possible initialization. One using 'creator' object which - is recommended for api usage. Second by passing information about - creator. - - Args: - product_type (str): Product type that will be created. - product_name (str): Name of product that will be created. - data (Dict[str, Any]): Data used for filling product name or override - data from already existing instance. - creator (Union[BaseCreator, None]): Creator responsible for instance. - creator_identifier (str): Identifier of creator plugin. - creator_label (str): Creator plugin label. - group_label (str): Default group label from creator plugin. - creator_attr_defs (List[AbstractAttrDef]): Attribute definitions from - creator. - """ - - # Keys that can't be changed or removed from data after loading using - # creator. - # - 'creator_attributes' and 'publish_attributes' can change values of - # their individual children but not on their own - __immutable_keys = ( - "id", - "instance_id", - "product_type", - "creator_identifier", - "creator_attributes", - "publish_attributes" - ) - - def __init__( - self, - product_type, - product_name, - data, - creator=None, - creator_identifier=None, - creator_label=None, - group_label=None, - creator_attr_defs=None, - ): - if creator is not None: - creator_identifier = creator.identifier - group_label = creator.get_group_label() - creator_label = creator.label - creator_attr_defs = creator.get_instance_attr_defs() - - self._creator_label = creator_label - self._group_label = group_label or creator_identifier - - # Instance members may have actions on them - # TODO implement members logic - self._members = [] - - # Data that can be used for lifetime of object - self._transient_data = {} - - # Create a copy of passed data to avoid changing them on the fly - data = copy.deepcopy(data or {}) - - # Pop dictionary values that will be converted to objects to be able - # catch changes - orig_creator_attributes = data.pop("creator_attributes", None) or {} - orig_publish_attributes = data.pop("publish_attributes", None) or {} - - # Store original value of passed data - self._orig_data = copy.deepcopy(data) - - # Pop 'productType' and 'productName' to prevent unexpected changes - data.pop("productType", None) - data.pop("productName", None) - # Backwards compatibility with OpenPype instances - data.pop("family", None) - data.pop("subset", None) - - asset_name = data.pop("asset", None) - if "folderPath" not in data: - data["folderPath"] = asset_name - - # QUESTION Does it make sense to have data stored as ordered dict? - self._data = collections.OrderedDict() - # QUESTION Do we need this "id" information on instance? - item_id = data.get("id") - # TODO use only 'AYON_INSTANCE_ID' when all hosts support it - if item_id not in {AYON_INSTANCE_ID, AVALON_INSTANCE_ID}: - item_id = AVALON_INSTANCE_ID - self._data["id"] = item_id - self._data["productType"] = product_type - self._data["productName"] = product_name - self._data["active"] = data.get("active", True) - self._data["creator_identifier"] = creator_identifier - - # Pop from source data all keys that are defined in `_data` before - # this moment and through their values away - # - they should be the same and if are not then should not change - # already set values - for key in self._data.keys(): - if key in data: - data.pop(key) - - self._data["variant"] = self._data.get("variant") or "" - # Stored creator specific attribute values - # {key: value} - creator_values = copy.deepcopy(orig_creator_attributes) - - self._data["creator_attributes"] = CreatorAttributeValues( - self, - list(creator_attr_defs), - creator_values, - orig_creator_attributes - ) - - # Stored publish specific attribute values - # {: {key: value}} - # - must be set using 'set_publish_plugins' - self._data["publish_attributes"] = PublishAttributes( - self, orig_publish_attributes, None - ) - if data: - self._data.update(data) - - if not self._data.get("instance_id"): - self._data["instance_id"] = str(uuid4()) - - self._folder_is_valid = self.has_set_folder - self._task_is_valid = self.has_set_task - - def __str__(self): - return ( - " {data}" - ).format( - creator_identifier=self.creator_identifier, - product={"name": self.product_name, "type": self.product_type}, - data=str(self._data) - ) - - # --- Dictionary like methods --- - def __getitem__(self, key): - return self._data[key] - - def __contains__(self, key): - return key in self._data - - def __setitem__(self, key, value): - # Validate immutable keys - if key not in self.__immutable_keys: - self._data[key] = value - - elif value != self._data.get(key): - # Raise exception if key is immutable and value has changed - raise ImmutableKeyError(key) - - def get(self, key, default=None): - return self._data.get(key, default) - - def pop(self, key, *args, **kwargs): - # Raise exception if is trying to pop key which is immutable - if key in self.__immutable_keys: - raise ImmutableKeyError(key) - - self._data.pop(key, *args, **kwargs) - - def keys(self): - return self._data.keys() - - def values(self): - return self._data.values() - - def items(self): - return self._data.items() - # ------ - - @property - def product_type(self): - return self._data["productType"] - - @property - def product_name(self): - return self._data["productName"] - - @property - def label(self): - label = self._data.get("label") - if not label: - label = self.product_name - return label - - @property - def group_label(self): - label = self._data.get("group") - if label: - return label - return self._group_label - - @property - def origin_data(self): - output = copy.deepcopy(self._orig_data) - output["creator_attributes"] = self.creator_attributes.origin_data - output["publish_attributes"] = self.publish_attributes.origin_data - return output - - @property - def creator_identifier(self): - return self._data["creator_identifier"] - - @property - def creator_label(self): - return self._creator_label or self.creator_identifier - - @property - def id(self): - """Instance identifier. - - Returns: - str: UUID of instance. - """ - - return self._data["instance_id"] - - @property - def data(self): - """Legacy access to data. - - Access to data is needed to modify values. - - Returns: - CreatedInstance: Object can be used as dictionary but with - validations of immutable keys. - """ - - return self - - @property - def transient_data(self): - """Data stored for lifetime of instance object. - - These data are not stored to scene and will be lost on object - deletion. - - Can be used to store objects. In some host implementations is not - possible to reference to object in scene with some unique identifier - (e.g. node in Fusion.). In that case it is handy to store the object - here. Should be used that way only if instance data are stored on the - node itself. - - Returns: - Dict[str, Any]: Dictionary object where you can store data related - to instance for lifetime of instance object. - """ - - return self._transient_data - - def changes(self): - """Calculate and return changes.""" - - return TrackChangesItem(self.origin_data, self.data_to_store()) - - def mark_as_stored(self): - """Should be called when instance data are stored. - - Origin data are replaced by current data so changes are cleared. - """ - - orig_keys = set(self._orig_data.keys()) - for key, value in self._data.items(): - orig_keys.discard(key) - if key in ("creator_attributes", "publish_attributes"): - continue - self._orig_data[key] = copy.deepcopy(value) - - for key in orig_keys: - self._orig_data.pop(key) - - self.creator_attributes.mark_as_stored() - self.publish_attributes.mark_as_stored() - - @property - def creator_attributes(self): - return self._data["creator_attributes"] - - @property - def creator_attribute_defs(self): - """Attribute definitions defined by creator plugin. - - Returns: - List[AbstractAttrDef]: Attribute definitions. - """ - - return self.creator_attributes.attr_defs - - @property - def publish_attributes(self): - return self._data["publish_attributes"] - - def data_to_store(self): - """Collect data that contain json parsable types. - - It is possible to recreate the instance using these data. - - Todos: - We probably don't need OrderedDict. When data are loaded they - are not ordered anymore. - - Returns: - OrderedDict: Ordered dictionary with instance data. - """ - - output = collections.OrderedDict() - for key, value in self._data.items(): - if key in ("creator_attributes", "publish_attributes"): - continue - output[key] = value - - output["creator_attributes"] = self.creator_attributes.data_to_store() - output["publish_attributes"] = self.publish_attributes.data_to_store() - - return output - - @classmethod - def from_existing(cls, instance_data, creator): - """Convert instance data from workfile to CreatedInstance. - - Args: - instance_data (Dict[str, Any]): Data in a structure ready for - 'CreatedInstance' object. - creator (BaseCreator): Creator plugin which is creating the - instance of for which the instance belong. - """ - - instance_data = copy.deepcopy(instance_data) - - product_type = instance_data.get("productType") - if product_type is None: - product_type = instance_data.get("family") - if product_type is None: - product_type = creator.product_type - product_name = instance_data.get("productName") - if product_name is None: - product_name = instance_data.get("subset") - - return cls( - product_type, product_name, instance_data, creator - ) - - def set_publish_plugins(self, attr_plugins): - """Set publish plugins with attribute definitions. - - This method should be called only from 'CreateContext'. - - Args: - attr_plugins (List[pyblish.api.Plugin]): Pyblish plugins which - inherit from 'AYONPyblishPluginMixin' and may contain - attribute definitions. - """ - - self.publish_attributes.set_publish_plugins(attr_plugins) - - def add_members(self, members): - """Currently unused method.""" - - for member in members: - if member not in self._members: - self._members.append(member) - - def serialize_for_remote(self): - """Serialize object into data to be possible recreated object. - - Returns: - Dict[str, Any]: Serialized data. - """ - - creator_attr_defs = self.creator_attributes.get_serialized_attr_defs() - publish_attributes = self.publish_attributes.serialize_attributes() - return { - "data": self.data_to_store(), - "orig_data": self.origin_data, - "creator_attr_defs": creator_attr_defs, - "publish_attributes": publish_attributes, - "creator_label": self._creator_label, - "group_label": self._group_label, - } - - @classmethod - def deserialize_on_remote(cls, serialized_data): - """Convert instance data to CreatedInstance. - - This is fake instance in remote process e.g. in UI process. The creator - is not a full creator and should not be used for calling methods when - instance is created from this method (matters on implementation). - - Args: - serialized_data (Dict[str, Any]): Serialized data for remote - recreating. Should contain 'data' and 'orig_data'. - """ - - instance_data = copy.deepcopy(serialized_data["data"]) - creator_identifier = instance_data["creator_identifier"] - - product_type = instance_data["productType"] - product_name = instance_data.get("productName", None) - - creator_label = serialized_data["creator_label"] - group_label = serialized_data["group_label"] - creator_attr_defs = deserialize_attr_defs( - serialized_data["creator_attr_defs"] - ) - publish_attributes = serialized_data["publish_attributes"] - - obj = cls( - product_type, - product_name, - instance_data, - creator_identifier=creator_identifier, - creator_label=creator_label, - group_label=group_label, - creator_attr_defs=creator_attr_defs - ) - obj._orig_data = serialized_data["orig_data"] - obj.publish_attributes.deserialize_attributes(publish_attributes) - - return obj - - # Context validation related methods/properties - @property - def has_set_folder(self): - """Folder path is set in data.""" - - return "folderPath" in self._data - - @property - def has_set_task(self): - """Task name is set in data.""" - - return "task" in self._data - - @property - def has_valid_context(self): - """Context data are valid for publishing.""" - - return self.has_valid_folder and self.has_valid_task - - @property - def has_valid_folder(self): - """Folder set in context exists in project.""" - - if not self.has_set_folder: - return False - return self._folder_is_valid - - @property - def has_valid_task(self): - """Task set in context exists in project.""" - - if not self.has_set_task: - return False - return self._task_is_valid - - def set_folder_invalid(self, invalid): - # TODO replace with `set_folder_path` - self._folder_is_valid = not invalid - - def set_task_invalid(self, invalid): - # TODO replace with `set_task_name` - self._task_is_valid = not invalid - - -class ConvertorItem(object): - """Item representing convertor plugin. - - Args: - identifier (str): Identifier of convertor. - label (str): Label which will be shown in UI. - """ - - def __init__(self, identifier, label): - self._id = str(uuid4()) - self.identifier = identifier - self.label = label - - @property - def id(self): - return self._id - - def to_data(self): - return { - "id": self.id, - "identifier": self.identifier, - "label": self.label - } - - @classmethod - def from_data(cls, data): - obj = cls(data["identifier"], data["label"]) - obj._id = data["id"] - return obj - - class CreateContext: """Context of instance creation. @@ -1450,6 +186,10 @@ class CreateContext: # Shared data across creators during collection phase self._collection_shared_data = None + # Context validation cache + self._folder_id_by_folder_path = {} + self._task_names_by_folder_path = {} + self.thumbnail_paths_by_instance_id = {} # Trigger reset if was enabled @@ -1469,17 +209,19 @@ class CreateContext: """Access to global publish attributes.""" return self._publish_attributes - def get_instance_by_id(self, instance_id): + def get_instance_by_id( + self, instance_id: str + ) -> Optional["CreatedInstance"]: """Receive instance by id. Args: instance_id (str): Instance id. Returns: - Union[CreatedInstance, None]: Instance or None if instance with + Optional[CreatedInstance]: Instance or None if instance with given id is not available. - """ + """ return self._instances_by_id.get(instance_id) def get_sorted_creators(self, identifiers=None): @@ -1491,8 +233,8 @@ class CreateContext: Returns: List[BaseCreator]: Sorted creator plugins by 'order' value. - """ + """ if identifiers is not None: identifiers = set(identifiers) creators = [ @@ -1653,7 +395,6 @@ class CreateContext: self._current_task_entity = task_entity return copy.deepcopy(self._current_task_entity) - def get_current_workfile_path(self): """Workfile path which was opened on context reset. @@ -1759,6 +500,8 @@ class CreateContext: # Give ability to store shared data for collection phase self._collection_shared_data = {} + self._folder_id_by_folder_path = {} + self._task_names_by_folder_path = {} def reset_finalization(self): """Cleanup of attributes after reset.""" @@ -1983,7 +726,7 @@ class CreateContext: self._original_context_data, self.context_data_to_store() ) - def creator_adds_instance(self, instance): + def creator_adds_instance(self, instance: "CreatedInstance"): """Creator adds new instance to context. Instances should be added only from creators. @@ -2152,6 +895,7 @@ class CreateContext: add_traceback = False result = None fail_info = None + exc_info = None success = False try: @@ -2209,7 +953,7 @@ class CreateContext: def _remove_instance(self, instance): self._instances_by_id.pop(instance.id, None) - def creator_removed_instance(self, instance): + def creator_removed_instance(self, instance: "CreatedInstance"): """When creator removes instance context should be acknowledged. If creator removes instance conext should know about it to avoid @@ -2245,9 +989,11 @@ class CreateContext: finally: self._bulk_counter -= 1 - # Trigger validation if there is no more context manager for bulk - # instance validation - if self._bulk_counter == 0: + # Trigger validation if there is no more context manager for bulk + # instance validation + if self._bulk_counter != 0: + return + ( self._bulk_instances_to_process, instances_to_validate @@ -2255,7 +1001,7 @@ class CreateContext: [], self._bulk_instances_to_process ) - self.validate_instances_context(instances_to_validate) + self.get_instances_context_info(instances_to_validate) def reset_instances(self): """Reload instances""" @@ -2344,26 +1090,70 @@ class CreateContext: if failed_info: raise CreatorsCreateFailed(failed_info) - def validate_instances_context(self, instances=None): - """Validate 'folder' and 'task' instance context.""" + def get_instances_context_info( + self, instances: Optional[Iterable["CreatedInstance"]] = None + ) -> Dict[str, InstanceContextInfo]: + """Validate 'folder' and 'task' instance context. + + Args: + instances (Optional[Iterable[CreatedInstance]]): Instances to + validate. If not provided all instances are validated. + + Returns: + Dict[str, InstanceContextInfo]: Validation results by instance id. + + """ # Use all instances from context if 'instances' are not passed if instances is None: - instances = tuple(self._instances_by_id.values()) + instances = self._instances_by_id.values() + instances = tuple(instances) + info_by_instance_id = { + instance.id: InstanceContextInfo( + instance.get("folderPath"), + instance.get("task"), + False, + False, + ) + for instance in instances + } # Skip if instances are empty - if not instances: - return + if not info_by_instance_id: + return info_by_instance_id project_name = self.project_name - task_names_by_folder_path = {} + to_validate = [] + task_names_by_folder_path = collections.defaultdict(set) for instance in instances: - folder_path = instance.get("folderPath") - task_name = instance.get("task") - if folder_path: - task_names_by_folder_path[folder_path] = set() - if task_name: - task_names_by_folder_path[folder_path].add(task_name) + context_info = info_by_instance_id[instance.id] + if instance.has_promised_context: + context_info.folder_is_valid = True + context_info.task_is_valid = True + continue + # TODO allow context promise + folder_path = context_info.folder_path + if not folder_path: + continue + + if folder_path in self._folder_id_by_folder_path: + folder_id = self._folder_id_by_folder_path[folder_path] + if folder_id is None: + continue + context_info.folder_is_valid = True + + task_name = context_info.task_name + if task_name is not None: + tasks_cache = self._task_names_by_folder_path.get(folder_path) + if tasks_cache is not None: + context_info.task_is_valid = task_name in tasks_cache + continue + + to_validate.append(instance) + task_names_by_folder_path[folder_path].add(task_name) + + if not to_validate: + return info_by_instance_id # Backwards compatibility for cases where folder name is set instead # of folder path @@ -2385,7 +1175,9 @@ class CreateContext: fields={"id", "path"} ): folder_id = folder_entity["id"] - folder_paths_by_id[folder_id] = folder_entity["path"] + folder_path = folder_entity["path"] + folder_paths_by_id[folder_id] = folder_path + self._folder_id_by_folder_path[folder_path] = folder_id folder_entities_by_name = collections.defaultdict(list) if folder_names: @@ -2396,8 +1188,10 @@ class CreateContext: ): folder_id = folder_entity["id"] folder_name = folder_entity["name"] - folder_paths_by_id[folder_id] = folder_entity["path"] + folder_path = folder_entity["path"] + folder_paths_by_id[folder_id] = folder_path folder_entities_by_name[folder_name].append(folder_entity) + self._folder_id_by_folder_path[folder_path] = folder_id tasks_entities = ayon_api.get_tasks( project_name, @@ -2410,12 +1204,11 @@ class CreateContext: folder_id = task_entity["folderId"] folder_path = folder_paths_by_id[folder_id] task_names_by_folder_path[folder_path].add(task_entity["name"]) + self._task_names_by_folder_path.update(task_names_by_folder_path) - for instance in instances: - if not instance.has_valid_folder or not instance.has_valid_task: - continue - + for instance in to_validate: folder_path = instance["folderPath"] + task_name = instance.get("task") if folder_path and "/" not in folder_path: folder_entities = folder_entities_by_name.get(folder_path) if len(folder_entities) == 1: @@ -2423,15 +1216,16 @@ class CreateContext: instance["folderPath"] = folder_path if folder_path not in task_names_by_folder_path: - instance.set_folder_invalid(True) continue + context_info = info_by_instance_id[instance.id] + context_info.folder_is_valid = True - task_name = instance["task"] - if not task_name: - continue - - if task_name not in task_names_by_folder_path[folder_path]: - instance.set_task_invalid(True) + if ( + not task_name + or task_name in task_names_by_folder_path[folder_path] + ): + context_info.task_is_valid = True + return info_by_instance_id def save_changes(self): """Save changes. Update all changed values.""" diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 624f1c9588..61c10ee736 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -26,16 +26,6 @@ if TYPE_CHECKING: from .context import CreateContext, CreatedInstance, UpdateData # noqa: F401 -class CreatorError(Exception): - """Should be raised when creator failed because of known issue. - - Message of error should be user readable. - """ - - def __init__(self, message): - super(CreatorError, self).__init__(message) - - class ProductConvertorPlugin(ABC): """Helper for conversion of instances created using legacy creators. @@ -654,7 +644,7 @@ class Creator(BaseCreator): cls._get_default_variant_wrap, cls._set_default_variant_wrap ) - super(Creator, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) @property def show_order(self): diff --git a/client/ayon_core/pipeline/create/exceptions.py b/client/ayon_core/pipeline/create/exceptions.py new file mode 100644 index 0000000000..8910d3fa09 --- /dev/null +++ b/client/ayon_core/pipeline/create/exceptions.py @@ -0,0 +1,127 @@ +import os +import inspect + + +class UnavailableSharedData(Exception): + """Shared data are not available at the moment when are accessed.""" + pass + + +class ImmutableKeyError(TypeError): + """Accessed key is immutable so does not allow changes or removals.""" + + def __init__(self, key, msg=None): + self.immutable_key = key + if not msg: + msg = "Key \"{}\" is immutable and does not allow changes.".format( + key + ) + super().__init__(msg) + + +class HostMissRequiredMethod(Exception): + """Host does not have implemented required functions for creation.""" + + def __init__(self, host, missing_methods): + self.missing_methods = missing_methods + self.host = host + joined_methods = ", ".join( + ['"{}"'.format(name) for name in missing_methods] + ) + dirpath = os.path.dirname( + os.path.normpath(inspect.getsourcefile(host)) + ) + dirpath_parts = dirpath.split(os.path.sep) + host_name = dirpath_parts.pop(-1) + if host_name == "api": + host_name = dirpath_parts.pop(-1) + + msg = "Host \"{}\" does not have implemented method/s {}".format( + host_name, joined_methods + ) + super().__init__(msg) + + +class ConvertorsOperationFailed(Exception): + def __init__(self, msg, failed_info): + super().__init__(msg) + self.failed_info = failed_info + + +class ConvertorsFindFailed(ConvertorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to find incompatible products" + super().__init__(msg, failed_info) + + +class ConvertorsConversionFailed(ConvertorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to convert incompatible products" + super().__init__(msg, failed_info) + + +class CreatorError(Exception): + """Should be raised when creator failed because of known issue. + + Message of error should be artist friendly. + """ + pass + + +class CreatorsOperationFailed(Exception): + """Raised when a creator process crashes in 'CreateContext'. + + The exception contains information about the creator and error. The data + are prepared using 'prepare_failed_creator_operation_info' and can be + serialized using json. + + Usage is for UI purposes which may not have access to exceptions directly + and would not have ability to catch exceptions 'per creator'. + + Args: + msg (str): General error message. + failed_info (list[dict[str, Any]]): List of failed creators with + exception message and optionally formatted traceback. + """ + + def __init__(self, msg, failed_info): + super().__init__(msg) + self.failed_info = failed_info + + +class CreatorsCollectionFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to collect instances" + super().__init__(msg, failed_info) + + +class CreatorsSaveFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed update instance changes" + super().__init__(msg, failed_info) + + +class CreatorsRemoveFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to remove instances" + super().__init__(msg, failed_info) + + +class CreatorsCreateFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to create instances" + super().__init__(msg, failed_info) + + +class TaskNotSetError(KeyError): + def __init__(self, msg=None): + if not msg: + msg = "Creator's product name template requires task name." + super().__init__(msg) + + +class TemplateFillError(Exception): + def __init__(self, msg=None): + if not msg: + msg = "Creator's product name template is missing key value." + super().__init__(msg) diff --git a/client/ayon_core/pipeline/create/legacy_create.py b/client/ayon_core/pipeline/create/legacy_create.py index fc24bcf934..ec9b23ac62 100644 --- a/client/ayon_core/pipeline/create/legacy_create.py +++ b/client/ayon_core/pipeline/create/legacy_create.py @@ -14,7 +14,7 @@ from ayon_core.pipeline.constants import AVALON_INSTANCE_ID from .product_name import get_product_name -class LegacyCreator(object): +class LegacyCreator: """Determine how assets are created""" label = None product_type = None diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 8a08bdc36c..eaeef6500e 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -1,23 +1,9 @@ import ayon_api - +from ayon_core.lib import StringTemplate, filter_profiles, prepare_template_data from ayon_core.settings import get_project_settings -from ayon_core.lib import filter_profiles, prepare_template_data from .constants import DEFAULT_PRODUCT_TEMPLATE - - -class TaskNotSetError(KeyError): - def __init__(self, msg=None): - if not msg: - msg = "Creator's product name template requires task name." - super(TaskNotSetError, self).__init__(msg) - - -class TemplateFillError(Exception): - def __init__(self, msg=None): - if not msg: - msg = "Creator's product name template is missing key value." - super(TemplateFillError, self).__init__(msg) +from .exceptions import TaskNotSetError, TemplateFillError def get_product_name_template( @@ -183,7 +169,10 @@ def get_product_name( fill_pairs[key] = value try: - return template.format(**prepare_template_data(fill_pairs)) + return StringTemplate.format_strict_template( + template=template, + data=prepare_template_data(fill_pairs) + ) except KeyError as exp: raise TemplateFillError( "Value for {} key is missing in template '{}'." diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py new file mode 100644 index 0000000000..311d382ac9 --- /dev/null +++ b/client/ayon_core/pipeline/create/structures.py @@ -0,0 +1,855 @@ +import copy +import collections +from uuid import uuid4 +from typing import Optional + +from ayon_core.lib.attribute_definitions import ( + UnknownDef, + serialize_attr_defs, + deserialize_attr_defs, +) +from ayon_core.pipeline import ( + AYON_INSTANCE_ID, + AVALON_INSTANCE_ID, +) + +from .exceptions import ImmutableKeyError +from .changes import TrackChangesItem + + +class ConvertorItem: + """Item representing convertor plugin. + + Args: + identifier (str): Identifier of convertor. + label (str): Label which will be shown in UI. + """ + + def __init__(self, identifier, label): + self._id = str(uuid4()) + self.identifier = identifier + self.label = label + + @property + def id(self): + return self._id + + def to_data(self): + return { + "id": self.id, + "identifier": self.identifier, + "label": self.label + } + + @classmethod + def from_data(cls, data): + obj = cls(data["identifier"], data["label"]) + obj._id = data["id"] + return obj + + +class InstanceMember: + """Representation of instance member. + + TODO: + Implement and use! + """ + + def __init__(self, instance, name): + self.instance = instance + + instance.add_members(self) + + self.name = name + self._actions = [] + + def add_action(self, label, callback): + self._actions.append({ + "label": label, + "callback": callback + }) + + +class AttributeValues: + """Container which keep values of Attribute definitions. + + Goal is to have one object which hold values of attribute definitions for + single instance. + + Has dictionary like methods. Not all of them are allowed all the time. + + Args: + attr_defs(AbstractAttrDef): Definitions of value type and properties. + values(dict): Values after possible conversion. + origin_data(dict): Values loaded from host before conversion. + """ + + def __init__(self, attr_defs, values, origin_data=None): + if origin_data is None: + origin_data = copy.deepcopy(values) + self._origin_data = origin_data + + attr_defs_by_key = { + attr_def.key: attr_def + for attr_def in attr_defs + if attr_def.is_value_def + } + for key, value in values.items(): + if key not in attr_defs_by_key: + new_def = UnknownDef(key, label=key, default=value) + attr_defs.append(new_def) + attr_defs_by_key[key] = new_def + + self._attr_defs = attr_defs + self._attr_defs_by_key = attr_defs_by_key + + self._data = {} + for attr_def in attr_defs: + value = values.get(attr_def.key) + if value is not None: + self._data[attr_def.key] = value + + def __setitem__(self, key, value): + if key not in self._attr_defs_by_key: + raise KeyError("Key \"{}\" was not found.".format(key)) + + self.update({key: value}) + + def __getitem__(self, key): + if key not in self._attr_defs_by_key: + return self._data[key] + return self._data.get(key, self._attr_defs_by_key[key].default) + + def __contains__(self, key): + return key in self._attr_defs_by_key + + def get(self, key, default=None): + if key in self._attr_defs_by_key: + return self[key] + return default + + def keys(self): + return self._attr_defs_by_key.keys() + + def values(self): + for key in self._attr_defs_by_key.keys(): + yield self._data.get(key) + + def items(self): + for key in self._attr_defs_by_key.keys(): + yield key, self._data.get(key) + + def update(self, value): + changes = {} + for _key, _value in dict(value).items(): + if _key in self._data and self._data.get(_key) == _value: + continue + self._data[_key] = _value + changes[_key] = _value + + def pop(self, key, default=None): + value = self._data.pop(key, default) + # Remove attribute definition if is 'UnknownDef' + # - gives option to get rid of unknown values + attr_def = self._attr_defs_by_key.get(key) + if isinstance(attr_def, UnknownDef): + self._attr_defs_by_key.pop(key) + self._attr_defs.remove(attr_def) + return value + + def reset_values(self): + self._data = {} + + def mark_as_stored(self): + self._origin_data = copy.deepcopy(self._data) + + @property + def attr_defs(self): + """Pointer to attribute definitions. + + Returns: + List[AbstractAttrDef]: Attribute definitions. + """ + + return list(self._attr_defs) + + @property + def origin_data(self): + return copy.deepcopy(self._origin_data) + + def data_to_store(self): + """Create new dictionary with data to store. + + Returns: + Dict[str, Any]: Attribute values that should be stored. + """ + + output = {} + for key in self._data: + output[key] = self[key] + + for key, attr_def in self._attr_defs_by_key.items(): + if key not in output: + output[key] = attr_def.default + return output + + def get_serialized_attr_defs(self): + """Serialize attribute definitions to json serializable types. + + Returns: + List[Dict[str, Any]]: Serialized attribute definitions. + """ + + return serialize_attr_defs(self._attr_defs) + + +class CreatorAttributeValues(AttributeValues): + """Creator specific attribute values of an instance. + + Args: + instance (CreatedInstance): Instance for which are values hold. + """ + + def __init__(self, instance, *args, **kwargs): + self.instance = instance + super().__init__(*args, **kwargs) + + +class PublishAttributeValues(AttributeValues): + """Publish plugin specific attribute values. + + Values are for single plugin which can be on `CreatedInstance` + or context values stored on `CreateContext`. + + Args: + publish_attributes(PublishAttributes): Wrapper for multiple publish + attributes is used as parent object. + """ + + def __init__(self, publish_attributes, *args, **kwargs): + self.publish_attributes = publish_attributes + super().__init__(*args, **kwargs) + + @property + def parent(self): + return self.publish_attributes.parent + + +class PublishAttributes: + """Wrapper for publish plugin attribute definitions. + + Cares about handling attribute definitions of multiple publish plugins. + Keep information about attribute definitions and their values. + + Args: + parent(CreatedInstance, CreateContext): Parent for which will be + data stored and from which are data loaded. + origin_data(dict): Loaded data by plugin class name. + attr_plugins(Union[List[pyblish.api.Plugin], None]): List of publish + plugins that may have defined attribute definitions. + """ + + def __init__(self, parent, origin_data, attr_plugins=None): + self.parent = parent + self._origin_data = copy.deepcopy(origin_data) + + attr_plugins = attr_plugins or [] + self.attr_plugins = attr_plugins + + self._data = copy.deepcopy(origin_data) + self._plugin_names_order = [] + self._missing_plugins = [] + + self.set_publish_plugins(attr_plugins) + + def __getitem__(self, key): + return self._data[key] + + def __contains__(self, key): + return key in self._data + + def keys(self): + return self._data.keys() + + def values(self): + return self._data.values() + + def items(self): + return self._data.items() + + def pop(self, key, default=None): + """Remove or reset value for plugin. + + Plugin values are reset to defaults if plugin is available but + data of plugin which was not found are removed. + + Args: + key(str): Plugin name. + default: Default value if plugin was not found. + """ + + if key not in self._data: + return default + + if key in self._missing_plugins: + self._missing_plugins.remove(key) + removed_item = self._data.pop(key) + return removed_item.data_to_store() + + value_item = self._data[key] + # Prepare value to return + output = value_item.data_to_store() + # Reset values + value_item.reset_values() + return output + + def plugin_names_order(self): + """Plugin names order by their 'order' attribute.""" + + for name in self._plugin_names_order: + yield name + + def mark_as_stored(self): + self._origin_data = copy.deepcopy(self.data_to_store()) + + def data_to_store(self): + """Convert attribute values to "data to store".""" + + output = {} + for key, attr_value in self._data.items(): + output[key] = attr_value.data_to_store() + return output + + @property + def origin_data(self): + return copy.deepcopy(self._origin_data) + + def set_publish_plugins(self, attr_plugins): + """Set publish plugins attribute definitions.""" + + self._plugin_names_order = [] + self._missing_plugins = [] + self.attr_plugins = attr_plugins or [] + + origin_data = self._origin_data + data = self._data + self._data = {} + added_keys = set() + for plugin in attr_plugins: + output = plugin.convert_attribute_values(data) + if output is not None: + data = output + attr_defs = plugin.get_attribute_defs() + if not attr_defs: + continue + + key = plugin.__name__ + added_keys.add(key) + self._plugin_names_order.append(key) + + value = data.get(key) or {} + orig_value = copy.deepcopy(origin_data.get(key) or {}) + self._data[key] = PublishAttributeValues( + self, attr_defs, value, orig_value + ) + + for key, value in data.items(): + if key not in added_keys: + self._missing_plugins.append(key) + self._data[key] = PublishAttributeValues( + self, [], value, value + ) + + def serialize_attributes(self): + return { + "attr_defs": { + plugin_name: attrs_value.get_serialized_attr_defs() + for plugin_name, attrs_value in self._data.items() + }, + "plugin_names_order": self._plugin_names_order, + "missing_plugins": self._missing_plugins + } + + def deserialize_attributes(self, data): + self._plugin_names_order = data["plugin_names_order"] + self._missing_plugins = data["missing_plugins"] + + attr_defs = deserialize_attr_defs(data["attr_defs"]) + + origin_data = self._origin_data + data = self._data + self._data = {} + + added_keys = set() + for plugin_name, attr_defs_data in attr_defs.items(): + attr_defs = deserialize_attr_defs(attr_defs_data) + value = data.get(plugin_name) or {} + orig_value = copy.deepcopy(origin_data.get(plugin_name) or {}) + self._data[plugin_name] = PublishAttributeValues( + self, attr_defs, value, orig_value + ) + + for key, value in data.items(): + if key not in added_keys: + self._missing_plugins.append(key) + self._data[key] = PublishAttributeValues( + self, [], value, value + ) + + +class InstanceContextInfo: + def __init__( + self, + folder_path: Optional[str], + task_name: Optional[str], + folder_is_valid: bool, + task_is_valid: bool, + ): + self.folder_path: Optional[str] = folder_path + self.task_name: Optional[str] = task_name + self.folder_is_valid: bool = folder_is_valid + self.task_is_valid: bool = task_is_valid + + @property + def is_valid(self) -> bool: + return self.folder_is_valid and self.task_is_valid + + +class CreatedInstance: + """Instance entity with data that will be stored to workfile. + + I think `data` must be required argument containing all minimum information + about instance like "folderPath" and "task" and all data used for filling + product name as creators may have custom data for product name filling. + + Notes: + Object have 2 possible initialization. One using 'creator' object which + is recommended for api usage. Second by passing information about + creator. + + Args: + product_type (str): Product type that will be created. + product_name (str): Name of product that will be created. + data (Dict[str, Any]): Data used for filling product name or override + data from already existing instance. + creator (Union[BaseCreator, None]): Creator responsible for instance. + creator_identifier (str): Identifier of creator plugin. + creator_label (str): Creator plugin label. + group_label (str): Default group label from creator plugin. + creator_attr_defs (List[AbstractAttrDef]): Attribute definitions from + creator. + """ + + # Keys that can't be changed or removed from data after loading using + # creator. + # - 'creator_attributes' and 'publish_attributes' can change values of + # their individual children but not on their own + __immutable_keys = ( + "id", + "instance_id", + "product_type", + "creator_identifier", + "creator_attributes", + "publish_attributes" + ) + + def __init__( + self, + product_type, + product_name, + data, + creator=None, + creator_identifier=None, + creator_label=None, + group_label=None, + creator_attr_defs=None, + ): + if creator is not None: + creator_identifier = creator.identifier + group_label = creator.get_group_label() + creator_label = creator.label + creator_attr_defs = creator.get_instance_attr_defs() + + self._creator_label = creator_label + self._group_label = group_label or creator_identifier + + # Instance members may have actions on them + # TODO implement members logic + self._members = [] + + # Data that can be used for lifetime of object + self._transient_data = {} + + # Create a copy of passed data to avoid changing them on the fly + data = copy.deepcopy(data or {}) + + # Pop dictionary values that will be converted to objects to be able + # catch changes + orig_creator_attributes = data.pop("creator_attributes", None) or {} + orig_publish_attributes = data.pop("publish_attributes", None) or {} + + # Store original value of passed data + self._orig_data = copy.deepcopy(data) + + # Pop 'productType' and 'productName' to prevent unexpected changes + data.pop("productType", None) + data.pop("productName", None) + # Backwards compatibility with OpenPype instances + data.pop("family", None) + data.pop("subset", None) + + asset_name = data.pop("asset", None) + if "folderPath" not in data: + data["folderPath"] = asset_name + + # QUESTION Does it make sense to have data stored as ordered dict? + self._data = collections.OrderedDict() + # QUESTION Do we need this "id" information on instance? + item_id = data.get("id") + # TODO use only 'AYON_INSTANCE_ID' when all hosts support it + if item_id not in {AYON_INSTANCE_ID, AVALON_INSTANCE_ID}: + item_id = AVALON_INSTANCE_ID + self._data["id"] = item_id + self._data["productType"] = product_type + self._data["productName"] = product_name + self._data["active"] = data.get("active", True) + self._data["creator_identifier"] = creator_identifier + + # Pop from source data all keys that are defined in `_data` before + # this moment and through their values away + # - they should be the same and if are not then should not change + # already set values + for key in self._data.keys(): + if key in data: + data.pop(key) + + self._data["variant"] = self._data.get("variant") or "" + # Stored creator specific attribute values + # {key: value} + creator_values = copy.deepcopy(orig_creator_attributes) + + self._data["creator_attributes"] = CreatorAttributeValues( + self, + list(creator_attr_defs), + creator_values, + orig_creator_attributes + ) + + # Stored publish specific attribute values + # {: {key: value}} + # - must be set using 'set_publish_plugins' + self._data["publish_attributes"] = PublishAttributes( + self, orig_publish_attributes, None + ) + if data: + self._data.update(data) + + if not self._data.get("instance_id"): + self._data["instance_id"] = str(uuid4()) + + def __str__(self): + return ( + " {data}" + ).format( + creator_identifier=self.creator_identifier, + product={"name": self.product_name, "type": self.product_type}, + data=str(self._data) + ) + + # --- Dictionary like methods --- + def __getitem__(self, key): + return self._data[key] + + def __contains__(self, key): + return key in self._data + + def __setitem__(self, key, value): + # Validate immutable keys + if key not in self.__immutable_keys: + self._data[key] = value + + elif value != self._data.get(key): + # Raise exception if key is immutable and value has changed + raise ImmutableKeyError(key) + + def get(self, key, default=None): + return self._data.get(key, default) + + def pop(self, key, *args, **kwargs): + # Raise exception if is trying to pop key which is immutable + if key in self.__immutable_keys: + raise ImmutableKeyError(key) + + self._data.pop(key, *args, **kwargs) + + def keys(self): + return self._data.keys() + + def values(self): + return self._data.values() + + def items(self): + return self._data.items() + # ------ + + @property + def product_type(self): + return self._data["productType"] + + @property + def product_name(self): + return self._data["productName"] + + @property + def label(self): + label = self._data.get("label") + if not label: + label = self.product_name + return label + + @property + def group_label(self): + label = self._data.get("group") + if label: + return label + return self._group_label + + @property + def origin_data(self): + output = copy.deepcopy(self._orig_data) + output["creator_attributes"] = self.creator_attributes.origin_data + output["publish_attributes"] = self.publish_attributes.origin_data + return output + + @property + def creator_identifier(self): + return self._data["creator_identifier"] + + @property + def creator_label(self): + return self._creator_label or self.creator_identifier + + @property + def id(self): + """Instance identifier. + + Returns: + str: UUID of instance. + """ + + return self._data["instance_id"] + + @property + def data(self): + """Legacy access to data. + + Access to data is needed to modify values. + + Returns: + CreatedInstance: Object can be used as dictionary but with + validations of immutable keys. + """ + + return self + + @property + def transient_data(self): + """Data stored for lifetime of instance object. + + These data are not stored to scene and will be lost on object + deletion. + + Can be used to store objects. In some host implementations is not + possible to reference to object in scene with some unique identifier + (e.g. node in Fusion.). In that case it is handy to store the object + here. Should be used that way only if instance data are stored on the + node itself. + + Returns: + Dict[str, Any]: Dictionary object where you can store data related + to instance for lifetime of instance object. + """ + + return self._transient_data + + def changes(self): + """Calculate and return changes.""" + + return TrackChangesItem(self.origin_data, self.data_to_store()) + + def mark_as_stored(self): + """Should be called when instance data are stored. + + Origin data are replaced by current data so changes are cleared. + """ + + orig_keys = set(self._orig_data.keys()) + for key, value in self._data.items(): + orig_keys.discard(key) + if key in ("creator_attributes", "publish_attributes"): + continue + self._orig_data[key] = copy.deepcopy(value) + + for key in orig_keys: + self._orig_data.pop(key) + + self.creator_attributes.mark_as_stored() + self.publish_attributes.mark_as_stored() + + @property + def creator_attributes(self): + return self._data["creator_attributes"] + + @property + def creator_attribute_defs(self): + """Attribute definitions defined by creator plugin. + + Returns: + List[AbstractAttrDef]: Attribute definitions. + """ + + return self.creator_attributes.attr_defs + + @property + def publish_attributes(self): + return self._data["publish_attributes"] + + @property + def has_promised_context(self) -> bool: + """Get context data that are promised to be set by creator. + + Returns: + bool: Has context that won't bo validated. Artist can't change + value when set to True. + + """ + return self._data.get("has_promised_context", False) + + def data_to_store(self): + """Collect data that contain json parsable types. + + It is possible to recreate the instance using these data. + + Todos: + We probably don't need OrderedDict. When data are loaded they + are not ordered anymore. + + Returns: + OrderedDict: Ordered dictionary with instance data. + """ + + output = collections.OrderedDict() + for key, value in self._data.items(): + if key in ("creator_attributes", "publish_attributes"): + continue + output[key] = value + + output["creator_attributes"] = self.creator_attributes.data_to_store() + output["publish_attributes"] = self.publish_attributes.data_to_store() + + return output + + @classmethod + def from_existing(cls, instance_data, creator): + """Convert instance data from workfile to CreatedInstance. + + Args: + instance_data (Dict[str, Any]): Data in a structure ready for + 'CreatedInstance' object. + creator (BaseCreator): Creator plugin which is creating the + instance of for which the instance belong. + """ + + instance_data = copy.deepcopy(instance_data) + + product_type = instance_data.get("productType") + if product_type is None: + product_type = instance_data.get("family") + if product_type is None: + product_type = creator.product_type + product_name = instance_data.get("productName") + if product_name is None: + product_name = instance_data.get("subset") + + return cls( + product_type, product_name, instance_data, creator + ) + + def set_publish_plugins(self, attr_plugins): + """Set publish plugins with attribute definitions. + + This method should be called only from 'CreateContext'. + + Args: + attr_plugins (List[pyblish.api.Plugin]): Pyblish plugins which + inherit from 'AYONPyblishPluginMixin' and may contain + attribute definitions. + """ + + self.publish_attributes.set_publish_plugins(attr_plugins) + + def add_members(self, members): + """Currently unused method.""" + + for member in members: + if member not in self._members: + self._members.append(member) + + def serialize_for_remote(self): + """Serialize object into data to be possible recreated object. + + Returns: + Dict[str, Any]: Serialized data. + """ + + creator_attr_defs = self.creator_attributes.get_serialized_attr_defs() + publish_attributes = self.publish_attributes.serialize_attributes() + return { + "data": self.data_to_store(), + "orig_data": self.origin_data, + "creator_attr_defs": creator_attr_defs, + "publish_attributes": publish_attributes, + "creator_label": self._creator_label, + "group_label": self._group_label, + } + + @classmethod + def deserialize_on_remote(cls, serialized_data): + """Convert instance data to CreatedInstance. + + This is fake instance in remote process e.g. in UI process. The creator + is not a full creator and should not be used for calling methods when + instance is created from this method (matters on implementation). + + Args: + serialized_data (Dict[str, Any]): Serialized data for remote + recreating. Should contain 'data' and 'orig_data'. + """ + + instance_data = copy.deepcopy(serialized_data["data"]) + creator_identifier = instance_data["creator_identifier"] + + product_type = instance_data["productType"] + product_name = instance_data.get("productName", None) + + creator_label = serialized_data["creator_label"] + group_label = serialized_data["group_label"] + creator_attr_defs = deserialize_attr_defs( + serialized_data["creator_attr_defs"] + ) + publish_attributes = serialized_data["publish_attributes"] + + obj = cls( + product_type, + product_name, + instance_data, + creator_identifier=creator_identifier, + creator_label=creator_label, + group_label=group_label, + creator_attr_defs=creator_attr_defs + ) + obj._orig_data = serialized_data["orig_data"] + obj.publish_attributes.deserialize_attributes(publish_attributes) + + return obj diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 72deee185e..b218dc78e5 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -1,5 +1,5 @@ -import os import copy +import os import re import warnings from copy import deepcopy @@ -7,14 +7,11 @@ from copy import deepcopy import attr import ayon_api import clique - -from ayon_core.pipeline import ( - get_current_project_name, - get_representation_path, -) from ayon_core.lib import Logger -from ayon_core.pipeline.publish import KnownPublishError +from ayon_core.pipeline import get_current_project_name, get_representation_path +from ayon_core.pipeline.create import get_product_name from ayon_core.pipeline.farm.patterning import match_aov_pattern +from ayon_core.pipeline.publish import KnownPublishError @attr.s @@ -250,6 +247,9 @@ def create_skeleton_instance( "colorspace": data.get("colorspace") } + if data.get("renderlayer"): + instance_skeleton_data["renderlayer"] = data["renderlayer"] + # skip locking version if we are creating v01 instance_version = data.get("version") # take this if exists if instance_version != 1: @@ -464,7 +464,9 @@ def create_instances_for_aov(instance, skeleton, aov_filter, Args: instance (pyblish.api.Instance): Original instance. skeleton (dict): Skeleton instance data. + aov_filter (dict): AOV filter. skip_integration_repre_list (list): skip + do_not_add_review (bool): Explicitly disable reviews Returns: list of pyblish.api.Instance: Instances created from @@ -515,6 +517,131 @@ def create_instances_for_aov(instance, skeleton, aov_filter, ) +def _get_legacy_product_name_and_group( + product_type, + source_product_name, + task_name, + dynamic_data): + """Get product name with legacy logic. + + This function holds legacy behaviour of creating product name + that is deprecated. This wasn't using product name templates + at all, only hardcoded values. It shouldn't be used anymore, + but transition to templates need careful checking of the project + and studio settings. + + Deprecated: + since 0.4.4 + + Args: + product_type (str): Product type. + source_product_name (str): Source product name. + task_name (str): Task name. + dynamic_data (dict): Dynamic data (camera, aov, ...) + + Returns: + tuple: product name and group name + + """ + warnings.warn("Using legacy product name for renders", + DeprecationWarning) + + if not source_product_name.startswith(product_type): + resulting_group_name = '{}{}{}{}{}'.format( + product_type, + task_name[0].upper(), task_name[1:], + source_product_name[0].upper(), source_product_name[1:]) + else: + resulting_group_name = source_product_name + + # create product name `` + if not source_product_name.startswith(product_type): + resulting_group_name = '{}{}{}{}{}'.format( + product_type, + task_name[0].upper(), task_name[1:], + source_product_name[0].upper(), source_product_name[1:]) + else: + resulting_group_name = source_product_name + + resulting_product_name = resulting_group_name + camera = dynamic_data.get("camera") + aov = dynamic_data.get("aov") + if camera: + if not aov: + resulting_product_name = '{}_{}'.format( + resulting_group_name, camera) + elif not aov.startswith(camera): + resulting_product_name = '{}_{}_{}'.format( + resulting_group_name, camera, aov) + else: + resulting_product_name = "{}_{}".format( + resulting_group_name, aov) + else: + if aov: + resulting_product_name = '{}_{}'.format( + resulting_group_name, aov) + + return resulting_product_name, resulting_group_name + + +def get_product_name_and_group_from_template( + project_name, + task_entity, + product_type, + variant, + host_name, + dynamic_data=None): + """Get product name and group name from template. + + This will get product name and group name from template based on + data provided. It is doing similar work as + `func::_get_legacy_product_name_and_group` but using templates. + + To get group name, template is called without any dynamic data, so + (depending on the template itself) it should be product name without + aov. + + Todo: + Maybe we should introduce templates for the groups themselves. + + Args: + task_entity (dict): Task entity. + project_name (str): Project name. + host_name (str): Host name. + product_type (str): Product type. + variant (str): Variant. + dynamic_data (dict): Dynamic data (aov, renderlayer, camera, ...). + + Returns: + tuple: product name and group name. + + """ + # remove 'aov' from data used to format group. See todo comment above + # for possible solution. + _dynamic_data = deepcopy(dynamic_data) or {} + _dynamic_data.pop("aov", None) + resulting_group_name = get_product_name( + project_name=project_name, + task_name=task_entity["name"], + task_type=task_entity["taskType"], + host_name=host_name, + product_type=product_type, + dynamic_data=_dynamic_data, + variant=variant, + ) + + resulting_product_name = get_product_name( + project_name=project_name, + task_name=task_entity["name"], + task_type=task_entity["taskType"], + host_name=host_name, + product_type=product_type, + dynamic_data=dynamic_data, + variant=variant, + ) + return resulting_product_name, resulting_group_name + + def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, skip_integration_repre_list, do_not_add_review): """Create instance for each AOV found. @@ -526,10 +653,10 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, instance (pyblish.api.Instance): Original instance. skeleton (dict): Skeleton data for instance (those needed) later by collector. - additional_data (dict): .. + additional_data (dict): ... skip_integration_repre_list (list): list of extensions that shouldn't be published - do_not_addbe _review (bool): explicitly disable review + do_not_add_review (bool): explicitly disable review Returns: @@ -539,68 +666,70 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, ValueError: """ - # TODO: this needs to be taking the task from context or instance - task = os.environ["AYON_TASK_NAME"] anatomy = instance.context.data["anatomy"] - s_product_name = skeleton["productName"] + source_product_name = skeleton["productName"] cameras = instance.data.get("cameras", []) - exp_files = instance.data["expectedFiles"] + expected_files = instance.data["expectedFiles"] log = Logger.get_logger("farm_publishing") instances = [] # go through AOVs in expected files - for aov, files in exp_files[0].items(): - cols, rem = clique.assemble(files) - # we shouldn't have any reminders. And if we do, it should - # be just one item for single frame renders. - if not cols and rem: - if len(rem) != 1: - raise ValueError("Found multiple non related files " - "to render, don't know what to do " - "with them.") - col = rem[0] - ext = os.path.splitext(col)[1].lstrip(".") - else: - # but we really expect only one collection. - # Nothing else make sense. - if len(cols) != 1: - raise ValueError("Only one image sequence type is expected.") # noqa: E501 - ext = cols[0].tail.lstrip(".") - col = list(cols[0]) + for aov, files in expected_files[0].items(): + collected_files = _collect_expected_files_for_aov(files) - # create product name `` - # TODO refactor/remove me - product_type = skeleton["productType"] - if not s_product_name.startswith(product_type): - group_name = '{}{}{}{}{}'.format( - product_type, - task[0].upper(), task[1:], - s_product_name[0].upper(), s_product_name[1:]) - else: - group_name = s_product_name + expected_filepath = collected_files + if isinstance(collected_files, (list, tuple)): + expected_filepath = collected_files[0] - # if there are multiple cameras, we need to add camera name - expected_filepath = col[0] if isinstance(col, (list, tuple)) else col - cams = [cam for cam in cameras if cam in expected_filepath] - if cams: - for cam in cams: - if not aov: - product_name = '{}_{}'.format(group_name, cam) - elif not aov.startswith(cam): - product_name = '{}_{}_{}'.format(group_name, cam, aov) - else: - product_name = "{}_{}".format(group_name, aov) - else: - if aov: - product_name = '{}_{}'.format(group_name, aov) - else: - product_name = '{}'.format(group_name) + dynamic_data = { + "aov": aov, + "renderlayer": instance.data.get("renderlayer"), + } + + # find if camera is used in the file path + # TODO: this must be changed to be more robust. Any coincidence + # of camera name in the file path will be considered as + # camera name. This is not correct. + camera = [cam for cam in cameras if cam in expected_filepath] + + # Is there just one camera matching? + # TODO: this is not true, we can have multiple cameras in the scene + # and we should be able to detect them all. Currently, we are + # keeping the old behavior, taking the first one found. + if camera: + dynamic_data["camera"] = camera[0] + + project_settings = instance.context.data.get("project_settings") + + use_legacy_product_name = True + try: + use_legacy_product_name = project_settings["core"]["tools"]["creator"]["use_legacy_product_names_for_renders"] # noqa: E501 + except KeyError: + warnings.warn( + ("use_legacy_for_renders not found in project settings. " + "Using legacy product name for renders. Please update " + "your ayon-core version."), DeprecationWarning) + use_legacy_product_name = True + + if use_legacy_product_name: + product_name, group_name = _get_legacy_product_name_and_group( + product_type=skeleton["productType"], + source_product_name=source_product_name, + task_name=instance.data["task"], + dynamic_data=dynamic_data) - if isinstance(col, (list, tuple)): - staging = os.path.dirname(col[0]) else: - staging = os.path.dirname(col) + product_name, group_name = get_product_name_and_group_from_template( + task_entity=instance.data["taskEntity"], + project_name=instance.context.data["projectName"], + host_name=instance.context.data["hostName"], + product_type=skeleton["productType"], + variant=instance.data.get("variant", source_product_name), + dynamic_data=dynamic_data + ) + + staging = os.path.dirname(expected_filepath) try: staging = remap_source(staging, anatomy) @@ -611,10 +740,8 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, app = os.environ.get("AYON_HOST_NAME", "") - if isinstance(col, list): - render_file_name = os.path.basename(col[0]) - else: - render_file_name = os.path.basename(col) + render_file_name = os.path.basename(expected_filepath) + aov_patterns = aov_filter preview = match_aov_pattern(app, aov_patterns, render_file_name) @@ -622,9 +749,10 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, new_instance = deepcopy(skeleton) new_instance["productName"] = product_name new_instance["productGroup"] = group_name + new_instance["aov"] = aov # toggle preview on if multipart is on - # Because we cant query the multipartExr data member of each AOV we'll + # Because we can't query the multipartExr data member of each AOV we'll # need to have hardcoded rule of excluding any renders with # "cryptomatte" in the file name from being a multipart EXR. This issue # happens with Redshift that forces Cryptomatte renders to be separate @@ -650,10 +778,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, new_instance["review"] = True # create representation - if isinstance(col, (list, tuple)): - files = [os.path.basename(f) for f in col] - else: - files = os.path.basename(col) + ext = os.path.splitext(render_file_name)[-1].lstrip(".") # Copy render product "colorspace" data to representation. colorspace = "" @@ -708,6 +833,35 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, return instances +def _collect_expected_files_for_aov(files): + """Collect expected files. + + Args: + files (list): List of files. + + Returns: + list or str: Collection of files or single file. + + Raises: + ValueError: If there are multiple collections. + + """ + cols, rem = clique.assemble(files) + # we shouldn't have any reminders. And if we do, it should + # be just one item for single frame renders. + if not cols and rem: + if len(rem) != 1: + raise ValueError("Found multiple non related files " + "to render, don't know what to do " + "with them.") + return rem[0] + # but we really expect only one collection. + # Nothing else make sense. + if len(cols) != 1: + raise ValueError("Only one image sequence type is expected.") # noqa: E501 + return list(cols[0]) + + def get_resources(project_name, version_entity, extension=None): """Get the files from the specific version. diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.pyi b/client/ayon_core/pipeline/farm/pyblish_functions.pyi deleted file mode 100644 index fe0ae57da0..0000000000 --- a/client/ayon_core/pipeline/farm/pyblish_functions.pyi +++ /dev/null @@ -1,24 +0,0 @@ -import pyblish.api -from ayon_core.pipeline import Anatomy -from typing import Tuple, List - - -class TimeData: - start: int - end: int - fps: float | int - step: int - handle_start: int - handle_end: int - - def __init__(self, start: int, end: int, fps: float | int, step: int, handle_start: int, handle_end: int): - ... - ... - -def remap_source(source: str, anatomy: Anatomy): ... -def extend_frames(folder_path: str, product_name: str, start: int, end: int) -> Tuple[int, int]: ... -def get_time_data_from_instance_or_context(instance: pyblish.api.Instance) -> TimeData: ... -def get_transferable_representations(instance: pyblish.api.Instance) -> list: ... -def create_skeleton_instance(instance: pyblish.api.Instance, families_transfer: list = ..., instance_transfer: dict = ...) -> dict: ... -def create_instances_for_aov(instance: pyblish.api.Instance, skeleton: dict, aov_filter: dict) -> List[pyblish.api.Instance]: ... -def attach_instances_to_product(attach_to: list, instances: list) -> list: ... diff --git a/client/ayon_core/pipeline/publish/README.md b/client/ayon_core/pipeline/publish/README.md index ee2124dfd3..954c10494f 100644 --- a/client/ayon_core/pipeline/publish/README.md +++ b/client/ayon_core/pipeline/publish/README.md @@ -1,5 +1,5 @@ # Publish -AYON is using `pyblish` for publishing process which is a little bit extented and modified mainly for UI purposes. OpenPype's (new) publish UI does not allow to enable/disable instances or plugins that can be done during creation part. Also does support actions only for validators after validation exception. +AYON is using `pyblish` for publishing process which is a little bit extented and modified mainly for UI purposes. AYON's (new) publish UI does not allow to enable/disable instances or plugins that can be done during creation part. Also does support actions only for validators after validation exception. ## Exceptions AYON define few specific exceptions that should be used in publish plugins. diff --git a/client/ayon_core/pipeline/publish/__init__.py b/client/ayon_core/pipeline/publish/__init__.py index ab19b6e360..cb181c2f2b 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -13,7 +13,6 @@ from .publish_plugins import ( PublishXmlValidationError, KnownPublishError, AYONPyblishPluginMixin, - OpenPypePyblishPluginMixin, OptionalPyblishPluginMixin, RepairAction, @@ -66,7 +65,6 @@ __all__ = ( "PublishXmlValidationError", "KnownPublishError", "AYONPyblishPluginMixin", - "OpenPypePyblishPluginMixin", "OptionalPyblishPluginMixin", "RepairAction", diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 8b82622e4c..dc2eef3bb9 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -379,7 +379,7 @@ def get_plugin_settings(plugin, project_settings, log, category=None): plugin_kind = split_path[-2] # TODO: change after all plugins are moved one level up - if category_from_file in ("ayon_core", "openpype"): + if category_from_file == "ayon_core": category_from_file = "core" try: diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 6b1984d92b..1eca8df7cb 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -165,9 +165,6 @@ class AYONPyblishPluginMixin: return self.get_attr_values_from_data_for_plugin(self.__class__, data) -OpenPypePyblishPluginMixin = AYONPyblishPluginMixin - - class OptionalPyblishPluginMixin(AYONPyblishPluginMixin): """Prepare mixin for optional plugins. diff --git a/client/ayon_core/pipeline/tempdir.py b/client/ayon_core/pipeline/tempdir.py index 29d4659393..d8f42ea60a 100644 --- a/client/ayon_core/pipeline/tempdir.py +++ b/client/ayon_core/pipeline/tempdir.py @@ -25,13 +25,7 @@ def create_custom_tempdir(project_name, anatomy=None): """ env_tmpdir = os.getenv("AYON_TMPDIR") if not env_tmpdir: - env_tmpdir = os.getenv("OPENPYPE_TMPDIR") - if not env_tmpdir: - return - print( - "DEPRECATION WARNING: Used 'OPENPYPE_TMPDIR' environment" - " variable. Please use 'AYON_TMPDIR' instead." - ) + return custom_tempdir = None if "{" in env_tmpdir: diff --git a/client/ayon_core/pipeline/thumbnails.py b/client/ayon_core/pipeline/thumbnails.py index dbb38615d8..401d95f273 100644 --- a/client/ayon_core/pipeline/thumbnails.py +++ b/client/ayon_core/pipeline/thumbnails.py @@ -4,7 +4,7 @@ import collections import ayon_api -from ayon_core.lib.local_settings import get_ayon_appdirs +from ayon_core.lib.local_settings import get_launcher_local_dir FileInfo = collections.namedtuple( @@ -54,7 +54,7 @@ class ThumbnailsCache: """ if self._thumbnails_dir is None: - self._thumbnails_dir = get_ayon_appdirs("thumbnails") + self._thumbnails_dir = get_launcher_local_dir("thumbnails") return self._thumbnails_dir thumbnails_dir = property(get_thumbnails_dir) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 7b15dff049..c38725ffba 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -859,7 +859,7 @@ class AbstractTemplateBuilder(ABC): "Settings\\Profiles" ).format(host_name.title())) - # Try fill path with environments and anatomy roots + # Try to fill path with environments and anatomy roots anatomy = Anatomy(project_name) fill_data = { key: value @@ -872,9 +872,7 @@ class AbstractTemplateBuilder(ABC): "code": anatomy.project_code, } - result = StringTemplate.format_template(path, fill_data) - if result.solved: - path = result.normalized() + path = self.resolve_template_path(path, fill_data) if path and os.path.exists(path): self.log.info("Found template at: '{}'".format(path)) @@ -914,6 +912,27 @@ class AbstractTemplateBuilder(ABC): "create_first_version": create_first_version } + def resolve_template_path(self, path, fill_data) -> str: + """Resolve the template path. + + By default, this does nothing except returning the path directly. + + This can be overridden in host integrations to perform additional + resolving over the template. Like, `hou.text.expandString` in Houdini. + + Arguments: + path (str): The input path. + fill_data (dict[str, str]): Data to use for template formatting. + + Returns: + str: The resolved path. + + """ + result = StringTemplate.format_template(path, fill_data) + if result.solved: + path = result.normalized() + return path + def emit_event(self, topic, data=None, source=None) -> Event: return self._event_system.emit(topic, data, source) @@ -1519,9 +1538,10 @@ class PlaceholderLoadMixin(object): if "asset" in placeholder.data: return [] - representation_name = placeholder.data["representation"] - if not representation_name: - return [] + representation_names = None + representation_name: str = placeholder.data["representation"] + if representation_name: + representation_names = [representation_name] project_name = self.builder.project_name current_folder_entity = self.builder.current_folder_entity @@ -1578,7 +1598,7 @@ class PlaceholderLoadMixin(object): ) return list(get_representations( project_name, - representation_names={representation_name}, + representation_names=representation_names, version_ids=version_ids )) diff --git a/client/ayon_core/plugins/publish/collect_addons.py b/client/ayon_core/plugins/publish/collect_addons.py index 9bba9978ab..661cf9cb31 100644 --- a/client/ayon_core/plugins/publish/collect_addons.py +++ b/client/ayon_core/plugins/publish/collect_addons.py @@ -15,5 +15,3 @@ class CollectAddons(pyblish.api.ContextPlugin): manager = AddonsManager() context.data["ayonAddonsManager"] = manager context.data["ayonAddons"] = manager.addons_by_name - # Backwards compatibility - remove - context.data["openPypeModules"] = manager.addons_by_name diff --git a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py index b6636696c1..a0bd57d7dc 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py @@ -138,7 +138,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): folder_path_by_id = {} for instance in context: folder_entity = instance.data.get("folderEntity") - # Skip if instnace does not have filled folder entity + # Skip if instance does not have filled folder entity if not folder_entity: continue folder_id = folder_entity["id"] @@ -217,9 +217,8 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): joined_paths = ", ".join( ["\"{}\"".format(path) for path in not_found_task_paths] ) - self.log.warning(( - "Not found task entities with paths \"{}\"." - ).format(joined_paths)) + self.log.warning( + f"Not found task entities with paths {joined_paths}.") def fill_latest_versions(self, context, project_name): """Try to find latest version for each instance's product name. @@ -321,7 +320,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): use_context_version = instance.data["followWorkfileVersion"] if use_context_version: - version_number = context.data("version") + version_number = context.data.get("version") # Even if 'follow_workfile_version' is enabled, it may not be set # because workfile version was not collected to 'context.data' @@ -385,8 +384,19 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): json.dumps(anatomy_data, indent=4) )) + # make render layer available in anatomy data + render_layer = instance.data.get("renderlayer") + if render_layer: + anatomy_data["renderlayer"] = render_layer + + # make aov name available in anatomy data + aov = instance.data.get("aov") + if aov: + anatomy_data["aov"] = aov + + def _fill_folder_data(self, instance, project_entity, anatomy_data): - # QUESTION should we make sure that all folder data are poped if + # QUESTION: should we make sure that all folder data are popped if # folder data cannot be found? # - 'folder', 'hierarchy', 'parent', 'folder' folder_entity = instance.data.get("folderEntity") @@ -426,7 +436,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): }) def _fill_task_data(self, instance, task_types_by_name, anatomy_data): - # QUESTION should we make sure that all task data are poped if task + # QUESTION: should we make sure that all task data are popped if task # data cannot be resolved? # - 'task' diff --git a/client/ayon_core/plugins/publish/collect_context_entities.py b/client/ayon_core/plugins/publish/collect_context_entities.py index f340178e4f..c8d25bc3e6 100644 --- a/client/ayon_core/plugins/publish/collect_context_entities.py +++ b/client/ayon_core/plugins/publish/collect_context_entities.py @@ -113,4 +113,4 @@ class CollectContextEntities(pyblish.api.ContextPlugin): "Task '{}' was not found in project '{}'.".format( task_path, project_name) ) - return task_entity \ No newline at end of file + return task_entity diff --git a/client/ayon_core/plugins/publish/collect_input_representations_to_versions.py b/client/ayon_core/plugins/publish/collect_input_representations_to_versions.py index b9fe97b80b..f8311f7dfb 100644 --- a/client/ayon_core/plugins/publish/collect_input_representations_to_versions.py +++ b/client/ayon_core/plugins/publish/collect_input_representations_to_versions.py @@ -7,7 +7,7 @@ class CollectInputRepresentationsToVersions(pyblish.api.ContextPlugin): """Converts collected input representations to input versions. Any data in `instance.data["inputRepresentations"]` gets converted into - `instance.data["inputVersions"]` as supported in OpenPype v3. + `instance.data["inputVersions"]` as supported in OpenPype. """ # This is a ContextPlugin because then we can query the database only once diff --git a/client/ayon_core/plugins/publish/collect_rendered_files.py b/client/ayon_core/plugins/publish/collect_rendered_files.py index 8a60e7619d..42ba096d14 100644 --- a/client/ayon_core/plugins/publish/collect_rendered_files.py +++ b/client/ayon_core/plugins/publish/collect_rendered_files.py @@ -138,10 +138,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): def process(self, context): self._context = context - publish_data_paths = ( - os.environ.get("AYON_PUBLISH_DATA") - or os.environ.get("OPENPYPE_PUBLISH_DATA") - ) + publish_data_paths = os.environ.get("AYON_PUBLISH_DATA") if not publish_data_paths: raise KnownPublishError("Missing `AYON_PUBLISH_DATA`") diff --git a/client/ayon_core/plugins/publish/collect_scene_version.py b/client/ayon_core/plugins/publish/collect_scene_version.py index ea4823d62a..8d643062bc 100644 --- a/client/ayon_core/plugins/publish/collect_scene_version.py +++ b/client/ayon_core/plugins/publish/collect_scene_version.py @@ -47,8 +47,9 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): return if not context.data.get('currentFile'): - raise KnownPublishError("Cannot get current workfile path. " - "Make sure your scene is saved.") + self.log.error("Cannot get current workfile path. " + "Make sure your scene is saved.") + return filename = os.path.basename(context.data.get('currentFile')) diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index be365520c7..64c73adbd5 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -49,7 +49,6 @@ class ExtractOTIOReview(publish.Extractor): hosts = ["resolve", "hiero", "flame"] # plugin default attributes - temp_file_head = "tempFile." to_width = 1280 to_height = 720 output_ext = ".jpg" @@ -62,6 +61,9 @@ class ExtractOTIOReview(publish.Extractor): make_sequence_collection ) + # TODO refactore from using instance variable + self.temp_file_head = self._get_folder_name_based_prefix(instance) + # TODO: convert resulting image sequence to mp4 # get otio clip and other time info from instance clip @@ -104,10 +106,19 @@ class ExtractOTIOReview(publish.Extractor): media_metadata = otio_media.metadata # get from media reference metadata source - if media_metadata.get("openpype.source.width"): - width = int(media_metadata.get("openpype.source.width")) - if media_metadata.get("openpype.source.height"): - height = int(media_metadata.get("openpype.source.height")) + # TODO 'openpype' prefix should be removed (added 24/09/03) + # NOTE it looks like it is set only in hiero integration + for key in {"ayon.source.width", "openpype.source.width"}: + value = media_metadata.get(key) + if value is not None: + width = int(value) + break + + for key in {"ayon.source.height", "openpype.source.height"}: + value = media_metadata.get(key) + if value is not None: + height = int(value) + break # compare and reset if width != self.to_width: @@ -491,3 +502,21 @@ class ExtractOTIOReview(publish.Extractor): out_frame_start = self.used_frames[-1] return output_path, out_frame_start + + def _get_folder_name_based_prefix(self, instance): + """Creates 'unique' human readable file prefix to differentiate. + + Multiple instances might share same temp folder, but each instance + would be differentiated by asset, eg. folder name. + + It ix expected that there won't be multiple instances for same asset. + """ + folder_path = instance.data["folderPath"] + folder_name = folder_path.split("/")[-1] + folder_path = folder_path.replace("/", "_").lstrip("_") + + file_prefix = f"{folder_path}_{folder_name}." + self.log.debug(f"file_prefix::{file_prefix}") + + return file_prefix + diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index c2793f98a2..06b451bfbe 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -95,7 +95,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ] # Supported extensions - image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga"] + image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"] video_exts = ["mov", "mp4"] supported_exts = image_exts + video_exts @@ -1900,7 +1900,7 @@ class OverscanCrop: string_value = re.sub(r"([ ]+)?px", " ", string_value) string_value = re.sub(r"([ ]+)%", "%", string_value) # Make sure +/- sign at the beginning of string is next to number - string_value = re.sub(r"^([\+\-])[ ]+", "\g<1>", string_value) + string_value = re.sub(r"^([\+\-])[ ]+", r"\g<1>", string_value) # Make sure +/- sign in the middle has zero spaces before number under # which belongs string_value = re.sub( diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index d1b6e4e0cc..4ffabf6028 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -455,6 +455,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # output file jpeg_items.append(path_to_subprocess_arg(dst_path)) subprocess_command = " ".join(jpeg_items) + try: run_subprocess( subprocess_command, shell=True, logger=self.log diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 162b7d3d41..acdc5276f7 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -4,7 +4,10 @@ import os from typing import Dict import pyblish.api -from pxr import Sdf +try: + from pxr import Sdf +except ImportError: + Sdf = None from ayon_core.lib import ( TextDef, @@ -13,21 +16,24 @@ from ayon_core.lib import ( UILabelDef, EnumDef ) -from ayon_core.pipeline.usdlib import ( - get_or_define_prim_spec, - add_ordered_reference, - variant_nested_prim_path, - setup_asset_layer, - add_ordered_sublayer, - set_layer_defaults -) +try: + from ayon_core.pipeline.usdlib import ( + get_or_define_prim_spec, + add_ordered_reference, + variant_nested_prim_path, + setup_asset_layer, + add_ordered_sublayer, + set_layer_defaults + ) +except ImportError: + pass from ayon_core.pipeline.entity_uri import ( construct_ayon_entity_uri, parse_ayon_entity_uri ) from ayon_core.pipeline.load.utils import get_representation_path_by_names from ayon_core.pipeline.publish.lib import get_instance_expected_output_path -from ayon_core.pipeline import publish +from ayon_core.pipeline import publish, KnownPublishError # This global toggle is here mostly for debugging purposes and should usually @@ -77,7 +83,7 @@ def get_representation_path_in_publish_context( Allow resolving 'latest' paths from a publishing context's instances as if they will exist after publishing without them being integrated yet. - + Use first instance that has same folder path and product name, and contains representation with passed name. @@ -138,13 +144,14 @@ def get_instance_uri_path( folder_path = instance.data["folderPath"] product_name = instance.data["productName"] project_name = context.data["projectName"] + version_name = instance.data["version"] # Get the layer's published path path = construct_ayon_entity_uri( project_name=project_name, folder_path=folder_path, product=product_name, - version="latest", + version=version_name, representation_name="usd" ) @@ -231,7 +238,7 @@ def add_representation(instance, name, class CollectUSDLayerContributions(pyblish.api.InstancePlugin, - publish.OpenPypePyblishPluginMixin): + publish.AYONPyblishPluginMixin): """Collect the USD Layer Contributions and create dependent instances. Our contributions go to the layer @@ -555,12 +562,24 @@ class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions): return defs +class ValidateUSDDependencies(pyblish.api.InstancePlugin): + families = ["usdLayer"] + + order = pyblish.api.ValidatorOrder + + def process(self, instance): + if Sdf is None: + raise KnownPublishError("USD library 'Sdf' is not available.") + + class ExtractUSDLayerContribution(publish.Extractor): families = ["usdLayer"] label = "Extract USD Layer Contributions (Asset/Shot)" order = pyblish.api.ExtractorOrder + 0.45 + use_ayon_entity_uri = False + def process(self, instance): folder_path = instance.data["folderPath"] @@ -578,7 +597,8 @@ class ExtractUSDLayerContribution(publish.Extractor): contributions = instance.data.get("usd_contributions", []) for contribution in sorted(contributions, key=attrgetter("order")): - path = get_instance_uri_path(contribution.instance) + path = get_instance_uri_path(contribution.instance, + resolve=not self.use_ayon_entity_uri) if isinstance(contribution, VariantContribution): # Add contribution as a reference inside a variant self.log.debug(f"Adding variant: {contribution}") @@ -652,14 +672,14 @@ class ExtractUSDLayerContribution(publish.Extractor): ) def remove_previous_reference_contribution(self, - prim_spec: Sdf.PrimSpec, + prim_spec: "Sdf.PrimSpec", instance: pyblish.api.Instance): # Remove existing contributions of the same product - ignoring # the picked version and representation. We assume there's only ever # one version of a product you want to have referenced into a Prim. remove_indices = set() for index, ref in enumerate(prim_spec.referenceList.prependedItems): - ref: Sdf.Reference # type hint + ref: "Sdf.Reference" uri = ref.customData.get("ayon_uri") if uri and self.instance_match_ayon_uri(instance, uri): @@ -674,8 +694,8 @@ class ExtractUSDLayerContribution(publish.Extractor): ] def add_reference_contribution(self, - layer: Sdf.Layer, - prim_path: Sdf.Path, + layer: "Sdf.Layer", + prim_path: "Sdf.Path", filepath: str, contribution: VariantContribution): instance = contribution.instance @@ -720,6 +740,8 @@ class ExtractUSDAssetContribution(publish.Extractor): label = "Extract USD Asset/Shot Contributions" order = ExtractUSDLayerContribution.order + 0.01 + use_ayon_entity_uri = False + def process(self, instance): folder_path = instance.data["folderPath"] @@ -795,15 +817,15 @@ class ExtractUSDAssetContribution(publish.Extractor): layer_id = layer_instance.data["usd_layer_id"] order = layer_instance.data["usd_layer_order"] - path = get_instance_uri_path(instance=layer_instance) + path = get_instance_uri_path(instance=layer_instance, + resolve=not self.use_ayon_entity_uri) add_ordered_sublayer(target_layer, contribution_path=path, layer_id=layer_id, order=order, # Add the sdf argument metadata which allows # us to later detect whether another path - # has the same layer id, so we can replace it - # it. + # has the same layer id, so we can replace it. add_sdf_arguments_metadata=True) # Save the file diff --git a/client/ayon_core/plugins/publish/help/validate_unique_subsets.xml b/client/ayon_core/plugins/publish/help/validate_unique_subsets.xml index e163fc39fe..96b07979b7 100644 --- a/client/ayon_core/plugins/publish/help/validate_unique_subsets.xml +++ b/client/ayon_core/plugins/publish/help/validate_unique_subsets.xml @@ -11,7 +11,11 @@ Multiples instances from your scene are set to publish into the same folder > pr ### How to repair? -Remove the offending instances or rename to have a unique name. +Remove the offending instances or rename to have a unique name. Also, please + check your product name templates to ensure that resolved names are + sufficiently unique. You can find that settings: + + ayon+settings://core/tools/creator/product_name_profiles - \ No newline at end of file + diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index a2cf910fa6..d3f6c04333 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -114,18 +114,19 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # the database even if not used by the destination template db_representation_context_keys = [ "project", - "asset", "hierarchy", "folder", "task", "product", - "subset", - "family", "version", "representation", "username", "user", - "output" + "output", + # OpenPype keys - should be removed + "asset", # folder[name] + "subset", # product[name] + "family", # product[type] ] def process(self, instance): @@ -743,6 +744,11 @@ class IntegrateAsset(pyblish.api.InstancePlugin): if not is_udim: repre_context["frame"] = first_index_padded + # store renderlayer in context if it exists + # to be later used for example by delivery templates + if instance.data.get("renderlayer"): + repre_context["renderlayer"] = instance.data["renderlayer"] + # Update the destination indexes and padding dst_collection = clique.assemble(dst_filepaths)[0][0] dst_collection.padding = destination_padding diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 4fb8b886a9..2163596864 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -265,6 +265,9 @@ class IntegrateHeroVersion( project_name, "version", new_hero_version ) + # Store hero entity to 'instance.data' + instance.data["heroVersionEntity"] = new_hero_version + # Separate old representations into `to replace` and `to delete` old_repres_to_replace = {} old_repres_to_delete = {} diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py new file mode 100644 index 0000000000..0a6b24adb4 --- /dev/null +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -0,0 +1,102 @@ +import os + +import pyblish.api +import ayon_api +from ayon_api.server_api import RequestTypes + +from ayon_core.lib import get_media_mime_type +from ayon_core.pipeline.publish import get_publish_repre_path + + +class IntegrateAYONReview(pyblish.api.InstancePlugin): + label = "Integrate AYON Review" + # Must happen after IntegrateAsset + order = pyblish.api.IntegratorOrder + 0.15 + + def process(self, instance): + project_name = instance.context.data["projectName"] + src_version_entity = instance.data.get("versionEntity") + src_hero_version_entity = instance.data.get("heroVersionEntity") + for version_entity in ( + src_version_entity, + src_hero_version_entity, + ): + if not version_entity: + continue + + version_id = version_entity["id"] + self._upload_reviewable(project_name, version_id, instance) + + def _upload_reviewable(self, project_name, version_id, instance): + ayon_con = ayon_api.get_server_api_connection() + major, minor, _, _, _ = ayon_con.get_server_version_tuple() + if (major, minor) < (1, 3): + self.log.info( + "Skipping reviewable upload, supported from server 1.3.x." + f" Current server version {ayon_con.get_server_version()}" + ) + return + + uploaded_labels = set() + for repre in instance.data["representations"]: + repre_tags = repre.get("tags") or [] + # Ignore representations that are not reviewable + if "webreview" not in repre_tags: + continue + + # exclude representations with are going to be published on farm + if "publish_on_farm" in repre_tags: + continue + + # Skip thumbnails + if repre.get("thumbnail") or "thumbnail" in repre_tags: + continue + + repre_path = get_publish_repre_path( + instance, repre, False + ) + if not repre_path or not os.path.exists(repre_path): + # TODO log skipper path + continue + + content_type = get_media_mime_type(repre_path) + if not content_type: + self.log.warning( + f"Could not determine Content-Type for {repre_path}" + ) + continue + + label = self._get_review_label(repre, uploaded_labels) + query = "" + if label: + query = f"?label={label}" + + endpoint = ( + f"/projects/{project_name}" + f"/versions/{version_id}/reviewables{query}" + ) + filename = os.path.basename(repre_path) + # Upload the reviewable + self.log.info(f"Uploading reviewable '{label or filename}' ...") + + headers = ayon_con.get_headers(content_type) + headers["x-file-name"] = filename + self.log.info(f"Uploading reviewable {repre_path}") + ayon_con.upload_file( + endpoint, + repre_path, + headers=headers, + request_type=RequestTypes.post, + ) + + def _get_review_label(self, repre, uploaded_labels): + # Use output name as label if available + label = repre.get("outputName") + if not label: + return None + orig_label = label + idx = 0 + while label in uploaded_labels: + idx += 1 + label = f"{orig_label}_{idx}" + return label diff --git a/client/ayon_core/plugins/publish/validate_file_saved.py b/client/ayon_core/plugins/publish/validate_file_saved.py index d459ba7ed4..d132ba8d3a 100644 --- a/client/ayon_core/plugins/publish/validate_file_saved.py +++ b/client/ayon_core/plugins/publish/validate_file_saved.py @@ -1,17 +1,59 @@ +import inspect + import pyblish.api from ayon_core.pipeline.publish import PublishValidationError +from ayon_core.tools.utils.host_tools import show_workfiles +from ayon_core.pipeline.context_tools import version_up_current_workfile + + +class SaveByVersionUpAction(pyblish.api.Action): + """Save Workfile.""" + label = "Save Workfile" + on = "failed" + icon = "save" + + def process(self, context, plugin): + version_up_current_workfile() + + +class ShowWorkfilesAction(pyblish.api.Action): + """Save Workfile.""" + label = "Show Workfiles Tool..." + on = "failed" + icon = "files-o" + + def process(self, context, plugin): + show_workfiles() class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): - """File must be saved before publishing""" + """File must be saved before publishing + + This does not validate for unsaved changes. It only validates whether + the current context was able to identify any 'currentFile'. + """ label = "Validate File Saved" order = pyblish.api.ValidatorOrder - 0.1 - hosts = ["maya", "houdini", "nuke"] + hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter"] + actions = [SaveByVersionUpAction, ShowWorkfilesAction] def process(self, context): current_file = context.data["currentFile"] if not current_file: - raise PublishValidationError("File not saved") + raise PublishValidationError( + "Workfile is not saved. Please save your scene to continue.", + title="File not saved", + description=self.get_description()) + + def get_description(self): + return inspect.cleandoc(""" + ### File not saved + + Your workfile must be saved to continue publishing. + + The **Save Workfile** action will save it for you with the first + available workfile version number in your current context. + """) diff --git a/client/ayon_core/resources/__init__.py b/client/ayon_core/resources/__init__.py index 2a98cc1968..ea8bf7ca6c 100644 --- a/client/ayon_core/resources/__init__.py +++ b/client/ayon_core/resources/__init__.py @@ -70,19 +70,3 @@ def get_ayon_splash_filepath(staging=None): else: splash_file_name = "AYON_splash.png" return get_resource("icons", splash_file_name) - - -def get_openpype_production_icon_filepath(): - return get_ayon_production_icon_filepath() - - -def get_openpype_staging_icon_filepath(): - return get_ayon_staging_icon_filepath() - - -def get_openpype_icon_filepath(staging=None): - return get_ayon_icon_filepath(staging) - - -def get_openpype_splash_filepath(staging=None): - return get_ayon_splash_filepath(staging) diff --git a/client/ayon_core/resources/images/popout.png b/client/ayon_core/resources/images/popout.png new file mode 100644 index 0000000000..838c29483e Binary files /dev/null and b/client/ayon_core/resources/images/popout.png differ diff --git a/client/ayon_core/scripts/slates/slate_base/items.py b/client/ayon_core/scripts/slates/slate_base/items.py index 6d19fc6a0c..ec3358ed5e 100644 --- a/client/ayon_core/scripts/slates/slate_base/items.py +++ b/client/ayon_core/scripts/slates/slate_base/items.py @@ -486,11 +486,11 @@ class TableField(BaseItem): line = self.ellide_text break - for idx, char in enumerate(_word): + for char_index, char in enumerate(_word): _line = line + char + self.ellide_text _line_width = font.getsize(_line)[0] if _line_width > max_width: - if idx == 0: + if char_index == 0: line = _line break line = line + char diff --git a/client/ayon_core/settings/local_settings.md b/client/ayon_core/settings/local_settings.md deleted file mode 100644 index fbb5cf3df1..0000000000 --- a/client/ayon_core/settings/local_settings.md +++ /dev/null @@ -1,79 +0,0 @@ -# Structure of local settings -- local settings do not have any validation schemas right now this should help to see what is stored to local settings and how it works -- they are stored by identifier site_id which should be unified identifier of workstation -- all keys may and may not available on load -- contain main categories: `general`, `applications`, `projects` - -## Categories -### General -- ATM contain only label of site -```json -{ - "general": { - "site_label": "MySite" - } -} -``` - -### Applications -- modifications of application executables -- output should match application groups and variants -```json -{ - "applications": { - "": { - "": { - "executable": "/my/path/to/nuke_12_2" - } - } - } -} -``` - -### Projects -- project specific modifications -- default project is stored under constant key defined in `pype.settings.contants` -```json -{ - "projects": { - "": { - "active_site": "", - "remote_site": "", - "roots": { - "": { - "": "" - } - } - } - } -} -``` - -## Final document -```json -{ - "_id": "", - "site_id": "", - "general": { - "site_label": "MySite" - }, - "applications": { - "": { - "": { - "executable": "" - } - } - }, - "projects": { - "": { - "active_site": "", - "remote_site": "", - "roots": { - "": { - "": "" - } - } - } - } -} -``` diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 607fd1fa31..10aa918d08 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -739,6 +739,31 @@ OverlayMessageWidget QWidget { background: transparent; } +/* Hinted Line Edit */ +HintedLineEditInput { + border-radius: 0.2em; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + border: 1px solid {color:border}; +} +HintedLineEditInput:hover { + border-color: {color:border-hover}; +} +HintedLineEditInput:focus{ + border-color: {color:border-focus}; +} +HintedLineEditInput:disabled { + background: {color:bg-inputs-disabled}; +} +HintedLineEditButton { + border: none; + border-radius: 0.2em; + border-bottom-left-radius: 0px; + border-top-left-radius: 0px; + padding: 0px; + qproperty-iconSize: 11px 11px; +} + /* Password dialog*/ #PasswordBtn { border: none; @@ -969,17 +994,6 @@ PixmapButton:disabled { #PublishLogConsole { font-family: "Noto Sans Mono"; } -#VariantInputsWidget QLineEdit { - border-bottom-right-radius: 0px; - border-top-right-radius: 0px; -} -#VariantInputsWidget QToolButton { - border-bottom-left-radius: 0px; - border-top-left-radius: 0px; - padding-top: 0.5em; - padding-bottom: 0.5em; - width: 0.5em; -} #VariantInput[state="new"], #VariantInput[state="new"]:focus, #VariantInput[state="new"]:hover { border-color: {color:publisher:success}; } @@ -1231,6 +1245,15 @@ ValidationArtistMessage QLabel { background: transparent; } +#PluginDetailsContent { + background: {color:bg-inputs}; + border-radius: 0.2em; +} +#PluginDetailsContent #PluginLabel { + font-size: 14pt; + font-weight: bold; +} + CreateNextPageOverlay { font-size: 32pt; } @@ -1449,14 +1472,6 @@ CreateNextPageOverlay { border-radius: 5px; } -#OpenPypeVersionLabel[state="success"] { - color: {color:settings:version-exists}; -} - -#OpenPypeVersionLabel[state="warning"] { - color: {color:settings:version-not-found}; -} - #ShadowWidget { font-size: 36pt; } diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 768f4b052f..ad566eb354 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -13,8 +13,11 @@ from typing import ( from ayon_core.lib import AbstractAttrDef from ayon_core.host import HostBase -from ayon_core.pipeline.create import CreateContext, CreatedInstance -from ayon_core.pipeline.create.context import ConvertorItem +from ayon_core.pipeline.create import ( + CreateContext, + CreatedInstance, + ConvertorItem, +) from ayon_core.tools.common_models import ( FolderItem, TaskItem, @@ -319,6 +322,12 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): ) -> Dict[str, Union[CreatedInstance, None]]: pass + @abstractmethod + def get_instances_context_info( + self, instance_ids: Optional[Iterable[str]] = None + ): + pass + @abstractmethod def get_existing_product_names(self, folder_path: str) -> List[str]: pass diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index 257b45de08..fe1545f219 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -190,6 +190,9 @@ class PublisherController( def get_instances_by_id(self, instance_ids=None): return self._create_model.get_instances_by_id(instance_ids) + def get_instances_context_info(self, instance_ids=None): + return self._create_model.get_instances_context_info(instance_ids) + def get_convertor_items(self): return self._create_model.get_convertor_items() diff --git a/client/ayon_core/tools/publisher/control_qt.py b/client/ayon_core/tools/publisher/control_qt.py index b42b9afea3..7d1c661603 100644 --- a/client/ayon_core/tools/publisher/control_qt.py +++ b/client/ayon_core/tools/publisher/control_qt.py @@ -60,9 +60,8 @@ class MainThreadProcess(QtCore.QObject): self._timer.stop() def clear(self): - if self._timer.isActive(): - self._timer.stop() self._items_to_process = collections.deque() + self.stop() class QtPublisherController(PublisherController): @@ -77,21 +76,32 @@ class QtPublisherController(PublisherController): self.register_event_callback( "publish.process.stopped", self._qt_on_publish_stop ) + # Capture if '_next_publish_item_process' is in + # '_main_thread_processor' loop + self._item_process_in_loop = False def reset(self): self._main_thread_processor.clear() + self._item_process_in_loop = False super().reset() def _start_publish(self, up_validation): self._publish_model.set_publish_up_validation(up_validation) self._publish_model.start_publish(wait=False) - self._process_main_thread_item( - MainThreadItem(self._next_publish_item_process) - ) + # Make sure '_next_publish_item_process' is only once in + # the '_main_thread_processor' loop + if not self._item_process_in_loop: + self._process_main_thread_item( + MainThreadItem(self._next_publish_item_process) + ) def _next_publish_item_process(self): if not self._publish_model.is_running(): + # This removes '_next_publish_item_process' from loop + self._item_process_in_loop = False return + + self._item_process_in_loop = True func = self._publish_model.get_next_process_func() self._process_main_thread_item(MainThreadItem(func)) self._process_main_thread_item( @@ -105,4 +115,6 @@ class QtPublisherController(PublisherController): self._main_thread_processor.start() def _qt_on_publish_stop(self): - self._main_thread_processor.stop() + self._process_main_thread_item( + MainThreadItem(self._main_thread_processor.stop) + ) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index ab2bf07614..dcd2ce4acc 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -18,7 +18,7 @@ from ayon_core.pipeline.create import ( CreateContext, CreatedInstance, ) -from ayon_core.pipeline.create.context import ( +from ayon_core.pipeline.create import ( CreatorsOperationFailed, ConvertorsOperationFailed, ConvertorItem, @@ -306,6 +306,14 @@ class CreateModel: for instance_id in instance_ids } + def get_instances_context_info( + self, instance_ids: Optional[Iterable[str]] = None + ): + instances = self.get_instances_by_id(instance_ids).values() + return self._create_context.get_instances_context_info( + instances + ) + def get_convertor_items(self) -> Dict[str, ConvertorItem]: return self._create_context.convertor_items_by_id diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index ef207bfb79..a60ef69fac 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -172,7 +172,7 @@ class PublishReportMaker: "crashed_file_paths": crashed_file_paths, "id": uuid.uuid4().hex, "created_at": now.isoformat(), - "report_version": "1.0.1", + "report_version": "1.1.0", } def _add_plugin_data_item(self, plugin: pyblish.api.Plugin): @@ -194,11 +194,23 @@ class PublishReportMaker: if hasattr(plugin, "label"): label = plugin.label + plugin_type = "instance" if plugin.__instanceEnabled__ else "context" + # Get docstring + # NOTE we do care only about docstring from the plugin so we can't + # use 'inspect.getdoc' which also looks for docstring in parent + # classes. + docstring = getattr(plugin, "__doc__", None) + if docstring: + docstring = inspect.cleandoc(docstring) return { "id": plugin.id, "name": plugin.__name__, "label": label, "order": plugin.order, + "filepath": inspect.getfile(plugin), + "docstring": docstring, + "plugin_type": plugin_type, + "families": list(plugin.families), "targets": list(plugin.targets), "instances_data": [], "actions_data": [], @@ -829,7 +841,9 @@ class PublishModel: ) # Plugin iterator - self._main_thread_iter: Iterable[partial] = [] + self._main_thread_iter: collections.abc.Generator[partial] = ( + self._default_iterator() + ) def reset(self): create_context = self._controller.get_create_context() @@ -895,29 +909,30 @@ class PublishModel: func() def get_next_process_func(self) -> partial: - # Validations of progress before using iterator - # - same conditions may be inside iterator but they may be used - # only in specific cases (e.g. when it happens for a first time) + # Raise error if this function is called when publishing + # is not running + if not self._publish_is_running: + raise ValueError("Publish is not running") + # Validations of progress before using iterator + # Any unexpected error happened + # - everything should stop + if self._publish_has_crashed: + return partial(self.stop_publish) + + # Stop if validation is over and validation errors happened + # or publishing should stop at validation if ( - self._main_thread_iter is None - # There are validation errors and validation is passed - # - can't do any progree - or ( - self._publish_has_validated - and self._publish_has_validation_errors + self._publish_has_validated + and ( + self._publish_has_validation_errors + or self._publish_up_validation ) - # Any unexpected error happened - # - everything should stop - or self._publish_has_crashed ): - item = partial(self.stop_publish) + return partial(self.stop_publish) # Everything is ok so try to get new processing item - else: - item = next(self._main_thread_iter) - - return item + return next(self._main_thread_iter) def stop_publish(self): if self._publish_is_running: @@ -1070,6 +1085,19 @@ class PublishModel: {"value": value} ) + def _default_iterator(self): + """Iterator used on initialization. + + Should be replaced by real iterator when 'reset' is called. + + Returns: + collections.abc.Generator[partial]: Generator with partial + functions that should be called in main thread. + + """ + while True: + yield partial(self.stop_publish) + def _start_publish(self): """Start or continue in publishing.""" if self._publish_is_running: @@ -1101,22 +1129,16 @@ class PublishModel: self._publish_progress = idx # Check if plugin is over validation order - if not self._publish_has_validated: - self._set_has_validated( - plugin.order >= self._validation_order - ) - - # Stop if plugin is over validation order and process - # should process up to validation. - if self._publish_up_validation and self._publish_has_validated: - yield partial(self.stop_publish) - - # Stop if validation is over and validation errors happened if ( - self._publish_has_validated - and self.has_validation_errors() + not self._publish_has_validated + and plugin.order >= self._validation_order ): - yield partial(self.stop_publish) + self._set_has_validated(True) + if ( + self._publish_up_validation + or self._publish_has_validation_errors + ): + yield partial(self.stop_publish) # Add plugin to publish report self._publish_report.add_plugin_iter( diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py b/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py index 206f999bac..a3c5a7a2fd 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py @@ -13,8 +13,16 @@ class PluginItem: self.skipped = plugin_data["skipped"] self.passed = plugin_data["passed"] + # Introduced in report '1.1.0' + self.docstring = plugin_data.get("docstring") + self.filepath = plugin_data.get("filepath") + self.plugin_type = plugin_data.get("plugin_type") + self.families = plugin_data.get("families") + errored = False + process_time = 0.0 for instance_data in plugin_data["instances_data"]: + process_time += instance_data["process_time"] for log_item in instance_data["logs"]: errored = log_item["type"] == "error" if errored: @@ -22,6 +30,7 @@ class PluginItem: if errored: break + self.process_time = process_time self.errored = errored @property diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 61a52533ba..5fa1c04dc0 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -1,7 +1,15 @@ from math import ceil from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.tools.utils import NiceCheckbox +from ayon_core.tools.utils import ( + NiceCheckbox, + ElideLabel, + SeparatorWidget, + IconButton, + paint_image_with_color, +) +from ayon_core.resources import get_image_path +from ayon_core.style import get_objected_colors # from ayon_core.tools.utils import DeselectableTreeView from .constants import ( @@ -22,33 +30,89 @@ TRACEBACK_ROLE = QtCore.Qt.UserRole + 2 IS_DETAIL_ITEM_ROLE = QtCore.Qt.UserRole + 3 -class PluginLoadReportModel(QtGui.QStandardItemModel): - def set_report(self, report): - parent = self.invisibleRootItem() - parent.removeRows(0, parent.rowCount()) +def get_pretty_milliseconds(value): + if value < 1000: + return f"{value:.3f}ms" + value /= 1000 + if value < 60: + return f"{value:.2f}s" + seconds = int(value % 60) + value /= 60 + if value < 60: + return f"{value:.2f}m {seconds:.2f}s" + minutes = int(value % 60) + value /= 60 + return f"{value:.2f}h {minutes:.2f}m" + +class PluginLoadReportModel(QtGui.QStandardItemModel): + def __init__(self): + super().__init__() + self._traceback_by_filepath = {} + self._items_by_filepath = {} + self._is_active = True + self._need_refresh = False + + def set_active(self, is_active): + if self._is_active is is_active: + return + self._is_active = is_active + self._update_items() + + def set_report(self, report): + self._need_refresh = True if report is None: + self._traceback_by_filepath.clear() + self._update_items() + return + + filepaths = set(report.crashed_plugin_paths.keys()) + to_remove = set(self._traceback_by_filepath) - filepaths + for filepath in filepaths: + self._traceback_by_filepath[filepath] = ( + report.crashed_plugin_paths[filepath] + ) + + for filepath in to_remove: + self._traceback_by_filepath.pop(filepath) + self._update_items() + + def _update_items(self): + if not self._is_active or not self._need_refresh: + return + parent = self.invisibleRootItem() + if not self._traceback_by_filepath: + parent.removeRows(0, parent.rowCount()) return new_items = [] new_items_by_filepath = {} - for filepath in report.crashed_plugin_paths.keys(): + to_remove = ( + set(self._items_by_filepath) - set(self._traceback_by_filepath) + ) + for filepath in self._traceback_by_filepath: + if filepath in self._items_by_filepath: + continue item = QtGui.QStandardItem(filepath) new_items.append(item) new_items_by_filepath[filepath] = item + self._items_by_filepath[filepath] = item - if not new_items: - return + if new_items: + parent.appendRows(new_items) - parent.appendRows(new_items) for filepath, item in new_items_by_filepath.items(): - traceback_txt = report.crashed_plugin_paths[filepath] + traceback_txt = self._traceback_by_filepath[filepath] detail_item = QtGui.QStandardItem() detail_item.setData(filepath, FILEPATH_ROLE) detail_item.setData(traceback_txt, TRACEBACK_ROLE) detail_item.setData(True, IS_DETAIL_ITEM_ROLE) item.appendRow(detail_item) + for filepath in to_remove: + item = self._items_by_filepath.pop(filepath) + parent.removeRow(item.row()) + class DetailWidget(QtWidgets.QTextEdit): def __init__(self, text, *args, **kwargs): @@ -95,10 +159,12 @@ class PluginLoadReportWidget(QtWidgets.QWidget): self._model = model self._widgets_by_filepath = {} - def _on_expand(self, index): - for row in range(self._model.rowCount(index)): - child_index = self._model.index(row, index.column(), index) - self._create_widget(child_index) + def set_active(self, is_active): + self._model.set_active(is_active) + + def set_report(self, report): + self._widgets_by_filepath = {} + self._model.set_report(report) def showEvent(self, event): super().showEvent(event) @@ -108,6 +174,11 @@ class PluginLoadReportWidget(QtWidgets.QWidget): super().resizeEvent(event) self._update_widgets_size_hints() + def _on_expand(self, index): + for row in range(self._model.rowCount(index)): + child_index = self._model.index(row, index.column(), index) + self._create_widget(child_index) + def _update_widgets_size_hints(self): for item in self._widgets_by_filepath.values(): widget, index = item @@ -136,10 +207,6 @@ class PluginLoadReportWidget(QtWidgets.QWidget): self._view.setIndexWidget(index, widget) self._widgets_by_filepath[filepath] = (widget, index) - def set_report(self, report): - self._widgets_by_filepath = {} - self._model.set_report(report) - class ZoomPlainText(QtWidgets.QPlainTextEdit): min_point_size = 1.0 @@ -229,6 +296,8 @@ class DetailsWidget(QtWidgets.QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(output_widget) + self._is_active = True + self._need_refresh = False self._output_widget = output_widget self._report_item = None self._instance_filter = set() @@ -237,21 +306,33 @@ class DetailsWidget(QtWidgets.QWidget): def clear(self): self._output_widget.setPlainText("") + def set_active(self, is_active): + if self._is_active is is_active: + return + self._is_active = is_active + self._update_logs() + def set_report(self, report): self._report_item = report self._plugin_filter = set() self._instance_filter = set() + self._need_refresh = True self._update_logs() def set_plugin_filter(self, plugin_filter): self._plugin_filter = plugin_filter + self._need_refresh = True self._update_logs() def set_instance_filter(self, instance_filter): self._instance_filter = instance_filter + self._need_refresh = True self._update_logs() def _update_logs(self): + if not self._is_active or not self._need_refresh: + return + if not self._report_item: self._output_widget.setPlainText("") return @@ -294,6 +375,242 @@ class DetailsWidget(QtWidgets.QWidget): self._output_widget.setPlainText(text) +class PluginDetailsWidget(QtWidgets.QWidget): + def __init__(self, plugin_item, parent): + super().__init__(parent) + + content_widget = QtWidgets.QFrame(self) + content_widget.setObjectName("PluginDetailsContent") + + plugin_label_widget = QtWidgets.QLabel(content_widget) + plugin_label_widget.setObjectName("PluginLabel") + + plugin_doc_widget = QtWidgets.QLabel(content_widget) + plugin_doc_widget.setWordWrap(True) + + form_separator = SeparatorWidget(parent=content_widget) + + plugin_class_label = QtWidgets.QLabel("Class:") + plugin_class_widget = QtWidgets.QLabel(content_widget) + + plugin_order_label = QtWidgets.QLabel("Order:") + plugin_order_widget = QtWidgets.QLabel(content_widget) + + plugin_families_label = QtWidgets.QLabel("Families:") + plugin_families_widget = QtWidgets.QLabel(content_widget) + plugin_families_widget.setWordWrap(True) + + plugin_path_label = QtWidgets.QLabel("File Path:") + plugin_path_widget = ElideLabel(content_widget) + plugin_path_widget.set_elide_mode(QtCore.Qt.ElideLeft) + + plugin_time_label = QtWidgets.QLabel("Time:") + plugin_time_widget = QtWidgets.QLabel(content_widget) + + # Set interaction flags + for label_widget in ( + plugin_label_widget, + plugin_doc_widget, + plugin_class_widget, + plugin_order_widget, + plugin_families_widget, + plugin_time_widget, + ): + label_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + + # Change style of form labels + for label_widget in ( + plugin_class_label, + plugin_order_label, + plugin_families_label, + plugin_path_label, + plugin_time_label, + ): + label_widget.setObjectName("PluginFormLabel") + + plugin_label = plugin_item.label or plugin_item.name + if plugin_item.plugin_type: + plugin_label += " ({})".format( + plugin_item.plugin_type.capitalize() + ) + + time_label = "Not started" + if plugin_item.passed: + time_label = get_pretty_milliseconds(plugin_item.process_time) + elif plugin_item.skipped: + time_label = "Skipped plugin" + + families = "N/A" + if plugin_item.families: + families = ", ".join(plugin_item.families) + + order = "N/A" + if plugin_item.order is not None: + order = str(plugin_item.order) + + plugin_label_widget.setText(plugin_label) + plugin_doc_widget.setText(plugin_item.docstring or "N/A") + plugin_class_widget.setText(plugin_item.name or "N/A") + plugin_order_widget.setText(order) + plugin_families_widget.setText(families) + plugin_path_widget.setText(plugin_item.filepath or "N/A") + plugin_path_widget.setToolTip(plugin_item.filepath or None) + plugin_time_widget.setText(time_label) + + content_layout = QtWidgets.QGridLayout(content_widget) + content_layout.setContentsMargins(8, 8, 8, 8) + content_layout.setColumnStretch(0, 0) + content_layout.setColumnStretch(1, 1) + row = 0 + + content_layout.addWidget(plugin_label_widget, row, 0, 1, 2) + row += 1 + + # Hide docstring if it is empty + if plugin_item.docstring: + content_layout.addWidget(plugin_doc_widget, row, 0, 1, 2) + row += 1 + else: + plugin_doc_widget.setVisible(False) + + content_layout.addWidget(form_separator, row, 0, 1, 2) + row += 1 + + for label_widget, value_widget in ( + (plugin_class_label, plugin_class_widget), + (plugin_order_label, plugin_order_widget), + (plugin_families_label, plugin_families_widget), + (plugin_path_label, plugin_path_widget), + (plugin_time_label, plugin_time_widget), + ): + content_layout.addWidget(label_widget, row, 0) + content_layout.addWidget(value_widget, row, 1) + row += 1 + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(content_widget, 0) + + +class PluginsDetailsWidget(QtWidgets.QWidget): + def __init__(self, parent): + super().__init__(parent) + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setWidgetResizable(True) + + scroll_content_widget = QtWidgets.QWidget(scroll_area) + + scroll_area.setWidget(scroll_content_widget) + + empty_label = QtWidgets.QLabel( + "

Select plugins to view more information...", + scroll_content_widget + ) + empty_label.setAlignment(QtCore.Qt.AlignCenter) + + content_widget = QtWidgets.QWidget(scroll_content_widget) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(10) + + scroll_content_layout = QtWidgets.QVBoxLayout(scroll_content_widget) + scroll_content_layout.setContentsMargins(0, 0, 0, 0) + scroll_content_layout.addWidget(empty_label, 0) + scroll_content_layout.addWidget(content_widget, 0) + scroll_content_layout.addStretch(1) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(scroll_area, 1) + + content_widget.setVisible(False) + + self._scroll_area = scroll_area + self._empty_label = empty_label + self._content_layout = content_layout + self._content_widget = content_widget + + self._widgets_by_plugin_id = {} + self._stretch_item_index = 0 + + self._is_active = True + self._need_refresh = False + + self._report_item = None + self._plugin_filter = set() + self._plugin_ids = None + + def set_active(self, is_active): + if self._is_active is is_active: + return + self._is_active = is_active + self._update_widgets() + + def set_plugin_filter(self, plugin_filter): + self._need_refresh = True + self._plugin_filter = plugin_filter + self._update_widgets() + + def set_report(self, report): + self._plugin_ids = None + self._plugin_filter = set() + self._need_refresh = True + self._report_item = report + self._update_widgets() + + def _get_plugin_ids(self): + if self._plugin_ids is not None: + return self._plugin_ids + + # Clear layout and clear widgets + while self._content_layout.count(): + item = self._content_layout.takeAt(0) + widget = item.widget() + if widget: + widget.setVisible(False) + widget.deleteLater() + + self._widgets_by_plugin_id.clear() + + plugin_ids = [] + if self._report_item is not None: + plugin_ids = list(self._report_item.plugins_id_order) + self._plugin_ids = plugin_ids + return plugin_ids + + def _update_widgets(self): + if not self._is_active or not self._need_refresh: + return + + self._need_refresh = False + + # Hide content widget before updating + # - add widgets to layout can happen without recalculating + # the layout and widget size hints + self._content_widget.setVisible(False) + + any_visible = False + for plugin_id in self._get_plugin_ids(): + widget = self._widgets_by_plugin_id.get(plugin_id) + if widget is None: + plugin_item = self._report_item.plugins_items_by_id[plugin_id] + widget = PluginDetailsWidget(plugin_item, self._content_widget) + self._widgets_by_plugin_id[plugin_id] = widget + self._content_layout.addWidget(widget, 0) + + is_visible = plugin_id in self._plugin_filter + widget.setVisible(is_visible) + if is_visible: + any_visible = True + + self._content_widget.setVisible(any_visible) + self._empty_label.setVisible(not any_visible) + + class DeselectableTreeView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" @@ -337,11 +654,15 @@ class DetailsPopup(QtWidgets.QDialog): def showEvent(self, event): layout = self.layout() + cw_size = self._center_widget.size() layout.insertWidget(0, self._center_widget) - super().showEvent(event) if self._first_show: self._first_show = False - self.resize(700, 400) + self.resize( + max(cw_size.width(), 700), + max(cw_size.height(), 400) + ) + super().showEvent(event) def closeEvent(self, event): super().closeEvent(event) @@ -410,20 +731,42 @@ class PublishReportViewerWidget(QtWidgets.QFrame): details_widget = QtWidgets.QWidget(self) details_tab_widget = QtWidgets.QTabWidget(details_widget) - details_popup_btn = QtWidgets.QPushButton("PopUp", details_widget) + + btns_widget = QtWidgets.QWidget(details_widget) + + popout_image = QtGui.QImage(get_image_path("popout.png")) + popout_color = get_objected_colors("font") + popout_icon = QtGui.QIcon( + paint_image_with_color(popout_image, popout_color.get_qcolor()) + ) + details_popup_btn = IconButton(btns_widget) + details_popup_btn.setIcon(popout_icon) + details_popup_btn.setToolTip("Pop Out") + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(details_popup_btn, 0) details_layout = QtWidgets.QVBoxLayout(details_widget) details_layout.setContentsMargins(0, 0, 0, 0) details_layout.addWidget(details_tab_widget, 1) - details_layout.addWidget(details_popup_btn, 0) + details_layout.addWidget(btns_widget, 0) details_popup = DetailsPopup(self, details_tab_widget) logs_text_widget = DetailsWidget(details_tab_widget) plugin_load_report_widget = PluginLoadReportWidget(details_tab_widget) + plugins_details_widget = PluginsDetailsWidget(details_tab_widget) + + plugin_load_report_widget.set_active(False) + plugins_details_widget.set_active(False) details_tab_widget.addTab(logs_text_widget, "Logs") - details_tab_widget.addTab(plugin_load_report_widget, "Crashed plugins") + details_tab_widget.addTab(plugins_details_widget, "Plugins Details") + details_tab_widget.addTab( + plugin_load_report_widget, "Crashed plugins" + ) middle_widget = QtWidgets.QWidget(self) middle_layout = QtWidgets.QGridLayout(middle_widget) @@ -440,6 +783,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame): layout.addWidget(middle_widget, 0) layout.addWidget(details_widget, 1) + details_tab_widget.currentChanged.connect(self._on_tab_change) instances_view.selectionModel().selectionChanged.connect( self._on_instance_change ) @@ -458,10 +802,12 @@ class PublishReportViewerWidget(QtWidgets.QFrame): details_popup_btn.clicked.connect(self._on_details_popup) details_popup.closed.connect(self._on_popup_close) + self._current_tab_idx = 0 self._ignore_selection_changes = False self._report_item = None self._logs_text_widget = logs_text_widget self._plugin_load_report_widget = plugin_load_report_widget + self._plugins_details_widget = plugins_details_widget self._removed_instances_check = removed_instances_check self._instances_view = instances_view @@ -498,6 +844,14 @@ class PublishReportViewerWidget(QtWidgets.QFrame): else: self._plugins_view.expand(index) + def set_active(self, active): + for idx in range(self._details_tab_widget.count()): + widget = self._details_tab_widget.widget(idx) + widget.set_active(active and idx == self._current_tab_idx) + + if not active: + self.close_details_popup() + def set_report_data(self, report_data): report = PublishReport(report_data) self.set_report(report) @@ -511,12 +865,22 @@ class PublishReportViewerWidget(QtWidgets.QFrame): self._plugins_model.set_report(report) self._logs_text_widget.set_report(report) self._plugin_load_report_widget.set_report(report) + self._plugins_details_widget.set_report(report) self._ignore_selection_changes = False self._instances_view.expandAll() self._plugins_view.expandAll() + def _on_tab_change(self, new_idx): + if self._current_tab_idx == new_idx: + return + old_widget = self._details_tab_widget.widget(self._current_tab_idx) + new_widget = self._details_tab_widget.widget(new_idx) + self._current_tab_idx = new_idx + old_widget.set_active(False) + new_widget.set_active(True) + def _on_instance_change(self, *_args): if self._ignore_selection_changes: return @@ -538,6 +902,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame): plugin_ids.add(index.data(ITEM_ID_ROLE)) self._logs_text_widget.set_plugin_filter(plugin_ids) + self._plugins_details_widget.set_plugin_filter(plugin_ids) def _on_skipped_plugin_check(self): self._plugins_proxy.set_ignore_skipped( diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/window.py b/client/ayon_core/tools/publisher/publish_report_viewer/window.py index aedc3b9e31..6921c5d162 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/window.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/window.py @@ -2,11 +2,11 @@ import os import json import uuid -import appdirs import arrow from qtpy import QtWidgets, QtCore, QtGui from ayon_core import style +from ayon_core.lib import get_launcher_local_dir from ayon_core.resources import get_ayon_icon_filepath from ayon_core.tools import resources from ayon_core.tools.utils import ( @@ -35,12 +35,8 @@ def get_reports_dir(): str: Path to directory where reports are stored. """ - report_dir = os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - "publish_report_viewer" - ) - if not os.path.exists(report_dir): - os.makedirs(report_dir) + report_dir = get_launcher_local_dir("publish_report_viewer") + os.makedirs(report_dir, exist_ok=True) return report_dir @@ -576,8 +572,7 @@ class LoadedFilesWidget(QtWidgets.QWidget): filepaths = [] for url in mime_data.urls(): filepath = url.toLocalFile() - ext = os.path.splitext(filepath)[-1] - if os.path.exists(filepath) and ext == ".json": + if os.path.exists(filepath): filepaths.append(filepath) self._add_filepaths(filepaths) event.accept() diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index d67252e302..c0e27d9c60 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -217,20 +217,22 @@ class InstanceGroupWidget(BaseGroupWidget): def update_icons(self, group_icons): self._group_icons = group_icons - def update_instance_values(self): + def update_instance_values(self, context_info_by_id): """Trigger update on instance widgets.""" - for widget in self._widgets_by_id.values(): - widget.update_instance_values() + for instance_id, widget in self._widgets_by_id.items(): + widget.update_instance_values(context_info_by_id[instance_id]) - def update_instances(self, instances): + def update_instances(self, instances, context_info_by_id): """Update instances for the group. Args: - instances(list): List of instances in + instances (list[CreatedInstance]): List of instances in CreateContext. - """ + context_info_by_id (Dict[str, InstanceContextInfo]): Instance + context info by instance id. + """ # Store instances by id and by product name instances_by_id = {} instances_by_product_name = collections.defaultdict(list) @@ -249,13 +251,14 @@ class InstanceGroupWidget(BaseGroupWidget): widget_idx = 1 for product_names in sorted_product_names: for instance in instances_by_product_name[product_names]: + context_info = context_info_by_id[instance.id] if instance.id in self._widgets_by_id: widget = self._widgets_by_id[instance.id] - widget.update_instance(instance) + widget.update_instance(instance, context_info) else: group_icon = self._group_icons[instance.creator_identifier] widget = InstanceCardWidget( - instance, group_icon, self + instance, context_info, group_icon, self ) widget.selected.connect(self._on_widget_selection) widget.active_changed.connect(self._on_active_changed) @@ -388,7 +391,7 @@ class ConvertorItemCardWidget(CardWidget): self._icon_widget = icon_widget self._label_widget = label_widget - def update_instance_values(self): + def update_instance_values(self, context_info): pass @@ -397,7 +400,7 @@ class InstanceCardWidget(CardWidget): active_changed = QtCore.Signal(str, bool) - def __init__(self, instance, group_icon, parent): + def __init__(self, instance, context_info, group_icon, parent): super().__init__(parent) self._id = instance.id @@ -458,7 +461,7 @@ class InstanceCardWidget(CardWidget): self._active_checkbox = active_checkbox self._expand_btn = expand_btn - self.update_instance_values() + self.update_instance_values(context_info) def set_active_toggle_enabled(self, enabled): self._active_checkbox.setEnabled(enabled) @@ -480,13 +483,13 @@ class InstanceCardWidget(CardWidget): if checkbox_value != new_value: self._active_checkbox.setChecked(new_value) - def update_instance(self, instance): + def update_instance(self, instance, context_info): """Update instance object and update UI.""" self.instance = instance - self.update_instance_values() + self.update_instance_values(context_info) - def _validate_context(self): - valid = self.instance.has_valid_context + def _validate_context(self, context_info): + valid = context_info.is_valid self._icon_widget.setVisible(valid) self._context_warning.setVisible(not valid) @@ -519,11 +522,11 @@ class InstanceCardWidget(CardWidget): QtCore.Qt.NoTextInteraction ) - def update_instance_values(self): + def update_instance_values(self, context_info): """Update instance data""" self._update_product_name() self.set_active(self.instance["active"]) - self._validate_context() + self._validate_context(context_info) def _set_expanded(self, expanded=None): if expanded is None: @@ -694,6 +697,8 @@ class InstanceCardView(AbstractInstanceView): self._update_convertor_items_group() + context_info_by_id = self._controller.get_instances_context_info() + # Prepare instances by group and identifiers by group instances_by_group = collections.defaultdict(list) identifiers_by_group = collections.defaultdict(set) @@ -747,7 +752,7 @@ class InstanceCardView(AbstractInstanceView): widget_idx += 1 group_widget.update_instances( - instances_by_group[group_name] + instances_by_group[group_name], context_info_by_id ) group_widget.set_active_toggle_enabled( self._active_toggle_enabled @@ -814,8 +819,9 @@ class InstanceCardView(AbstractInstanceView): def refresh_instance_states(self): """Trigger update of instances on group widgets.""" + context_info_by_id = self._controller.get_instances_context_info() for widget in self._widgets_by_group.values(): - widget.update_instance_values() + widget.update_instance_values(context_info_by_id) def _on_active_changed(self, group_name, instance_id, value): group_widget = self._widgets_by_group[group_name] diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index 479a63ebc9..4c94c5c9b9 100644 --- a/client/ayon_core/tools/publisher/widgets/create_widget.py +++ b/client/ayon_core/tools/publisher/widgets/create_widget.py @@ -19,6 +19,7 @@ from ayon_core.tools.publisher.constants import ( INPUTS_LAYOUT_HSPACING, INPUTS_LAYOUT_VSPACING, ) +from ayon_core.tools.utils import HintedLineEdit from .thumbnail_widget import ThumbnailWidget from .widgets import ( @@ -28,8 +29,6 @@ from .widgets import ( from .create_context_widgets import CreateContextWidget from .precreate_widget import PreCreateWidget -SEPARATORS = ("---separator---", "---") - class ResizeControlWidget(QtWidgets.QWidget): resized = QtCore.Signal() @@ -168,25 +167,9 @@ class CreateWidget(QtWidgets.QWidget): product_variant_widget = QtWidgets.QWidget(creator_basics_widget) # Variant and product input - variant_widget = ResizeControlWidget(product_variant_widget) - variant_widget.setObjectName("VariantInputsWidget") - - variant_input = QtWidgets.QLineEdit(variant_widget) - variant_input.setObjectName("VariantInput") - variant_input.setToolTip(VARIANT_TOOLTIP) - - variant_hints_btn = QtWidgets.QToolButton(variant_widget) - variant_hints_btn.setArrowType(QtCore.Qt.DownArrow) - variant_hints_btn.setIconSize(QtCore.QSize(12, 12)) - - variant_hints_menu = QtWidgets.QMenu(variant_widget) - variant_hints_group = QtWidgets.QActionGroup(variant_hints_menu) - - variant_layout = QtWidgets.QHBoxLayout(variant_widget) - variant_layout.setContentsMargins(0, 0, 0, 0) - variant_layout.setSpacing(0) - variant_layout.addWidget(variant_input, 1) - variant_layout.addWidget(variant_hints_btn, 0, QtCore.Qt.AlignVCenter) + variant_widget = HintedLineEdit(parent=product_variant_widget) + variant_widget.set_text_widget_object_name("VariantInput") + variant_widget.setToolTip(VARIANT_TOOLTIP) product_name_input = QtWidgets.QLineEdit(product_variant_widget) product_name_input.setEnabled(False) @@ -262,15 +245,12 @@ class CreateWidget(QtWidgets.QWidget): prereq_timer.timeout.connect(self._invalidate_prereq) create_btn.clicked.connect(self._on_create) - variant_widget.resized.connect(self._on_variant_widget_resize) creator_basics_widget.resized.connect(self._on_creator_basics_resize) - variant_input.returnPressed.connect(self._on_create) - variant_input.textChanged.connect(self._on_variant_change) + variant_widget.returnPressed.connect(self._on_create) + variant_widget.textChanged.connect(self._on_variant_change) creators_view.selectionModel().currentChanged.connect( self._on_creator_item_change ) - variant_hints_btn.clicked.connect(self._on_variant_btn_click) - variant_hints_menu.triggered.connect(self._on_variant_action) context_widget.folder_changed.connect(self._on_folder_change) context_widget.task_changed.connect(self._on_task_change) thumbnail_widget.thumbnail_created.connect(self._on_thumbnail_create) @@ -291,10 +271,7 @@ class CreateWidget(QtWidgets.QWidget): self.product_name_input = product_name_input - self.variant_input = variant_input - self.variant_hints_btn = variant_hints_btn - self.variant_hints_menu = variant_hints_menu - self.variant_hints_group = variant_hints_group + self._variant_widget = variant_widget self._creators_model = creators_model self._creators_sort_model = creators_sort_model @@ -314,6 +291,7 @@ class CreateWidget(QtWidgets.QWidget): self._last_current_context_folder_path = None self._last_current_context_task = None self._use_current_context = True + self._current_creator_variant_hints = [] def get_current_folder_path(self): return self._controller.get_current_folder_path() @@ -438,8 +416,7 @@ class CreateWidget(QtWidgets.QWidget): self._create_btn.setEnabled(prereq_available) - self.variant_input.setEnabled(prereq_available) - self.variant_hints_btn.setEnabled(prereq_available) + self._variant_widget.setEnabled(prereq_available) tooltip = "" if creator_btn_tooltips: @@ -611,35 +588,15 @@ class CreateWidget(QtWidgets.QWidget): if not default_variant: default_variant = default_variants[0] - for action in tuple(self.variant_hints_menu.actions()): - self.variant_hints_menu.removeAction(action) - action.deleteLater() - - for variant in default_variants: - if variant in SEPARATORS: - self.variant_hints_menu.addSeparator() - elif variant: - self.variant_hints_menu.addAction(variant) + self._current_creator_variant_hints = list(default_variants) + self._variant_widget.set_options(default_variants) variant_text = default_variant or DEFAULT_VARIANT_VALUE # Make sure product name is updated to new plugin - if variant_text == self.variant_input.text(): + if variant_text == self._variant_widget.text(): self._on_variant_change() else: - self.variant_input.setText(variant_text) - - def _on_variant_widget_resize(self): - self.variant_hints_btn.setFixedHeight(self.variant_input.height()) - - def _on_variant_btn_click(self): - pos = self.variant_hints_btn.rect().bottomLeft() - point = self.variant_hints_btn.mapToGlobal(pos) - self.variant_hints_menu.popup(point) - - def _on_variant_action(self, action): - value = action.text() - if self.variant_input.text() != value: - self.variant_input.setText(value) + self._variant_widget.setText(variant_text) def _on_variant_change(self, variant_value=None): if not self._prereq_available: @@ -652,7 +609,7 @@ class CreateWidget(QtWidgets.QWidget): return if variant_value is None: - variant_value = self.variant_input.text() + variant_value = self._variant_widget.text() if not self._compiled_name_pattern.match(variant_value): self._create_btn.setEnabled(False) @@ -707,20 +664,12 @@ class CreateWidget(QtWidgets.QWidget): if _result: variant_hints |= set(_result.groups()) - # Remove previous hints from menu - for action in tuple(self.variant_hints_group.actions()): - self.variant_hints_group.removeAction(action) - self.variant_hints_menu.removeAction(action) - action.deleteLater() - - # Add separator if there are hints and menu already has actions - if variant_hints and self.variant_hints_menu.actions(): - self.variant_hints_menu.addSeparator() - + options = list(self._current_creator_variant_hints) + if options: + options.append("---") + options.extend(variant_hints) # Add hints to actions - for variant_hint in variant_hints: - action = self.variant_hints_menu.addAction(variant_hint) - self.variant_hints_group.addAction(action) + self._variant_widget.set_options(options) # Indicate product existence if not variant_value: @@ -741,10 +690,7 @@ class CreateWidget(QtWidgets.QWidget): self._create_btn.setEnabled(variant_is_valid) def _set_variant_state_property(self, state): - current_value = self.variant_input.property("state") - if current_value != state: - self.variant_input.setProperty("state", state) - self.variant_input.style().polish(self.variant_input) + self._variant_widget.set_text_widget_property("state", state) def _on_first_show(self): width = self.width() @@ -776,7 +722,7 @@ class CreateWidget(QtWidgets.QWidget): index = indexes[0] creator_identifier = index.data(CREATOR_IDENTIFIER_ROLE) product_type = index.data(PRODUCT_TYPE_ROLE) - variant = self.variant_input.text() + variant = self._variant_widget.text() # Care about product name only if context change is enabled product_name = None folder_path = None @@ -810,7 +756,7 @@ class CreateWidget(QtWidgets.QWidget): if success: self._set_creator(self._selected_creator) - self.variant_input.setText(variant) + self._variant_widget.setText(variant) self._controller.emit_card_message("Creation finished...") self._last_thumbnail_path = None self._thumbnail_widget.set_current_thumbnails() diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 930d6bb88c..ab9f2db52c 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -115,7 +115,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): active_changed = QtCore.Signal(str, bool) double_clicked = QtCore.Signal() - def __init__(self, instance, parent): + def __init__(self, instance, context_info, parent): super().__init__(parent) self.instance = instance @@ -151,7 +151,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): self._has_valid_context = None - self._set_valid_property(instance.has_valid_context) + self._set_valid_property(context_info.is_valid) def mouseDoubleClickEvent(self, event): widget = self.childAt(event.pos()) @@ -188,12 +188,12 @@ class InstanceListItemWidget(QtWidgets.QWidget): if checkbox_value != new_value: self._active_checkbox.setChecked(new_value) - def update_instance(self, instance): + def update_instance(self, instance, context_info): """Update instance object.""" self.instance = instance - self.update_instance_values() + self.update_instance_values(context_info) - def update_instance_values(self): + def update_instance_values(self, context_info): """Update instance data propagated to widgets.""" # Check product name label = self.instance.label @@ -202,7 +202,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): # Check active state self.set_active(self.instance["active"]) # Check valid states - self._set_valid_property(self.instance.has_valid_context) + self._set_valid_property(context_info.is_valid) def _on_active_change(self): new_value = self._active_checkbox.isChecked() @@ -583,6 +583,8 @@ class InstanceListView(AbstractInstanceView): self._update_convertor_items_group() + context_info_by_id = self._controller.get_instances_context_info() + # Prepare instances by their groups instances_by_group_name = collections.defaultdict(list) group_names = set() @@ -643,13 +645,15 @@ class InstanceListView(AbstractInstanceView): elif activity != instance["active"]: activity = -1 + context_info = context_info_by_id[instance_id] + self._group_by_instance_id[instance_id] = group_name # Remove instance id from `to_remove` if already exists and # trigger update of widget if instance_id in to_remove: to_remove.remove(instance_id) widget = self._widgets_by_id[instance_id] - widget.update_instance(instance) + widget.update_instance(instance, context_info) continue # Create new item and store it as new @@ -695,7 +699,8 @@ class InstanceListView(AbstractInstanceView): group_item.appendRows(new_items) for item, instance in new_items_with_instance: - if not instance.has_valid_context: + context_info = context_info_by_id[instance.id] + if not context_info.is_valid: expand_groups.add(group_name) item_index = self._instance_model.index( item.row(), @@ -704,7 +709,7 @@ class InstanceListView(AbstractInstanceView): ) proxy_index = self._proxy_model.mapFromSource(item_index) widget = InstanceListItemWidget( - instance, self._instance_view + instance, context_info, self._instance_view ) widget.set_active_toggle_enabled( self._active_toggle_enabled @@ -870,8 +875,10 @@ class InstanceListView(AbstractInstanceView): def refresh_instance_states(self): """Trigger update of all instances.""" - for widget in self._widgets_by_id.values(): - widget.update_instance_values() + context_info_by_id = self._controller.get_instances_context_info() + for instance_id, widget in self._widgets_by_id.items(): + context_info = context_info_by_id[instance_id] + widget.update_instance_values(context_info) def _on_active_changed(self, changed_instance_id, new_value): selected_instance_ids, _, _ = self.get_selected_items() diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index 52a45d0881..d00edb9883 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -387,7 +387,7 @@ class OverviewWidget(QtWidgets.QFrame): Returns: list[str]: Selected legacy convertor identifiers. - Example: ['io.openpype.creators.houdini.legacy'] + Example: ['io.ayon.creators.houdini.legacy'] """ _, _, convertor_identifiers = self.get_selected_items() diff --git a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py index 08a0a790b7..0706299f32 100644 --- a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py +++ b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py @@ -1,10 +1,171 @@ import os import tempfile +import uuid from qtpy import QtCore, QtGui, QtWidgets -class ScreenMarquee(QtWidgets.QDialog): +class ScreenMarqueeDialog(QtWidgets.QDialog): + mouse_moved = QtCore.Signal() + mouse_pressed = QtCore.Signal(QtCore.QPoint, str) + mouse_released = QtCore.Signal(QtCore.QPoint) + close_requested = QtCore.Signal() + + def __init__(self, screen: QtCore.QObject, screen_id: str): + super().__init__() + self.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.FramelessWindowHint + | QtCore.Qt.WindowStaysOnTopHint + | QtCore.Qt.CustomizeWindowHint + ) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.setCursor(QtCore.Qt.CrossCursor) + self.setMouseTracking(True) + + screen.geometryChanged.connect(self._fit_screen_geometry) + + self._screen = screen + self._opacity = 100 + self._click_pos = None + self._screen_id = screen_id + + def set_click_pos(self, pos): + self._click_pos = pos + self.repaint() + + def convert_end_pos(self, pos): + glob_pos = self.mapFromGlobal(pos) + new_pos = self._convert_pos(glob_pos) + return self.mapToGlobal(new_pos) + + def paintEvent(self, event): + """Paint event""" + # Convert click and current mouse positions to local space. + mouse_pos = self._convert_pos(self.mapFromGlobal(QtGui.QCursor.pos())) + + rect = event.rect() + fill_path = QtGui.QPainterPath() + fill_path.addRect(rect) + + capture_rect = None + if self._click_pos is not None: + click_pos = self.mapFromGlobal(self._click_pos) + capture_rect = QtCore.QRect(click_pos, mouse_pos) + + # Clear the capture area + sub_path = QtGui.QPainterPath() + sub_path.addRect(capture_rect) + fill_path = fill_path.subtracted(sub_path) + + painter = QtGui.QPainter(self) + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + + # Draw background. Aside from aesthetics, this makes the full + # tool region accept mouse events. + painter.setBrush(QtGui.QColor(0, 0, 0, self._opacity)) + painter.setPen(QtCore.Qt.NoPen) + painter.drawPath(fill_path) + + # Draw cropping markers at current mouse position + pen_color = QtGui.QColor(255, 255, 255, self._opacity) + pen = QtGui.QPen(pen_color, 1, QtCore.Qt.DotLine) + painter.setPen(pen) + painter.drawLine( + rect.left(), mouse_pos.y(), + rect.right(), mouse_pos.y() + ) + painter.drawLine( + mouse_pos.x(), rect.top(), + mouse_pos.x(), rect.bottom() + ) + + # Draw rectangle around selection area + if capture_rect is not None: + pen_color = QtGui.QColor(92, 173, 214) + pen = QtGui.QPen(pen_color, 2) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.NoBrush) + l_x = capture_rect.left() + r_x = capture_rect.right() + if l_x > r_x: + l_x, r_x = r_x, l_x + t_y = capture_rect.top() + b_y = capture_rect.bottom() + if t_y > b_y: + t_y, b_y = b_y, t_y + + # -1 to draw 1px over the border + r_x -= 1 + b_y -= 1 + sel_rect = QtCore.QRect( + QtCore.QPoint(l_x, t_y), + QtCore.QPoint(r_x, b_y) + ) + painter.drawRect(sel_rect) + + painter.end() + + def mousePressEvent(self, event): + """Mouse click event""" + + if event.button() == QtCore.Qt.LeftButton: + # Begin click drag operation + self._click_pos = event.globalPos() + self.mouse_pressed.emit(self._click_pos, self._screen_id) + + def mouseReleaseEvent(self, event): + """Mouse release event""" + if event.button() == QtCore.Qt.LeftButton: + # End click drag operation and commit the current capture rect + self._click_pos = None + self.mouse_released.emit(event.globalPos()) + + def mouseMoveEvent(self, event): + """Mouse move event""" + self.mouse_moved.emit() + + def keyPressEvent(self, event): + """Mouse press event""" + if event.key() == QtCore.Qt.Key_Escape: + self._click_pos = None + event.accept() + self.close_requested.emit() + return + return super().keyPressEvent(event) + + def showEvent(self, event): + super().showEvent(event) + self._fit_screen_geometry() + + def closeEvent(self, event): + self._click_pos = None + super().closeEvent(event) + + def _convert_pos(self, pos): + geo = self.geometry() + if pos.x() > geo.width(): + pos.setX(geo.width() - 1) + elif pos.x() < 0: + pos.setX(0) + + if pos.y() > geo.height(): + pos.setY(geo.height() - 1) + elif pos.y() < 0: + pos.setY(0) + return pos + + def _fit_screen_geometry(self): + # On macOs it is required to set screen explicitly + if hasattr(self, "setScreen"): + self.setScreen(self._screen) + self.setGeometry(self._screen.geometry()) + + +class ScreenMarquee(QtCore.QObject): """Dialog to interactively define screen area. This allows to select a screen area through a marquee selection. @@ -17,187 +178,186 @@ class ScreenMarquee(QtWidgets.QDialog): def __init__(self, parent=None): super().__init__(parent=parent) - self.setWindowFlags( - QtCore.Qt.Window - | QtCore.Qt.FramelessWindowHint - | QtCore.Qt.WindowStaysOnTopHint - | QtCore.Qt.CustomizeWindowHint - ) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - self.setCursor(QtCore.Qt.CrossCursor) - self.setMouseTracking(True) - - app = QtWidgets.QApplication.instance() - if hasattr(app, "screenAdded"): - app.screenAdded.connect(self._on_screen_added) - app.screenRemoved.connect(self._fit_screen_geometry) - elif hasattr(app, "desktop"): - desktop = app.desktop() - desktop.screenCountChanged.connect(self._fit_screen_geometry) - + screens_by_id = {} for screen in QtWidgets.QApplication.screens(): - screen.geometryChanged.connect(self._fit_screen_geometry) + screen_id = uuid.uuid4().hex + screen_dialog = ScreenMarqueeDialog(screen, screen_id) + screens_by_id[screen_id] = screen_dialog + screen_dialog.mouse_moved.connect(self._on_mouse_move) + screen_dialog.mouse_pressed.connect(self._on_mouse_press) + screen_dialog.mouse_released.connect(self._on_mouse_release) + screen_dialog.close_requested.connect(self._on_close_request) - self._opacity = 50 - self._click_pos = None - self._capture_rect = None + self._screens_by_id = screens_by_id + self._finished = False + self._captured = False + self._start_pos = None + self._end_pos = None + self._start_screen_id = None + self._pix = None def get_captured_pixmap(self): - if self._capture_rect is None: + if self._pix is None: return QtGui.QPixmap() + return self._pix - return self.get_desktop_pixmap(self._capture_rect) + def _close_dialogs(self): + for dialog in self._screens_by_id.values(): + dialog.close() - def paintEvent(self, event): - """Paint event""" + def _on_close_request(self): + self._close_dialogs() + self._finished = True - # Convert click and current mouse positions to local space. - mouse_pos = self.mapFromGlobal(QtGui.QCursor.pos()) - click_pos = None - if self._click_pos is not None: - click_pos = self.mapFromGlobal(self._click_pos) - - painter = QtGui.QPainter(self) - painter.setRenderHints( - QtGui.QPainter.Antialiasing - | QtGui.QPainter.SmoothPixmapTransform - ) - - # Draw background. Aside from aesthetics, this makes the full - # tool region accept mouse events. - painter.setBrush(QtGui.QColor(0, 0, 0, self._opacity)) - painter.setPen(QtCore.Qt.NoPen) - rect = event.rect() - fill_path = QtGui.QPainterPath() - fill_path.addRect(rect) - - # Clear the capture area - if click_pos is not None: - sub_path = QtGui.QPainterPath() - capture_rect = QtCore.QRect(click_pos, mouse_pos) - sub_path.addRect(capture_rect) - fill_path = fill_path.subtracted(sub_path) - - painter.drawPath(fill_path) - - pen_color = QtGui.QColor(255, 255, 255, self._opacity) - pen = QtGui.QPen(pen_color, 1, QtCore.Qt.DotLine) - painter.setPen(pen) - - # Draw cropping markers at click position - if click_pos is not None: - painter.drawLine( - rect.left(), click_pos.y(), - rect.right(), click_pos.y() - ) - painter.drawLine( - click_pos.x(), rect.top(), - click_pos.x(), rect.bottom() - ) - - # Draw cropping markers at current mouse position - painter.drawLine( - rect.left(), mouse_pos.y(), - rect.right(), mouse_pos.y() - ) - painter.drawLine( - mouse_pos.x(), rect.top(), - mouse_pos.x(), rect.bottom() - ) - painter.end() - - def mousePressEvent(self, event): - """Mouse click event""" - - if event.button() == QtCore.Qt.LeftButton: - # Begin click drag operation - self._click_pos = event.globalPos() - - def mouseReleaseEvent(self, event): - """Mouse release event""" - if ( - self._click_pos is not None - and event.button() == QtCore.Qt.LeftButton - ): - # End click drag operation and commit the current capture rect - self._capture_rect = QtCore.QRect( - self._click_pos, event.globalPos() - ).normalized() - self._click_pos = None - self.close() - - def mouseMoveEvent(self, event): - """Mouse move event""" - self.repaint() - - def keyPressEvent(self, event): - """Mouse press event""" - if event.key() == QtCore.Qt.Key_Escape: - self._click_pos = None - self._capture_rect = None - event.accept() - self.close() + def _on_mouse_release(self, pos): + start_screen_dialog = self._screens_by_id.get(self._start_screen_id) + if start_screen_dialog is None: + self._finished = True + self._captured = False return - return super().keyPressEvent(event) - def showEvent(self, event): - self._fit_screen_geometry() + end_pos = start_screen_dialog.convert_end_pos(pos) - def _fit_screen_geometry(self): - # Compute the union of all screen geometries, and resize to fit. - workspace_rect = QtCore.QRect() - for screen in QtWidgets.QApplication.screens(): - workspace_rect = workspace_rect.united(screen.geometry()) - self.setGeometry(workspace_rect) + self._close_dialogs() + self._end_pos = end_pos + self._finished = True + self._captured = True - def _on_screen_added(self): - for screen in QtGui.QGuiApplication.screens(): - screen.geometryChanged.connect(self._fit_screen_geometry) + def _on_mouse_press(self, pos, screen_id): + self._start_pos = pos + self._start_screen_id = screen_id + + def _on_mouse_move(self): + for dialog in self._screens_by_id.values(): + dialog.repaint() + + def start_capture(self): + for dialog in self._screens_by_id.values(): + dialog.show() + # Activate so Escape event is not ignored. + dialog.setWindowState(QtCore.Qt.WindowActive) + + app = QtWidgets.QApplication.instance() + while not self._finished: + app.processEvents() + + # Give time to cloe dialogs + for _ in range(2): + app.processEvents() + + if self._captured: + self._pix = self.get_desktop_pixmap( + self._start_pos, self._end_pos + ) @classmethod - def get_desktop_pixmap(cls, rect): + def get_desktop_pixmap(cls, pos_start, pos_end): """Performs a screen capture on the specified rectangle. Args: - rect (QtCore.QRect): The rectangle to capture. + pos_start (QtCore.QPoint): Start of screen capture. + pos_end (QtCore.QPoint): End of screen capture. Returns: QtGui.QPixmap: Captured pixmap image - """ + """ + # Unify start and end points + # - start is top left + # - end is bottom right + if pos_start.y() > pos_end.y(): + pos_start, pos_end = pos_end, pos_start + + if pos_start.x() > pos_end.x(): + new_start = QtCore.QPoint(pos_end.x(), pos_start.y()) + new_end = QtCore.QPoint(pos_start.x(), pos_end.y()) + pos_start = new_start + pos_end = new_end + + # Validate if the rectangle is valid + rect = QtCore.QRect(pos_start, pos_end) if rect.width() < 1 or rect.height() < 1: return QtGui.QPixmap() - screen_pixes = [] - for screen in QtWidgets.QApplication.screens(): - screen_geo = screen.geometry() - if not screen_geo.intersects(rect): - continue + screen = QtWidgets.QApplication.screenAt(pos_start) + return screen.grabWindow( + 0, + pos_start.x() - screen.geometry().x(), + pos_start.y() - screen.geometry().y(), + pos_end.x() - pos_start.x(), + pos_end.y() - pos_start.y() + ) + # Multiscreen capture that does not work + # - does not handle pixel aspect ratio and positioning of screens - screen_pix_rect = screen_geo.intersected(rect) - screen_pix = screen.grabWindow( - 0, - screen_pix_rect.x() - screen_geo.x(), - screen_pix_rect.y() - screen_geo.y(), - screen_pix_rect.width(), screen_pix_rect.height() - ) - paste_point = QtCore.QPoint( - screen_pix_rect.x() - rect.x(), - screen_pix_rect.y() - rect.y() - ) - screen_pixes.append((screen_pix, paste_point)) - - output_pix = QtGui.QPixmap(rect.width(), rect.height()) - output_pix.fill(QtCore.Qt.transparent) - pix_painter = QtGui.QPainter() - pix_painter.begin(output_pix) - for item in screen_pixes: - (screen_pix, offset) = item - pix_painter.drawPixmap(offset, screen_pix) - - pix_painter.end() - - return output_pix + # most_left = None + # most_top = None + # for screen in QtWidgets.QApplication.screens(): + # screen_geo = screen.geometry() + # if most_left is None or most_left > screen_geo.x(): + # most_left = screen_geo.x() + # + # if most_top is None or most_top > screen_geo.y(): + # most_top = screen_geo.y() + # + # most_left = most_left or 0 + # most_top = most_top or 0 + # + # screen_pixes = [] + # for screen in QtWidgets.QApplication.screens(): + # screen_geo = screen.geometry() + # if not screen_geo.intersects(rect): + # continue + # + # pos_l_x = screen_geo.x() + # pos_l_y = screen_geo.y() + # pos_r_x = screen_geo.x() + screen_geo.width() + # pos_r_y = screen_geo.y() + screen_geo.height() + # if pos_start.x() > pos_l_x: + # pos_l_x = pos_start.x() + # + # if pos_start.y() > pos_l_y: + # pos_l_y = pos_start.y() + # + # if pos_end.x() < pos_r_x: + # pos_r_x = pos_end.x() + # + # if pos_end.y() < pos_r_y: + # pos_r_y = pos_end.y() + # + # capture_pos_x = pos_l_x - screen_geo.x() + # capture_pos_y = pos_l_y - screen_geo.y() + # capture_screen_width = pos_r_x - pos_l_x + # capture_screen_height = pos_r_y - pos_l_y + # screen_pix = screen.grabWindow( + # 0, + # capture_pos_x, capture_pos_y, + # capture_screen_width, capture_screen_height + # ) + # paste_point = QtCore.QPoint( + # (pos_l_x - screen_geo.x()) - rect.x(), + # (pos_l_y - screen_geo.y()) - rect.y() + # ) + # screen_pixes.append((screen_pix, paste_point)) + # + # output_pix = QtGui.QPixmap(rect.width(), rect.height()) + # output_pix.fill(QtCore.Qt.transparent) + # pix_painter = QtGui.QPainter() + # pix_painter.begin(output_pix) + # render_hints = ( + # QtGui.QPainter.Antialiasing + # | QtGui.QPainter.SmoothPixmapTransform + # ) + # if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + # render_hints |= QtGui.QPainter.HighQualityAntialiasing + # pix_painter.setRenderHints(render_hints) + # for item in screen_pixes: + # (screen_pix, offset) = item + # pix_painter.drawPixmap(offset, screen_pix) + # + # pix_painter.end() + # + # return output_pix @classmethod def capture_to_pixmap(cls): @@ -209,12 +369,8 @@ class ScreenMarquee(QtWidgets.QDialog): Returns: QtGui.QPixmap: Captured pixmap image. """ - tool = cls() - # Activate so Escape event is not ignored. - tool.setWindowState(QtCore.Qt.WindowActive) - # Exec dialog and return captured pixmap. - tool.exec_() + tool.start_capture() return tool.get_captured_pixmap() @classmethod diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index 1f782ddc67..83a2d9e6c1 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -1182,6 +1182,10 @@ class GlobalAttrsWidget(QtWidgets.QWidget): invalid_tasks = False folder_paths = [] for instance in self._current_instances: + # Ignore instances that have promised context + if instance.has_promised_context: + continue + new_variant_value = instance.get("variant") new_folder_path = instance.get("folderPath") new_task_name = instance.get("task") @@ -1206,7 +1210,6 @@ class GlobalAttrsWidget(QtWidgets.QWidget): except TaskNotSetError: invalid_tasks = True - instance.set_task_invalid(True) product_names.add(instance["productName"]) continue @@ -1216,11 +1219,9 @@ class GlobalAttrsWidget(QtWidgets.QWidget): if folder_path is not None: instance["folderPath"] = folder_path - instance.set_folder_invalid(False) if task_name is not None: instance["task"] = task_name or None - instance.set_task_invalid(False) instance["productName"] = new_product_name @@ -1306,7 +1307,13 @@ class GlobalAttrsWidget(QtWidgets.QWidget): editable = False folder_task_combinations = [] + context_editable = None for instance in instances: + if not instance.has_promised_context: + context_editable = True + elif context_editable is None: + context_editable = False + # NOTE I'm not sure how this can even happen? if instance.creator_identifier is None: editable = False @@ -1319,6 +1326,11 @@ class GlobalAttrsWidget(QtWidgets.QWidget): folder_task_combinations.append((folder_path, task_name)) product_names.add(instance.get("productName") or self.unknown_value) + if not editable: + context_editable = False + elif context_editable is None: + context_editable = True + self.variant_input.set_value(variants) # Set context of folder widget @@ -1329,8 +1341,21 @@ class GlobalAttrsWidget(QtWidgets.QWidget): self.product_value_widget.set_value(product_names) self.variant_input.setEnabled(editable) - self.folder_value_widget.setEnabled(editable) - self.task_value_widget.setEnabled(editable) + self.folder_value_widget.setEnabled(context_editable) + self.task_value_widget.setEnabled(context_editable) + + if not editable: + folder_tooltip = "Select instances to change folder path." + task_tooltip = "Select instances to change task name." + elif not context_editable: + folder_tooltip = "Folder path is defined by Create plugin." + task_tooltip = "Task is defined by Create plugin." + else: + folder_tooltip = "Change folder path of selected instances." + task_tooltip = "Change task of selected instances." + + self.folder_value_widget.setToolTip(folder_tooltip) + self.task_value_widget.setToolTip(task_tooltip) class CreatorAttrsWidget(QtWidgets.QWidget): @@ -1339,7 +1364,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): Attributes are defined on creator so are dynamic. Their look and type is based on attribute definitions that are defined in `~/ayon_core/lib/attribute_definitions.py` and their widget - representation in `~/openpype/tools/attribute_defs/*`. + representation in `~/ayon_core/tools/attribute_defs/*`. Widgets are disabled if context of instance is not valid. @@ -1768,9 +1793,16 @@ class ProductAttributesWidget(QtWidgets.QWidget): self.bottom_separator = bottom_separator def _on_instance_context_changed(self): + instance_ids = { + instance.id + for instance in self._current_instances + } + context_info_by_id = self._controller.get_instances_context_info( + instance_ids + ) all_valid = True - for instance in self._current_instances: - if not instance.has_valid_context: + for instance_id, context_info in context_info_by_id.items(): + if not context_info.is_valid: all_valid = False break @@ -1795,9 +1827,17 @@ class ProductAttributesWidget(QtWidgets.QWidget): convertor_identifiers(List[str]): Identifiers of convert items. """ + instance_ids = { + instance.id + for instance in instances + } + context_info_by_id = self._controller.get_instances_context_info( + instance_ids + ) + all_valid = True - for instance in instances: - if not instance.has_valid_context: + for context_info in context_info_by_id.values(): + if not context_info.is_valid: all_valid = False break diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index 1218221420..a8ca605ecb 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -687,13 +687,14 @@ class PublisherWindow(QtWidgets.QDialog): def _on_tab_change(self, old_tab, new_tab): if old_tab == "details": - self._publish_details_widget.close_details_popup() + self._publish_details_widget.set_active(False) if new_tab == "details": self._content_stacked_layout.setCurrentWidget( self._publish_details_widget ) self._update_publish_details_widget() + self._publish_details_widget.set_active(True) elif new_tab == "report": self._content_stacked_layout.setCurrentWidget( @@ -912,12 +913,18 @@ class PublisherWindow(QtWidgets.QDialog): self._set_footer_enabled(True) return + active_instances_by_id = { + instance.id: instance + for instance in self._controller.get_instances() + if instance["active"] + } + context_info_by_id = self._controller.get_instances_context_info( + active_instances_by_id.keys() + ) all_valid = None - for instance in self._controller.get_instances(): - if not instance["active"]: - continue - - if not instance.has_valid_context: + for instance_id, instance in active_instances_by_id.items(): + context_info = context_info_by_id[instance_id] + if not context_info.is_valid: all_valid = False break diff --git a/client/ayon_core/tools/pyblish_pype/util.py b/client/ayon_core/tools/pyblish_pype/util.py index 09a370c6e4..d24b07a409 100644 --- a/client/ayon_core/tools/pyblish_pype/util.py +++ b/client/ayon_core/tools/pyblish_pype/util.py @@ -135,7 +135,6 @@ class OrderGroups: def env_variable_to_bool(env_key, default=False): """Boolean based on environment variable value.""" - # TODO: move to pype lib value = os.environ.get(env_key) if value is not None: value = value.lower() diff --git a/client/ayon_core/tools/sceneinventory/model.py b/client/ayon_core/tools/sceneinventory/model.py index a40d110476..b7f79986ac 100644 --- a/client/ayon_core/tools/sceneinventory/model.py +++ b/client/ayon_core/tools/sceneinventory/model.py @@ -217,10 +217,7 @@ class InventoryModel(QtGui.QStandardItemModel): version_item = version_items[repre_info.version_id] version_label = format_version(version_item.version) is_hero = version_item.version < 0 - is_latest = version_item.is_latest - # TODO maybe use different colors for last approved and last - # version? Or don't care about color at all? - if not is_latest and not version_item.is_last_approved: + if not version_item.is_latest: version_color = self.OUTDATED_COLOR status_name = version_item.status diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index ad190482a8..39fcc2cdd3 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -10,11 +10,16 @@ import signal import locale from typing import Optional, Dict, Tuple, Any -import ayon_api import requests +from ayon_api.utils import get_default_settings_variant -from ayon_core.lib import Logger, get_ayon_launcher_args, run_detached_process -from ayon_core.lib.local_settings import get_ayon_appdirs +from ayon_core.lib import ( + Logger, + get_ayon_launcher_args, + run_detached_process, + get_ayon_username, +) +from ayon_core.lib.local_settings import get_launcher_local_dir class TrayState: @@ -34,7 +39,7 @@ def _get_default_server_url() -> str: def _get_default_variant() -> str: """Get default settings variant.""" - return ayon_api.get_default_settings_variant() + return get_default_settings_variant() def _get_server_and_variant( @@ -141,18 +146,7 @@ def get_tray_storage_dir() -> str: str: Tray storage directory where metadata files are stored. """ - return get_ayon_appdirs("tray") - - -def _get_tray_information(tray_url: str) -> Optional[Dict[str, Any]]: - if not tray_url: - return None - try: - response = requests.get(f"{tray_url}/tray") - response.raise_for_status() - return response.json() - except (requests.HTTPError, requests.ConnectionError): - return None + return get_launcher_local_dir("tray") def _get_tray_info_filepath( @@ -165,6 +159,51 @@ def _get_tray_info_filepath( return os.path.join(hash_dir, filename) +def _get_tray_file_info( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> Tuple[Optional[Dict[str, Any]], Optional[float]]: + filepath = _get_tray_info_filepath(server_url, variant) + if not os.path.exists(filepath): + return None, None + file_modified = os.path.getmtime(filepath) + try: + with open(filepath, "r") as stream: + data = json.load(stream) + except Exception: + return None, file_modified + + return data, file_modified + + +def _remove_tray_server_url( + server_url: Optional[str], + variant: Optional[str], + file_modified: Optional[float], +): + """Remove tray information file. + + Called from tray logic, do not use on your own. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + file_modified (Optional[float]): File modified timestamp. Is validated + against current state of file. + + """ + filepath = _get_tray_info_filepath(server_url, variant) + if not os.path.exists(filepath): + return + + if ( + file_modified is not None + and os.path.getmtime(filepath) != file_modified + ): + return + os.remove(filepath) + + def get_tray_file_info( server_url: Optional[str] = None, variant: Optional[str] = None @@ -182,15 +221,156 @@ def get_tray_file_info( Optional[Dict[str, Any]]: Tray information. """ - filepath = _get_tray_info_filepath(server_url, variant) - if not os.path.exists(filepath): + file_info, _ = _get_tray_file_info(server_url, variant) + return file_info + + +def _get_tray_rest_information(tray_url: str) -> Optional[Dict[str, Any]]: + if not tray_url: return None try: - with open(filepath, "r") as stream: - data = json.load(stream) - except Exception: + response = requests.get(f"{tray_url}/tray") + response.raise_for_status() + return response.json() + except (requests.HTTPError, requests.ConnectionError): return None - return data + + +class TrayInfo: + def __init__( + self, + server_url: str, + variant: str, + timeout: Optional[int] = None + ): + self.server_url = server_url + self.variant = variant + + if timeout is None: + timeout = 10 + + self._timeout = timeout + + self._file_modified = None + self._file_info = None + self._file_info_cached = False + self._tray_info = None + self._tray_info_cached = False + self._file_state = None + self._state = None + + @classmethod + def new( + cls, + server_url: Optional[str] = None, + variant: Optional[str] = None, + timeout: Optional[int] = None, + wait_to_start: Optional[bool] = True + ) -> "TrayInfo": + server_url, variant = _get_server_and_variant(server_url, variant) + obj = cls(server_url, variant, timeout=timeout) + if wait_to_start: + obj.wait_to_start() + return obj + + def get_pid(self) -> Optional[int]: + file_info = self.get_file_info() + if file_info: + return file_info.get("pid") + return None + + def reset(self): + self._file_modified = None + self._file_info = None + self._file_info_cached = False + self._tray_info = None + self._tray_info_cached = False + self._state = None + self._file_state = None + + def get_file_info(self) -> Optional[Dict[str, Any]]: + if not self._file_info_cached: + file_info, file_modified = _get_tray_file_info( + self.server_url, self.variant + ) + self._file_info = file_info + self._file_modified = file_modified + self._file_info_cached = True + return self._file_info + + def get_file_url(self) -> Optional[str]: + file_info = self.get_file_info() + if file_info: + return file_info.get("url") + return None + + def get_tray_url(self) -> Optional[str]: + info = self.get_tray_info() + if info: + return self.get_file_url() + return None + + def get_tray_info(self) -> Optional[Dict[str, Any]]: + if self._tray_info_cached: + return self._tray_info + + tray_url = self.get_file_url() + tray_info = None + if tray_url: + tray_info = _get_tray_rest_information(tray_url) + + self._tray_info = tray_info + self._tray_info_cached = True + return self._tray_info + + def get_file_state(self) -> int: + if self._file_state is not None: + return self._file_state + + state = TrayState.NOT_RUNNING + file_info = self.get_file_info() + if file_info: + state = TrayState.STARTING + if file_info.get("started") is True: + state = TrayState.RUNNING + self._file_state = state + return self._file_state + + def get_state(self) -> int: + if self._state is not None: + return self._state + + state = self.get_file_state() + if state == TrayState.RUNNING and not self.get_tray_info(): + state = TrayState.NOT_RUNNING + pid = self.pid + if pid: + _kill_tray_process(pid) + # Remove the file as tray is not running anymore and update + # the state of this object. + _remove_tray_server_url( + self.server_url, self.variant, self._file_modified + ) + self.reset() + + self._state = state + return self._state + + def get_ayon_username(self) -> Optional[str]: + tray_info = self.get_tray_info() + if tray_info: + return tray_info.get("username") + return None + + def wait_to_start(self) -> bool: + _wait_for_starting_tray( + self.server_url, self.variant, self._timeout + ) + self.reset() + return self.get_file_state() == TrayState.RUNNING + + pid = property(get_pid) + state = property(get_state) def get_tray_server_url( @@ -214,25 +394,12 @@ def get_tray_server_url( Optional[str]: Tray server url. """ - data = get_tray_file_info(server_url, variant) - if data is None: - return None - - if data.get("started") is False: - data = _wait_for_starting_tray(server_url, variant, timeout) - if data is None: - return None - - url = data.get("url") - if not url: - return None - - if not validate: - return url - - if _get_tray_information(url): - return url - return None + tray_info = TrayInfo.new( + server_url, variant, timeout, wait_to_start=True + ) + if validate: + return tray_info.get_tray_url() + return tray_info.get_file_url() def set_tray_server_url(tray_url: Optional[str], started: bool): @@ -246,10 +413,13 @@ def set_tray_server_url(tray_url: Optional[str], started: bool): that tray is starting up. """ - file_info = get_tray_file_info() - if file_info and file_info["pid"] != os.getpid(): - if not file_info["started"] or _get_tray_information(file_info["url"]): - raise TrayIsRunningError("Tray is already running.") + info = TrayInfo.new(wait_to_start=False) + if ( + info.pid + and info.pid != os.getpid() + and info.state in (TrayState.RUNNING, TrayState.STARTING) + ): + raise TrayIsRunningError("Tray is already running.") filepath = _get_tray_info_filepath() os.makedirs(os.path.dirname(filepath), exist_ok=True) @@ -292,20 +462,21 @@ def remove_tray_server_url(force: Optional[bool] = False): def get_tray_information( server_url: Optional[str] = None, - variant: Optional[str] = None -) -> Optional[Dict[str, Any]]: + variant: Optional[str] = None, + timeout: Optional[int] = None, +) -> TrayInfo: """Get information about tray. Args: server_url (Optional[str]): AYON server url. variant (Optional[str]): Settings variant. + timeout (Optional[int]): Timeout for tray start-up. Returns: - Optional[Dict[str, Any]]: Tray information. + TrayInfo: Tray information. """ - tray_url = get_tray_server_url(server_url, variant) - return _get_tray_information(tray_url) + return TrayInfo.new(server_url, variant, timeout) def get_tray_state( @@ -322,20 +493,8 @@ def get_tray_state( int: Tray state. """ - file_info = get_tray_file_info(server_url, variant) - if file_info is None: - return TrayState.NOT_RUNNING - - if file_info.get("started") is False: - return TrayState.STARTING - - tray_url = file_info.get("url") - info = _get_tray_information(tray_url) - if not info: - # Remove the information as the tray is not running - remove_tray_server_url(force=True) - return TrayState.NOT_RUNNING - return TrayState.RUNNING + tray_info = get_tray_information(server_url, variant) + return tray_info.state def is_tray_running( @@ -392,6 +551,7 @@ def show_message_in_tray( def make_sure_tray_is_running( ayon_url: Optional[str] = None, variant: Optional[str] = None, + username: Optional[str] = None, env: Optional[Dict[str, str]] = None ): """Make sure that tray for AYON url and variant is running. @@ -399,23 +559,26 @@ def make_sure_tray_is_running( Args: ayon_url (Optional[str]): AYON server url. variant (Optional[str]): Settings variant. + username (Optional[str]): Username under which should be tray running. env (Optional[Dict[str, str]]): Environment variables for the process. """ - state = get_tray_state(ayon_url, variant) - if state == TrayState.RUNNING: - return + tray_info = TrayInfo.new( + ayon_url, variant, wait_to_start=False + ) + if tray_info.state == TrayState.STARTING: + tray_info.wait_to_start() - if state == TrayState.STARTING: - _wait_for_starting_tray(ayon_url, variant) - state = get_tray_state(ayon_url, variant) - if state == TrayState.RUNNING: + if tray_info.state == TrayState.RUNNING: + if not username: + username = get_ayon_username() + if tray_info.get_ayon_username() == username: return args = get_ayon_launcher_args("tray", "--force") if env is None: env = os.environ.copy() - + # Make sure 'QT_API' is not set env.pop("QT_API", None) @@ -435,38 +598,51 @@ def main(force=False): Logger.set_process_name("Tray") - state = get_tray_state() - if force and state in (TrayState.RUNNING, TrayState.STARTING): - file_info = get_tray_file_info() or {} - pid = file_info.get("pid") + tray_info = TrayInfo.new(wait_to_start=False) + + file_state = tray_info.get_file_state() + if force and file_state in (TrayState.RUNNING, TrayState.STARTING): + pid = tray_info.pid if pid is not None: _kill_tray_process(pid) remove_tray_server_url(force=True) - state = TrayState.NOT_RUNNING + file_state = TrayState.NOT_RUNNING - if state == TrayState.RUNNING: - show_message_in_tray( - "Tray is already running", - "Your AYON tray application is already running." - ) - print("Tray is already running.") - return + if file_state in (TrayState.RUNNING, TrayState.STARTING): + expected_username = get_ayon_username() + username = tray_info.get_ayon_username() + # TODO probably show some message to the user??? + if expected_username != username: + pid = tray_info.pid + if pid is not None: + _kill_tray_process(pid) + remove_tray_server_url(force=True) + file_state = TrayState.NOT_RUNNING - if state == TrayState.STARTING: + if file_state == TrayState.RUNNING: + if tray_info.get_state() == TrayState.RUNNING: + show_message_in_tray( + "Tray is already running", + "Your AYON tray application is already running." + ) + print("Tray is already running.") + return + file_state = tray_info.get_file_state() + + if file_state == TrayState.STARTING: print("Tray is starting. Waiting for it to start.") - _wait_for_starting_tray() - state = get_tray_state() - if state == TrayState.RUNNING: + tray_info.wait_to_start() + file_state = tray_info.get_file_state() + if file_state == TrayState.RUNNING: print("Tray started. Exiting.") return - if state == TrayState.STARTING: + if file_state == TrayState.STARTING: print( "Tray did not start in expected time." " Killing the process and starting new." ) - file_info = get_tray_file_info() or {} - pid = file_info.get("pid") + pid = tray_info.pid if pid is not None: _kill_tray_process(pid) remove_tray_server_url(force=True) diff --git a/client/ayon_core/tools/tray/ui/addons_manager.py b/client/ayon_core/tools/tray/ui/addons_manager.py index 3fe4bb8dd8..2e6f0c0aae 100644 --- a/client/ayon_core/tools/tray/ui/addons_manager.py +++ b/client/ayon_core/tools/tray/ui/addons_manager.py @@ -237,11 +237,8 @@ class TrayAddonsManager(AddonsManager): webserver_url = self.webserver_url statics_url = f"{webserver_url}/res" + # Deprecated # TODO stop using these env variables # - function 'get_tray_server_url' should be used instead os.environ[self.webserver_url_env] = webserver_url os.environ["AYON_STATICS_SERVER"] = statics_url - - # Deprecated - os.environ["OPENPYPE_WEBSERVER_URL"] = webserver_url - os.environ["OPENPYPE_STATICS_SERVER"] = statics_url diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 4b5fbeaf67..4714e76ea3 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -5,6 +5,8 @@ from .widgets import ( ComboBox, CustomTextComboBox, PlaceholderLineEdit, + ElideLabel, + HintedLineEdit, ExpandingTextEdit, BaseClickableFrame, ClickableFrame, @@ -36,7 +38,6 @@ from .lib import ( qt_app_context, get_qt_app, get_ayon_qt_app, - get_openpype_qt_app, get_qt_icon, ) @@ -88,6 +89,8 @@ __all__ = ( "ComboBox", "CustomTextComboBox", "PlaceholderLineEdit", + "ElideLabel", + "HintedLineEdit", "ExpandingTextEdit", "BaseClickableFrame", "ClickableFrame", @@ -118,7 +121,6 @@ __all__ = ( "qt_app_context", "get_qt_app", "get_ayon_qt_app", - "get_openpype_qt_app", "get_qt_icon", "RecursiveSortFilterProxyModel", diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index 8689a97451..200e281664 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -196,10 +196,6 @@ def get_ayon_qt_app(): return app -def get_openpype_qt_app(): - return get_ayon_qt_app() - - def iter_model_rows(model, column=0, include_root=False): """Iterate over all row indices in a model""" indexes_queue = collections.deque() diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 28331fbc35..73c8819758 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -1,4 +1,5 @@ import logging +from typing import Optional, List, Set, Any from qtpy import QtWidgets, QtCore, QtGui import qargparse @@ -11,7 +12,7 @@ from ayon_core.style import ( ) from ayon_core.lib.attribute_definitions import AbstractAttrDef -from .lib import get_qta_icon_by_name_and_color +from .lib import get_qta_icon_by_name_and_color, set_style_property log = logging.getLogger(__name__) @@ -104,6 +105,253 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit): self.setPalette(filter_palette) +class ElideLabel(QtWidgets.QLabel): + """Label which elide text. + + By default, elide happens on right side. Can be changed with + 'set_elide_mode' method. + + It is not possible to use other features of QLabel like word wrap or + interactive text. This is a simple label which elide text. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Preferred + ) + # Store text set during init + self._text = self.text() + # Define initial elide mode + self._elide_mode = QtCore.Qt.ElideRight + # Make sure that text of QLabel is empty + super().setText("") + + def setText(self, text): + # Update private text attribute and force update + self._text = text + self.update() + + def setWordWrap(self, word_wrap): + # Word wrap is not supported in 'ElideLabel' + if word_wrap: + raise ValueError("Word wrap is not supported in 'ElideLabel'.") + + def contextMenuEvent(self, event): + menu = self.create_context_menu(event.pos()) + if menu is None: + event.ignore() + return + event.accept() + menu.setAttribute(QtCore.Qt.WA_DeleteOnClose) + menu.popup(event.globalPos()) + + def create_context_menu(self, pos): + if not self._text: + return None + menu = QtWidgets.QMenu(self) + + # Copy text action + copy_action = menu.addAction("Copy") + copy_action.setObjectName("edit-copy") + icon = QtGui.QIcon.fromTheme("edit-copy") + if not icon.isNull(): + copy_action.setIcon(icon) + + copy_action.triggered.connect(self._on_copy_text) + return menu + + def set_set(self, text): + self.setText(text) + + def set_elide_mode(self, elide_mode): + """Change elide type. + + Args: + elide_mode: Possible elide type. Available in 'QtCore.Qt' + 'ElideLeft', 'ElideRight' and 'ElideMiddle'. + + """ + if elide_mode == QtCore.Qt.ElideNone: + raise ValueError( + "Invalid elide type. 'ElideNone' is not supported." + ) + + if elide_mode not in ( + QtCore.Qt.ElideLeft, + QtCore.Qt.ElideRight, + QtCore.Qt.ElideMiddle, + ): + raise ValueError(f"Unknown value '{elide_mode}'") + self._elide_mode = elide_mode + self.update() + + def paintEvent(self, event): + super().paintEvent(event) + + painter = QtGui.QPainter(self) + fm = painter.fontMetrics() + elided_line = fm.elidedText( + self._text, self._elide_mode, self.width() + ) + painter.drawText(QtCore.QPoint(0, fm.ascent()), elided_line) + + def _on_copy_text(self): + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(self._text) + + +class _LocalCache: + down_arrow_icon = None + + +def get_down_arrow_icon() -> QtGui.QIcon: + if _LocalCache.down_arrow_icon is not None: + return _LocalCache.down_arrow_icon + + normal_pixmap = QtGui.QPixmap( + get_style_image_path("down_arrow") + ) + on_pixmap = QtGui.QPixmap( + get_style_image_path("down_arrow_on") + ) + disabled_pixmap = QtGui.QPixmap( + get_style_image_path("down_arrow_disabled") + ) + icon = QtGui.QIcon(normal_pixmap) + icon.addPixmap(on_pixmap, QtGui.QIcon.Active) + icon.addPixmap(disabled_pixmap, QtGui.QIcon.Disabled) + _LocalCache.down_arrow_icon = icon + return icon + + +# These are placeholders for adding style +class HintedLineEditInput(PlaceholderLineEdit): + pass + + +class HintedLineEditButton(QtWidgets.QPushButton): + pass + + +class HintedLineEdit(QtWidgets.QWidget): + SEPARATORS: Set[str] = {"---", "---separator---"} + returnPressed = QtCore.Signal() + textChanged = QtCore.Signal(str) + textEdited = QtCore.Signal(str) + + def __init__( + self, + options: Optional[List[str]] = None, + parent: Optional[QtWidgets.QWidget] = None + ): + super().__init__(parent) + + text_input = HintedLineEditInput(self) + options_button = HintedLineEditButton(self) + options_button.setIcon(get_down_arrow_icon()) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(text_input, 1) + main_layout.addWidget(options_button, 0) + + # Expand line edit and button vertically so they have same height + for widget in (text_input, options_button): + w_size_policy = widget.sizePolicy() + w_size_policy.setVerticalPolicy( + QtWidgets.QSizePolicy.MinimumExpanding) + widget.setSizePolicy(w_size_policy) + + # Set size hint of this frame to fixed so size hint height is + # used as fixed height + size_policy = self.sizePolicy() + size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Fixed) + self.setSizePolicy(size_policy) + + text_input.returnPressed.connect(self.returnPressed) + text_input.textChanged.connect(self.textChanged) + text_input.textEdited.connect(self.textEdited) + options_button.clicked.connect(self._on_options_button_clicked) + + self._text_input = text_input + self._options_button = options_button + self._options = None + + # Set default state + self.set_options(options) + + def text(self) -> str: + return self._text_input.text() + + def setText(self, text: str): + self._text_input.setText(text) + + def setPlaceholderText(self, text: str): + self._text_input.setPlaceholderText(text) + + def placeholderText(self) -> str: + return self._text_input.placeholderText() + + def setReadOnly(self, state: bool): + self._text_input.setReadOnly(state) + + def setIcon(self, icon: QtGui.QIcon): + self._options_button.setIcon(icon) + + def setToolTip(self, text: str): + self._text_input.setToolTip(text) + + def set_button_tool_tip(self, text: str): + self._options_button.setToolTip(text) + + def set_options(self, options: Optional[List[str]] = None): + self._options = options + self._options_button.setEnabled(bool(options)) + + def sizeHint(self) -> QtCore.QSize: + hint = super().sizeHint() + tsz = self._text_input.sizeHint() + bsz = self._options_button.sizeHint() + hint.setHeight(max(tsz.height(), bsz.height())) + return hint + + # Adds ability to change style of the widgets + # - because style change of the 'HintedLineEdit' may not propagate + # correctly 'HintedLineEditInput' and 'HintedLineEditButton' + def set_text_widget_object_name(self, name: str): + self._text_input.setObjectName(name) + + def set_text_widget_property(self, name: str, value: Any): + set_style_property(self._text_input, name, value) + + def set_button_widget_object_name(self, name: str): + self._text_input.setObjectName(name) + + def set_button_widget_property(self, name: str, value: Any): + set_style_property(self._options_button, name, value) + + def _on_options_button_clicked(self): + if not self._options: + return + + menu = QtWidgets.QMenu(self) + menu.triggered.connect(self._on_option_action) + for option in self._options: + if option in self.SEPARATORS: + menu.addSeparator() + else: + menu.addAction(option) + + rect = self._options_button.rect() + pos = self._options_button.mapToGlobal(rect.bottomLeft()) + menu.exec_(pos) + + def _on_option_action(self, action): + self.setText(action.text()) + + class ExpandingTextEdit(QtWidgets.QTextEdit): """QTextEdit which does not have sroll area but expands height.""" @@ -206,6 +454,8 @@ class ExpandBtnLabel(QtWidgets.QLabel): """Label showing expand icon meant for ExpandBtn.""" state_changed = QtCore.Signal() + branch_closed_path = get_style_image_path("branch_closed") + branch_open_path = get_style_image_path("branch_open") def __init__(self, parent): super(ExpandBtnLabel, self).__init__(parent) @@ -216,14 +466,10 @@ class ExpandBtnLabel(QtWidgets.QLabel): self._collapsed = True def _create_collapsed_pixmap(self): - return QtGui.QPixmap( - get_style_image_path("branch_closed") - ) + return QtGui.QPixmap(self.branch_closed_path) def _create_expanded_pixmap(self): - return QtGui.QPixmap( - get_style_image_path("branch_open") - ) + return QtGui.QPixmap(self.branch_open_path) @property def collapsed(self): @@ -291,15 +537,14 @@ class ExpandBtn(ClickableFrame): class ClassicExpandBtnLabel(ExpandBtnLabel): + right_arrow_path = get_style_image_path("right_arrow") + down_arrow_path = get_style_image_path("down_arrow") + def _create_collapsed_pixmap(self): - return QtGui.QPixmap( - get_style_image_path("right_arrow") - ) + return QtGui.QPixmap(self.right_arrow_path) def _create_expanded_pixmap(self): - return QtGui.QPixmap( - get_style_image_path("down_arrow") - ) + return QtGui.QPixmap(self.down_arrow_path) class ClassicExpandBtn(ExpandBtn): diff --git a/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py b/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py index c8b0c777de..496278ac6f 100644 --- a/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py +++ b/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py @@ -130,7 +130,7 @@ def main(title="Scripts", parent=None, objectName=None): # Register control + shift callback to add to shelf (maya behavior) modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier - if int(cmds.about(version=True)) <= 2025: + if int(cmds.about(version=True)) < 2025: modifiers = int(modifiers) menu.register_callback(modifiers, to_shelf) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index a8c42ec80a..3ee3c976b9 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON core addon version.""" -__version__ = "0.4.3-dev.1" +__version__ = "0.4.5-dev.1" diff --git a/client/pyproject.toml b/client/pyproject.toml index ca88a37125..a0be9605b6 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -15,6 +15,6 @@ qtawesome = "0.7.3" aiohttp-middlewares = "^2.0.0" Click = "^8" OpenTimelineIO = "0.16.0" -opencolorio = "2.2.1" +opencolorio = "^2.3.2" Pillow = "9.5.0" websocket-client = ">=0.40.0,<2" diff --git a/package.py b/package.py index 4f2d2b16b4..26c004ae84 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "0.4.3-dev.1" +version = "0.4.5-dev.1" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 27b8387cbd..db98ee4eba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ target-version = "py39" [tool.ruff.lint] # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -select = ["E4", "E7", "E9", "F"] +select = ["E4", "E7", "E9", "F", "W"] ignore = [] # Allow fix for all enabled rules (when `--fix`) is provided. @@ -85,7 +85,6 @@ exclude = [ [tool.ruff.lint.per-file-ignores] "client/ayon_core/lib/__init__.py" = ["E402"] -"client/ayon_core/hosts/max/startup/startup.py" = ["E402"] [tool.ruff.format] # Like Black, use double quotes for strings. diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 1ca487969f..61972e64c4 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -57,7 +57,7 @@ class CollectFramesFixDefModel(BaseSettingsModel): True, title="Show 'Rewrite latest version' toggle" ) - + class ContributionLayersModel(BaseSettingsModel): _layout = "compact" @@ -84,6 +84,17 @@ class CollectUSDLayerContributionsModel(BaseSettingsModel): return value +class AyonEntityURIModel(BaseSettingsModel): + use_ayon_entity_uri: bool = SettingsField( + title="Use AYON Entity URI", + description=( + "When enabled the USD paths written using the contribution " + "workflow will use ayon entity URIs instead of resolved published " + "paths. You can only load these if you use the AYON USD Resolver." + ) + ) + + class PluginStateByHostModelProfile(BaseSettingsModel): _layout = "expanded" # Filtering @@ -562,12 +573,12 @@ class ExtractBurninDef(BaseSettingsModel): _isGroup = True _layout = "expanded" name: str = SettingsField("") - TOP_LEFT: str = SettingsField("", topic="Top Left") - TOP_CENTERED: str = SettingsField("", topic="Top Centered") - TOP_RIGHT: str = SettingsField("", topic="Top Right") - BOTTOM_LEFT: str = SettingsField("", topic="Bottom Left") - BOTTOM_CENTERED: str = SettingsField("", topic="Bottom Centered") - BOTTOM_RIGHT: str = SettingsField("", topic="Bottom Right") + TOP_LEFT: str = SettingsField("", title="Top Left") + TOP_CENTERED: str = SettingsField("", title="Top Centered") + TOP_RIGHT: str = SettingsField("", title="Top Right") + BOTTOM_LEFT: str = SettingsField("", title="Bottom Left") + BOTTOM_CENTERED: str = SettingsField("", title="Bottom Centered") + BOTTOM_RIGHT: str = SettingsField("", title="Bottom Right") filter: ExtractBurninDefFilter = SettingsField( default_factory=ExtractBurninDefFilter, title="Additional filtering" @@ -857,6 +868,14 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ExtractBurninModel, title="Extract Burnin" ) + ExtractUSDAssetContribution: AyonEntityURIModel = SettingsField( + default_factory=AyonEntityURIModel, + title="Extract USD Asset Contribution", + ) + ExtractUSDLayerContribution: AyonEntityURIModel = SettingsField( + default_factory=AyonEntityURIModel, + title="Extract USD Layer Contribution", + ) PreIntegrateThumbnails: PreIntegrateThumbnailsModel = SettingsField( default_factory=PreIntegrateThumbnailsModel, title="Override Integrate Thumbnail Representations" @@ -1012,7 +1031,8 @@ DEFAULT_PUBLISH_VALUES = { "ext": "png", "tags": [ "ftrackreview", - "kitsureview" + "kitsureview", + "webreview" ], "burnins": [], "ffmpeg_args": { @@ -1052,7 +1072,8 @@ DEFAULT_PUBLISH_VALUES = { "tags": [ "burnin", "ftrackreview", - "kitsureview" + "kitsureview", + "webreview" ], "burnins": [], "ffmpeg_args": { @@ -1064,7 +1085,10 @@ DEFAULT_PUBLISH_VALUES = { "output": [ "-pix_fmt yuv420p", "-crf 18", - "-intra" + "-c:a aac", + "-b:a 192k", + "-g 1", + "-movflags faststart" ] }, "filter": { @@ -1162,6 +1186,12 @@ DEFAULT_PUBLISH_VALUES = { } ] }, + "ExtractUSDAssetContribution": { + "use_ayon_entity_uri": False, + }, + "ExtractUSDLayerContribution": { + "use_ayon_entity_uri": False, + }, "PreIntegrateThumbnails": { "enabled": True, "integrate_profiles": [] diff --git a/server/settings/tools.py b/server/settings/tools.py index 85a66f6a70..a2785c1edf 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -22,6 +22,7 @@ class ProductTypeSmartSelectModel(BaseSettingsModel): class ProductNameProfile(BaseSettingsModel): _layout = "expanded" + product_types: list[str] = SettingsField( default_factory=list, title="Product types" ) @@ -65,6 +66,15 @@ class CreatorToolModel(BaseSettingsModel): title="Create Smart Select" ) ) + # TODO: change to False in next releases + use_legacy_product_names_for_renders: bool = SettingsField( + True, + title="Use legacy product names for renders", + description="Use product naming templates for renders. " + "This is for backwards compatibility enabled by default." + "When enabled, it will ignore any templates for renders " + "that are set in the product name profiles.") + product_name_profiles: list[ProductNameProfile] = SettingsField( default_factory=list, title="Product name profiles"