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 @@ # ------------------------- 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 @@ "AYON_CORE_ROOT", "PACKAGE_DIR", "PLUGINS_DIR", - "AYON_SERVER_ENABLED", - "USE_AYON_ENTITIES", ) diff --git a/client/ayon_core/addon/README.md b/client/ayon_core/addon/README.md index e1c04ea0d6..ded2d50e9c 100644 --- a/client/ayon_core/addon/README.md +++ b/client/ayon_core/addon/README.md @@ -86,7 +86,3 @@ AYON addons should contain separated logic of specific kind of implementation, s "inventory": [] } ``` - -### TrayAddonsManager -- inherits from `AddonsManager` -- has specific implementation for AYON Tray and handle `ITrayAddon` methods diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index fe8865c730..6a7ce8a3cb 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -9,12 +9,18 @@ ) from .base import ( + ProcessPreparationError, + ProcessContext, AYONAddon, AddonsManager, - TrayAddonsManager, load_addons, ) +from .utils import ( + ensure_addons_are_process_context_ready, + ensure_addons_are_process_ready, +) + __all__ = ( "click_wrap", @@ -25,8 +31,12 @@ "ITrayService", "IHostAddon", + "ProcessPreparationError", + "ProcessContext", "AYONAddon", "AddonsManager", - "TrayAddonsManager", "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 308494b4d8..982626ad9d 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -10,20 +10,23 @@ 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 ( IPluginPaths, IHostAddon, - ITrayAddon, - ITrayService ) # Files that will be always ignored on addons import @@ -33,9 +36,6 @@ # 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 @@ -66,77 +66,65 @@ } -# 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. - Object of this class can be stored to `sys.modules` and used for storing - dynamically imported modules. - """ + 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. - def __init__(self, name): - # Call setattr on super class - super(_ModuleClass, self).__setattr__("name", name) - super(_ModuleClass, self).__setattr__("__name__", name) + """ + pass - # Where modules and interfaces are stored - super(_ModuleClass, self).__setattr__("__attributes__", dict()) - super(_ModuleClass, self).__setattr__("__defaults__", set()) - super(_ModuleClass, self).__setattr__("_log", None) +class ProcessContext: + """Hold context of process that is going to be started. - 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] + 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 __iter__(self): - for module in self.values(): - yield module - - 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 + 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 __setitem__(self, key, value): - self.__setattr__(key, value) + At the moment of creation is 'ProcessContext' only data holder, but that + might change in future if there will be need. - 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() + 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 values(self): - return self.__attributes__.values() + """ + 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 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): @@ -237,10 +225,10 @@ def _handle_moved_addons(addon_name, milestone_version, log): "client", ) if not os.path.exists(addon_dir): - log.error(( - "Addon '{}' is not be available." - " Please update applications addon to '{}' or higher." - ).format(addon_name, milestone_version)) + log.error( + f"Addon '{addon_name}' is not available. Please update " + f"{addon_name} addon to '{milestone_version}' or higher." + ) return None log.warning(( @@ -250,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 @@ -258,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 = {} @@ -300,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, {}) @@ -339,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 @@ -366,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: @@ -375,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) + return all_addon_modules - sys.modules[new_import_str] = mod - setattr(openpype_modules, addon_alias, mod) - return addons_to_skip_in_core - - -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: @@ -427,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 @@ -448,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 @@ -530,8 +464,8 @@ def id(self): Returns: str: Object id. - """ + """ if self._id is None: self._id = uuid4() return self._id @@ -543,8 +477,8 @@ def name(self): Returns: str: Addon name. - """ + """ pass @property @@ -575,18 +509,40 @@ def initialize(self, settings): 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): @@ -596,8 +552,8 @@ def get_global_environments(self): Returns: dict[str, str]: Environment variables. - """ + """ return {} def modify_application_launch_arguments(self, application, env): @@ -609,8 +565,8 @@ def modify_application_launch_arguments(self, application, env): 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): @@ -629,8 +585,8 @@ def on_host_install(self, host, host_name, project_name): 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): @@ -657,31 +613,11 @@ def mycommand(): 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 @@ -713,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 @@ -750,8 +686,8 @@ def get(self, addon_name, default=None): Returns: Union[AYONAddon, Any]: Addon found by name or `default`. - """ + """ return self._addons_by_name.get(addon_name, default) @property @@ -778,8 +714,8 @@ def get_enabled_addon(self, addon_name, default=None): 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 @@ -790,8 +726,8 @@ def get_enabled_addons(self): Returns: list[AYONAddon]: Initialized and enabled addons. - """ + """ return [ addon for addon in self._addons @@ -803,8 +739,6 @@ def initialize_addons(self): # Make sure modules are loaded load_addons() - import openpype_modules - self.log.debug("*** AYON addons initialization.") # Prepare settings for addons @@ -812,14 +746,12 @@ def initialize_addons(self): 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) @@ -828,8 +760,6 @@ def initialize_addons(self): 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 @@ -855,33 +785,14 @@ def initialize_addons(self): 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 @@ -900,17 +811,6 @@ def initialize_addons(self): 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 @@ -923,20 +823,11 @@ def connect_addons(self): report = {} time_start = time.time() prev_start_time = time_start - enabled_modules = self.get_enabled_addons() - self.log.debug("Has {} enabled modules.".format(len(enabled_modules))) - for module in enabled_modules: + enabled_addons = self.get_enabled_addons() + self.log.debug("Has {} enabled addons.".format(len(enabled_addons))) + for addon in enabled_addons: try: - if not is_func_marked(module.connect_with_addons): - module.connect_with_addons(enabled_modules) - - elif hasattr(module, "connect_with_modules"): - self.log.warning(( - "DEPRECATION WARNING: Addon '{}' still uses" - " 'connect_with_modules' method. Please switch to use" - " 'connect_with_addons' method." - ).format(module.name)) - module.connect_with_modules(enabled_modules) + addon.connect_with_addons(enabled_addons) except Exception: self.log.error( @@ -945,7 +836,7 @@ def connect_addons(self): ) now = time.time() - report[module.__class__.__name__] = now - prev_start_time + report[addon.__class__.__name__] = now - prev_start_time prev_start_time = now if self._report is not None: @@ -1285,238 +1176,3 @@ class name and value is time delta of it's processing. # 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) - - -class TrayAddonsManager(AddonsManager): - # Define order of addons in menu - # TODO find better way how to define order - addons_menu_order = ( - "user", - "ftrack", - "kitsu", - "launcher_tool", - "avalon", - "clockify", - "traypublish_tool", - "log_viewer", - ) - - def __init__(self, settings=None): - super(TrayAddonsManager, self).__init__(settings, initialize=False) - - self.tray_manager = None - - self.doubleclick_callbacks = {} - self.doubleclick_callback = None - - def add_doubleclick_callback(self, addon, callback): - """Register double-click callbacks on tray icon. - - Currently, there is no way how to determine which is launched. Name of - callback can be defined with `doubleclick_callback` attribute. - - Missing feature how to define default callback. - - Args: - addon (AYONAddon): Addon object. - callback (FunctionType): Function callback. - """ - - callback_name = "_".join([addon.name, callback.__name__]) - if callback_name not in self.doubleclick_callbacks: - self.doubleclick_callbacks[callback_name] = callback - if self.doubleclick_callback is None: - self.doubleclick_callback = callback_name - return - - self.log.warning(( - "Callback with name \"{}\" is already registered." - ).format(callback_name)) - - def initialize(self, tray_manager, tray_menu): - self.tray_manager = tray_manager - self.initialize_addons() - self.tray_init() - self.connect_addons() - self.tray_menu(tray_menu) - - def get_enabled_tray_addons(self): - """Enabled tray addons. - - Returns: - list[AYONAddon]: Enabled addons that inherit from tray interface. - """ - - return [ - addon - for addon in self.get_enabled_addons() - if isinstance(addon, ITrayAddon) - ] - - def restart_tray(self): - if self.tray_manager: - self.tray_manager.restart() - - def tray_init(self): - report = {} - time_start = time.time() - prev_start_time = time_start - for addon in self.get_enabled_tray_addons(): - try: - addon._tray_manager = self.tray_manager - addon.tray_init() - addon.tray_initialized = True - except Exception: - self.log.warning( - "Addon \"{}\" crashed on `tray_init`.".format( - addon.name - ), - exc_info=True - ) - - now = time.time() - report[addon.__class__.__name__] = now - prev_start_time - prev_start_time = now - - if self._report is not None: - report[self._report_total_key] = time.time() - time_start - self._report["Tray init"] = report - - def tray_menu(self, tray_menu): - ordered_addons = [] - enabled_by_name = { - addon.name: addon - for addon in self.get_enabled_tray_addons() - } - - for name in self.addons_menu_order: - addon_by_name = enabled_by_name.pop(name, None) - if addon_by_name: - ordered_addons.append(addon_by_name) - ordered_addons.extend(enabled_by_name.values()) - - report = {} - time_start = time.time() - prev_start_time = time_start - for addon in ordered_addons: - if not addon.tray_initialized: - continue - - try: - addon.tray_menu(tray_menu) - except Exception: - # Unset initialized mark - addon.tray_initialized = False - self.log.warning( - "Addon \"{}\" crashed on `tray_menu`.".format( - addon.name - ), - exc_info=True - ) - now = time.time() - report[addon.__class__.__name__] = now - prev_start_time - prev_start_time = now - - if self._report is not None: - report[self._report_total_key] = time.time() - time_start - self._report["Tray menu"] = report - - def start_addons(self): - report = {} - time_start = time.time() - prev_start_time = time_start - for addon in self.get_enabled_tray_addons(): - if not addon.tray_initialized: - if isinstance(addon, ITrayService): - addon.set_service_failed_icon() - continue - - try: - addon.tray_start() - except Exception: - self.log.warning( - "Addon \"{}\" crashed on `tray_start`.".format( - addon.name - ), - exc_info=True - ) - now = time.time() - report[addon.__class__.__name__] = now - prev_start_time - prev_start_time = now - - if self._report is not None: - report[self._report_total_key] = time.time() - time_start - self._report["Addons start"] = report - - def on_exit(self): - for addon in self.get_enabled_tray_addons(): - if addon.tray_initialized: - try: - addon.tray_exit() - except Exception: - self.log.warning( - "Addon \"{}\" crashed on `tray_exit`.".format( - addon.name - ), - exc_info=True - ) - - # DEPRECATED - def get_enabled_tray_modules(self): - return self.get_enabled_tray_addons() - - def start_modules(self): - self.start_addons() 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 60cf5624b0..b80b243db2 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -5,6 +5,7 @@ import code import traceback from pathlib import Path +import warnings import click import acre @@ -12,26 +13,15 @@ from ayon_core import AYON_CORE_ROOT from ayon_core.addon import AddonsManager from ayon_core.settings import get_general_environments -from ayon_core.lib import initialize_ayon_connection, is_running_from_build - -from .cli_commands import Commands - - -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 +from ayon_core.lib import ( + initialize_ayon_connection, + is_running_from_build, + Logger, +) - 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") @@ -39,7 +29,8 @@ def get_command(self, ctx, cmd_name): help="Enable debug") @click.option("--verbose", expose_value=False, help=("Change AYON log level (debug - critical or 0-50)")) -def main_cli(ctx): +@click.option("--force", is_flag=True, hidden=True) +def main_cli(ctx, force): """AYON is main command serving as entry point to pipeline system. It wraps different commands together. @@ -51,20 +42,26 @@ def main_cli(ctx): print(ctx.get_help()) sys.exit(0) else: - ctx.invoke(tray) + ctx.forward(tray) @main_cli.command() -def tray(): +@click.option( + "--force", + is_flag=True, + help="Force to start tray and close any existing one.") +def tray(force): """Launch AYON tray. Default action of AYON command is to launch tray widget to control basic aspects of AYON. See documentation for more information. """ - Commands.launch_tray() + + from ayon_core.tools.tray import main + + main(force) -@Commands.add_addons @main_cli.group(help="Run command line arguments of AYON addons") @click.pass_context def addon(ctx): @@ -75,11 +72,8 @@ 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") @click.option("--project", help="Project name", default=None) @click.option("--asset", help="Folder path", default=None) @@ -88,7 +82,9 @@ def addon(ctx): @click.option( "--envgroup", help="Environment group (e.g. \"farm\")", default=None ) -def extractenvironments(output_json_path, project, asset, task, app, envgroup): +def extractenvironments( + ctx, output_json_path, project, asset, task, app, envgroup +): """Extract environment variables for entered context to a json file. Entered output filepath will be created if does not exists. @@ -102,24 +98,42 @@ def extractenvironments(output_json_path, project, asset, task, app, envgroup): This function is deprecated and will be removed in future. Please use 'addon applications extractenvironments ...' instead. """ - Commands.extractenvironments( + warnings.warn( + ( + "Command 'extractenvironments' is deprecated and will be" + " removed in future. Please use" + " 'addon applications extractenvironments ...' instead." + ), + DeprecationWarning + ) + + addons_manager = ctx.obj["addons_manager"] + applications_addon = addons_manager.get_enabled_addon("applications") + if applications_addon is None: + raise RuntimeError( + "Applications addon is not available or enabled." + ) + + # Please ignore the fact this is using private method + applications_addon._cli_extract_environments( output_json_path, project, asset, task, app, envgroup ) @main_cli.command() +@click.pass_context @click.argument("path", required=True) @click.option("-t", "--targets", help="Targets", default=None, multiple=True) -@click.option("-g", "--gui", is_flag=True, - help="Show Publish UI", default=False) -def publish(path, targets, gui): +def publish(ctx, path, targets): """Start CLI publishing. Publish collects json from path provided as an argument. -S + """ - Commands.publish(path, targets, gui) + from ayon_core.pipeline.publish import main_cli_publish + + main_cli_publish(path, targets, ctx.obj["addons_manager"]) @main_cli.command(context_settings={"ignore_unknown_options": True}) @@ -149,12 +163,10 @@ def contextselection( Context is project name, folder path and task name. The result is stored into json file which path is passed in first argument. """ - Commands.contextselection( - output_path, - project, - folder, - strict - ) + from ayon_core.tools.context_dialog import main + + main(output_path, project, folder, strict) + @main_cli.command( @@ -245,11 +257,9 @@ def _set_global_environments() -> None: os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" -def _set_addons_environments(): +def _set_addons_environments(addons_manager): """Set global environments for AYON addons.""" - addons_manager = AddonsManager() - # Merge environments with current environments and update values if module_envs := addons_manager.collect_global_environments(): parsed_envs = acre.parse(module_envs) @@ -258,6 +268,21 @@ def _set_addons_environments(): os.environ.update(env) +def _add_addons(addons_manager): + """Modules/Addons can add their cli commands dynamically.""" + log = Logger.get_logger("CLI-AddAddons") + for addon_obj in addons_manager.addons: + try: + addon_obj.cli(addon) + + except Exception: + log.warning( + "Failed to add cli command for module \"{}\"".format( + addon_obj.name + ), exc_info=True + ) + + def main(*args, **kwargs): initialize_ayon_connection() python_path = os.getenv("PYTHONPATH", "") @@ -281,10 +306,14 @@ def main(*args, **kwargs): print(" - global AYON ...") _set_global_environments() print(" - for addons ...") - _set_addons_environments() - + addons_manager = AddonsManager() + _set_addons_environments(addons_manager) + _add_addons(addons_manager) try: - main_cli(obj={}, prog_name="ayon") + main_cli( + prog_name="ayon", + obj={"addons_manager": addons_manager}, + ) except Exception: # noqa exc_info = sys.exc_info() print("!!! AYON crashed:") diff --git a/client/ayon_core/cli_commands.py b/client/ayon_core/cli_commands.py deleted file mode 100644 index 35b7e294de..0000000000 --- a/client/ayon_core/cli_commands.py +++ /dev/null @@ -1,195 +0,0 @@ -# -*- coding: utf-8 -*- -"""Implementation of AYON commands.""" -import os -import sys -import warnings - - -class Commands: - """Class implementing commands used by AYON. - - Most of its methods are called by :mod:`cli` module. - """ - @staticmethod - def launch_tray(): - from ayon_core.lib import Logger - from ayon_core.tools import tray - - Logger.set_process_name("Tray") - - tray.main() - - @staticmethod - def add_addons(click_func): - """Modules/Addons can add their cli commands dynamically.""" - - from ayon_core.lib import Logger - from ayon_core.addon import AddonsManager - - manager = AddonsManager() - log = Logger.get_logger("CLI-AddModules") - for addon in manager.addons: - try: - addon.cli(click_func) - - except Exception: - log.warning( - "Failed to add cli command for module \"{}\"".format( - addon.name - ), exc_info=True - ) - return click_func - - @staticmethod - def publish(path: str, targets: list=None, gui:bool=False) -> None: - """Start headless publishing. - - Publish use json from passed path argument. - - Args: - path (str): Path to JSON. - targets (list of str): List of pyblish targets. - gui (bool): Show publish UI. - - Raises: - RuntimeError: When there is no path to process. - RuntimeError: When executed with list of JSON paths. - - """ - from ayon_core.lib import Logger - - from ayon_core.addon import AddonsManager - from ayon_core.pipeline import ( - install_ayon_plugins, - get_global_context, - ) - - import ayon_api - import pyblish.util - - # Register target and host - if not isinstance(path, str): - raise RuntimeError("Path to JSON must be a string.") - - # Fix older jobs - for src_key, dst_key in ( - ("AVALON_PROJECT", "AYON_PROJECT_NAME"), - ("AVALON_ASSET", "AYON_FOLDER_PATH"), - ("AVALON_TASK", "AYON_TASK_NAME"), - ("AVALON_WORKDIR", "AYON_WORKDIR"), - ("AVALON_APP_NAME", "AYON_APP_NAME"), - ("AVALON_APP", "AYON_HOST_NAME"), - ): - if src_key in os.environ and dst_key not in os.environ: - os.environ[dst_key] = os.environ[src_key] - # Remove old keys, so we're sure they're not used - os.environ.pop(src_key, None) - - log = Logger.get_logger("CLI-publish") - - # Make public ayon api behave as other user - # - this works only if public ayon api is using service user - username = os.environ.get("AYON_USERNAME") - if username: - # NOTE: ayon-python-api does not have public api function to find - # out if is used service user. So we need to have try > except - # block. - con = ayon_api.get_server_api_connection() - try: - con.set_default_service_username(username) - except ValueError: - pass - - install_ayon_plugins() - - manager = AddonsManager() - - publish_paths = manager.collect_plugin_paths()["publish"] - - for plugin_path in publish_paths: - pyblish.api.register_plugin_path(plugin_path) - - applications_addon = manager.get_enabled_addon("applications") - if applications_addon is not None: - context = get_global_context() - env = applications_addon.get_farm_publish_environment_variables( - context["project_name"], - context["folder_path"], - context["task_name"], - ) - os.environ.update(env) - - pyblish.api.register_host("shell") - - if targets: - for target in targets: - print(f"setting target: {target}") - pyblish.api.register_target(target) - else: - pyblish.api.register_target("farm") - - os.environ["AYON_PUBLISH_DATA"] = path - os.environ["HEADLESS_PUBLISH"] = 'true' # to use in app lib - - log.info("Running publish ...") - - plugins = pyblish.api.discover() - print("Using plugins:") - for plugin in plugins: - print(plugin) - - if gui: - from ayon_core.tools.utils.host_tools import show_publish - from ayon_core.tools.utils.lib import qt_app_context - with qt_app_context(): - show_publish() - else: - # Error exit as soon as any error occurs. - error_format = ("Failed {plugin.__name__}: " - "{error} -- {error.traceback}") - - for result in pyblish.util.publish_iter(): - if result["error"]: - log.error(error_format.format(**result)) - # uninstall() - sys.exit(1) - - log.info("Publish finished.") - - @staticmethod - def extractenvironments( - output_json_path, project, asset, task, app, env_group - ): - """Produces json file with environment based on project and app. - - Called by Deadline plugin to propagate environment into render jobs. - """ - - from ayon_core.addon import AddonsManager - - warnings.warn( - ( - "Command 'extractenvironments' is deprecated and will be" - " removed in future. Please use " - "'addon applications extractenvironments ...' instead." - ), - DeprecationWarning - ) - - addons_manager = AddonsManager() - applications_addon = addons_manager.get_enabled_addon("applications") - if applications_addon is None: - raise RuntimeError( - "Applications addon is not available or enabled." - ) - - # Please ignore the fact this is using private method - applications_addon._cli_extract_environments( - output_json_path, project, asset, task, app, env_group - ) - - @staticmethod - def contextselection(output_path, project_name, folder_path, strict): - from ayon_core.tools.context_dialog import main - - main(output_path, project_name, folder_path, strict) diff --git a/client/ayon_core/hooks/pre_filter_farm_environments.py b/client/ayon_core/hooks/pre_filter_farm_environments.py new file mode 100644 index 0000000000..d231acf5e9 --- /dev/null +++ b/client/ayon_core/hooks/pre_filter_farm_environments.py @@ -0,0 +1,72 @@ +import re + +from ayon_applications import PreLaunchHook, LaunchTypes +from ayon_core.lib import filter_profiles + + +class FilterFarmEnvironments(PreLaunchHook): + """Filter or modify calculated environment variables for farm rendering. + + This hook must run last, only after all other hooks are finished to get + correct environment for launch context. + + Implemented modifications to self.launch_context.env: + - skipping (list) of environment variable keys + - removing value in environment variable: + - supports regular expression in pattern + """ + order = 1000 + + launch_types = {LaunchTypes.farm_publish} + + def execute(self): + data = self.launch_context.data + project_settings = data["project_settings"] + filter_env_profiles = ( + project_settings["core"]["filter_env_profiles"]) + + if not filter_env_profiles: + self.log.debug("No profiles found for env var filtering") + return + + task_entity = data["task_entity"] + + filter_data = { + "host_names": self.host_name, + "task_types": task_entity["taskType"], + "task_names": task_entity["name"], + "folder_paths": data["folder_path"] + } + matching_profile = filter_profiles( + filter_env_profiles, filter_data, logger=self.log + ) + if not matching_profile: + self.log.debug("No matching profile found for env var filtering " + f"for {filter_data}") + return + + self._skip_environment_variables( + self.launch_context.env, matching_profile) + + self._modify_environment_variables( + self.launch_context.env, matching_profile) + + def _modify_environment_variables(self, calculated_env, matching_profile): + """Modify environment variable values.""" + for env_item in matching_profile["replace_in_environment"]: + key = env_item["environment_key"] + value = calculated_env.get(key) + if not value: + continue + + value = re.sub(value, env_item["pattern"], env_item["replacement"]) + if value: + calculated_env[key] = value + else: + calculated_env.pop(key) + + def _skip_environment_variables(self, calculated_env, matching_profile): + """Skips list of environment variable names""" + for skip_env in matching_profile["skip_env_keys"]: + self.log.info(f"Skipping {skip_env}") + calculated_env.pop(skip_env) 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 @@ def prepare_global_data(self): 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 1f864284cd..03ed574081 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -7,11 +7,10 @@ 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 .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 @@ convert_ffprobe_fps_value, convert_ffprobe_fps_to_float, get_rescaled_command_arguments, + get_media_mime_type, ) from .plugin_tools import ( @@ -129,6 +127,7 @@ 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, @@ -141,11 +140,10 @@ "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", @@ -156,13 +154,11 @@ "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", @@ -209,6 +205,7 @@ "convert_ffprobe_fps_value", "convert_ffprobe_fps_to_float", "get_rescaled_command_arguments", + "get_media_mime_type", "compile_list_of_regexes", @@ -239,6 +236,7 @@ "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 uuid import json import copy -from abc import ABCMeta, abstractmethod, abstractproperty +from abc import ABCMeta, abstractmethod import clique @@ -16,7 +16,7 @@ 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 @@ def __eq__(self, other): 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 @@ def deserialize(cls, data): # ----------------------------------------- -# UI attribute definitoins won't hold value +# UI attribute definitions won't hold value # ----------------------------------------- class UIDef(AbstractAttrDef): @@ -245,7 +246,7 @@ def __eq__(self, other): # --------------------------------------- -# Attribute defintioins should hold value +# Attribute definitions should hold value # --------------------------------------- class UnknownDef(AbstractAttrDef): @@ -311,7 +312,7 @@ def __init__( ): 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 @@ def convert_value(self, value): 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 @@ def convert_value(self, value): 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 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 @@ def validate_signature(self, *args, **kwargs): ) -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 @@ def _validate_ref(self): 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 @@ def from_data(cls, event_data, event_system=None): 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,27 +223,16 @@ def run_ayon_launcher_process(*args, **kwargs): # - fill more if you find more env = clean_envs_for_ayon_process(os.environ) - 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") + 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) - Args: - *args (tuple): AYON cli arguments. - **kwargs (dict): Keyword arguments for subprocess.Popen. - - """ - return run_ayon_launcher_process(*args, **kwargs) + return run_subprocess(args, env=env, **kwargs) def run_detached_process(args, **kwargs): @@ -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 @@ def wrapper(*args, **kwargs): _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 @@ def _delete_item(self, name): 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 @@ def __init__(self, template, missing_keys, invalid_types): ) -class StringTemplate(object): +class StringTemplate: """String that can be formatted.""" def __init__(self, template): if not isinstance(template, str): @@ -410,7 +410,7 @@ def add_invalid_type(self, key, value): 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 @@ def validate_value_type(value): 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 @@ def format(self, data, result): 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 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`.""" - 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 - dst_module = None - - if full_module_name in sys.modules: - return sys.modules[full_module_name] - - import imp +def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None): + """Import passed directory as a python module. - 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) + 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. - return module + 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. -def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name): - """Import passed dirpath as python module using Python 3 modules.""" + """ + # 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 + 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 bff28614ea..ead8b621b9 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -6,6 +6,7 @@ import tempfile import subprocess import platform +from typing import Optional import xml.etree.ElementTree @@ -1455,3 +1456,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 0dfd7d663c..e69de29bb2 100644 --- a/client/ayon_core/modules/__init__.py +++ b/client/ayon_core/modules/__init__.py @@ -1,42 +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, - TrayModulesManager, -) - - -__all__ = ( - "click_wrap", - - "IPluginPaths", - "ITrayAddon", - "ITrayModule", - "ITrayAction", - "ITrayService", - "IHostAddon", - - "AYONAddon", - "OpenPypeModule", - "OpenPypeAddOn", - - "load_modules", - - "ModulesManager", - "TrayModulesManager", -) diff --git a/client/ayon_core/modules/base.py b/client/ayon_core/modules/base.py deleted file mode 100644 index 3f2a7d4ea5..0000000000 --- a/client/ayon_core/modules/base.py +++ /dev/null @@ -1,29 +0,0 @@ -# Backwards compatibility support -# - TODO should be removed before release 1.0.0 -from ayon_core.addon import ( - AYONAddon, - AddonsManager, - TrayAddonsManager, - load_addons, -) -from ayon_core.addon.base import ( - OpenPypeModule, - OpenPypeAddOn, -) - -ModulesManager = AddonsManager -TrayModulesManager = TrayAddonsManager -load_modules = load_addons - - -__all__ = ( - "AYONAddon", - "AddonsManager", - "TrayAddonsManager", - "load_addons", - "OpenPypeModule", - "OpenPypeAddOn", - "ModulesManager", - "TrayModulesManager", - "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/modules/webserver/__init__.py b/client/ayon_core/modules/webserver/__init__.py deleted file mode 100644 index 32f2c55f65..0000000000 --- a/client/ayon_core/modules/webserver/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .version import __version__ -from .structures import HostMsgAction -from .webserver_module import ( - WebServerAddon -) - - -__all__ = ( - "__version__", - - "HostMsgAction", - "WebServerAddon", -) diff --git a/client/ayon_core/modules/webserver/version.py b/client/ayon_core/modules/webserver/version.py deleted file mode 100644 index 5becc17c04..0000000000 --- a/client/ayon_core/modules/webserver/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "1.0.0" diff --git a/client/ayon_core/modules/webserver/webserver_module.py b/client/ayon_core/modules/webserver/webserver_module.py deleted file mode 100644 index 997b6f754c..0000000000 --- a/client/ayon_core/modules/webserver/webserver_module.py +++ /dev/null @@ -1,212 +0,0 @@ -"""WebServerAddon spawns aiohttp server in asyncio loop. - -Main usage of the module is in AYON tray where make sense to add ability -of other modules to add theirs routes. Module which would want use that -option must have implemented method `webserver_initialization` which must -expect `WebServerManager` object where is possible to add routes or paths -with handlers. - -WebServerManager is by default created only in tray. - -It is possible to create server manager without using module logic at all -using `create_new_server_manager`. That can be handy for standalone scripts -with predefined host and port and separated routes and logic. - -Running multiple servers in one process is not recommended and probably won't -work as expected. It is because of few limitations connected to asyncio module. - -When module's `create_server_manager` is called it is also set environment -variable "AYON_WEBSERVER_URL". Which should lead to root access point -of server. -""" - -import os -import socket - -from ayon_core import resources -from ayon_core.addon import AYONAddon, ITrayService - -from .version import __version__ - - -class WebServerAddon(AYONAddon, ITrayService): - name = "webserver" - version = __version__ - label = "WebServer" - - webserver_url_env = "AYON_WEBSERVER_URL" - - def initialize(self, settings): - self._server_manager = None - self._host_listener = None - - self._port = self.find_free_port() - self._webserver_url = None - - @property - def server_manager(self): - """ - - Returns: - Union[WebServerManager, None]: Server manager instance. - - """ - return self._server_manager - - @property - def port(self): - """ - - Returns: - int: Port on which is webserver running. - - """ - return self._port - - @property - def webserver_url(self): - """ - - Returns: - str: URL to webserver. - - """ - return self._webserver_url - - def connect_with_addons(self, enabled_modules): - if not self._server_manager: - return - - for module in enabled_modules: - if not hasattr(module, "webserver_initialization"): - continue - - try: - module.webserver_initialization(self._server_manager) - except Exception: - self.log.warning( - ( - "Failed to connect module \"{}\" to webserver." - ).format(module.name), - exc_info=True - ) - - def tray_init(self): - self.create_server_manager() - self._add_resources_statics() - self._add_listeners() - - def tray_start(self): - self.start_server() - - def tray_exit(self): - self.stop_server() - - def start_server(self): - if self._server_manager is not None: - self._server_manager.start_server() - - def stop_server(self): - if self._server_manager is not None: - self._server_manager.stop_server() - - @staticmethod - def create_new_server_manager(port=None, host=None): - """Create webserver manager for passed port and host. - - Args: - port(int): Port on which wil webserver listen. - host(str): Host name or IP address. Default is 'localhost'. - - Returns: - WebServerManager: Prepared manager. - """ - from .server import WebServerManager - - return WebServerManager(port, host) - - def create_server_manager(self): - if self._server_manager is not None: - return - - self._server_manager = self.create_new_server_manager(self._port) - self._server_manager.on_stop_callbacks.append( - self.set_service_failed_icon - ) - - webserver_url = self._server_manager.url - os.environ["OPENPYPE_WEBSERVER_URL"] = str(webserver_url) - os.environ[self.webserver_url_env] = str(webserver_url) - self._webserver_url = webserver_url - - @staticmethod - def find_free_port( - port_from=None, port_to=None, exclude_ports=None, host=None - ): - """Find available socket port from entered range. - - It is also possible to only check if entered port is available. - - Args: - port_from (int): Port number which is checked as first. - port_to (int): Last port that is checked in sequence from entered - `port_from`. Only `port_from` is checked if is not entered. - Nothing is processed if is equeal to `port_from`! - exclude_ports (list, tuple, set): List of ports that won't be - checked form entered range. - host (str): Host where will check for free ports. Set to - "localhost" by default. - """ - if port_from is None: - port_from = 8079 - - if port_to is None: - port_to = 65535 - - # Excluded ports (e.g. reserved for other servers/clients) - if exclude_ports is None: - exclude_ports = [] - - # Default host is localhost but it is possible to look for other hosts - if host is None: - host = "localhost" - - found_port = None - for port in range(port_from, port_to + 1): - if port in exclude_ports: - continue - - sock = None - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.bind((host, port)) - found_port = port - - except socket.error: - continue - - finally: - if sock: - sock.close() - - if found_port is not None: - break - - return found_port - - def _add_resources_statics(self): - static_prefix = "/res" - self._server_manager.add_static(static_prefix, resources.RESOURCES_DIR) - statisc_url = "{}{}".format( - self._webserver_url, static_prefix - ) - - os.environ["AYON_STATICS_SERVER"] = statisc_url - os.environ["OPENPYPE_STATICS_SERVER"] = statisc_url - - def _add_listeners(self): - from . import host_console_listener - - self._host_listener = host_console_listener.HostListener( - self._server_manager, self - ) 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 @@ PublishXmlValidationError, KnownPublishError, AYONPyblishPluginMixin, - OpenPypePyblishPluginMixin, OptionalPyblishPluginMixin, ) @@ -77,7 +76,6 @@ from .context_tools import ( install_ayon_plugins, - install_openpype_plugins, install_host, uninstall_host, is_installed, @@ -168,7 +166,6 @@ "PublishXmlValidationError", "KnownPublishError", "AYONPyblishPluginMixin", - "OpenPypePyblishPluginMixin", "OptionalPyblishPluginMixin", # --- Actions --- @@ -187,7 +184,6 @@ # --- 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 @@ 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 @@ cache_and_get_instances, ) -from .context import ( - CreatedInstance, - CreateContext -) +from .context import CreateContext from .legacy_create import ( LegacyCreator, @@ -53,10 +70,31 @@ "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 @@ "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 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 @@ 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 @@ _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 @@ def __init__( # 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 @@ def publish_attributes(self): """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 @@ def get_sorted_creators(self, identifiers=None): Returns: List[BaseCreator]: Sorted creator plugins by 'order' value. - """ + """ if identifiers is not None: identifiers = set(identifiers) creators = [ @@ -1653,7 +395,6 @@ def get_current_task_entity(self): 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 @@ def reset_preparation(self): # 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 @@ def context_data_changes(self): 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 @@ def _create_with_unified_error( add_traceback = False result = None fail_info = None + exc_info = None success = False try: @@ -2209,7 +953,7 @@ def create_with_unified_error(self, identifier, *args, **kwargs): 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 @@ def bulk_instances_collection(self): 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 @@ def bulk_instances_collection(self): [], 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 @@ def execute_autocreators(self): 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 @@ def validate_instances_context(self, instances=None): 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 @@ def validate_instances_context(self, instances=None): ): 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 @@ def validate_instances_context(self, instances=None): 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 @@ def validate_instances_context(self, instances=None): 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 @@ 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 @@ def __init__(self, *args, **kwargs): 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 .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..9019b05b21 --- /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._transient_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 @@ 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 - - # 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) + expected_filepath = collected_files + if isinstance(collected_files, (list, tuple)): + expected_filepath = collected_files[0] + + 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 d507972664..cb181c2f2b 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -13,7 +13,6 @@ PublishXmlValidationError, KnownPublishError, AYONPyblishPluginMixin, - OpenPypePyblishPluginMixin, OptionalPyblishPluginMixin, RepairAction, @@ -42,6 +41,8 @@ get_plugin_settings, get_publish_instance_label, get_publish_instance_families, + + main_cli_publish, ) from .abstract_expected_files import ExpectedFiles @@ -64,7 +65,6 @@ "PublishXmlValidationError", "KnownPublishError", "AYONPyblishPluginMixin", - "OpenPypePyblishPluginMixin", "OptionalPyblishPluginMixin", "RepairAction", @@ -92,6 +92,8 @@ "get_publish_instance_label", "get_publish_instance_families", + "main_cli_publish", + "ExpectedFiles", "RenderInstance", diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index c4e7b2a42c..dc2eef3bb9 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -4,8 +4,9 @@ import copy import tempfile import xml.etree.ElementTree -from typing import Optional, Union +from typing import Optional, Union, List +import ayon_api import pyblish.util import pyblish.plugin import pyblish.api @@ -16,6 +17,7 @@ filter_profiles, ) from ayon_core.settings import get_project_settings +from ayon_core.addon import AddonsManager from ayon_core.pipeline import ( tempdir, Anatomy @@ -377,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: @@ -978,3 +980,113 @@ def get_instance_expected_output_path( path_template_obj = anatomy.get_template_item("publish", "default")["path"] template_filled = path_template_obj.format_strict(template_data) return os.path.normpath(template_filled) + + +def main_cli_publish( + path: str, + targets: Optional[List[str]] = None, + addons_manager: Optional[AddonsManager] = None, +): + """Start headless publishing. + + Publish use json from passed path argument. + + Args: + path (str): Path to JSON. + targets (Optional[List[str]]): List of pyblish targets. + addons_manager (Optional[AddonsManager]): Addons manager instance. + + Raises: + RuntimeError: When there is no path to process or when executed with + list of JSON paths. + + """ + from ayon_core.pipeline import ( + install_ayon_plugins, + get_global_context, + ) + + # Register target and host + if not isinstance(path, str): + raise RuntimeError("Path to JSON must be a string.") + + # Fix older jobs + for src_key, dst_key in ( + ("AVALON_PROJECT", "AYON_PROJECT_NAME"), + ("AVALON_ASSET", "AYON_FOLDER_PATH"), + ("AVALON_TASK", "AYON_TASK_NAME"), + ("AVALON_WORKDIR", "AYON_WORKDIR"), + ("AVALON_APP_NAME", "AYON_APP_NAME"), + ("AVALON_APP", "AYON_HOST_NAME"), + ): + if src_key in os.environ and dst_key not in os.environ: + os.environ[dst_key] = os.environ[src_key] + # Remove old keys, so we're sure they're not used + os.environ.pop(src_key, None) + + log = Logger.get_logger("CLI-publish") + + # Make public ayon api behave as other user + # - this works only if public ayon api is using service user + username = os.environ.get("AYON_USERNAME") + if username: + # NOTE: ayon-python-api does not have public api function to find + # out if is used service user. So we need to have try > except + # block. + con = ayon_api.get_server_api_connection() + try: + con.set_default_service_username(username) + except ValueError: + pass + + install_ayon_plugins() + + if addons_manager is None: + addons_manager = AddonsManager() + + # TODO validate if this has to happen + # - it should happen during 'install_ayon_plugins' + publish_paths = addons_manager.collect_plugin_paths()["publish"] + for plugin_path in publish_paths: + pyblish.api.register_plugin_path(plugin_path) + + applications_addon = addons_manager.get_enabled_addon("applications") + if applications_addon is not None: + context = get_global_context() + env = applications_addon.get_farm_publish_environment_variables( + context["project_name"], + context["folder_path"], + context["task_name"], + ) + os.environ.update(env) + + pyblish.api.register_host("shell") + + if targets: + for target in targets: + print(f"setting target: {target}") + pyblish.api.register_target(target) + else: + pyblish.api.register_target("farm") + + os.environ["AYON_PUBLISH_DATA"] = path + os.environ["HEADLESS_PUBLISH"] = 'true' # to use in app lib + + log.info("Running publish ...") + + plugins = pyblish.api.discover() + print("Using plugins:") + for plugin in plugins: + print(plugin) + + # Error exit as soon as any error occurs. + error_format = ("Failed {plugin.__name__}: " + "{error} -- {error.traceback}") + + for result in pyblish.util.publish_iter(): + if result["error"]: + log.error(error_format.format(**result)) + # uninstall() + sys.exit(1) + + log.info("Publish finished.") 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 @@ def get_attr_values_from_data(self, data): 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 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 @@ def get_thumbnails_dir(self): """ 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 @@ def get_template_preset(self): "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 @@ def get_template_preset(self): "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 @@ def get_template_preset(self): "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 @@ def _get_representations(self, placeholder): 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 @@ def _get_representations(self, placeholder): ) 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/actions/show_in_ayon.py b/client/ayon_core/plugins/actions/show_in_ayon.py new file mode 100644 index 0000000000..e30eaa2bc9 --- /dev/null +++ b/client/ayon_core/plugins/actions/show_in_ayon.py @@ -0,0 +1,87 @@ +import os +import urllib.parse +import webbrowser + +from ayon_core.pipeline import LauncherAction +from ayon_core.resources import get_ayon_icon_filepath +import ayon_api + + +def get_ayon_entity_uri( + project_name, + entity_id, + entity_type, +) -> str: + """Resolve AYON Entity URI from representation context. + + Note: + The representation context is the `get_representation_context` dict + containing the `project`, `folder, `representation` and so forth. + It is not the representation entity `context` key. + + Arguments: + project_name (str): The project name. + entity_id (str): The entity UUID. + entity_type (str): The entity type, like "folder" or"task". + + Raises: + RuntimeError: Unable to resolve to a single valid URI. + + Returns: + str: The AYON entity URI. + + """ + response = ayon_api.post( + f"projects/{project_name}/uris", + entityType=entity_type, + ids=[entity_id]) + if response.status_code != 200: + raise RuntimeError( + f"Unable to resolve AYON entity URI for '{project_name}' " + f"{entity_type} id '{entity_id}': {response.text}" + ) + uris = response.data["uris"] + if len(uris) != 1: + raise RuntimeError( + f"Unable to resolve AYON entity URI for '{project_name}' " + f"{entity_type} id '{entity_id}' to single URI. " + f"Received data: {response.data}" + ) + return uris[0]["uri"] + + +class ShowInAYON(LauncherAction): + """Open AYON browser page to the current context.""" + name = "showinayon" + label = "Show in AYON" + icon = get_ayon_icon_filepath() + order = 999 + + def process(self, selection, **kwargs): + url = os.environ["AYON_SERVER_URL"] + if selection.is_project_selected: + project_name = selection.project_name + url += f"/projects/{project_name}/browser" + + # Specify entity URI if task or folder is select + entity = None + entity_type = None + if selection.is_task_selected: + entity = selection.get_task_entity() + entity_type = "task" + elif selection.is_folder_selected: + entity = selection.get_folder_entity() + entity_type = "folder" + + if entity and entity_type: + uri = get_ayon_entity_uri( + project_name, + entity_id=entity["id"], + entity_type=entity_type + ) + uri_encoded = urllib.parse.quote_plus(uri) + url += f"?uri={uri_encoded}" + + # Open URL in webbrowser + self.log.info(f"Opening URL: {url}") + webbrowser.open_new_tab(url) diff --git a/client/ayon_core/plugins/load/export_otio.py b/client/ayon_core/plugins/load/export_otio.py new file mode 100644 index 0000000000..e7a844aed3 --- /dev/null +++ b/client/ayon_core/plugins/load/export_otio.py @@ -0,0 +1,591 @@ +import logging +import os +from pathlib import Path +from collections import defaultdict + +from qtpy import QtWidgets, QtCore, QtGui +from ayon_api import get_representations + +from ayon_core.pipeline import load, Anatomy +from ayon_core import resources, style +from ayon_core.lib.transcoding import ( + IMAGE_EXTENSIONS, + get_oiio_info_for_input, +) +from ayon_core.lib import ( + get_ffprobe_data, + is_oiio_supported, +) +from ayon_core.pipeline.load import get_representation_path_with_anatomy +from ayon_core.tools.utils import show_message_dialog + +OTIO = None +FRAME_SPLITTER = "__frame_splitter__" + +def _import_otio(): + global OTIO + if OTIO is None: + import opentimelineio + OTIO = opentimelineio + + +class ExportOTIO(load.ProductLoaderPlugin): + """Export selected versions to OpenTimelineIO.""" + + is_multiple_contexts_compatible = True + sequence_splitter = "__sequence_splitter__" + + representations = {"*"} + product_types = {"*"} + tool_names = ["library_loader"] + + label = "Export OTIO" + order = 35 + icon = "save" + color = "#d8d8d8" + + def load(self, contexts, name=None, namespace=None, options=None): + _import_otio() + try: + dialog = ExportOTIOOptionsDialog(contexts, self.log) + dialog.exec_() + except Exception: + self.log.error("Failed to export OTIO.", exc_info=True) + + +class ExportOTIOOptionsDialog(QtWidgets.QDialog): + """Dialog to select template where to deliver selected representations.""" + + def __init__(self, contexts, log=None, parent=None): + # Not all hosts have OpenTimelineIO available. + self.log = log + + super().__init__(parent=parent) + + self.setWindowTitle("AYON - Export OTIO") + icon = QtGui.QIcon(resources.get_ayon_icon_filepath()) + self.setWindowIcon(icon) + + self.setWindowFlags( + QtCore.Qt.WindowStaysOnTopHint + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowMinimizeButtonHint + ) + + project_name = contexts[0]["project"]["name"] + versions_by_id = { + context["version"]["id"]: context["version"] + for context in contexts + } + repre_entities = list(get_representations( + project_name, version_ids=set(versions_by_id) + )) + version_by_representation_id = { + repre_entity["id"]: versions_by_id[repre_entity["versionId"]] + for repre_entity in repre_entities + } + version_path_by_id = {} + representations_by_version_id = {} + for context in contexts: + version_id = context["version"]["id"] + if version_id in version_path_by_id: + continue + representations_by_version_id[version_id] = [] + version_path_by_id[version_id] = "/".join([ + context["folder"]["path"], + context["product"]["name"], + context["version"]["name"] + ]) + + for repre_entity in repre_entities: + representations_by_version_id[repre_entity["versionId"]].append( + repre_entity + ) + + all_representation_names = list(sorted({ + repo_entity["name"] + for repo_entity in repre_entities + })) + + input_widget = QtWidgets.QWidget(self) + input_layout = QtWidgets.QGridLayout(input_widget) + input_layout.setContentsMargins(8, 8, 8, 8) + + row = 0 + repres_label = QtWidgets.QLabel("Representations:", input_widget) + input_layout.addWidget(repres_label, row, 0) + repre_name_buttons = [] + for idx, name in enumerate(all_representation_names): + repre_name_btn = QtWidgets.QPushButton(name, input_widget) + input_layout.addWidget( + repre_name_btn, row, idx + 1, + alignment=QtCore.Qt.AlignCenter + ) + repre_name_btn.clicked.connect(self._toggle_all) + repre_name_buttons.append(repre_name_btn) + + row += 1 + + representation_widgets = defaultdict(list) + items = representations_by_version_id.items() + for version_id, representations in items: + version_path = version_path_by_id[version_id] + label_widget = QtWidgets.QLabel(version_path, input_widget) + input_layout.addWidget(label_widget, row, 0) + + repres_by_name = { + repre_entity["name"]: repre_entity + for repre_entity in representations + } + radio_group = QtWidgets.QButtonGroup(input_widget) + for idx, name in enumerate(all_representation_names): + if name in repres_by_name: + widget = QtWidgets.QRadioButton(input_widget) + radio_group.addButton(widget) + representation_widgets[name].append( + { + "widget": widget, + "representation": repres_by_name[name] + } + ) + else: + widget = QtWidgets.QLabel("x", input_widget) + + input_layout.addWidget( + widget, row, idx + 1, 1, 1, + alignment=QtCore.Qt.AlignCenter + ) + + row += 1 + + export_widget = QtWidgets.QWidget(self) + + options_widget = QtWidgets.QWidget(export_widget) + + uri_label = QtWidgets.QLabel("URI paths:", options_widget) + uri_path_format = QtWidgets.QCheckBox(options_widget) + uri_path_format.setToolTip( + "Use URI paths (file:///) instead of absolute paths. " + "This is useful when the OTIO file will be used on Foundry Hiero." + ) + + button_output_path = QtWidgets.QPushButton( + "Output Path:", options_widget + ) + button_output_path.setToolTip( + "Click to select the output path for the OTIO file." + ) + + line_edit_output_path = QtWidgets.QLineEdit( + (Path.home() / f"{project_name}.otio").as_posix(), + options_widget + ) + + options_layout = QtWidgets.QHBoxLayout(options_widget) + options_layout.setContentsMargins(0, 0, 0, 0) + options_layout.addWidget(uri_label) + options_layout.addWidget(uri_path_format) + options_layout.addWidget(button_output_path) + options_layout.addWidget(line_edit_output_path) + + button_export = QtWidgets.QPushButton("Export", export_widget) + + export_layout = QtWidgets.QVBoxLayout(export_widget) + export_layout.setContentsMargins(0, 0, 0, 0) + export_layout.addWidget(options_widget, 0) + export_layout.addWidget(button_export, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(8, 8, 8, 8) + main_layout.addWidget(input_widget, 0) + main_layout.addStretch(1) + # TODO add line spacer? + main_layout.addSpacing(30) + main_layout.addWidget(export_widget, 0) + + button_export.clicked.connect(self._on_export_click) + button_output_path.clicked.connect(self._set_output_path) + + self._project_name = project_name + self._version_path_by_id = version_path_by_id + self._version_by_representation_id = version_by_representation_id + self._representation_widgets = representation_widgets + self._repre_name_buttons = repre_name_buttons + + self._uri_path_format = uri_path_format + self._button_output_path = button_output_path + self._line_edit_output_path = line_edit_output_path + self._button_export = button_export + + self._first_show = True + + def showEvent(self, event): + super().showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(style.load_stylesheet()) + + def _toggle_all(self): + representation_name = self.sender().text() + for item in self._representation_widgets[representation_name]: + item["widget"].setChecked(True) + + def _set_output_path(self): + file_path, _ = QtWidgets.QFileDialog.getSaveFileName( + None, "Save OTIO file.", "", "OTIO Files (*.otio)" + ) + if file_path: + self._line_edit_output_path.setText(file_path) + + def _on_export_click(self): + output_path = self._line_edit_output_path.text() + # Validate output path is not empty. + if not output_path: + show_message_dialog( + "Missing output path", + ( + "Output path is empty. Please enter a path to export the " + "OTIO file to." + ), + level="critical", + parent=self + ) + return + + # Validate output path ends with .otio. + if not output_path.endswith(".otio"): + show_message_dialog( + "Wrong extension.", + ( + "Output path needs to end with \".otio\"." + ), + level="critical", + parent=self + ) + return + + representations = [] + for name, items in self._representation_widgets.items(): + for item in items: + if item["widget"].isChecked(): + representations.append(item["representation"]) + + anatomy = Anatomy(self._project_name) + clips_data = {} + for representation in representations: + version = self._version_by_representation_id[ + representation["id"] + ] + name = ( + f'{self._version_path_by_id[version["id"]]}' + f'/{representation["name"]}' + ).replace("/", "_") + + clips_data[name] = { + "representation": representation, + "anatomy": anatomy, + "frames": ( + version["attrib"]["frameEnd"] + - version["attrib"]["frameStart"] + ), + "framerate": version["attrib"]["fps"], + } + + self.export_otio(clips_data, output_path) + + # Feedback about success. + show_message_dialog( + "Success!", + "Export was successful.", + level="info", + parent=self + ) + + self.close() + + def create_clip(self, name, clip_data, timeline_framerate): + representation = clip_data["representation"] + anatomy = clip_data["anatomy"] + frames = clip_data["frames"] + framerate = clip_data["framerate"] + + # Get path to representation with correct frame number + repre_path = get_representation_path_with_anatomy( + representation, anatomy) + + media_start_frame = clip_start_frame = 0 + media_framerate = framerate + if file_metadata := get_image_info_metadata( + repre_path, ["timecode", "duration", "framerate"], self.log + ): + # get media framerate and convert to float with 3 decimal places + media_framerate = file_metadata["framerate"] + media_framerate = float(f"{media_framerate:.4f}") + framerate = float(f"{timeline_framerate:.4f}") + + media_start_frame = self.get_timecode_start_frame( + media_framerate, file_metadata + ) + clip_start_frame = self.get_timecode_start_frame( + timeline_framerate, file_metadata + ) + + if "duration" in file_metadata: + frames = int(float(file_metadata["duration"]) * framerate) + + repre_path = Path(repre_path) + + first_frame = representation["context"].get("frame") + if first_frame is None: + media_range = OTIO.opentime.TimeRange( + start_time=OTIO.opentime.RationalTime( + media_start_frame, media_framerate + ), + duration=OTIO.opentime.RationalTime( + frames, media_framerate), + ) + clip_range = OTIO.opentime.TimeRange( + start_time=OTIO.opentime.RationalTime( + clip_start_frame, timeline_framerate + ), + duration=OTIO.opentime.RationalTime( + frames, timeline_framerate), + ) + + # Use 'repre_path' as single file + media_reference = OTIO.schema.ExternalReference( + available_range=media_range, + target_url=self.convert_to_uri_or_posix(repre_path), + ) + else: + # This is sequence + repre_files = [ + file["path"].format(root=anatomy.roots) + for file in representation["files"] + ] + # Change frame in representation context to get path with frame + # splitter. + representation["context"]["frame"] = FRAME_SPLITTER + frame_repre_path = get_representation_path_with_anatomy( + representation, anatomy + ) + frame_repre_path = Path(frame_repre_path) + repre_dir, repre_filename = ( + frame_repre_path.parent, frame_repre_path.name) + # Get sequence prefix and suffix + file_prefix, file_suffix = repre_filename.split(FRAME_SPLITTER) + # Get frame number from path as string to get frame padding + frame_str = str(repre_path)[len(file_prefix):][:len(file_suffix)] + frame_padding = len(frame_str) + + media_range = OTIO.opentime.TimeRange( + start_time=OTIO.opentime.RationalTime( + media_start_frame, media_framerate + ), + duration=OTIO.opentime.RationalTime( + len(repre_files), media_framerate + ), + ) + clip_range = OTIO.opentime.TimeRange( + start_time=OTIO.opentime.RationalTime( + clip_start_frame, timeline_framerate + ), + duration=OTIO.opentime.RationalTime( + len(repre_files), timeline_framerate + ), + ) + + media_reference = OTIO.schema.ImageSequenceReference( + available_range=media_range, + start_frame=int(first_frame), + frame_step=1, + rate=framerate, + target_url_base=f"{self.convert_to_uri_or_posix(repre_dir)}/", + name_prefix=file_prefix, + name_suffix=file_suffix, + frame_zero_padding=frame_padding, + ) + + return OTIO.schema.Clip( + name=name, media_reference=media_reference, source_range=clip_range + ) + + def convert_to_uri_or_posix(self, path: Path) -> str: + """Convert path to URI or Posix path. + + Args: + path (Path): Path to convert. + + Returns: + str: Path as URI or Posix path. + """ + if self._uri_path_format.isChecked(): + return path.as_uri() + + return path.as_posix() + + def get_timecode_start_frame(self, framerate, file_metadata): + # use otio to convert timecode into frame number + timecode_start_frame = OTIO.opentime.from_timecode( + file_metadata["timecode"], framerate) + return timecode_start_frame.to_frames() + + def export_otio(self, clips_data, output_path): + # first find the highest framerate and set it as default framerate + # for the timeline + timeline_framerate = 0 + for clip_data in clips_data.values(): + framerate = clip_data["framerate"] + if framerate > timeline_framerate: + timeline_framerate = framerate + + # reduce decimal places to 3 - otio does not like more + timeline_framerate = float(f"{timeline_framerate:.4f}") + + # create clips from the representations + clips = [ + self.create_clip(name, clip_data, timeline_framerate) + for name, clip_data in clips_data.items() + ] + timeline = OTIO.schema.timeline_from_clips(clips) + + # set the timeline framerate to the highest framerate + timeline.global_start_time = OTIO.opentime.RationalTime( + 0, timeline_framerate) + + OTIO.adapters.write_to_file(timeline, output_path) + + +def get_image_info_metadata( + path_to_file, + keys=None, + logger=None, +): + """Get flattened metadata from image file + + With combined approach via FFMPEG and OIIOTool. + + At first it will try to detect if the image input is supported by + OpenImageIO. If it is then it gets the metadata from the image using + OpenImageIO. If it is not supported by OpenImageIO then it will try to + get the metadata using FFprobe. + + Args: + path_to_file (str): Path to image file. + keys (list[str]): List of keys that should be returned. If None then + all keys are returned. Keys are expected all lowercase. + Additional keys are: + - "framerate" - will be created from "r_frame_rate" or + "framespersecond" and evaluated to float value. + logger (logging.Logger): Logger used for logging. + """ + if logger is None: + logger = logging.getLogger(__name__) + + def _ffprobe_metadata_conversion(metadata): + """Convert ffprobe metadata unified format.""" + output = {} + for key, val in metadata.items(): + if key in ("tags", "disposition"): + output.update(val) + else: + output[key] = val + return output + + def _get_video_metadata_from_ffprobe(ffprobe_stream): + """Extract video metadata from ffprobe stream. + + Args: + ffprobe_stream (dict): Stream data obtained from ffprobe. + + Returns: + dict: Video metadata extracted from the ffprobe stream. + """ + video_stream = None + for stream in ffprobe_stream["streams"]: + if stream["codec_type"] == "video": + video_stream = stream + break + metadata_stream = _ffprobe_metadata_conversion(video_stream) + return metadata_stream + + metadata_stream = None + ext = os.path.splitext(path_to_file)[-1].lower() + if ext not in IMAGE_EXTENSIONS: + logger.info( + ( + 'File extension "{}" is not supported by OpenImageIO.' + " Trying to get metadata using FFprobe." + ).format(ext) + ) + ffprobe_stream = get_ffprobe_data(path_to_file, logger) + if "streams" in ffprobe_stream and len(ffprobe_stream["streams"]) > 0: + metadata_stream = _get_video_metadata_from_ffprobe(ffprobe_stream) + + if not metadata_stream and is_oiio_supported(): + oiio_stream = get_oiio_info_for_input(path_to_file, logger=logger) + if "attribs" in (oiio_stream or {}): + metadata_stream = {} + for key, val in oiio_stream["attribs"].items(): + if "smpte:" in key.lower(): + key = key.replace("smpte:", "") + metadata_stream[key.lower()] = val + for key, val in oiio_stream.items(): + if key == "attribs": + continue + metadata_stream[key] = val + else: + logger.info( + ( + "OpenImageIO is not supported on this system." + " Trying to get metadata using FFprobe." + ) + ) + ffprobe_stream = get_ffprobe_data(path_to_file, logger) + if "streams" in ffprobe_stream and len(ffprobe_stream["streams"]) > 0: + metadata_stream = _get_video_metadata_from_ffprobe(ffprobe_stream) + + if not metadata_stream: + logger.warning("Failed to get metadata from image file.") + return {} + + if keys is None: + return metadata_stream + + # create framerate key from available ffmpeg:r_frame_rate + # or oiiotool:framespersecond and evaluate its string expression + # value into flaot value + if ( + "r_frame_rate" in metadata_stream + or "framespersecond" in metadata_stream + ): + rate_info = metadata_stream.get("r_frame_rate") + if rate_info is None: + rate_info = metadata_stream.get("framespersecond") + + # calculate framerate from string expression + if "/" in str(rate_info): + time, frame = str(rate_info).split("/") + rate_info = float(time) / float(frame) + + try: + metadata_stream["framerate"] = float(str(rate_info)) + except Exception as e: + logger.warning( + "Failed to evaluate '{}' value to framerate. Error: {}".format( + rate_info, e + ) + ) + + # aggregate all required metadata from prepared metadata stream + output = {} + for key in keys: + for k, v in metadata_stream.items(): + if key == k: + output[key] = v + break + if isinstance(v, dict) and key in v: + output[key] = v[key] + break + + return output 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 @@ def process(self, context): 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 @@ def fill_missing_task_entities(self, context, project_name): 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 @@ def fill_missing_task_entities(self, context, project_name): 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 @@ def fill_anatomy_data(self, context): 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 @@ def fill_anatomy_data(self, context): 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 @@ def _fill_folder_data(self, instance, project_entity, anatomy_data): }) 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..4de83f0d53 100644 --- a/client/ayon_core/plugins/publish/collect_context_entities.py +++ b/client/ayon_core/plugins/publish/collect_context_entities.py @@ -53,8 +53,9 @@ def process(self, context): context.data["folderEntity"] = folder_entity context.data["taskEntity"] = task_entity - - folder_attributes = folder_entity["attrib"] + context_attributes = ( + task_entity["attrib"] if task_entity else folder_entity["attrib"] + ) # Task type task_type = None @@ -63,12 +64,12 @@ def process(self, context): context.data["taskType"] = task_type - frame_start = folder_attributes.get("frameStart") + frame_start = context_attributes.get("frameStart") if frame_start is None: frame_start = 1 self.log.warning("Missing frame start. Defaulting to 1.") - frame_end = folder_attributes.get("frameEnd") + frame_end = context_attributes.get("frameEnd") if frame_end is None: frame_end = 2 self.log.warning("Missing frame end. Defaulting to 2.") @@ -76,8 +77,8 @@ def process(self, context): context.data["frameStart"] = frame_start context.data["frameEnd"] = frame_end - handle_start = folder_attributes.get("handleStart") or 0 - handle_end = folder_attributes.get("handleEnd") or 0 + handle_start = context_attributes.get("handleStart") or 0 + handle_end = context_attributes.get("handleEnd") or 0 context.data["handleStart"] = int(handle_start) context.data["handleEnd"] = int(handle_end) @@ -87,7 +88,7 @@ def process(self, context): context.data["frameStartHandle"] = frame_start_h context.data["frameEndHandle"] = frame_end_h - context.data["fps"] = folder_attributes["fps"] + context.data["fps"] = context_attributes["fps"] def _get_folder_entity(self, project_name, folder_path): if not folder_path: @@ -113,4 +114,4 @@ def _get_task_entity(self, project_name, folder_entity, task_name): "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 @@ def _process_path(self, data, anatomy): 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 @@ def process(self, context): 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_burnin.py b/client/ayon_core/plugins/publish/extract_burnin.py index 58a032a030..2007240d3d 100644 --- a/client/ayon_core/plugins/publish/extract_burnin.py +++ b/client/ayon_core/plugins/publish/extract_burnin.py @@ -199,7 +199,7 @@ def main_process(self, instance): if not burnins_per_repres: self.log.debug( "Skipped instance. No representations found matching a burnin" - "definition in: %s", burnin_defs + " definition in: %s", burnin_defs ) return @@ -399,7 +399,7 @@ def main_process(self, instance): add_repre_files_for_cleanup(instance, new_repre) - # Cleanup temp staging dir after procesisng of output definitions + # Cleanup temp staging dir after processing of output definitions if do_convert: temp_dir = repre["stagingDir"] shutil.rmtree(temp_dir) @@ -420,6 +420,12 @@ def main_process(self, instance): self.log.debug("Removed: \"{}\"".format(filepath)) def _get_burnin_options(self): + """Get the burnin options from `ExtractBurnin` settings. + + Returns: + dict[str, Any]: Burnin options. + + """ # Prepare burnin options burnin_options = copy.deepcopy(self.default_options) if self.options: @@ -696,7 +702,7 @@ def prepare_repre_data(self, instance, repre, burnin_data, temp_data): """Prepare data for representation. Args: - instance (Instance): Currently processed Instance. + instance (pyblish.api.Instance): Currently processed Instance. repre (dict): Currently processed representation. burnin_data (dict): Copy of basic burnin data based on instance data. @@ -752,9 +758,11 @@ def filter_burnins_defs(self, profile, instance): Args: profile (dict): Profile from presets matching current context. + instance (pyblish.api.Instance): Publish instance. Returns: - list: Contain all valid output definitions. + list[dict[str, Any]]: Contain all valid output definitions. + """ filtered_burnin_defs = [] @@ -773,12 +781,11 @@ def filter_burnins_defs(self, profile, instance): if not self.families_filter_validation( families, families_filters ): - self.log.debug(( - "Skipped burnin definition \"{}\". Family" - " filters ({}) does not match current instance families: {}" - ).format( - filename_suffix, str(families_filters), str(families) - )) + self.log.debug( + f"Skipped burnin definition \"{filename_suffix}\"." + f" Family filters ({families_filters}) does not match" + f" current instance families: {families}" + ) continue # Burnin values 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 @@ def process(self, instance): 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 @@ def process(self, instance): 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 @@ def _get_ffmpeg_output(self): 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 @@ def _convert_string_to_values(self, orig_string_value): 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 @@ def _create_thumbnail_ffmpeg(self, src_path, dst_path): # 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 @@ 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 @@ 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 @@ def get_attribute_defs(cls): 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 @@ def process(self, instance): 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 @@ def process(self, instance): ) 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 @@ def remove_previous_reference_contribution(self, ] 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 @@ def sort_by_order(instance): 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 @@ def prepare_representation( 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 @@ def integrate_instance( 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 @@ def recalculate_by_width(self, value, max_width): 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/tests/conftest.py b/client/ayon_core/tests/conftest.py new file mode 100644 index 0000000000..f66af706e1 --- /dev/null +++ b/client/ayon_core/tests/conftest.py @@ -0,0 +1,16 @@ +import pytest +from pathlib import Path + +collect_ignore = ["vendor", "resources"] + +RESOURCES_PATH = 'resources' + + +@pytest.fixture +def resources_path_factory(): + def factory(*args): + dirpath = Path(__file__).parent / RESOURCES_PATH + for arg in args: + dirpath = dirpath / arg + return dirpath + return factory diff --git a/client/ayon_core/tests/plugins/load/test_export_otio.py b/client/ayon_core/tests/plugins/load/test_export_otio.py new file mode 100644 index 0000000000..cdcb15033a --- /dev/null +++ b/client/ayon_core/tests/plugins/load/test_export_otio.py @@ -0,0 +1,52 @@ +import pytest +import logging +from pathlib import Path +from ayon_core.plugins.load.export_otio import get_image_info_metadata + +logger = logging.getLogger('test_transcoding') + + +@pytest.mark.parametrize( + "resources_path_factory, metadata, expected, test_id", + [ + ( + Path(__file__).parent.parent + / "resources" + / "lib" + / "transcoding" + / "a01vfxd_sh010_plateP01_v002.1013.exr", + ["timecode", "framerate"], + {"timecode": "01:00:06:03", "framerate": 23.976023976023978}, + "test_01", + ), + ( + Path(__file__).parent.parent + / "resources" + / "lib" + / "transcoding" + / "a01vfxd_sh010_plateP01_v002.1013.exr", + ["timecode", "width", "height", "duration"], + {"timecode": "01:00:06:03", "width": 1920, "height": 1080}, + "test_02", + ), + ( + Path(__file__).parent.parent + / "resources" + / "lib" + / "transcoding" + / "a01vfxd_sh010_plateP01_v002.mov", + ["width", "height", "duration"], + {"width": 1920, "height": 1080, "duration": "0.041708"}, + "test_03", + ), + ], +) +def test_get_image_info_metadata_happy_path( + resources_path_factory, metadata, expected, test_id +): + path_to_file = resources_path_factory.as_posix() + + returned_data = get_image_info_metadata(path_to_file, metadata, logger) + logger.info(f"Returned data: {returned_data}") + + assert returned_data == expected diff --git a/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.1013.exr b/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.1013.exr new file mode 100644 index 0000000000..9f7bc625bc Binary files /dev/null and b/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.1013.exr differ diff --git a/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.mov b/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.mov new file mode 100644 index 0000000000..7b477114b3 Binary files /dev/null and b/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.mov differ diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 6a68af1eb5..0b790dfbbd 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import List from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, @@ -13,19 +14,16 @@ class ProductTypeItem: Args: name (str): Product type name. icon (dict[str, Any]): Product type icon definition. - checked (bool): Is product type checked for filtering. """ - def __init__(self, name, icon, checked): + def __init__(self, name, icon): self.name = name self.icon = icon - self.checked = checked def to_data(self): return { "name": self.name, "icon": self.icon, - "checked": self.checked, } @classmethod @@ -346,6 +344,16 @@ def from_data(cls, data): return cls(**data) +class ProductTypesFilter: + """Product types filter. + + Defines the filtering for product types. + """ + def __init__(self, product_types: List[str], is_allow_list: bool): + self.product_types: List[str] = product_types + self.is_allow_list: bool = is_allow_list + + class _BaseLoaderController(ABC): """Base loader controller abstraction. @@ -1006,3 +1014,13 @@ def get_representations_sync_status( """ pass + + @abstractmethod + def get_product_types_filter(self): + """Return product type filter for current context. + + Returns: + ProductTypesFilter: Product type filter for current context + """ + + pass diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index f4b00e985f..2da77337fb 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -3,7 +3,9 @@ import ayon_api -from ayon_core.lib import NestedCacheItem, CacheItem +from ayon_core.settings import get_project_settings +from ayon_core.pipeline import get_current_host_name +from ayon_core.lib import NestedCacheItem, CacheItem, filter_profiles from ayon_core.lib.events import QueuedEventSystem from ayon_core.pipeline import Anatomy, get_current_context from ayon_core.host import ILoadHost @@ -13,7 +15,11 @@ ThumbnailsModel, ) -from .abstract import BackendLoaderController, FrontendLoaderController +from .abstract import ( + BackendLoaderController, + FrontendLoaderController, + ProductTypesFilter +) from .models import ( SelectionModel, ProductsModel, @@ -331,11 +337,11 @@ def get_current_context(self): project_name = context.get("project_name") folder_path = context.get("folder_path") if project_name and folder_path: - folder = ayon_api.get_folder_by_path( + folder_entity = ayon_api.get_folder_by_path( project_name, folder_path, fields=["id"] ) - if folder: - folder_id = folder["id"] + if folder_entity: + folder_id = folder_entity["id"] return { "project_name": project_name, "folder_id": folder_id, @@ -425,3 +431,59 @@ def _create_event_system(self): def _emit_event(self, topic, data=None): self._event_system.emit(topic, data or {}, "controller") + + def get_product_types_filter(self): + output = ProductTypesFilter( + is_allow_list=False, + product_types=[] + ) + # Without host is not determined context + if self._host is None: + return output + + context = self.get_current_context() + project_name = context.get("project_name") + if not project_name: + return output + settings = get_project_settings(project_name) + profiles = ( + settings + ["core"] + ["tools"] + ["loader"] + ["product_type_filter_profiles"] + ) + if not profiles: + return output + + folder_id = context.get("folder_id") + task_name = context.get("task_name") + task_type = None + if folder_id and task_name: + task_entity = ayon_api.get_task_by_name( + project_name, + folder_id, + task_name, + fields={"taskType"} + ) + if task_entity: + task_type = task_entity.get("taskType") + + host_name = getattr(self._host, "name", get_current_host_name()) + profile = filter_profiles( + profiles, + { + "hosts": host_name, + "task_types": task_type, + } + ) + if profile: + # TODO remove 'is_include' after release '0.4.3' + is_allow_list = profile.get("is_include") + if is_allow_list is None: + is_allow_list = profile["filter_type"] == "is_allow_list" + output = ProductTypesFilter( + is_allow_list=is_allow_list, + product_types=profile["filter_product_types"] + ) + return output diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index c9325c4480..58eab0cabe 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -123,7 +123,7 @@ def product_type_item_from_data(product_type_data): "color": "#0091B2", } # TODO implement checked logic - return ProductTypeItem(product_type_data["name"], icon, True) + return ProductTypeItem(product_type_data["name"], icon) def create_default_product_type_item(product_type): @@ -132,7 +132,7 @@ def create_default_product_type_item(product_type): "name": "fa.folder", "color": "#0091B2", } - return ProductTypeItem(product_type, icon, True) + return ProductTypeItem(product_type, icon) class ProductsModel: diff --git a/client/ayon_core/tools/loader/ui/product_types_widget.py b/client/ayon_core/tools/loader/ui/product_types_widget.py index 180994fd7f..9b1bf6326f 100644 --- a/client/ayon_core/tools/loader/ui/product_types_widget.py +++ b/client/ayon_core/tools/loader/ui/product_types_widget.py @@ -13,10 +13,17 @@ def __init__(self, controller): super(ProductTypesQtModel, self).__init__() self._controller = controller + self._reset_filters_on_refresh = True self._refreshing = False self._bulk_change = False + self._last_project = None self._items_by_name = {} + controller.register_event_callback( + "controller.reset.finished", + self._on_controller_reset_finish, + ) + def is_refreshing(self): return self._refreshing @@ -37,14 +44,19 @@ def refresh(self, project_name): self._refreshing = True product_type_items = self._controller.get_product_type_items( project_name) + self._last_project = project_name items_to_remove = set(self._items_by_name.keys()) new_items = [] + items_filter_required = {} for product_type_item in product_type_items: name = product_type_item.name items_to_remove.discard(name) - item = self._items_by_name.get(product_type_item.name) + item = self._items_by_name.get(name) + # Apply filter to new items or if filters reset is requested + filter_required = self._reset_filters_on_refresh if item is None: + filter_required = True item = QtGui.QStandardItem(name) item.setData(name, PRODUCT_TYPE_ROLE) item.setEditable(False) @@ -52,14 +64,26 @@ def refresh(self, project_name): new_items.append(item) self._items_by_name[name] = item - item.setCheckState( - QtCore.Qt.Checked - if product_type_item.checked - else QtCore.Qt.Unchecked - ) + if filter_required: + items_filter_required[name] = item + icon = get_qt_icon(product_type_item.icon) item.setData(icon, QtCore.Qt.DecorationRole) + if items_filter_required: + product_types_filter = self._controller.get_product_types_filter() + for product_type, item in items_filter_required.items(): + matching = ( + int(product_type in product_types_filter.product_types) + + int(product_types_filter.is_allow_list) + ) + state = ( + QtCore.Qt.Checked + if matching % 2 == 0 + else QtCore.Qt.Unchecked + ) + item.setCheckState(state) + root_item = self.invisibleRootItem() if new_items: root_item.appendRows(new_items) @@ -68,9 +92,13 @@ def refresh(self, project_name): item = self._items_by_name.pop(name) root_item.removeRow(item.row()) + self._reset_filters_on_refresh = False self._refreshing = False self.refreshed.emit() + def reset_product_types_filter_on_refresh(self): + self._reset_filters_on_refresh = True + def setData(self, index, value, role=None): checkstate_changed = False if role is None: @@ -122,6 +150,9 @@ def change_states(self, checked, product_types): if changed: self.filter_changed.emit() + def _on_controller_reset_finish(self): + self.refresh(self._last_project) + class ProductTypesView(QtWidgets.QListView): filter_changed = QtCore.Signal() @@ -151,6 +182,7 @@ def __init__(self, controller, parent): ) self._controller = controller + self._refresh_product_types_filter = False self._product_types_model = product_types_model self._product_types_proxy_model = product_types_proxy_model @@ -158,11 +190,15 @@ def __init__(self, controller, parent): def get_filter_info(self): return self._product_types_model.get_filter_info() + def reset_product_types_filter_on_refresh(self): + self._product_types_model.reset_product_types_filter_on_refresh() + def _on_project_change(self, event): project_name = event["project_name"] self._product_types_model.refresh(project_name) def _on_refresh_finished(self): + # Apply product types filter on first show self.filter_changed.emit() def _on_filter_change(self): diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 0ed8fe8fe7..9753da37af 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -140,12 +140,6 @@ def _on_index_change(self): self.value_changed.emit(self._product_id, value) -class EditorInfo: - def __init__(self, widget): - self.widget = widget - self.added = False - - class VersionDelegate(QtWidgets.QStyledItemDelegate): """A delegate that display version integer formatted as version string.""" @@ -154,7 +148,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._editor_by_id: Dict[str, EditorInfo] = {} + self._editor_by_id: Dict[str, VersionComboBox] = {} self._statuses_filter = None def displayText(self, value, locale): @@ -164,8 +158,8 @@ def displayText(self, value, locale): def set_statuses_filter(self, status_names): self._statuses_filter = set(status_names) - for info in self._editor_by_id.values(): - info.widget.set_statuses_filter(status_names) + for widget in self._editor_by_id.values(): + widget.set_statuses_filter(status_names) def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) @@ -229,11 +223,11 @@ def createEditor(self, parent, option, index): editor = VersionComboBox(product_id, parent) editor.setProperty("itemId", item_id) - self._editor_by_id[item_id] = EditorInfo(editor) - editor.value_changed.connect(self._on_editor_change) editor.destroyed.connect(self._on_destroy) + self._editor_by_id[item_id] = editor + return editor def setEditorData(self, editor, index): @@ -242,12 +236,10 @@ def setEditorData(self, editor, index): # Current value of the index versions = index.data(VERSION_NAME_EDIT_ROLE) or [] version_id = index.data(VERSION_ID_ROLE) + editor.update_versions(versions, version_id) editor.set_statuses_filter(self._statuses_filter) - item_id = editor.property("itemId") - self._editor_by_id[item_id].added = True - def setModelData(self, editor, model, index): """Apply the integer version back in the model""" diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 734be5dd90..bc24d4d7f7 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -132,7 +132,7 @@ def __init__(self, controller): def get_product_item_indexes(self): return [ - item.index() + self.indexFromItem(item) for item in self._items_by_id.values() ] @@ -156,16 +156,18 @@ def set_product_version(self, product_id, version_id): if product_item is None: return - self.setData( - product_item.index(), version_id, VERSION_NAME_EDIT_ROLE - ) + index = self.indexFromItem(product_item) + self.setData(index, version_id, VERSION_NAME_EDIT_ROLE) def set_enable_grouping(self, enable_grouping): if enable_grouping is self._grouping_enabled: return self._grouping_enabled = enable_grouping # Ignore change if groups are not available - self.refresh(self._last_project_name, self._last_folder_ids) + self.refresh( + self._last_project_name, + self._last_folder_ids + ) def flags(self, index): # Make the version column editable @@ -454,7 +456,7 @@ def _get_product_model_item( def get_last_project_name(self): return self._last_project_name - def refresh(self, project_name, folder_ids, status_names): + def refresh(self, project_name, folder_ids): self._clear() self._last_project_name = project_name @@ -486,17 +488,9 @@ def refresh(self, project_name, folder_ids, status_names): } last_version_by_product_id = {} for product_item in product_items: - all_versions = list(product_item.version_items.values()) - all_versions.sort() - versions = [ - version_item - for version_item in all_versions - if status_names is None or version_item.status in status_names - ] - if versions: - last_version = versions[-1] - else: - last_version = all_versions[-1] + versions = list(product_item.version_items.values()) + versions.sort() + last_version = versions[-1] last_version_by_product_id[product_item.product_id] = ( last_version ) @@ -543,10 +537,11 @@ def refresh(self, project_name, folder_ids, status_names): for product_name, product_items in groups.items(): group_product_types |= {p.product_type for p in product_items} for product_item in product_items: - group_product_types |= { + group_status_names |= { version_item.status for version_item in product_item.version_items.values() } + group_product_types.add(product_item.product_type) if len(product_items) == 1: top_items.append(product_items[0]) @@ -589,13 +584,15 @@ def refresh(self, project_name, folder_ids, status_names): product_name, product_items = path_info (merged_color_hex, merged_color_qt) = self._get_next_color() merged_color = qtawesome.icon( - "fa.circle", color=merged_color_qt) + "fa.circle", color=merged_color_qt + ) merged_item = self._get_merged_model_item( product_name, len(product_items), merged_color_hex) merged_item.setData(merged_color, QtCore.Qt.DecorationRole) new_items.append(merged_item) merged_product_types = set() + merged_status_names = set() new_merged_items = [] for product_item in product_items: item = self._get_product_model_item( @@ -608,9 +605,21 @@ def refresh(self, project_name, folder_ids, status_names): ) new_merged_items.append(item) merged_product_types.add(product_item.product_type) + merged_status_names |= { + version_item.status + for version_item in ( + product_item.version_items.values() + ) + } merged_item.setData( - "|".join(merged_product_types), PRODUCT_TYPE_ROLE) + "|".join(merged_product_types), + PRODUCT_TYPE_ROLE + ) + merged_item.setData( + "|".join(merged_status_names), + STATUS_NAME_FILTER_ROLE + ) if new_merged_items: merged_item.appendRows(new_merged_items) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index e37c327a17..748a1b5fb8 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -186,11 +186,12 @@ def __init__(self, controller, parent): products_proxy_model.rowsInserted.connect(self._on_rows_inserted) products_proxy_model.rowsMoved.connect(self._on_rows_moved) products_model.refreshed.connect(self._on_refresh) + products_model.version_changed.connect(self._on_version_change) products_view.customContextMenuRequested.connect( self._on_context_menu) - products_view.selectionModel().selectionChanged.connect( + products_view_sel_model = products_view.selectionModel() + products_view_sel_model.selectionChanged.connect( self._on_selection_change) - products_model.version_changed.connect(self._on_version_change) version_delegate.version_changed.connect( self._on_version_delegate_change ) @@ -321,8 +322,7 @@ def _on_rows_moved(self): def _refresh_model(self): self._products_model.refresh( self._selected_project_name, - self._selected_folder_ids, - self._products_proxy_model.get_statuses_filter() + self._selected_folder_ids ) def _on_context_menu(self, point): diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 58af6f0b1f..31c9908b23 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -345,6 +345,8 @@ def showEvent(self, event): def closeEvent(self, event): super(LoaderWindow, self).closeEvent(event) + self._product_types_widget.reset_product_types_filter_on_refresh() + self._reset_on_show = True def keyPressEvent(self, event): 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 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 @@ def get_instances_by_id( ) -> 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 @@ def get_instances(self): 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 @@ def stop(self): 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 @@ def __init__(self, *args, **kwargs): 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 @@ def _qt_on_publish_start(self): 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 @@ CreateContext, CreatedInstance, ) -from ayon_core.pipeline.create.context import ( +from ayon_core.pipeline.create import ( CreatorsOperationFailed, ConvertorsOperationFailed, ConvertorItem, @@ -306,6 +306,14 @@ def get_instances_by_id( 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 @@ def get_report( "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 @@ def _create_plugin_data_item( 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 @@ def __init__(self, controller: AbstractPublisherBackend): ) # 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 @@ def start_publish(self, wait: Optional[bool] = True): func() def get_next_process_func(self) -> partial: + # 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 - # - same conditions may be inside iterator but they may be used - # only in specific cases (e.g. when it happens for a first time) + # 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 @@ def _set_publish_error_msg(self, value: Optional[str]): {"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 @@ def _publish_iterator(self) -> Iterable[partial]: 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 @@ def __init__(self, plugin_data): 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 @@ def __init__(self, plugin_data): 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 @@ IS_DETAIL_ITEM_ROLE = QtCore.Qt.UserRole + 3 +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 set_report(self, report): - parent = self.invisibleRootItem() - parent.removeRows(0, parent.rowCount()) + 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 @@ def __init__(self, parent): 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 @@ def resizeEvent(self, event): 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 @@ def _create_widget(self, index): 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 @@ def __init__(self, parent): 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 @@ def __init__(self, parent): 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 @@ def _set_logs(self, logs): 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 @@ def __init__(self, parent, center_widget): 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 @@ def __init__(self, parent=None): 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 @@ def __init__(self, parent=None): 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 @@ def __init__(self, parent=None): 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 @@ def _on_plugin_view_clicked(self, index): 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 @@ def set_report(self, report): 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 @@ def _on_plugin_change(self, *_args): 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 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 @@ def dropEvent(self, event): 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 @@ def __init__(self, group_icons, *args, **kwargs): 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 @@ def update_instances(self, instances): 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 @@ def __init__(self, item, parent): 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 @@ def __init__(self, instance, group_icon, parent): 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 @@ def set_active(self, new_value): 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 @@ def _update_product_name(self): 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 @@ def refresh(self): 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 @@ def refresh(self): 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 @@ def _update_convertor_items_group(self): 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 @@ 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 .create_context_widgets import CreateContextWidget from .precreate_widget import PreCreateWidget -SEPARATORS = ("---separator---", "---") - class ResizeControlWidget(QtWidgets.QWidget): resized = QtCore.Signal() @@ -168,25 +167,9 @@ def __init__(self, controller, parent=None): 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 @@ def __init__(self, controller, parent=None): 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 @@ def __init__(self, controller, parent=None): 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 @@ def __init__(self, controller, parent=None): 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 @@ def _invalidate_prereq(self): 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 @@ def _set_creator(self, creator_item): 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 @@ def _on_variant_change(self, variant_value=None): 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 @@ def _validate_product_name(self, product_name, variant_value): 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 @@ def _validate_product_name(self, product_name, variant_value): 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 @@ def _on_create(self): 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 @@ def _on_create(self): 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 @@ def __init__(self, instance, parent): 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 @@ def set_active(self, new_value): 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 @@ def update_instance_values(self): # 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 @@ def refresh(self): 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 @@ def refresh(self): 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 @@ def refresh(self): 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 @@ def refresh(self): ) 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 @@ def _remove_groups_except(self, group_names): 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 @@ def get_selected_legacy_convertors(self): 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,22 +1,18 @@ import os import tempfile +import uuid from qtpy import QtCore, QtGui, QtWidgets -class ScreenMarquee(QtWidgets.QDialog): - """Dialog to interactively define screen area. - - This allows to select a screen area through a marquee selection. - - You can use any of its classmethods for easily saving an image, - capturing to QClipboard or returning a QPixmap, respectively - `capture_to_file`, `capture_to_clipboard` and `capture_to_pixmap`. - """ - - def __init__(self, parent=None): - super().__init__(parent=parent) +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 @@ -27,35 +23,40 @@ def __init__(self, parent=None): 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) + screen.geometryChanged.connect(self._fit_screen_geometry) - for screen in QtWidgets.QApplication.screens(): - screen.geometryChanged.connect(self._fit_screen_geometry) - - self._opacity = 50 + self._screen = screen + self._opacity = 100 self._click_pos = None - self._capture_rect = None + self._screen_id = screen_id - def get_captured_pixmap(self): - if self._capture_rect is None: - return QtGui.QPixmap() + def set_click_pos(self, pos): + self._click_pos = pos + self.repaint() - return self.get_desktop_pixmap(self._capture_rect) + 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.mapFromGlobal(QtGui.QCursor.pos()) - click_pos = None + 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( @@ -67,35 +68,12 @@ def paintEvent(self, event): # 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) + # 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) - - # 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() @@ -104,6 +82,31 @@ def paintEvent(self, event): 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): @@ -112,92 +115,249 @@ def mousePressEvent(self, 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 ( - self._click_pos is not None - and event.button() == QtCore.Qt.LeftButton - ): + if 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() + self.mouse_released.emit(event.globalPos()) def mouseMoveEvent(self, event): """Mouse move event""" - self.repaint() + self.mouse_moved.emit() 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() + 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): - # Compute the union of all screen geometries, and resize to fit. - workspace_rect = QtCore.QRect() + # 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. + + You can use any of its classmethods for easily saving an image, + capturing to QClipboard or returning a QPixmap, respectively + `capture_to_file`, `capture_to_clipboard` and `capture_to_pixmap`. + """ + + def __init__(self, parent=None): + super().__init__(parent=parent) + + screens_by_id = {} for screen in QtWidgets.QApplication.screens(): - workspace_rect = workspace_rect.united(screen.geometry()) - self.setGeometry(workspace_rect) + 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._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._pix is None: + return QtGui.QPixmap() + return self._pix - def _on_screen_added(self): - for screen in QtGui.QGuiApplication.screens(): - screen.geometryChanged.connect(self._fit_screen_geometry) + def _close_dialogs(self): + for dialog in self._screens_by_id.values(): + dialog.close() + + def _on_close_request(self): + self._close_dialogs() + self._finished = True + + 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 + + end_pos = start_screen_dialog.convert_end_pos(pos) + + self._close_dialogs() + self._end_pos = end_pos + self._finished = True + self._captured = True + + 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_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 + 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 + + # 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 @@ def capture_to_pixmap(cls): 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 @@ def _on_submit(self): 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 @@ def _on_submit(self): except TaskNotSetError: invalid_tasks = True - instance.set_task_invalid(True) product_names.add(instance["productName"]) continue @@ -1216,11 +1219,9 @@ def _on_submit(self): 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 @@ def set_current_instances(self, instances): 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 @@ def set_current_instances(self, instances): 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 @@ def set_current_instances(self, instances): 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 @@ def __init__( 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 @@ def set_current_instances( 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 @@ def _on_create_overlay_button_click(self): 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 @@ def _validate_create_instances(self): 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 @@ def refresh(self, selected=None): 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/stdout_broker/broker.py b/client/ayon_core/tools/stdout_broker/broker.py index 291936008b..c449fa7df9 100644 --- a/client/ayon_core/tools/stdout_broker/broker.py +++ b/client/ayon_core/tools/stdout_broker/broker.py @@ -8,7 +8,7 @@ import websocket from ayon_core.lib import Logger -from ayon_core.modules.webserver import HostMsgAction +from ayon_core.tools.tray import HostMsgAction log = Logger.get_logger(__name__) diff --git a/client/ayon_core/tools/tray/__init__.py b/client/ayon_core/tools/tray/__init__.py index 49130e660a..2e179f0620 100644 --- a/client/ayon_core/tools/tray/__init__.py +++ b/client/ayon_core/tools/tray/__init__.py @@ -1,6 +1,21 @@ -from .tray import main +from .structures import HostMsgAction +from .lib import ( + TrayState, + get_tray_state, + is_tray_running, + get_tray_server_url, + make_sure_tray_is_running, + main, +) __all__ = ( + "HostMsgAction", + + "TrayState", + "get_tray_state", + "is_tray_running", + "get_tray_server_url", + "make_sure_tray_is_running", "main", ) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py new file mode 100644 index 0000000000..39fcc2cdd3 --- /dev/null +++ b/client/ayon_core/tools/tray/lib.py @@ -0,0 +1,658 @@ +import os +import sys +import json +import hashlib +import platform +import subprocess +import csv +import time +import signal +import locale +from typing import Optional, Dict, Tuple, Any + +import requests +from ayon_api.utils import get_default_settings_variant + +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: + NOT_RUNNING = 0 + STARTING = 1 + RUNNING = 2 + + +class TrayIsRunningError(Exception): + pass + + +def _get_default_server_url() -> str: + """Get default AYON server url.""" + return os.getenv("AYON_SERVER_URL") + + +def _get_default_variant() -> str: + """Get default settings variant.""" + return get_default_settings_variant() + + +def _get_server_and_variant( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> Tuple[str, str]: + if not server_url: + server_url = _get_default_server_url() + if not variant: + variant = _get_default_variant() + return server_url, variant + + +def _windows_pid_is_running(pid: int) -> bool: + args = ["tasklist.exe", "/fo", "csv", "/fi", f"PID eq {pid}"] + output = subprocess.check_output(args) + encoding = locale.getpreferredencoding() + csv_content = csv.DictReader(output.decode(encoding).splitlines()) + # if "PID" not in csv_content.fieldnames: + # return False + for _ in csv_content: + return True + return False + + +def _is_process_running(pid: int) -> bool: + """Check whether process with pid is running.""" + if platform.system().lower() == "windows": + return _windows_pid_is_running(pid) + + if pid == 0: + return True + + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _kill_tray_process(pid: int): + if _is_process_running(pid): + os.kill(pid, signal.SIGTERM) + + +def _create_tray_hash(server_url: str, variant: str) -> str: + """Create tray hash for metadata filename. + + Args: + server_url (str): AYON server url. + variant (str): Settings variant. + + Returns: + str: Hash for metadata filename. + + """ + data = f"{server_url}|{variant}" + return hashlib.sha256(data.encode()).hexdigest() + + +def _wait_for_starting_tray( + server_url: Optional[str] = None, + variant: Optional[str] = None, + timeout: Optional[int] = None +) -> Optional[Dict[str, Any]]: + """Wait for tray to start. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + timeout (Optional[int]): Timeout for tray validation. + + Returns: + Optional[Dict[str, Any]]: Tray file information. + + """ + if timeout is None: + timeout = 10 + started_at = time.time() + while True: + data = get_tray_file_info(server_url, variant) + if data is None: + return None + + if data.get("started") is True: + return data + + pid = data.get("pid") + if pid and not _is_process_running(pid): + remove_tray_server_url() + return None + + if time.time() - started_at > timeout: + return None + time.sleep(0.1) + + +def get_tray_storage_dir() -> str: + """Get tray storage directory. + + Returns: + str: Tray storage directory where metadata files are stored. + + """ + return get_launcher_local_dir("tray") + + +def _get_tray_info_filepath( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> str: + hash_dir = get_tray_storage_dir() + server_url, variant = _get_server_and_variant(server_url, variant) + filename = _create_tray_hash(server_url, variant) + 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 +) -> Optional[Dict[str, Any]]: + """Get tray information from file. + + Metadata information about running tray that should contain tray + server url. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + + Returns: + Optional[Dict[str, Any]]: Tray information. + + """ + 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: + response = requests.get(f"{tray_url}/tray") + response.raise_for_status() + return response.json() + except (requests.HTTPError, requests.ConnectionError): + return None + + +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( + validate: Optional[bool] = False, + server_url: Optional[str] = None, + variant: Optional[str] = None, + timeout: Optional[int] = None +) -> Optional[str]: + """Get tray server url. + + Does not validate if tray is running. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + validate (Optional[bool]): Validate if tray is running. + By default, does not validate. + timeout (Optional[int]): Timeout for tray start-up. + + Returns: + Optional[str]: Tray server url. + + """ + 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): + """Add tray server information file. + + Called from tray logic, do not use on your own. + + Args: + tray_url (Optional[str]): Webserver url with port. + started (bool): If tray is started. When set to 'False' it means + that tray is starting up. + + """ + 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) + data = { + "url": tray_url, + "pid": os.getpid(), + "started": started + } + with open(filepath, "w") as stream: + json.dump(data, stream) + + +def remove_tray_server_url(force: Optional[bool] = False): + """Remove tray information file. + + Called from tray logic, do not use on your own. + + Args: + force (Optional[bool]): Force remove tray information file. + + """ + filepath = _get_tray_info_filepath() + if not os.path.exists(filepath): + return + + try: + with open(filepath, "r") as stream: + data = json.load(stream) + except BaseException: + data = {} + + if ( + force + or not data + or data.get("pid") == os.getpid() + or not _is_process_running(data.get("pid")) + ): + os.remove(filepath) + + +def get_tray_information( + server_url: Optional[str] = None, + 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: + TrayInfo: Tray information. + + """ + return TrayInfo.new(server_url, variant, timeout) + + +def get_tray_state( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> int: + """Get tray state for AYON server and variant. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + + Returns: + int: Tray state. + + """ + tray_info = get_tray_information(server_url, variant) + return tray_info.state + + +def is_tray_running( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> bool: + """Check if tray is running. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + + Returns: + bool: True if tray is running + + """ + state = get_tray_state(server_url, variant) + return state != TrayState.NOT_RUNNING + + +def show_message_in_tray( + title, message, icon=None, msecs=None, tray_url=None +): + """Show message in tray. + + Args: + title (str): Message title. + message (str): Message content. + icon (Optional[Literal["information", "warning", "critical"]]): Icon + for the message. + msecs (Optional[int]): Duration of the message. + tray_url (Optional[str]): Tray server url. + + """ + if not tray_url: + tray_url = get_tray_server_url() + + # TODO handle this case, e.g. raise an error? + if not tray_url: + return + + # TODO handle response, can fail whole request or can fail on status + requests.post( + f"{tray_url}/tray/message", + json={ + "title": title, + "message": message, + "icon": icon, + "msecs": msecs + } + ) + + +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. + + 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. + + """ + tray_info = TrayInfo.new( + ayon_url, variant, wait_to_start=False + ) + if tray_info.state == TrayState.STARTING: + tray_info.wait_to_start() + + 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) + + if ayon_url: + env["AYON_SERVER_URL"] = ayon_url + + # TODO maybe handle variant in a better way + if variant: + if variant == "staging": + args.append("--use-staging") + + run_detached_process(args, env=env) + + +def main(force=False): + from ayon_core.tools.tray.ui import main + + Logger.set_process_name("Tray") + + 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) + file_state = TrayState.NOT_RUNNING + + 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 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.") + tray_info.wait_to_start() + file_state = tray_info.get_file_state() + if file_state == TrayState.RUNNING: + print("Tray started. Exiting.") + return + + if file_state == TrayState.STARTING: + print( + "Tray did not start in expected time." + " Killing the process and starting new." + ) + pid = tray_info.pid + if pid is not None: + _kill_tray_process(pid) + remove_tray_server_url(force=True) + + # Prepare the file with 'pid' information as soon as possible + try: + set_tray_server_url(None, False) + except TrayIsRunningError: + print("Tray is running") + sys.exit(1) + + main() + diff --git a/client/ayon_core/modules/webserver/structures.py b/client/ayon_core/tools/tray/structures.py similarity index 100% rename from client/ayon_core/modules/webserver/structures.py rename to client/ayon_core/tools/tray/structures.py diff --git a/client/ayon_core/tools/tray/ui/__init__.py b/client/ayon_core/tools/tray/ui/__init__.py new file mode 100644 index 0000000000..49130e660a --- /dev/null +++ b/client/ayon_core/tools/tray/ui/__init__.py @@ -0,0 +1,6 @@ +from .tray import main + + +__all__ = ( + "main", +) diff --git a/client/ayon_core/tools/tray/__main__.py b/client/ayon_core/tools/tray/ui/__main__.py similarity index 100% rename from client/ayon_core/tools/tray/__main__.py rename to client/ayon_core/tools/tray/ui/__main__.py diff --git a/client/ayon_core/tools/tray/ui/addons_manager.py b/client/ayon_core/tools/tray/ui/addons_manager.py new file mode 100644 index 0000000000..2e6f0c0aae --- /dev/null +++ b/client/ayon_core/tools/tray/ui/addons_manager.py @@ -0,0 +1,244 @@ +import os +import time +from typing import Callable + +from ayon_core.addon import AddonsManager, ITrayAddon, ITrayService +from ayon_core.tools.tray.webserver import ( + find_free_port, + WebServerManager, +) + + +class TrayAddonsManager(AddonsManager): + # TODO do not use env variable + webserver_url_env = "AYON_WEBSERVER_URL" + # Define order of addons in menu + # TODO find better way how to define order + addons_menu_order = ( + "ftrack", + "kitsu", + "launcher_tool", + "clockify", + ) + + def __init__(self, tray_manager): + super().__init__(initialize=False) + + self._tray_manager = tray_manager + + self._webserver_manager = WebServerManager(find_free_port(), None) + + self.doubleclick_callbacks = {} + self.doubleclick_callback = None + + @property + def webserver_url(self): + return self._webserver_manager.url + + def get_doubleclick_callback(self): + callback_name = self.doubleclick_callback + return self.doubleclick_callbacks.get(callback_name) + + def add_doubleclick_callback(self, addon, callback): + """Register double-click callbacks on tray icon. + + Currently, there is no way how to determine which is launched. Name of + callback can be defined with `doubleclick_callback` attribute. + + Missing feature how to define default callback. + + Args: + addon (AYONAddon): Addon object. + callback (FunctionType): Function callback. + """ + + callback_name = "_".join([addon.name, callback.__name__]) + if callback_name not in self.doubleclick_callbacks: + self.doubleclick_callbacks[callback_name] = callback + if self.doubleclick_callback is None: + self.doubleclick_callback = callback_name + return + + self.log.warning(( + "Callback with name \"{}\" is already registered." + ).format(callback_name)) + + def initialize(self, tray_menu): + self.initialize_addons() + self.tray_init() + self.connect_addons() + self.tray_menu(tray_menu) + + def add_route(self, request_method: str, path: str, handler: Callable): + self._webserver_manager.add_route(request_method, path, handler) + + def add_static(self, prefix: str, path: str): + self._webserver_manager.add_static(prefix, path) + + def add_addon_route( + self, + addon_name: str, + path: str, + request_method: str, + handler: Callable + ) -> str: + return self._webserver_manager.add_addon_route( + addon_name, + path, + request_method, + handler + ) + + def add_addon_static( + self, addon_name: str, prefix: str, path: str + ) -> str: + return self._webserver_manager.add_addon_static( + addon_name, + prefix, + path + ) + + def get_enabled_tray_addons(self): + """Enabled tray addons. + + Returns: + list[AYONAddon]: Enabled addons that inherit from tray interface. + """ + + return [ + addon + for addon in self.get_enabled_addons() + if isinstance(addon, ITrayAddon) + ] + + def restart_tray(self): + if self._tray_manager: + self._tray_manager.restart() + + def tray_init(self): + self._init_tray_webserver() + report = {} + time_start = time.time() + prev_start_time = time_start + for addon in self.get_enabled_tray_addons(): + try: + addon._tray_manager = self._tray_manager + addon.tray_init() + addon.tray_initialized = True + except Exception: + self.log.warning( + "Addon \"{}\" crashed on `tray_init`.".format( + addon.name + ), + exc_info=True + ) + + now = time.time() + report[addon.__class__.__name__] = now - prev_start_time + prev_start_time = now + + if self._report is not None: + report[self._report_total_key] = time.time() - time_start + self._report["Tray init"] = report + + def connect_addons(self): + self._webserver_manager.connect_with_addons( + self.get_enabled_addons() + ) + super().connect_addons() + + def tray_menu(self, tray_menu): + ordered_addons = [] + enabled_by_name = { + addon.name: addon + for addon in self.get_enabled_tray_addons() + } + + for name in self.addons_menu_order: + addon_by_name = enabled_by_name.pop(name, None) + if addon_by_name: + ordered_addons.append(addon_by_name) + ordered_addons.extend(enabled_by_name.values()) + + report = {} + time_start = time.time() + prev_start_time = time_start + for addon in ordered_addons: + if not addon.tray_initialized: + continue + + try: + addon.tray_menu(tray_menu) + except Exception: + # Unset initialized mark + addon.tray_initialized = False + self.log.warning( + "Addon \"{}\" crashed on `tray_menu`.".format( + addon.name + ), + exc_info=True + ) + now = time.time() + report[addon.__class__.__name__] = now - prev_start_time + prev_start_time = now + + if self._report is not None: + report[self._report_total_key] = time.time() - time_start + self._report["Tray menu"] = report + + def start_addons(self): + self._webserver_manager.start_server() + + report = {} + time_start = time.time() + prev_start_time = time_start + for addon in self.get_enabled_tray_addons(): + if not addon.tray_initialized: + if isinstance(addon, ITrayService): + addon.set_service_failed_icon() + continue + + try: + addon.tray_start() + except Exception: + self.log.warning( + "Addon \"{}\" crashed on `tray_start`.".format( + addon.name + ), + exc_info=True + ) + now = time.time() + report[addon.__class__.__name__] = now - prev_start_time + prev_start_time = now + + if self._report is not None: + report[self._report_total_key] = time.time() - time_start + self._report["Addons start"] = report + + def on_exit(self): + self._webserver_manager.stop_server() + for addon in self.get_enabled_tray_addons(): + if addon.tray_initialized: + try: + addon.tray_exit() + except Exception: + self.log.warning( + "Addon \"{}\" crashed on `tray_exit`.".format( + addon.name + ), + exc_info=True + ) + + def get_tray_webserver(self): + # TODO rename/remove method + return self._webserver_manager + + def _init_tray_webserver(self): + 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 diff --git a/client/ayon_core/tools/tray/dialogs.py b/client/ayon_core/tools/tray/ui/dialogs.py similarity index 100% rename from client/ayon_core/tools/tray/dialogs.py rename to client/ayon_core/tools/tray/ui/dialogs.py diff --git a/client/ayon_core/modules/webserver/host_console_listener.py b/client/ayon_core/tools/tray/ui/host_console_listener.py similarity index 80% rename from client/ayon_core/modules/webserver/host_console_listener.py rename to client/ayon_core/tools/tray/ui/host_console_listener.py index 2efd768e24..62bca2f51b 100644 --- a/client/ayon_core/modules/webserver/host_console_listener.py +++ b/client/ayon_core/tools/tray/ui/host_console_listener.py @@ -9,7 +9,7 @@ from ayon_core.addon import ITrayService from ayon_core.tools.stdout_broker.window import ConsoleDialog -from .structures import HostMsgAction +from ayon_core.tools.tray import HostMsgAction log = logging.getLogger(__name__) @@ -22,18 +22,19 @@ class IconType: class HostListener: - def __init__(self, webserver, module): - self._window_per_id = {} - self.module = module - self.webserver = webserver + def __init__(self, addons_manager, tray_manager): + self._tray_manager = tray_manager self._window_per_id = {} # dialogs per host name self._action_per_id = {} # QAction per host name - webserver.add_route('*', "/ws/host_listener", self.websocket_handler) + addons_manager.add_route( + "*", "/ws/host_listener", self.websocket_handler + ) def _host_is_connecting(self, host_name, label): - """ Initialize dialog, adds to submenu. """ - services_submenu = self.module._services_submenu + """ Initialize dialog, adds to submenu.""" + ITrayService.services_submenu(self._tray_manager) + services_submenu = self._tray_manager.get_services_submenu() action = QtWidgets.QAction(label, services_submenu) action.triggered.connect(lambda: self.show_widget(host_name)) @@ -73,8 +74,9 @@ def show_widget(self, host_name): Dialog get initialized when 'host_name' is connecting. """ - self.module.execute_in_main_thread( - lambda: self._show_widget(host_name)) + self._tray_manager.execute_in_main_thread( + self._show_widget, host_name + ) def _show_widget(self, host_name): widget = self._window_per_id[host_name] @@ -95,21 +97,23 @@ async def websocket_handler(self, request): if action == HostMsgAction.CONNECTING: self._action_per_id[host_name] = None # must be sent to main thread, or action wont trigger - self.module.execute_in_main_thread( - lambda: self._host_is_connecting(host_name, text)) + self._tray_manager.execute_in_main_thread( + self._host_is_connecting, host_name, text + ) elif action == HostMsgAction.CLOSE: # clean close self._close(host_name) await ws.close() elif action == HostMsgAction.INITIALIZED: - self.module.execute_in_main_thread( + self._tray_manager.execute_in_main_thread( # must be queued as _host_is_connecting might not # be triggered/finished yet - lambda: self._set_host_icon(host_name, - IconType.RUNNING)) + self._set_host_icon, host_name, IconType.RUNNING + ) elif action == HostMsgAction.ADD: - self.module.execute_in_main_thread( - lambda: self._add_text(host_name, text)) + self._tray_manager.execute_in_main_thread( + self._add_text, host_name, text + ) elif msg.type == aiohttp.WSMsgType.ERROR: print('ws connection closed with exception %s' % ws.exception()) @@ -131,7 +135,7 @@ def _add_text(self, host_name, text): def _close(self, host_name): """ Clean close - remove from menu, delete widget.""" - services_submenu = self.module._services_submenu + services_submenu = self._tray_manager.get_services_submenu() action = self._action_per_id.pop(host_name) services_submenu.removeAction(action) widget = self._window_per_id.pop(host_name) diff --git a/client/ayon_core/tools/tray/images/gifts.png b/client/ayon_core/tools/tray/ui/images/gifts.png similarity index 100% rename from client/ayon_core/tools/tray/images/gifts.png rename to client/ayon_core/tools/tray/ui/images/gifts.png diff --git a/client/ayon_core/tools/tray/info_widget.py b/client/ayon_core/tools/tray/ui/info_widget.py similarity index 100% rename from client/ayon_core/tools/tray/info_widget.py rename to client/ayon_core/tools/tray/ui/info_widget.py diff --git a/client/ayon_core/tools/tray/tray.py b/client/ayon_core/tools/tray/ui/tray.py similarity index 71% rename from client/ayon_core/tools/tray/tray.py rename to client/ayon_core/tools/tray/ui/tray.py index c0b90dd764..f6a8add861 100644 --- a/client/ayon_core/tools/tray/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -1,12 +1,13 @@ import os import sys +import time import collections import atexit - import platform import ayon_api from qtpy import QtCore, QtGui, QtWidgets +from aiohttp.web import Response, json_response, Request from ayon_core import resources, style from ayon_core.lib import ( @@ -21,13 +22,19 @@ from ayon_core.addon import ( ITrayAction, ITrayService, - TrayAddonsManager, ) from ayon_core.tools.utils import ( WrappedCallbackItem, get_ayon_qt_app, ) +from ayon_core.tools.tray.lib import ( + set_tray_server_url, + remove_tray_server_url, + TrayIsRunningError, +) +from .addons_manager import TrayAddonsManager +from .host_console_listener import HostListener from .info_widget import InfoWidget from .dialogs import ( UpdateDialog, @@ -54,25 +61,55 @@ def __init__(self, tray_widget, main_window): ) if update_check_interval is None: update_check_interval = 5 - self._update_check_interval = update_check_interval * 60 * 1000 - self._addons_manager = TrayAddonsManager() + update_check_interval = update_check_interval * 60 * 1000 + + # create timer loop to check callback functions + main_thread_timer = QtCore.QTimer() + main_thread_timer.setInterval(300) + + update_check_timer = QtCore.QTimer() + if update_check_interval > 0: + update_check_timer.setInterval(update_check_interval) + + main_thread_timer.timeout.connect(self._main_thread_execution) + update_check_timer.timeout.connect(self._on_update_check_timer) + + self._addons_manager = TrayAddonsManager(self) + self._host_listener = HostListener(self._addons_manager, self) self.errors = [] - self._update_check_timer = None self._outdated_dialog = None - self._main_thread_timer = None + self._update_check_timer = update_check_timer + self._update_check_interval = update_check_interval + self._main_thread_timer = main_thread_timer self._main_thread_callbacks = collections.deque() self._execution_in_progress = None + self._services_submenu = None + self._start_time = time.time() + + # Cache AYON username used in process + # - it can change only by changing ayon_api global connection + # should be safe for tray application to cache the value only once + self._cached_username = None self._closing = False + try: + set_tray_server_url( + self._addons_manager.webserver_url, False + ) + except TrayIsRunningError: + self.log.error("Tray is already running.") + self._closing = True + + def is_closing(self): + return self._closing @property def doubleclick_callback(self): """Double-click callback for Tray icon.""" - callback_name = self._addons_manager.doubleclick_callback - return self._addons_manager.doubleclick_callbacks.get(callback_name) + return self._addons_manager.get_doubleclick_callback() def execute_doubleclick(self): """Execute double click callback in main thread.""" @@ -99,53 +136,71 @@ def show_tray_message(self, title, message, icon=None, msecs=None): kwargs["msecs"] = msecs self.tray_widget.showMessage(*args, **kwargs) + # TODO validate 'self.tray_widget.supportsMessages()' def initialize_addons(self): """Add addons to tray.""" + if self._closing: + return - self._addons_manager.initialize(self, self.tray_widget.menu) + tray_menu = self.tray_widget.menu + self._addons_manager.initialize(tray_menu) - admin_submenu = ITrayAction.admin_submenu(self.tray_widget.menu) - self.tray_widget.menu.addMenu(admin_submenu) + self._addons_manager.add_route( + "GET", "/tray", self._web_get_tray_info + ) + self._addons_manager.add_route( + "POST", "/tray/message", self._web_show_tray_message + ) + + admin_submenu = ITrayAction.admin_submenu(tray_menu) + tray_menu.addMenu(admin_submenu) # Add services if they are - services_submenu = ITrayService.services_submenu( - self.tray_widget.menu - ) - self.tray_widget.menu.addMenu(services_submenu) + services_submenu = ITrayService.services_submenu(tray_menu) + self._services_submenu = services_submenu + tray_menu.addMenu(services_submenu) # Add separator - self.tray_widget.menu.addSeparator() + tray_menu.addSeparator() self._add_version_item() # Add Exit action to menu exit_action = QtWidgets.QAction("Exit", self.tray_widget) exit_action.triggered.connect(self.tray_widget.exit) - self.tray_widget.menu.addAction(exit_action) + tray_menu.addAction(exit_action) # Tell each addon which addons were imported - self._addons_manager.start_addons() + # TODO Capture only webserver issues (the only thing that can crash). + try: + self._addons_manager.start_addons() + except Exception: + self.log.error( + "Failed to start addons.", + exc_info=True + ) + return self.exit() # Print time report self._addons_manager.print_report() - # create timer loop to check callback functions - main_thread_timer = QtCore.QTimer() - main_thread_timer.setInterval(300) - main_thread_timer.timeout.connect(self._main_thread_execution) - main_thread_timer.start() - - self._main_thread_timer = main_thread_timer + self._main_thread_timer.start() - update_check_timer = QtCore.QTimer() if self._update_check_interval > 0: - update_check_timer.timeout.connect(self._on_update_check_timer) - update_check_timer.setInterval(self._update_check_interval) - update_check_timer.start() - self._update_check_timer = update_check_timer + self._update_check_timer.start() self.execute_in_main_thread(self._startup_validations) + try: + set_tray_server_url( + self._addons_manager.webserver_url, True + ) + except TrayIsRunningError: + self.log.warning("Other tray started meanwhile. Exiting.") + self.exit() + + def get_services_submenu(self): + return self._services_submenu def restart(self): """Restart Tray tool. @@ -207,9 +262,13 @@ def restart(self): def exit(self): self._closing = True - self.tray_widget.exit() + if self._main_thread_timer.isActive(): + self.execute_in_main_thread(self.tray_widget.exit) + else: + self.tray_widget.exit() def on_exit(self): + remove_tray_server_url() self._addons_manager.on_exit() def execute_in_main_thread(self, callback, *args, **kwargs): @@ -222,6 +281,53 @@ def execute_in_main_thread(self, callback, *args, **kwargs): return item + async def _web_get_tray_info(self, _request: Request) -> Response: + if self._cached_username is None: + self._cached_username = ayon_api.get_user()["name"] + + return json_response({ + "username": self._cached_username, + "bundle": os.getenv("AYON_BUNDLE_NAME"), + "dev_mode": is_dev_mode_enabled(), + "staging_mode": is_staging_enabled(), + "addons": { + addon.name: addon.version + for addon in self._addons_manager.get_enabled_addons() + }, + "installer_version": os.getenv("AYON_VERSION"), + "running_time": time.time() - self._start_time, + }) + + async def _web_show_tray_message(self, request: Request) -> Response: + data = await request.json() + try: + title = data["title"] + message = data["message"] + icon = data.get("icon") + msecs = data.get("msecs") + except KeyError as exc: + return json_response( + { + "error": f"Missing required data. {exc}", + "success": False, + }, + status=400, + ) + + if icon == "information": + icon = QtWidgets.QSystemTrayIconInformation + elif icon == "warning": + icon = QtWidgets.QSystemTrayIconWarning + elif icon == "critical": + icon = QtWidgets.QSystemTrayIcon.Critical + else: + icon = None + + self.execute_in_main_thread( + self.show_tray_message, title, message, icon, msecs + ) + return json_response({"success": True}) + def _on_update_check_timer(self): try: bundles = ayon_api.get_bundles() @@ -298,20 +404,24 @@ def _outdated_bundle_ignored(self): ) def _main_thread_execution(self): - if self._execution_in_progress: - return - self._execution_in_progress = True - for _ in range(len(self._main_thread_callbacks)): - if self._main_thread_callbacks: - item = self._main_thread_callbacks.popleft() - try: - item.execute() - except BaseException: - self.log.erorr( - "Main thread execution failed", exc_info=True - ) - - self._execution_in_progress = False + try: + if self._execution_in_progress: + return + self._execution_in_progress = True + for _ in range(len(self._main_thread_callbacks)): + if self._main_thread_callbacks: + item = self._main_thread_callbacks.popleft() + try: + item.execute() + except BaseException: + self.log.erorr( + "Main thread execution failed", exc_info=True + ) + + self._execution_in_progress = False + + except KeyboardInterrupt: + self.execute_in_main_thread(self.exit) def _startup_validations(self): """Run possible startup validations.""" @@ -319,9 +429,10 @@ def _startup_validations(self): self._update_check_timer.timeout.emit() def _add_version_item(self): + tray_menu = self.tray_widget.menu login_action = QtWidgets.QAction("Login", self.tray_widget) login_action.triggered.connect(self._on_ayon_login) - self.tray_widget.menu.addAction(login_action) + tray_menu.addAction(login_action) version_string = os.getenv("AYON_VERSION", "AYON Info") version_action = QtWidgets.QAction(version_string, self.tray_widget) @@ -333,9 +444,9 @@ def _add_version_item(self): restart_action.triggered.connect(self._on_restart_action) restart_action.setVisible(False) - self.tray_widget.menu.addAction(version_action) - self.tray_widget.menu.addAction(restart_action) - self.tray_widget.menu.addSeparator() + tray_menu.addAction(version_action) + tray_menu.addAction(restart_action) + tray_menu.addSeparator() self._restart_action = restart_action @@ -424,19 +535,23 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): def __init__(self, parent): icon = QtGui.QIcon(resources.get_ayon_icon_filepath()) - super(SystemTrayIcon, self).__init__(icon, parent) + super().__init__(icon, parent) self._exited = False + self._doubleclick = False + self._click_pos = None + self._initializing_addons = False + # Store parent - QtWidgets.QMainWindow() - self.parent = parent + self._parent = parent # Setup menu in Tray self.menu = QtWidgets.QMenu() self.menu.setStyleSheet(style.load_stylesheet()) # Set addons - self.tray_man = TrayManager(self, self.parent) + self._tray_manager = TrayManager(self, parent) # Add menu to Context of SystemTrayIcon self.setContextMenu(self.menu) @@ -456,10 +571,9 @@ def __init__(self, parent): click_timer.timeout.connect(self._click_timer_timeout) self._click_timer = click_timer - self._doubleclick = False - self._click_pos = None - self._initializing_addons = False + def is_closing(self) -> bool: + return self._tray_manager.is_closing() @property def initializing_addons(self): @@ -468,7 +582,7 @@ def initializing_addons(self): def initialize_addons(self): self._initializing_addons = True try: - self.tray_man.initialize_addons() + self._tray_manager.initialize_addons() finally: self._initializing_addons = False @@ -478,7 +592,7 @@ def _click_timer_timeout(self): # Reset bool value self._doubleclick = False if doubleclick: - self.tray_man.execute_doubleclick() + self._tray_manager.execute_doubleclick() else: self._show_context_menu() @@ -492,7 +606,7 @@ def _show_context_menu(self): def on_systray_activated(self, reason): # show contextMenu if left click if reason == QtWidgets.QSystemTrayIcon.Trigger: - if self.tray_man.doubleclick_callback: + if self._tray_manager.doubleclick_callback: self._click_pos = QtGui.QCursor().pos() self._click_timer.start() else: @@ -511,7 +625,7 @@ def exit(self): self._exited = True self.hide() - self.tray_man.on_exit() + self._tray_manager.on_exit() QtCore.QCoreApplication.exit() @@ -536,6 +650,11 @@ def __init__(self, app): self._start_timer = start_timer def _on_start_timer(self): + if self._tray_widget.is_closing(): + self._start_timer.stop() + self._tray_widget.exit() + return + if self._timer_counter == 0: self._timer_counter += 1 splash = self._get_splash() diff --git a/client/ayon_core/tools/tray/webserver/__init__.py b/client/ayon_core/tools/tray/webserver/__init__.py new file mode 100644 index 0000000000..c40b5b85c3 --- /dev/null +++ b/client/ayon_core/tools/tray/webserver/__init__.py @@ -0,0 +1,9 @@ +from .base_routes import RestApiEndpoint +from .server import find_free_port, WebServerManager + + +__all__ = ( + "RestApiEndpoint", + "find_free_port", + "WebServerManager", +) diff --git a/client/ayon_core/modules/webserver/base_routes.py b/client/ayon_core/tools/tray/webserver/base_routes.py similarity index 94% rename from client/ayon_core/modules/webserver/base_routes.py rename to client/ayon_core/tools/tray/webserver/base_routes.py index f4f1abe16c..82568c201c 100644 --- a/client/ayon_core/modules/webserver/base_routes.py +++ b/client/ayon_core/tools/tray/webserver/base_routes.py @@ -1,7 +1,6 @@ """Helper functions or classes for Webserver module. -These must not be imported in module itself to not break Python 2 -applications. +These must not be imported in module itself to not break in-DCC process. """ import inspect diff --git a/client/ayon_core/modules/webserver/cors_middleware.py b/client/ayon_core/tools/tray/webserver/cors_middleware.py similarity index 100% rename from client/ayon_core/modules/webserver/cors_middleware.py rename to client/ayon_core/tools/tray/webserver/cors_middleware.py diff --git a/client/ayon_core/modules/webserver/server.py b/client/ayon_core/tools/tray/webserver/server.py similarity index 60% rename from client/ayon_core/modules/webserver/server.py rename to client/ayon_core/tools/tray/webserver/server.py index 99d9badb6a..d2a9b0fc6b 100644 --- a/client/ayon_core/modules/webserver/server.py +++ b/client/ayon_core/tools/tray/webserver/server.py @@ -1,24 +1,85 @@ import re import threading import asyncio +import socket +import random +from typing import Callable, Optional from aiohttp import web from ayon_core.lib import Logger +from ayon_core.resources import RESOURCES_DIR + from .cors_middleware import cors_middleware +def find_free_port( + port_from=None, port_to=None, exclude_ports=None, host=None +): + """Find available socket port from entered range. + + It is also possible to only check if entered port is available. + + Args: + port_from (int): Port number which is checked as first. + port_to (int): Last port that is checked in sequence from entered + `port_from`. Only `port_from` is checked if is not entered. + Nothing is processed if is equeal to `port_from`! + exclude_ports (list, tuple, set): List of ports that won't be + checked form entered range. + host (str): Host where will check for free ports. Set to + "localhost" by default. + """ + if port_from is None: + port_from = 8079 + + if port_to is None: + port_to = 65535 + + # Excluded ports (e.g. reserved for other servers/clients) + if exclude_ports is None: + exclude_ports = [] + + # Default host is localhost but it is possible to look for other hosts + if host is None: + host = "localhost" + + found_port = None + while True: + port = random.randint(port_from, port_to) + if port in exclude_ports: + continue + + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind((host, port)) + found_port = port + + except socket.error: + continue + + finally: + if sock: + sock.close() + + if found_port is not None: + break + + return found_port + + class WebServerManager: """Manger that care about web server thread.""" - def __init__(self, port=None, host=None): + def __init__( + self, port: Optional[int] = None, host: Optional[str] = None + ): self._log = None self.port = port or 8079 self.host = host or "localhost" - self.client = None - self.handlers = {} self.on_stop_callbacks = [] self.app = web.Application( @@ -30,9 +91,10 @@ def __init__(self, port=None, host=None): ) # add route with multiple methods for single "external app" - self.webserver_thread = WebServerThread(self) + self.add_static("/res", RESOURCES_DIR) + @property def log(self): if self._log is None: @@ -40,14 +102,46 @@ def log(self): return self._log @property - def url(self): - return "http://{}:{}".format(self.host, self.port) - - def add_route(self, *args, **kwargs): - self.app.router.add_route(*args, **kwargs) - - def add_static(self, *args, **kwargs): - self.app.router.add_static(*args, **kwargs) + def url(self) -> str: + return f"http://{self.host}:{self.port}" + + def add_route(self, request_method: str, path: str, handler: Callable): + self.app.router.add_route(request_method, path, handler) + + def add_static(self, prefix: str, path: str): + self.app.router.add_static(prefix, path) + + def add_addon_route( + self, + addon_name: str, + path: str, + request_method: str, + handler: Callable + ) -> str: + path = path.lstrip("/") + full_path = f"/addons/{addon_name}/{path}" + self.app.router.add_route(request_method, full_path, handler) + return full_path + + def add_addon_static( + self, addon_name: str, prefix: str, path: str + ) -> str: + full_path = f"/addons/{addon_name}/{prefix}" + self.app.router.add_static(full_path, path) + return full_path + + def connect_with_addons(self, addons): + for addon in addons: + if not hasattr(addon, "webserver_initialization"): + continue + + try: + addon.webserver_initialization(self) + except Exception: + self.log.warning( + f"Failed to connect addon \"{addon.name}\" to webserver.", + exc_info=True + ) def start_server(self): if self.webserver_thread and not self.webserver_thread.is_alive(): @@ -68,7 +162,7 @@ def stop_server(self): ) @property - def is_running(self): + def is_running(self) -> bool: if not self.webserver_thread: return False return self.webserver_thread.is_running 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 @@ ComboBox, CustomTextComboBox, PlaceholderLineEdit, + ElideLabel, + HintedLineEdit, ExpandingTextEdit, BaseClickableFrame, ClickableFrame, @@ -36,7 +38,6 @@ qt_app_context, get_qt_app, get_ayon_qt_app, - get_openpype_qt_app, get_qt_icon, ) @@ -88,6 +89,8 @@ "ComboBox", "CustomTextComboBox", "PlaceholderLineEdit", + "ElideLabel", + "HintedLineEdit", "ExpandingTextEdit", "BaseClickableFrame", "ClickableFrame", @@ -118,7 +121,6 @@ "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.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 @@ def __init__(self, *args, **kwargs): 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 @@ def __init__(self, parent): 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 @@ def set_collapsed(self, collapsed=None): 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 f8f840d2c9..db98ee4eba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ ayon-python-api = "^1.0" ruff = "^0.3.3" pre-commit = "^3.6.2" codespell = "^2.2.6" +semver = "^3.0.2" [tool.ruff] @@ -67,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. @@ -84,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. @@ -114,3 +114,12 @@ quiet-level = 3 [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + + +[tool.pytest.ini_options] +log_cli = true +log_cli_level = "INFO" +addopts = "-ra -q" +testpaths = [ + "client/ayon_core/tests" +] diff --git a/server/settings/main.py b/server/settings/main.py index 40e16e7e91..0972ccdfb9 100644 --- a/server/settings/main.py +++ b/server/settings/main.py @@ -169,6 +169,46 @@ class VersionStartCategoryModel(BaseSettingsModel): ) +class EnvironmentReplacementModel(BaseSettingsModel): + environment_key: str = SettingsField("", title="Enviroment variable") + pattern: str = SettingsField("", title="Pattern") + replacement: str = SettingsField("", title="Replacement") + + +class FilterEnvsProfileModel(BaseSettingsModel): + _layout = "expanded" + + host_names: list[str] = SettingsField( + default_factory=list, + title="Host names" + ) + + task_types: list[str] = SettingsField( + default_factory=list, + title="Task types", + enum_resolver=task_types_enum + ) + + task_names: list[str] = SettingsField( + default_factory=list, + title="Task names" + ) + + folder_paths: list[str] = SettingsField( + default_factory=list, + title="Folder paths" + ) + + skip_env_keys: list[str] = SettingsField( + default_factory=list, + title="Skip environment variables" + ) + replace_in_environment: list[EnvironmentReplacementModel] = SettingsField( + default_factory=list, + title="Replace values in environment" + ) + + class CoreSettings(BaseSettingsModel): studio_name: str = SettingsField("", title="Studio name", scope=["studio"]) studio_code: str = SettingsField("", title="Studio code", scope=["studio"]) @@ -219,6 +259,9 @@ class CoreSettings(BaseSettingsModel): title="Project environments", section="---" ) + filter_env_profiles: list[FilterEnvsProfileModel] = SettingsField( + default_factory=list, + ) @validator( "environments", @@ -313,5 +356,6 @@ def validate_json(cls, value): "project_environments": json.dumps( {}, indent=4 - ) + ), + "filter_env_profiles": [], } diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 36bb3f7340..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 @@ def validate_unique_outputs(cls, value): 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" @@ -964,7 +983,8 @@ class PublishPuginsModel(BaseSettingsModel): "nuke", "harmony", "photoshop", - "aftereffects" + "aftereffects", + "fusion" ], "enabled": True, "optional": True, @@ -1011,7 +1031,8 @@ class PublishPuginsModel(BaseSettingsModel): "ext": "png", "tags": [ "ftrackreview", - "kitsureview" + "kitsureview", + "webreview" ], "burnins": [], "ffmpeg_args": { @@ -1051,7 +1072,8 @@ class PublishPuginsModel(BaseSettingsModel): "tags": [ "burnin", "ftrackreview", - "kitsureview" + "kitsureview", + "webreview" ], "burnins": [], "ffmpeg_args": { @@ -1063,7 +1085,10 @@ class PublishPuginsModel(BaseSettingsModel): "output": [ "-pix_fmt yuv420p", "-crf 18", - "-intra" + "-c:a aac", + "-b:a 192k", + "-g 1", + "-movflags faststart" ] }, "filter": { @@ -1161,6 +1186,12 @@ class PublishPuginsModel(BaseSettingsModel): } ] }, + "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 3ed12d3d0a..a2785c1edf 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -22,6 +22,7 @@ def normalize_value(cls, value): 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" @@ -195,6 +205,7 @@ def _product_types_enum(): "editorial", "gizmo", "image", + "imagesequence", "layout", "look", "matchmove", @@ -212,7 +223,6 @@ def _product_types_enum(): "setdress", "take", "usd", - "usdShade", "vdbcache", "vrayproxy", "workfile", @@ -222,6 +232,13 @@ def _product_types_enum(): ] +def filter_type_enum(): + return [ + {"value": "is_allow_list", "label": "Allow list"}, + {"value": "is_deny_list", "label": "Deny list"}, + ] + + class LoaderProductTypeFilterProfile(BaseSettingsModel): _layout = "expanded" # TODO this should use hosts enum @@ -231,9 +248,15 @@ class LoaderProductTypeFilterProfile(BaseSettingsModel): title="Task types", enum_resolver=task_types_enum ) - is_include: bool = SettingsField(True, title="Exclude / Include") + filter_type: str = SettingsField( + "is_allow_list", + title="Filter type", + section="Product type filter", + enum_resolver=filter_type_enum + ) filter_product_types: list[str] = SettingsField( default_factory=list, + title="Product types", enum_resolver=_product_types_enum ) @@ -499,14 +522,7 @@ class GlobalToolsModel(BaseSettingsModel): "workfile_lock_profiles": [] }, "loader": { - "product_type_filter_profiles": [ - { - "hosts": [], - "task_types": [], - "is_include": True, - "filter_product_types": [] - } - ] + "product_type_filter_profiles": [] }, "publish": { "template_name_profiles": [