diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2cef7d13b0..ce5982969c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,12 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.5.3 + - 1.5.2 + - 1.5.1 + - 1.5.0 + - 1.4.1 + - 1.4.0 - 1.3.2 - 1.3.1 - 1.3.0 diff --git a/.gitignore b/.gitignore index 72c4204dc0..4b2dbb6b63 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,7 @@ poetry.lock .editorconfig .pre-commit-config.yaml mypy.ini +poetry.lock .github_changelog_generator diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index 6a7ce8a3cb..a8cf51ae25 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -1,42 +1,38 @@ -# -*- coding: utf-8 -*- +"""Addons for AYON.""" from . import click_wrap -from .interfaces import ( - IPluginPaths, - ITrayAddon, - ITrayAction, - ITrayService, - IHostAddon, -) - from .base import ( - ProcessPreparationError, - ProcessContext, - AYONAddon, AddonsManager, + AYONAddon, + ProcessContext, + ProcessPreparationError, load_addons, ) - +from .interfaces import ( + IHostAddon, + IPluginPaths, + ITraits, + ITrayAction, + ITrayAddon, + ITrayService, +) from .utils import ( ensure_addons_are_process_context_ready, ensure_addons_are_process_ready, ) - __all__ = ( - "click_wrap", - - "IPluginPaths", - "ITrayAddon", - "ITrayAction", - "ITrayService", - "IHostAddon", - - "ProcessPreparationError", - "ProcessContext", "AYONAddon", "AddonsManager", - "load_addons", - + "IHostAddon", + "IPluginPaths", + "ITraits", + "ITrayAction", + "ITrayAddon", + "ITrayService", + "ProcessContext", + "ProcessPreparationError", + "click_wrap", "ensure_addons_are_process_context_ready", "ensure_addons_are_process_ready", + "load_addons", ) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 72270fa585..f7fee13dc7 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -8,6 +8,7 @@ import inspect import logging import threading import collections +import warnings from uuid import uuid4 from abc import ABC, abstractmethod from typing import Optional @@ -155,18 +156,33 @@ def load_addons(force=False): def _get_ayon_bundle_data(): + studio_bundle_name = os.environ.get("AYON_STUDIO_BUNDLE_NAME") + project_bundle_name = os.getenv("AYON_BUNDLE_NAME") bundles = ayon_api.get_bundles()["bundles"] - - bundle_name = os.getenv("AYON_BUNDLE_NAME") - - return next( + project_bundle = next( ( bundle for bundle in bundles - if bundle["name"] == bundle_name + if bundle["name"] == project_bundle_name ), None ) + studio_bundle = None + if studio_bundle_name and project_bundle_name != studio_bundle_name: + studio_bundle = next( + ( + bundle + for bundle in bundles + if bundle["name"] == studio_bundle_name + ), + None + ) + + if project_bundle and studio_bundle: + addons = copy.deepcopy(studio_bundle["addons"]) + addons.update(project_bundle["addons"]) + project_bundle["addons"] = addons + return project_bundle def _get_ayon_addons_information(bundle_info): @@ -815,10 +831,26 @@ class AddonsManager: Unknown keys are logged out. + Deprecated: + Use targeted methods 'collect_launcher_action_paths', + 'collect_create_plugin_paths', 'collect_load_plugin_paths', + 'collect_publish_plugin_paths' and + 'collect_inventory_action_paths' to collect plugin paths. + Returns: dict: Output is dictionary with keys "publish", "create", "load", "actions" and "inventory" each containing list of paths. + """ + warnings.warn( + "Used deprecated method 'collect_plugin_paths'. Please use" + " targeted methods 'collect_launcher_action_paths'," + " 'collect_create_plugin_paths', 'collect_load_plugin_paths'" + " 'collect_publish_plugin_paths' and" + " 'collect_inventory_action_paths'", + DeprecationWarning, + stacklevel=2 + ) # Output structure output = { "publish": [], @@ -874,24 +906,28 @@ class AddonsManager: if not isinstance(addon, IPluginPaths): continue + paths = None method = getattr(addon, method_name) try: paths = method(*args, **kwargs) except Exception: self.log.warning( - ( - "Failed to get plugin paths from addon" - " '{}' using '{}'." - ).format(addon.__class__.__name__, method_name), + "Failed to get plugin paths from addon" + f" '{addon.name}' using '{method_name}'.", exc_info=True ) + + if not paths: continue - if paths: - # Convert to list if value is not list - if not isinstance(paths, (list, tuple, set)): - paths = [paths] - output.extend(paths) + if isinstance(paths, str): + paths = [paths] + self.log.warning( + f"Addon '{addon.name}' returned invalid output type" + f" from '{method_name}'." + f" Got 'str' expected 'list[str]'." + ) + output.extend(paths) return output def collect_launcher_action_paths(self): diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index 72191e3453..bf08ccd48c 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -1,16 +1,28 @@ +"""Addon interfaces for AYON.""" +from __future__ import annotations + +import warnings from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Callable, Optional, Type from ayon_core import resources +if TYPE_CHECKING: + from qtpy import QtWidgets + + from ayon_core.addon.base import AddonsManager + from ayon_core.pipeline.traits import TraitBase + from ayon_core.tools.tray.ui.tray import TrayManager + class _AYONInterfaceMeta(ABCMeta): - """AYONInterface meta class to print proper string.""" + """AYONInterface metaclass to print proper string.""" - def __str__(self): - return "<'AYONInterface.{}'>".format(self.__name__) + def __str__(cls): + return f"<'AYONInterface.{cls.__name__}'>" - def __repr__(self): - return str(self) + def __repr__(cls): + return str(cls) class AYONInterface(metaclass=_AYONInterfaceMeta): @@ -24,24 +36,42 @@ class AYONInterface(metaclass=_AYONInterfaceMeta): in the interface. By default, interface does not have any abstract parts. """ - pass + log = None class IPluginPaths(AYONInterface): - """Addon has plugin paths to return. + """Addon wants to register plugin paths.""" - Expected result is dictionary with keys "publish", "create", "load", - "actions" or "inventory" and values as list or string. - { - "publish": ["path/to/publish_plugins"] - } - """ + def get_plugin_paths(self) -> dict[str, list[str]]: + """Return plugin paths for addon. - @abstractmethod - def get_plugin_paths(self): - pass + This method was abstract (required) in the past, so raise the required + 'core' addon version when 'get_plugin_paths' is removed from + addon. - def _get_plugin_paths_by_type(self, plugin_type): + Deprecated: + Please implement specific methods 'get_create_plugin_paths', + 'get_load_plugin_paths', 'get_inventory_action_paths' and + 'get_publish_plugin_paths' to return plugin paths. + + Returns: + dict[str, list[str]]: Plugin paths for addon. + + """ + return {} + + def _get_plugin_paths_by_type( + self, plugin_type: str + ) -> list[str]: + """Get plugin paths by type. + + Args: + plugin_type (str): Type of plugin paths to get. + + Returns: + list[str]: List of plugin paths. + + """ paths = self.get_plugin_paths() if not paths or plugin_type not in paths: return [] @@ -52,16 +82,38 @@ class IPluginPaths(AYONInterface): if not isinstance(paths, (list, tuple, set)): paths = [paths] + + new_function_name = "get_launcher_action_paths" + if plugin_type == "create": + new_function_name = "get_create_plugin_paths" + elif plugin_type == "load": + new_function_name = "get_load_plugin_paths" + elif plugin_type == "publish": + new_function_name = "get_publish_plugin_paths" + elif plugin_type == "inventory": + new_function_name = "get_inventory_action_paths" + + warnings.warn( + f"Addon '{self.name}' returns '{plugin_type}' paths using" + " 'get_plugin_paths' method. Please implement" + f" '{new_function_name}' instead.", + DeprecationWarning, + stacklevel=2 + ) return paths - def get_launcher_action_paths(self): + def get_launcher_action_paths(self) -> list[str]: """Receive launcher actions paths. Give addons ability to add launcher actions paths. + + Returns: + list[str]: List of launcher action paths. + """ return self._get_plugin_paths_by_type("actions") - def get_create_plugin_paths(self, host_name): + def get_create_plugin_paths(self, host_name: str) -> list[str]: """Receive create plugin paths. Give addons ability to add create plugin paths based on host name. @@ -72,11 +124,14 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + Returns: + list[str]: List of create plugin paths. + + """ return self._get_plugin_paths_by_type("create") - def get_load_plugin_paths(self, host_name): + def get_load_plugin_paths(self, host_name: str) -> list[str]: """Receive load plugin paths. Give addons ability to add load plugin paths based on host name. @@ -87,11 +142,14 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + Returns: + list[str]: List of load plugin paths. + + """ return self._get_plugin_paths_by_type("load") - def get_publish_plugin_paths(self, host_name): + def get_publish_plugin_paths(self, host_name: str) -> list[str]: """Receive publish plugin paths. Give addons ability to add publish plugin paths based on host name. @@ -102,11 +160,14 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + Returns: + list[str]: List of publish plugin paths. + + """ return self._get_plugin_paths_by_type("publish") - def get_inventory_action_paths(self, host_name): + def get_inventory_action_paths(self, host_name: str) -> list[str]: """Receive inventory action paths. Give addons ability to add inventory action plugin paths. @@ -117,77 +178,84 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + Returns: + list[str]: List of inventory action plugin paths. + + """ return self._get_plugin_paths_by_type("inventory") class ITrayAddon(AYONInterface): """Addon has special procedures when used in Tray tool. - IMPORTANT: - The addon. still must be usable if is not used in tray even if - would do nothing. - """ + Important: + The addon. still must be usable if is not used in tray even if it + would do nothing. + """ + manager: AddonsManager tray_initialized = False - _tray_manager = None + _tray_manager: TrayManager = None _admin_submenu = None @abstractmethod - def tray_init(self): + def tray_init(self) -> None: """Initialization part of tray implementation. Triggered between `initialization` and `connect_with_addons`. This is where GUIs should be loaded or tray specific parts should be - prepared. + prepared + """ - pass - @abstractmethod - def tray_menu(self, tray_menu): + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: """Add addon's action to tray menu.""" - pass - @abstractmethod - def tray_start(self): + def tray_start(self) -> None: """Start procedure in tray tool.""" - pass - @abstractmethod - def tray_exit(self): + def tray_exit(self) -> None: """Cleanup method which is executed on tray shutdown. This is place where all threads should be shut. + """ - pass + def execute_in_main_thread(self, callback: Callable) -> None: + """Pushes callback to the queue or process 'callback' on a main thread. - def execute_in_main_thread(self, callback): - """ Pushes callback to the queue or process 'callback' on a main thread + Some callbacks need to be processed on main thread (menu actions + must be added on main thread else they won't get triggered etc.) + + Args: + callback (Callable): Function to be executed on main thread - Some callbacks need to be processed on main thread (menu actions - must be added on main thread or they won't get triggered etc.) """ - if not self.tray_initialized: - # TODO Called without initialized tray, still main thread needed + # TODO (Illicit): Called without initialized tray, still + # main thread needed. try: callback() - except Exception: + except Exception: # noqa: BLE001 self.log.warning( - "Failed to execute {} in main thread".format(callback), - exc_info=True) + "Failed to execute %s callback in main thread", + str(callback), exc_info=True) return - self.manager.tray_manager.execute_in_main_thread(callback) + self._tray_manager.tray_manager.execute_in_main_thread(callback) - def show_tray_message(self, title, message, icon=None, msecs=None): + def show_tray_message( + self, + title: str, + message: str, + icon: Optional[QtWidgets.QSystemTrayIcon] = None, + msecs: Optional[int] = None) -> None: """Show tray message. Args: @@ -198,16 +266,22 @@ class ITrayAddon(AYONInterface): msecs (int): Duration of message visibility in milliseconds. Default is 10000 msecs, may differ by Qt version. """ - if self._tray_manager: self._tray_manager.show_tray_message(title, message, icon, msecs) - def add_doubleclick_callback(self, callback): + def add_doubleclick_callback(self, callback: Callable) -> None: + """Add callback to be triggered on tray icon double click.""" if hasattr(self.manager, "add_doubleclick_callback"): self.manager.add_doubleclick_callback(self, callback) @staticmethod - def admin_submenu(tray_menu): + def admin_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu: + """Get or create admin submenu. + + Returns: + QtWidgets.QMenu: Admin submenu. + + """ if ITrayAddon._admin_submenu is None: from qtpy import QtWidgets @@ -217,7 +291,18 @@ class ITrayAddon(AYONInterface): return ITrayAddon._admin_submenu @staticmethod - def add_action_to_admin_submenu(label, tray_menu): + def add_action_to_admin_submenu( + label: str, tray_menu: QtWidgets.QMenu) -> QtWidgets.QAction: + """Add action to admin submenu. + + Args: + label (str): Label of action. + tray_menu (QtWidgets.QMenu): Tray menu to add action to. + + Returns: + QtWidgets.QAction: Action added to admin submenu + + """ from qtpy import QtWidgets menu = ITrayAddon.admin_submenu(tray_menu) @@ -244,16 +329,15 @@ class ITrayAction(ITrayAddon): @property @abstractmethod - def label(self): + def label(self) -> str: """Service label showed in menu.""" - pass @abstractmethod - def on_action_trigger(self): + def on_action_trigger(self) -> None: """What happens on actions click.""" - pass - def tray_menu(self, tray_menu): + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: + """Add action to tray menu.""" from qtpy import QtWidgets if self.admin_action: @@ -265,36 +349,44 @@ class ITrayAction(ITrayAddon): action.triggered.connect(self.on_action_trigger) self._action_item = action - def tray_start(self): + def tray_start(self) -> None: # noqa: PLR6301 + """Start procedure in tray tool.""" return - def tray_exit(self): + def tray_exit(self) -> None: # noqa: PLR6301 + """Cleanup method which is executed on tray shutdown.""" return class ITrayService(ITrayAddon): + """Tray service Interface.""" # Module's property - menu_action = None + menu_action: QtWidgets.QAction = None # Class properties - _services_submenu = None - _icon_failed = None - _icon_running = None - _icon_idle = None + _services_submenu: QtWidgets.QMenu = None + _icon_failed: QtWidgets.QIcon = None + _icon_running: QtWidgets.QIcon = None + _icon_idle: QtWidgets.QIcon = None @property @abstractmethod - def label(self): + def label(self) -> str: """Service label showed in menu.""" - pass - # TODO be able to get any sort of information to show/print + # TODO (Illicit): be able to get any sort of information to show/print # @abstractmethod # def get_service_info(self): # pass @staticmethod - def services_submenu(tray_menu): + def services_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu: + """Get or create services submenu. + + Returns: + QtWidgets.QMenu: Services submenu. + + """ if ITrayService._services_submenu is None: from qtpy import QtWidgets @@ -304,13 +396,15 @@ class ITrayService(ITrayAddon): return ITrayService._services_submenu @staticmethod - def add_service_action(action): + def add_service_action(action: QtWidgets.QAction) -> None: + """Add service action to services submenu.""" ITrayService._services_submenu.addAction(action) if not ITrayService._services_submenu.menuAction().isVisible(): ITrayService._services_submenu.menuAction().setVisible(True) @staticmethod - def _load_service_icons(): + def _load_service_icons() -> None: + """Load service icons.""" from qtpy import QtGui ITrayService._failed_icon = QtGui.QIcon( @@ -324,24 +418,43 @@ class ITrayService(ITrayAddon): ) @staticmethod - def get_icon_running(): + def get_icon_running() -> QtWidgets.QIcon: + """Get running icon. + + Returns: + QtWidgets.QIcon: Returns "running" icon. + + """ if ITrayService._icon_running is None: ITrayService._load_service_icons() return ITrayService._icon_running @staticmethod - def get_icon_idle(): + def get_icon_idle() -> QtWidgets.QIcon: + """Get idle icon. + + Returns: + QtWidgets.QIcon: Returns "idle" icon. + + """ if ITrayService._icon_idle is None: ITrayService._load_service_icons() return ITrayService._icon_idle @staticmethod - def get_icon_failed(): - if ITrayService._failed_icon is None: - ITrayService._load_service_icons() - return ITrayService._failed_icon + def get_icon_failed() -> QtWidgets.QIcon: + """Get failed icon. - def tray_menu(self, tray_menu): + Returns: + QtWidgets.QIcon: Returns "failed" icon. + + """ + if ITrayService._icon_failed is None: + ITrayService._load_service_icons() + return ITrayService._icon_failed + + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: + """Add service to tray menu.""" from qtpy import QtWidgets action = QtWidgets.QAction( @@ -354,21 +467,18 @@ class ITrayService(ITrayAddon): self.set_service_running_icon() - def set_service_running_icon(self): + def set_service_running_icon(self) -> None: """Change icon of an QAction to green circle.""" - if self.menu_action: self.menu_action.setIcon(self.get_icon_running()) - def set_service_failed_icon(self): + def set_service_failed_icon(self) -> None: """Change icon of an QAction to red circle.""" - if self.menu_action: self.menu_action.setIcon(self.get_icon_failed()) - def set_service_idle_icon(self): + def set_service_idle_icon(self) -> None: """Change icon of an QAction to orange circle.""" - if self.menu_action: self.menu_action.setIcon(self.get_icon_idle()) @@ -378,18 +488,29 @@ class IHostAddon(AYONInterface): @property @abstractmethod - def host_name(self): + def host_name(self) -> str: """Name of host which addon represents.""" - pass - - def get_workfile_extensions(self): + def get_workfile_extensions(self) -> list[str]: # noqa: PLR6301 """Define workfile extensions for host. Not all hosts support workfiles thus this is optional implementation. Returns: List[str]: Extensions used for workfiles with dot. - """ + """ return [] + + +class ITraits(AYONInterface): + """Interface for traits.""" + + @abstractmethod + def get_addon_traits(self) -> list[Type[TraitBase]]: + """Get trait classes for the addon. + + Returns: + list[Type[TraitBase]]: Traits for the addon. + + """ diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 322c294cfb..85c254e7eb 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -2,6 +2,7 @@ """Package for handling AYON command line arguments.""" import os import sys +import logging import code import traceback from pathlib import Path @@ -26,25 +27,40 @@ from ayon_core.lib.env_tools import ( @click.group(invoke_without_command=True) @click.pass_context -@click.option("--use-staging", is_flag=True, - expose_value=False, help="use staging variants") -@click.option("--debug", is_flag=True, expose_value=False, - help="Enable debug") -@click.option("--verbose", expose_value=False, - help=("Change AYON log level (debug - critical or 0-50)")) -@click.option("--force", is_flag=True, hidden=True) -def main_cli(ctx, force): +@click.option( + "--use-staging", + is_flag=True, + expose_value=False, + help="use staging variants") +@click.option( + "--debug", + is_flag=True, + expose_value=False, + help="Enable debug") +@click.option( + "--project", + help="Project name") +@click.option( + "--verbose", + expose_value=False, + help="Change AYON log level (debug - critical or 0-50)") +@click.option( + "--use-dev", + is_flag=True, + expose_value=False, + help="use dev bundle") +def main_cli(ctx, *_args, **_kwargs): """AYON is main command serving as entry point to pipeline system. It wraps different commands together. """ - if ctx.invoked_subcommand is None: # Print help if headless mode is used if os.getenv("AYON_HEADLESS_MODE") == "1": print(ctx.get_help()) sys.exit(0) else: + ctx.params.pop("project") ctx.forward(tray) @@ -59,7 +75,6 @@ def tray(force): Default action of AYON command is to launch tray widget to control basic aspects of AYON. See documentation for more information. """ - from ayon_core.tools.tray import main main(force) @@ -235,6 +250,30 @@ def version(build): print(os.environ["AYON_VERSION"]) +@main_cli.command() +@click.option( + "--project", + type=str, + help="Project name", + required=True) +def create_project_structure( + project, +): + """Create project folder structure as defined in setting + `ayon+settings://core/project_folder_structure` + + Args: + project (str): The name of the project for which you + want to create its additional folder structure. + + """ + + from ayon_core.pipeline.project_folders import create_project_folders + + print(f">>> Creating project folder structure for project '{project}'.") + create_project_folders(project) + + def _set_global_environments() -> None: """Set global AYON environments.""" # First resolve general environment @@ -281,7 +320,46 @@ def _add_addons(addons_manager): ) +def _cleanup_project_args(): + rem_args = list(sys.argv[1:]) + if "--project" not in rem_args: + return + + cmd = None + current_ctx = None + parent_name = "ayon" + parent_cmd = main_cli + while hasattr(parent_cmd, "resolve_command"): + if current_ctx is None: + current_ctx = main_cli.make_context(parent_name, rem_args) + else: + current_ctx = parent_cmd.make_context( + parent_name, + rem_args, + parent=current_ctx + ) + if not rem_args: + break + cmd_name, cmd, rem_args = parent_cmd.resolve_command( + current_ctx, rem_args + ) + parent_name = cmd_name + parent_cmd = cmd + + if cmd is None: + return + + param_names = {param.name for param in cmd.params} + if "project" in param_names: + return + idx = sys.argv.index("--project") + sys.argv.pop(idx) + sys.argv.pop(idx) + + def main(*args, **kwargs): + logging.basicConfig() + initialize_ayon_connection() python_path = os.getenv("PYTHONPATH", "") split_paths = python_path.split(os.pathsep) @@ -305,10 +383,14 @@ def main(*args, **kwargs): addons_manager = AddonsManager() _set_addons_environments(addons_manager) _add_addons(addons_manager) + + _cleanup_project_args() + try: main_cli( prog_name="ayon", obj={"addons_manager": addons_manager}, + args=(sys.argv[1:]), ) except Exception: # noqa exc_info = sys.exc_info() diff --git a/client/ayon_core/hooks/pre_add_last_workfile_arg.py b/client/ayon_core/hooks/pre_add_last_workfile_arg.py index bafc075888..c6afaaa083 100644 --- a/client/ayon_core/hooks/pre_add_last_workfile_arg.py +++ b/client/ayon_core/hooks/pre_add_last_workfile_arg.py @@ -32,6 +32,8 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "openrv", "cinema4d", "silhouette", + "gaffer", + "loki", } launch_types = {LaunchTypes.local} diff --git a/client/ayon_core/hooks/pre_global_host_data.py b/client/ayon_core/hooks/pre_global_host_data.py index 23f725901c..83c4118136 100644 --- a/client/ayon_core/hooks/pre_global_host_data.py +++ b/client/ayon_core/hooks/pre_global_host_data.py @@ -32,8 +32,8 @@ class GlobalHostDataHook(PreLaunchHook): "app": app, "project_entity": self.data["project_entity"], - "folder_entity": self.data["folder_entity"], - "task_entity": self.data["task_entity"], + "folder_entity": self.data.get("folder_entity"), + "task_entity": self.data.get("task_entity"), "anatomy": self.data["anatomy"], diff --git a/client/ayon_core/hooks/pre_ocio_hook.py b/client/ayon_core/hooks/pre_ocio_hook.py index d1a02e613d..85fcef47f2 100644 --- a/client/ayon_core/hooks/pre_ocio_hook.py +++ b/client/ayon_core/hooks/pre_ocio_hook.py @@ -23,6 +23,8 @@ class OCIOEnvHook(PreLaunchHook): "openrv", "cinema4d", "silhouette", + "gaffer", + "loki", } launch_types = set() diff --git a/client/ayon_core/host/__init__.py b/client/ayon_core/host/__init__.py index da1237c739..950c14564e 100644 --- a/client/ayon_core/host/__init__.py +++ b/client/ayon_core/host/__init__.py @@ -1,9 +1,14 @@ +from .constants import ContextChangeReason +from .abstract import AbstractHost from .host import ( HostBase, + ContextChangeData, ) from .interfaces import ( IWorkfileHost, + WorkfileInfo, + PublishedWorkfileInfo, ILoadHost, IPublishHost, INewPublisher, @@ -13,9 +18,16 @@ from .dirmap import HostDirmap __all__ = ( + "ContextChangeReason", + + "AbstractHost", + "HostBase", + "ContextChangeData", "IWorkfileHost", + "WorkfileInfo", + "PublishedWorkfileInfo", "ILoadHost", "IPublishHost", "INewPublisher", diff --git a/client/ayon_core/host/abstract.py b/client/ayon_core/host/abstract.py new file mode 100644 index 0000000000..26771aaffa --- /dev/null +++ b/client/ayon_core/host/abstract.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +import typing +from typing import Optional, Any + +from .constants import ContextChangeReason + +if typing.TYPE_CHECKING: + from ayon_core.pipeline import Anatomy + + from .typing import HostContextData + + +class AbstractHost(ABC): + """Abstract definition of host implementation.""" + @property + @abstractmethod + def log(self) -> logging.Logger: + pass + + @property + @abstractmethod + def name(self) -> str: + """Host name.""" + pass + + @abstractmethod + def get_current_context(self) -> HostContextData: + """Get the current context of the host. + + Current context is defined by project name, folder path and task name. + + Returns: + HostContextData: The current context of the host. + + """ + pass + + @abstractmethod + def set_current_context( + self, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + reason: ContextChangeReason = ContextChangeReason.undefined, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional[Anatomy] = None, + ) -> HostContextData: + """Change context of the host. + + Args: + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + reason (ContextChangeReason): Reason for change. + project_entity (dict[str, Any]): Project entity. + anatomy (Anatomy): Anatomy entity. + + """ + pass + + @abstractmethod + def get_current_project_name(self) -> str: + """Get the current project name. + + Returns: + Optional[str]: The current project name. + + """ + pass + + @abstractmethod + def get_current_folder_path(self) -> Optional[str]: + """Get the current folder path. + + Returns: + Optional[str]: The current folder path. + + """ + pass + + @abstractmethod + def get_current_task_name(self) -> Optional[str]: + """Get the current task name. + + Returns: + Optional[str]: The current task name. + + """ + pass + + @abstractmethod + def get_context_title(self) -> str: + """Get the context title used in UIs.""" + pass diff --git a/client/ayon_core/host/constants.py b/client/ayon_core/host/constants.py new file mode 100644 index 0000000000..2564c5d54d --- /dev/null +++ b/client/ayon_core/host/constants.py @@ -0,0 +1,15 @@ +from enum import Enum + + +class StrEnum(str, Enum): + """A string-based Enum class that allows for string comparison.""" + + def __str__(self) -> str: + return self.value + + +class ContextChangeReason(StrEnum): + """Reasons for context change in the host.""" + undefined = "undefined" + workfile_open = "workfile.opened" + workfile_save = "workfile.saved" diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 5a29de6cd7..28cb6b0a09 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -1,13 +1,35 @@ +from __future__ import annotations + import os import logging import contextlib -from abc import ABC, abstractproperty +import typing +from typing import Optional, Any +from dataclasses import dataclass -# NOTE can't import 'typing' because of issues in Maya 2020 -# - shiboken crashes on 'typing' module import +import ayon_api + +from ayon_core.lib import emit_event + +from .constants import ContextChangeReason +from .abstract import AbstractHost + +if typing.TYPE_CHECKING: + from ayon_core.pipeline import Anatomy + + from .typing import HostContextData -class HostBase(ABC): +@dataclass +class ContextChangeData: + project_entity: dict[str, Any] + folder_entity: dict[str, Any] + task_entity: dict[str, Any] + reason: ContextChangeReason + anatomy: Anatomy + + +class HostBase(AbstractHost): """Base of host implementation class. Host is pipeline implementation of DCC application. This class should help @@ -82,47 +104,41 @@ class HostBase(ABC): It is called automatically when 'ayon_core.pipeline.install_host' is triggered. - """ + """ pass @property - def log(self): + def log(self) -> logging.Logger: if self._log is None: self._log = logging.getLogger(self.__class__.__name__) return self._log - @abstractproperty - def name(self): - """Host name.""" - - pass - - def get_current_project_name(self): + def get_current_project_name(self) -> str: """ Returns: - Union[str, None]: Current project name. + str: Current project name. + """ + return os.environ["AYON_PROJECT_NAME"] - return os.environ.get("AYON_PROJECT_NAME") - - def get_current_folder_path(self): + def get_current_folder_path(self) -> Optional[str]: """ Returns: - Union[str, None]: Current asset name. - """ + Optional[str]: Current asset name. + """ return os.environ.get("AYON_FOLDER_PATH") - def get_current_task_name(self): + def get_current_task_name(self) -> Optional[str]: """ Returns: - Union[str, None]: Current task name. - """ + Optional[str]: Current task name. + """ return os.environ.get("AYON_TASK_NAME") - def get_current_context(self): + def get_current_context(self) -> HostContextData: """Get current context information. This method should be used to get current context of host. Usage of @@ -131,16 +147,85 @@ class HostBase(ABC): can't be caught properly. Returns: - Dict[str, Union[str, None]]: Context with 3 keys 'project_name', - 'folder_path' and 'task_name'. All of them can be 'None'. - """ + HostContextData: Current context with 'project_name', + 'folder_path' and 'task_name'. + """ return { "project_name": self.get_current_project_name(), "folder_path": self.get_current_folder_path(), "task_name": self.get_current_task_name() } + def set_current_context( + self, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + reason: ContextChangeReason = ContextChangeReason.undefined, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional[Anatomy] = None, + ) -> HostContextData: + """Set current context information. + + This method should be used to set current context of host. Usage of + this method can be crucial for host implementations in DCCs where + can be opened multiple workfiles at one moment and change of context + can't be caught properly. + + Notes: + This method should not care about change of workdir and expect any + of the arguments. + + Args: + folder_entity (Optional[dict[str, Any]]): Folder entity. + task_entity (Optional[dict[str, Any]]): Task entity. + reason (ContextChangeReason): Reason for context change. + project_entity (Optional[dict[str, Any]]): Project entity data. + anatomy (Optional[Anatomy]): Anatomy instance for the project. + + Returns: + dict[str, Optional[str]]: Context information with project name, + folder path and task name. + + """ + from ayon_core.pipeline import Anatomy + + folder_path = folder_entity["path"] + task_name = task_entity["name"] + + context = self.get_current_context() + # Don't do anything if context did not change + if ( + context["folder_path"] == folder_path + and context["task_name"] == task_name + ): + return context + + project_name = self.get_current_project_name() + if project_entity is None: + project_entity = ayon_api.get_project(project_name) + + if anatomy is None: + anatomy = Anatomy(project_name, project_entity=project_entity) + + context_change_data = ContextChangeData( + project_entity, + folder_entity, + task_entity, + reason, + anatomy, + ) + self._before_context_change(context_change_data) + self._set_current_context(context_change_data) + self._after_context_change(context_change_data) + + return self._emit_context_change_event( + project_name, + folder_path, + task_name, + ) + def get_context_title(self): """Context title shown for UI purposes. @@ -187,3 +272,91 @@ class HostBase(ABC): yield finally: pass + + def _emit_context_change_event( + self, + project_name: str, + folder_path: Optional[str], + task_name: Optional[str], + ) -> HostContextData: + """Emit context change event. + + Args: + project_name (str): Name of the project. + folder_path (Optional[str]): Path of the folder. + task_name (Optional[str]): Name of the task. + + Returns: + HostContextData: Data send to context change event. + + """ + data: HostContextData = { + "project_name": project_name, + "folder_path": folder_path, + "task_name": task_name, + } + emit_event("taskChanged", data) + return data + + def _set_current_context( + self, context_change_data: ContextChangeData + ) -> None: + """Method that changes the context in host. + + Can be overriden for hosts that do need different handling of context + than using environment variables. + + Args: + context_change_data (ContextChangeData): Context change related + data. + + """ + project_name = self.get_current_project_name() + folder_path = None + task_name = None + if context_change_data.folder_entity: + folder_path = context_change_data.folder_entity["path"] + if context_change_data.task_entity: + task_name = context_change_data.task_entity["name"] + + envs = { + "AYON_PROJECT_NAME": project_name, + "AYON_FOLDER_PATH": folder_path, + "AYON_TASK_NAME": task_name, + } + + # Update the Session and environments. Pop from environments all + # keys with value set to None. + for key, value in envs.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + def _before_context_change(self, context_change_data: ContextChangeData): + """Before context is changed. + + This method is called before the context is changed in the host. + + Can be overridden to implement host specific logic. + + Args: + context_change_data (ContextChangeData): Object with information + about context change. + + """ + pass + + def _after_context_change(self, context_change_data: ContextChangeData): + """After context is changed. + + This method is called after the context is changed in the host. + + Can be overridden to implement host specific logic. + + Args: + context_change_data (ContextChangeData): Object with information + about context change. + + """ + pass diff --git a/client/ayon_core/host/interfaces.py b/client/ayon_core/host/interfaces.py deleted file mode 100644 index c077dfeae9..0000000000 --- a/client/ayon_core/host/interfaces.py +++ /dev/null @@ -1,384 +0,0 @@ -from abc import ABC, abstractmethod - - -class MissingMethodsError(ValueError): - """Exception when host miss some required methods for specific workflow. - - Args: - host (HostBase): Host implementation where are missing methods. - missing_methods (list[str]): List of missing methods. - """ - - def __init__(self, host, missing_methods): - joined_missing = ", ".join( - ['"{}"'.format(item) for item in missing_methods] - ) - host_name = getattr(host, "name", None) - if not host_name: - try: - host_name = host.__file__.replace("\\", "/").split("/")[-3] - except Exception: - host_name = str(host) - message = ( - "Host \"{}\" miss methods {}".format(host_name, joined_missing) - ) - super(MissingMethodsError, self).__init__(message) - - -class ILoadHost: - """Implementation requirements to be able use reference of representations. - - The load plugins can do referencing even without implementation of methods - here, but switch and removement of containers would not be possible. - - Questions: - - Is list container dependency of host or load plugins? - - Should this be directly in HostBase? - - how to find out if referencing is available? - - do we need to know that? - """ - - @staticmethod - def get_missing_load_methods(host): - """Look for missing methods on "old type" host implementation. - - Method is used for validation of implemented functions related to - loading. Checks only existence of methods. - - Args: - Union[ModuleType, HostBase]: Object of host where to look for - required methods. - - Returns: - list[str]: Missing method implementations for loading workflow. - """ - - if isinstance(host, ILoadHost): - return [] - - required = ["ls"] - missing = [] - for name in required: - if not hasattr(host, name): - missing.append(name) - return missing - - @staticmethod - def validate_load_methods(host): - """Validate implemented methods of "old type" host for load workflow. - - Args: - Union[ModuleType, HostBase]: Object of host to validate. - - Raises: - MissingMethodsError: If there are missing methods on host - implementation. - """ - missing = ILoadHost.get_missing_load_methods(host) - if missing: - raise MissingMethodsError(host, missing) - - @abstractmethod - def get_containers(self): - """Retrieve referenced containers from scene. - - This can be implemented in hosts where referencing can be used. - - Todo: - Rename function to something more self explanatory. - Suggestion: 'get_containers' - - Returns: - list[dict]: Information about loaded containers. - """ - - pass - - # --- Deprecated method names --- - def ls(self): - """Deprecated variant of 'get_containers'. - - Todo: - Remove when all usages are replaced. - """ - - return self.get_containers() - - -class IWorkfileHost(ABC): - """Implementation requirements to be able use workfile utils and tool.""" - - @staticmethod - def get_missing_workfile_methods(host): - """Look for missing methods on "old type" host implementation. - - Method is used for validation of implemented functions related to - workfiles. Checks only existence of methods. - - Args: - Union[ModuleType, HostBase]: Object of host where to look for - required methods. - - Returns: - list[str]: Missing method implementations for workfiles workflow. - """ - - if isinstance(host, IWorkfileHost): - return [] - - required = [ - "open_file", - "save_file", - "current_file", - "has_unsaved_changes", - "file_extensions", - "work_root", - ] - missing = [] - for name in required: - if not hasattr(host, name): - missing.append(name) - return missing - - @staticmethod - def validate_workfile_methods(host): - """Validate methods of "old type" host for workfiles workflow. - - Args: - Union[ModuleType, HostBase]: Object of host to validate. - - Raises: - MissingMethodsError: If there are missing methods on host - implementation. - """ - - missing = IWorkfileHost.get_missing_workfile_methods(host) - if missing: - raise MissingMethodsError(host, missing) - - @abstractmethod - def get_workfile_extensions(self): - """Extensions that can be used as save. - - Questions: - This could potentially use 'HostDefinition'. - """ - - return [] - - @abstractmethod - def save_workfile(self, dst_path=None): - """Save currently opened scene. - - Args: - dst_path (str): Where the current scene should be saved. Or use - current path if 'None' is passed. - """ - - pass - - @abstractmethod - def open_workfile(self, filepath): - """Open passed filepath in the host. - - Args: - filepath (str): Path to workfile. - """ - - pass - - @abstractmethod - def get_current_workfile(self): - """Retrieve path to current opened file. - - Returns: - str: Path to file which is currently opened. - None: If nothing is opened. - """ - - return None - - def workfile_has_unsaved_changes(self): - """Currently opened scene is saved. - - Not all hosts can know if current scene is saved because the API of - DCC does not support it. - - Returns: - bool: True if scene is saved and False if has unsaved - modifications. - None: Can't tell if workfiles has modifications. - """ - - return None - - def work_root(self, session): - """Modify workdir per host. - - Default implementation keeps workdir untouched. - - Warnings: - We must handle this modification with more sophisticated way - because this can't be called out of DCC so opening of last workfile - (calculated before DCC is launched) is complicated. Also breaking - defined work template is not a good idea. - Only place where it's really used and can make sense is Maya. There - workspace.mel can modify subfolders where to look for maya files. - - Args: - session (dict): Session context data. - - Returns: - str: Path to new workdir. - """ - - return session["AYON_WORKDIR"] - - # --- Deprecated method names --- - def file_extensions(self): - """Deprecated variant of 'get_workfile_extensions'. - - Todo: - Remove when all usages are replaced. - """ - return self.get_workfile_extensions() - - def save_file(self, dst_path=None): - """Deprecated variant of 'save_workfile'. - - Todo: - Remove when all usages are replaced. - """ - - self.save_workfile(dst_path) - - def open_file(self, filepath): - """Deprecated variant of 'open_workfile'. - - Todo: - Remove when all usages are replaced. - """ - - return self.open_workfile(filepath) - - def current_file(self): - """Deprecated variant of 'get_current_workfile'. - - Todo: - Remove when all usages are replaced. - """ - - return self.get_current_workfile() - - def has_unsaved_changes(self): - """Deprecated variant of 'workfile_has_unsaved_changes'. - - Todo: - Remove when all usages are replaced. - """ - - return self.workfile_has_unsaved_changes() - - -class IPublishHost: - """Functions related to new creation system in new publisher. - - New publisher is not storing information only about each created instance - but also some global data. At this moment are data related only to context - publish plugins but that can extend in future. - """ - - @staticmethod - def get_missing_publish_methods(host): - """Look for missing methods on "old type" host implementation. - - Method is used for validation of implemented functions related to - new publish creation. Checks only existence of methods. - - Args: - Union[ModuleType, HostBase]: Host module where to look for - required methods. - - Returns: - list[str]: Missing method implementations for new publisher - workflow. - """ - - if isinstance(host, IPublishHost): - return [] - - required = [ - "get_context_data", - "update_context_data", - "get_context_title", - "get_current_context", - ] - missing = [] - for name in required: - if not hasattr(host, name): - missing.append(name) - return missing - - @staticmethod - def validate_publish_methods(host): - """Validate implemented methods of "old type" host. - - Args: - Union[ModuleType, HostBase]: Host module to validate. - - Raises: - MissingMethodsError: If there are missing methods on host - implementation. - """ - missing = IPublishHost.get_missing_publish_methods(host) - if missing: - raise MissingMethodsError(host, missing) - - @abstractmethod - def get_context_data(self): - """Get global data related to creation-publishing from workfile. - - These data are not related to any created instance but to whole - publishing context. Not saving/returning them will cause that each - reset of publishing resets all values to default ones. - - Context data can contain information about enabled/disabled publish - plugins or other values that can be filled by artist. - - Returns: - dict: Context data stored using 'update_context_data'. - """ - - pass - - @abstractmethod - def update_context_data(self, data, changes): - """Store global context data to workfile. - - Called when some values in context data has changed. - - Without storing the values in a way that 'get_context_data' would - return them will each reset of publishing cause loose of filled values - by artist. Best practice is to store values into workfile, if possible. - - Args: - data (dict): New data as are. - changes (dict): Only data that has been changed. Each value has - tuple with '(, )' value. - """ - - pass - - -class INewPublisher(IPublishHost): - """Legacy interface replaced by 'IPublishHost'. - - Deprecated: - 'INewPublisher' is replaced by 'IPublishHost' please change your - imports. - There is no "reasonable" way hot mark these classes as deprecated - to show warning of wrong import. Deprecated since 3.14.* will be - removed in 3.15.* - """ - - pass diff --git a/client/ayon_core/host/interfaces/__init__.py b/client/ayon_core/host/interfaces/__init__.py new file mode 100644 index 0000000000..8f11ad4e2f --- /dev/null +++ b/client/ayon_core/host/interfaces/__init__.py @@ -0,0 +1,66 @@ +from .exceptions import MissingMethodsError +from .workfiles import ( + IWorkfileHost, + WorkfileInfo, + PublishedWorkfileInfo, + + OpenWorkfileOptionalData, + ListWorkfilesOptionalData, + ListPublishedWorkfilesOptionalData, + SaveWorkfileOptionalData, + CopyWorkfileOptionalData, + CopyPublishedWorkfileOptionalData, + + get_open_workfile_context, + get_list_workfiles_context, + get_list_published_workfiles_context, + get_save_workfile_context, + get_copy_workfile_context, + get_copy_repre_workfile_context, + + OpenWorkfileContext, + ListWorkfilesContext, + ListPublishedWorkfilesContext, + SaveWorkfileContext, + CopyWorkfileContext, + CopyPublishedWorkfileContext, +) +from .interfaces import ( + IPublishHost, + INewPublisher, + ILoadHost, +) + + +__all__ = ( + "MissingMethodsError", + + "IWorkfileHost", + "WorkfileInfo", + "PublishedWorkfileInfo", + + "OpenWorkfileOptionalData", + "ListWorkfilesOptionalData", + "ListPublishedWorkfilesOptionalData", + "SaveWorkfileOptionalData", + "CopyWorkfileOptionalData", + "CopyPublishedWorkfileOptionalData", + + "get_open_workfile_context", + "get_list_workfiles_context", + "get_list_published_workfiles_context", + "get_save_workfile_context", + "get_copy_workfile_context", + "get_copy_repre_workfile_context", + + "OpenWorkfileContext", + "ListWorkfilesContext", + "ListPublishedWorkfilesContext", + "SaveWorkfileContext", + "CopyWorkfileContext", + "CopyPublishedWorkfileContext", + + "IPublishHost", + "INewPublisher", + "ILoadHost", +) diff --git a/client/ayon_core/host/interfaces/exceptions.py b/client/ayon_core/host/interfaces/exceptions.py new file mode 100644 index 0000000000..eec4564142 --- /dev/null +++ b/client/ayon_core/host/interfaces/exceptions.py @@ -0,0 +1,15 @@ +class MissingMethodsError(ValueError): + """Exception when host miss some required methods for a specific workflow. + + Args: + host (HostBase): Host implementation where are missing methods. + missing_methods (list[str]): List of missing methods. + """ + + def __init__(self, host, missing_methods): + joined_missing = ", ".join( + ['"{}"'.format(item) for item in missing_methods] + ) + super().__init__( + f"Host \"{host.name}\" miss methods {joined_missing}" + ) diff --git a/client/ayon_core/host/interfaces/interfaces.py b/client/ayon_core/host/interfaces/interfaces.py new file mode 100644 index 0000000000..6f9a3d8c87 --- /dev/null +++ b/client/ayon_core/host/interfaces/interfaces.py @@ -0,0 +1,189 @@ +from abc import abstractmethod + +from ayon_core.host.abstract import AbstractHost + +from .exceptions import MissingMethodsError + + +class ILoadHost(AbstractHost): + """Implementation requirements to be able use reference of representations. + + The load plugins can do referencing even without implementation of methods + here, but switch and removement of containers would not be possible. + + Questions: + - Is list container dependency of host or load plugins? + - Should this be directly in HostBase? + - how to find out if referencing is available? + - do we need to know that? + """ + + @staticmethod + def get_missing_load_methods(host): + """Look for missing methods on "old type" host implementation. + + Method is used for validation of implemented functions related to + loading. Checks only existence of methods. + + Args: + Union[ModuleType, AbstractHost]: Object of host where to look for + required methods. + + Returns: + list[str]: Missing method implementations for loading workflow. + """ + + if isinstance(host, ILoadHost): + return [] + + required = ["ls"] + missing = [] + for name in required: + if not hasattr(host, name): + missing.append(name) + return missing + + @staticmethod + def validate_load_methods(host): + """Validate implemented methods of "old type" host for load workflow. + + Args: + Union[ModuleType, AbstractHost]: Object of host to validate. + + Raises: + MissingMethodsError: If there are missing methods on host + implementation. + """ + missing = ILoadHost.get_missing_load_methods(host) + if missing: + raise MissingMethodsError(host, missing) + + @abstractmethod + def get_containers(self): + """Retrieve referenced containers from scene. + + This can be implemented in hosts where referencing can be used. + + Todo: + Rename function to something more self explanatory. + Suggestion: 'get_containers' + + Returns: + list[dict]: Information about loaded containers. + """ + + pass + + # --- Deprecated method names --- + def ls(self): + """Deprecated variant of 'get_containers'. + + Todo: + Remove when all usages are replaced. + """ + + return self.get_containers() + + +class IPublishHost(AbstractHost): + """Functions related to new creation system in new publisher. + + New publisher is not storing information only about each created instance + but also some global data. At this moment are data related only to context + publish plugins but that can extend in future. + """ + + @staticmethod + def get_missing_publish_methods(host): + """Look for missing methods on "old type" host implementation. + + Method is used for validation of implemented functions related to + new publish creation. Checks only existence of methods. + + Args: + Union[ModuleType, AbstractHost]: Host module where to look for + required methods. + + Returns: + list[str]: Missing method implementations for new publisher + workflow. + """ + + if isinstance(host, IPublishHost): + return [] + + required = [ + "get_context_data", + "update_context_data", + "get_context_title", + "get_current_context", + ] + missing = [] + for name in required: + if not hasattr(host, name): + missing.append(name) + return missing + + @staticmethod + def validate_publish_methods(host): + """Validate implemented methods of "old type" host. + + Args: + Union[ModuleType, AbstractHost]: Host module to validate. + + Raises: + MissingMethodsError: If there are missing methods on host + implementation. + """ + missing = IPublishHost.get_missing_publish_methods(host) + if missing: + raise MissingMethodsError(host, missing) + + @abstractmethod + def get_context_data(self): + """Get global data related to creation-publishing from workfile. + + These data are not related to any created instance but to whole + publishing context. Not saving/returning them will cause that each + reset of publishing resets all values to default ones. + + Context data can contain information about enabled/disabled publish + plugins or other values that can be filled by artist. + + Returns: + dict: Context data stored using 'update_context_data'. + """ + + pass + + @abstractmethod + def update_context_data(self, data, changes): + """Store global context data to workfile. + + Called when some values in context data has changed. + + Without storing the values in a way that 'get_context_data' would + return them will each reset of publishing cause loose of filled values + by artist. Best practice is to store values into workfile, if possible. + + Args: + data (dict): New data as are. + changes (dict): Only data that has been changed. Each value has + tuple with '(, )' value. + """ + + pass + + +class INewPublisher(IPublishHost): + """Legacy interface replaced by 'IPublishHost'. + + Deprecated: + 'INewPublisher' is replaced by 'IPublishHost' please change your + imports. + There is no "reasonable" way hot mark these classes as deprecated + to show warning of wrong import. Deprecated since 3.14.* will be + removed in 3.15.* + """ + + pass diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py new file mode 100644 index 0000000000..93aad4c117 --- /dev/null +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -0,0 +1,1798 @@ +from __future__ import annotations + +import os +import platform +import shutil +import typing +import warnings +import functools +from abc import abstractmethod +from dataclasses import dataclass, asdict +from typing import Optional, Any + +import ayon_api +import arrow + +from ayon_core.lib import emit_event +from ayon_core.settings import get_project_settings +from ayon_core.host.abstract import AbstractHost +from ayon_core.host.constants import ContextChangeReason + +if typing.TYPE_CHECKING: + from ayon_core.pipeline import Anatomy + + +def deprecated(reason): + def decorator(func): + message = f"Call to deprecated function {func.__name__} ({reason})." + + @functools.wraps(func) + def new_func(*args, **kwargs): + warnings.simplefilter("always", DeprecationWarning) + warnings.warn( + message, + category=DeprecationWarning, + stacklevel=2 + ) + warnings.simplefilter("default", DeprecationWarning) + return func(*args, **kwargs) + + return new_func + + return decorator + + +# Wrappers for optional arguments that might change in future +class _WorkfileOptionalData: + """Base class for optional data used in workfile operations.""" + def __init__( + self, + *, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + project_settings: Optional[dict[str, Any]] = None, + **kwargs + ): + if kwargs: + cls_name = self.__class__.__name__ + keys = ", ".join(['"{}"'.format(k) for k in kwargs.keys()]) + warnings.warn( + f"Unknown keywords passed to {cls_name}: {keys}", + ) + + self.project_entity = project_entity + self.anatomy = anatomy + self.project_settings = project_settings + + def get_project_data( + self, project_name: str + ) -> tuple[dict[str, Any], "Anatomy", dict[str, Any]]: + from ayon_core.pipeline import Anatomy + + project_entity = self.project_entity + anatomy = self.anatomy + project_settings = self.project_settings + + if project_entity is None: + project_entity = ayon_api.get_project(project_name) + + if anatomy is None: + anatomy = Anatomy( + project_name, + project_entity=project_entity + ) + + if project_settings is None: + project_settings = get_project_settings(project_name) + return ( + project_entity, + anatomy, + project_settings, + ) + + +class OpenWorkfileOptionalData(_WorkfileOptionalData): + """Optional data for opening workfile.""" + data_version = 1 + + +class ListWorkfilesOptionalData(_WorkfileOptionalData): + """Optional data to list workfiles.""" + data_version = 1 + + def __init__( + self, + *, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + project_settings: Optional[dict[str, Any]] = None, + template_key: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, + **kwargs + ): + super().__init__( + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + **kwargs + ) + self.template_key = template_key + self.workfile_entities = workfile_entities + + def get_template_key( + self, + project_name: str, + task_type: str, + host_name: str, + project_settings: dict[str, Any], + ) -> str: + from ayon_core.pipeline.workfile import get_workfile_template_key + + if self.template_key is not None: + return self.template_key + + return get_workfile_template_key( + project_name=project_name, + task_type=task_type, + host_name=host_name, + project_settings=project_settings, + ) + + def get_workfile_entities( + self, project_name: str, task_id: str + ) -> list[dict[str, Any]]: + """Fill workfile entities if not provided.""" + if self.workfile_entities is not None: + return self.workfile_entities + return list(ayon_api.get_workfiles_info( + project_name, task_ids=[task_id] + )) + + +class ListPublishedWorkfilesOptionalData(_WorkfileOptionalData): + """Optional data to list published workfiles.""" + data_version = 1 + + def __init__( + self, + *, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + project_settings: Optional[dict[str, Any]] = None, + product_entities: Optional[list[dict[str, Any]]] = None, + version_entities: Optional[list[dict[str, Any]]] = None, + repre_entities: Optional[list[dict[str, Any]]] = None, + **kwargs + ): + super().__init__( + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + **kwargs + ) + + self.product_entities = product_entities + self.version_entities = version_entities + self.repre_entities = repre_entities + + def get_entities( + self, + project_name: str, + folder_id: str, + ) -> tuple[ + list[dict[str, Any]], + list[dict[str, Any]], + list[dict[str, Any]] + ]: + product_entities = self.product_entities + if product_entities is None: + product_entities = list(ayon_api.get_products( + project_name, + folder_ids={folder_id}, + product_types={"workfile"}, + fields={"id", "name"}, + )) + + version_entities = self.version_entities + if version_entities is None: + product_ids = {p["id"] for p in product_entities} + version_entities = list(ayon_api.get_versions( + project_name, + product_ids=product_ids, + fields={"id", "author", "taskId"}, + )) + + repre_entities = self.repre_entities + if repre_entities is None: + version_ids = {v["id"] for v in version_entities} + repre_entities = list(ayon_api.get_representations( + project_name, + version_ids=version_ids, + )) + return product_entities, version_entities, repre_entities + + +class SaveWorkfileOptionalData(_WorkfileOptionalData): + """Optional data to save workfile.""" + data_version = 1 + + def __init__( + self, + *, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + project_settings: Optional[dict[str, Any]] = None, + rootless_path: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, + **kwargs + ): + super().__init__( + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + **kwargs + ) + + self.rootless_path = rootless_path + self.workfile_entities = workfile_entities + + def get_workfile_entities(self, project_name: str, task_id: str): + """Fill workfile entities if not provided.""" + if self.workfile_entities is not None: + return self.workfile_entities + return list(ayon_api.get_workfiles_info( + project_name, task_ids=[task_id] + )) + + def get_rootless_path( + self, + workfile_path: str, + project_name: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + host_name: str, + project_entity: dict[str, Any], + project_settings: dict[str, Any], + anatomy: "Anatomy", + ): + from ayon_core.pipeline.workfile.utils import ( + find_workfile_rootless_path + ) + + if self.rootless_path is not None: + return self.rootless_path + + return find_workfile_rootless_path( + workfile_path, + project_name, + folder_entity, + task_entity, + host_name, + project_entity=project_entity, + project_settings=project_settings, + anatomy=anatomy, + ) + + +class CopyWorkfileOptionalData(SaveWorkfileOptionalData): + """Optional data to copy workfile.""" + data_version = 1 + + +class CopyPublishedWorkfileOptionalData(SaveWorkfileOptionalData): + """Optional data to copy published workfile.""" + data_version = 1 + + def __init__( + self, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + project_settings: Optional[dict[str, Any]] = None, + rootless_path: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, + src_anatomy: Optional["Anatomy"] = None, + src_representation_path: Optional[str] = None, + **kwargs + ): + super().__init__( + rootless_path=rootless_path, + workfile_entities=workfile_entities, + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + **kwargs + ) + self.src_anatomy = src_anatomy + self.src_representation_path = src_representation_path + + def get_source_data( + self, + current_anatomy: Optional["Anatomy"], + project_name: str, + representation_entity: dict[str, Any], + ) -> tuple["Anatomy", str]: + from ayon_core.pipeline import Anatomy + from ayon_core.pipeline.load import ( + get_representation_path_with_anatomy + ) + + src_anatomy = self.src_anatomy + + if ( + src_anatomy is None + and current_anatomy is not None + and current_anatomy.project_name == project_name + ): + src_anatomy = current_anatomy + else: + src_anatomy = Anatomy(project_name) + + repre_path = self.src_representation_path + if repre_path is None: + repre_path = get_representation_path_with_anatomy( + representation_entity, + src_anatomy, + ) + return src_anatomy, repre_path + + +# Dataclasses used during workfile operations +@dataclass +class OpenWorkfileContext: + data_version: int + project_name: str + filepath: str + project_entity: dict[str, Any] + folder_entity: dict[str, Any] + task_entity: dict[str, Any] + anatomy: "Anatomy" + project_settings: dict[str, Any] + + +@dataclass +class ListWorkfilesContext: + data_version: int + project_name: str + project_entity: dict[str, Any] + folder_entity: dict[str, Any] + task_entity: dict[str, Any] + anatomy: "Anatomy" + project_settings: dict[str, Any] + template_key: str + workfile_entities: list[dict[str, Any]] + + +@dataclass +class ListPublishedWorkfilesContext: + data_version: int + project_name: str + project_entity: dict[str, Any] + folder_id: str + anatomy: "Anatomy" + project_settings: dict[str, Any] + product_entities: list[dict[str, Any]] + version_entities: list[dict[str, Any]] + repre_entities: list[dict[str, Any]] + + +@dataclass +class SaveWorkfileContext: + data_version: int + project_name: str + project_entity: dict[str, Any] + folder_entity: dict[str, Any] + task_entity: dict[str, Any] + anatomy: "Anatomy" + project_settings: dict[str, Any] + dst_path: str + rootless_path: str + workfile_entities: list[dict[str, Any]] + + +@dataclass +class CopyWorkfileContext(SaveWorkfileContext): + src_path: str + version: Optional[int] + comment: Optional[str] + description: Optional[str] + open_workfile: bool + + +@dataclass +class CopyPublishedWorkfileContext(CopyWorkfileContext): + src_project_name: str + src_representation_entity: dict[str, Any] + src_anatomy: "Anatomy" + + +def get_open_workfile_context( + project_name: str, + filepath: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + prepared_data: Optional[OpenWorkfileOptionalData], +) -> OpenWorkfileContext: + if prepared_data is None: + prepared_data = OpenWorkfileOptionalData() + ( + project_entity, anatomy, project_settings + ) = prepared_data.get_project_data(project_name) + return OpenWorkfileContext( + data_version=prepared_data.data_version, + filepath=filepath, + folder_entity=folder_entity, + task_entity=task_entity, + project_name=project_name, + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + ) + + +def get_list_workfiles_context( + project_name: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + host_name: str, + prepared_data: Optional[ListWorkfilesOptionalData], +) -> ListWorkfilesContext: + if prepared_data is None: + prepared_data = ListWorkfilesOptionalData() + ( + project_entity, anatomy, project_settings + ) = prepared_data.get_project_data(project_name) + + template_key = prepared_data.get_template_key( + project_name, + task_entity["taskType"], + host_name, + project_settings, + ) + workfile_entities = prepared_data.get_workfile_entities( + project_name, task_entity["id"] + ) + return ListWorkfilesContext( + data_version=prepared_data.data_version, + project_entity=project_entity, + folder_entity=folder_entity, + task_entity=task_entity, + project_name=project_name, + anatomy=anatomy, + project_settings=project_settings, + template_key=template_key, + workfile_entities=workfile_entities, + ) + + +def get_list_published_workfiles_context( + project_name: str, + folder_id: str, + prepared_data: Optional[ListPublishedWorkfilesOptionalData], +) -> ListPublishedWorkfilesContext: + if prepared_data is None: + prepared_data = ListPublishedWorkfilesOptionalData() + ( + project_entity, anatomy, project_settings + ) = prepared_data.get_project_data(project_name) + ( + product_entities, + version_entities, + repre_entities, + ) = prepared_data.get_entities(project_name, folder_id) + + return ListPublishedWorkfilesContext( + data_version=prepared_data.data_version, + project_name=project_name, + project_entity=project_entity, + folder_id=folder_id, + anatomy=anatomy, + project_settings=project_settings, + product_entities=product_entities, + version_entities=version_entities, + repre_entities=repre_entities, + ) + + +def get_save_workfile_context( + project_name: str, + filepath: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + host_name: str, + prepared_data: Optional[SaveWorkfileOptionalData], +) -> SaveWorkfileContext: + if prepared_data is None: + prepared_data = SaveWorkfileOptionalData() + + ( + project_entity, anatomy, project_settings + ) = prepared_data.get_project_data(project_name) + + rootless_path = prepared_data.get_rootless_path( + filepath, + project_name, + folder_entity, + task_entity, + host_name, + project_entity, + project_settings, + anatomy, + ) + workfile_entities = prepared_data.get_workfile_entities( + project_name, task_entity["id"] + ) + return SaveWorkfileContext( + data_version=prepared_data.data_version, + project_name=project_name, + project_entity=project_entity, + folder_entity=folder_entity, + task_entity=task_entity, + anatomy=anatomy, + project_settings=project_settings, + dst_path=filepath, + rootless_path=rootless_path, + workfile_entities=workfile_entities, + ) + + +def get_copy_workfile_context( + project_name: str, + src_path: str, + dst_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + version: Optional[int], + comment: Optional[str], + description: Optional[str], + open_workfile: bool, + host_name: str, + prepared_data: Optional[CopyWorkfileOptionalData], +) -> CopyWorkfileContext: + if prepared_data is None: + prepared_data = CopyWorkfileOptionalData() + context: SaveWorkfileContext = get_save_workfile_context( + project_name, + dst_path, + folder_entity, + task_entity, + host_name, + prepared_data, + ) + return CopyWorkfileContext( + data_version=prepared_data.data_version, + src_path=src_path, + project_name=context.project_name, + project_entity=context.project_entity, + folder_entity=context.folder_entity, + task_entity=context.task_entity, + version=version, + comment=comment, + description=description, + open_workfile=open_workfile, + anatomy=context.anatomy, + project_settings=context.project_settings, + dst_path=context.dst_path, + rootless_path=context.rootless_path, + workfile_entities=context.workfile_entities, + ) + + +def get_copy_repre_workfile_context( + project_name: str, + src_project_name: str, + src_representation_entity: dict[str, Any], + dst_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + version: Optional[int], + comment: Optional[str], + description: Optional[str], + open_workfile: bool, + host_name: str, + prepared_data: Optional[CopyPublishedWorkfileOptionalData], +) -> CopyPublishedWorkfileContext: + if prepared_data is None: + prepared_data = CopyPublishedWorkfileOptionalData() + + context: SaveWorkfileContext = get_save_workfile_context( + project_name, + dst_path, + folder_entity, + task_entity, + host_name, + prepared_data, + ) + src_anatomy, repre_path = prepared_data.get_source_data( + context.anatomy, + src_project_name, + src_representation_entity, + ) + return CopyPublishedWorkfileContext( + data_version=prepared_data.data_version, + src_project_name=src_project_name, + src_representation_entity=src_representation_entity, + src_path=repre_path, + dst_path=context.dst_path, + project_name=context.project_name, + project_entity=context.project_entity, + folder_entity=context.folder_entity, + task_entity=context.task_entity, + version=version, + comment=comment, + description=description, + open_workfile=open_workfile, + anatomy=context.anatomy, + project_settings=context.project_settings, + rootless_path=context.rootless_path, + workfile_entities=context.workfile_entities, + src_anatomy=src_anatomy, + ) + + +@dataclass +class WorkfileInfo: + """Information about workfile. + + Host can open, copy and use the workfile using this information object. + + Attributes: + filepath (str): Path to the workfile. + rootless_path (str): Path to the workfile without the root. And without + backslashes on Windows. + version (Optional[int]): Version of the workfile. + comment (Optional[str]): Comment of the workfile. + file_size (Optional[float]): Size of the workfile in bytes. + file_created (Optional[float]): Timestamp when the workfile was + created on the filesystem. + file_modified (Optional[float]): Timestamp when the workfile was + modified on the filesystem. + workfile_entity_id (Optional[str]): Workfile entity id. If None then + the workfile is not in the database. + description (str): Description of the workfile. + created_by (Optional[str]): User id of the user who created the + workfile entity. + updated_by (Optional[str]): User id of the user who updated the + workfile entity. + available (bool): True if workfile is available on the machine. + + """ + filepath: str + rootless_path: str + version: Optional[int] + comment: Optional[str] + file_size: Optional[float] + file_created: Optional[float] + file_modified: Optional[float] + workfile_entity_id: Optional[str] + description: str + created_by: Optional[str] + updated_by: Optional[str] + available: bool + + @classmethod + def new( + cls, + filepath: str, + rootless_path: str, + *, + version: Optional[int], + comment: Optional[str], + available: bool, + workfile_entity: dict[str, Any], + ): + file_size = file_modified = file_created = None + if filepath and os.path.exists(filepath): + filestat = os.stat(filepath) + file_size = filestat.st_size + file_created = filestat.st_ctime + file_modified = filestat.st_mtime + + if workfile_entity is None: + workfile_entity = {} + + attrib = {} + if workfile_entity: + attrib = workfile_entity["attrib"] + + return cls( + filepath=filepath, + rootless_path=rootless_path, + version=version, + comment=comment, + file_size=file_size, + file_created=file_created, + file_modified=file_modified, + workfile_entity_id=workfile_entity.get("id"), + description=attrib.get("description") or "", + created_by=workfile_entity.get("createdBy"), + updated_by=workfile_entity.get("updatedBy"), + available=available, + ) + + def to_data(self) -> dict[str, Any]: + """Converts file item to data. + + Returns: + dict[str, Any]: Workfile item data. + + """ + return asdict(self) + + @classmethod + def from_data(cls, data: dict[str, Any]) -> WorkfileInfo: + """Converts data to workfile item. + + Args: + data (dict[str, Any]): Workfile item data. + + Returns: + WorkfileInfo: File item. + + """ + return WorkfileInfo(**data) + + +@dataclass +class PublishedWorkfileInfo: + """Information about published workfile. + + Host can copy and use the workfile using this information object. + + Attributes: + project_name (str): Name of the project where workfile lives. + folder_id (str): Folder id under which is workfile stored. + task_id (Optional[str]): Task id under which is workfile stored. + representation_id (str): Representation id of the workfile. + filepath (str): Path to the workfile. + created_at (float): Timestamp when the workfile representation + was created. + author (str): Author of the workfile representation. + available (bool): True if workfile is available on the machine. + file_size (Optional[float]): Size of the workfile in bytes. + file_created (Optional[float]): Timestamp when the workfile was + created on the filesystem. + file_modified (Optional[float]): Timestamp when the workfile was + modified on the filesystem. + + """ + project_name: str + folder_id: str + task_id: Optional[str] + representation_id: str + filepath: str + created_at: float + author: str + available: bool + file_size: Optional[float] + file_created: Optional[float] + file_modified: Optional[float] + + @classmethod + def new( + cls, + project_name: str, + folder_id: str, + task_id: Optional[str], + repre_entity: dict[str, Any], + *, + filepath: str, + author: str, + available: bool, + file_size: Optional[float], + file_modified: Optional[float], + file_created: Optional[float], + ) -> "PublishedWorkfileInfo": + created_at = arrow.get(repre_entity["createdAt"]).to("local") + + return cls( + project_name=project_name, + folder_id=folder_id, + task_id=task_id, + representation_id=repre_entity["id"], + filepath=filepath, + created_at=created_at.float_timestamp, + author=author, + available=available, + file_size=file_size, + file_created=file_created, + file_modified=file_modified, + ) + + def to_data(self) -> dict[str, Any]: + """Converts file item to data. + + Returns: + dict[str, Any]: Workfile item data. + + """ + return asdict(self) + + @classmethod + def from_data(cls, data: dict[str, Any]) -> "PublishedWorkfileInfo": + """Converts data to workfile item. + + Args: + data (dict[str, Any]): Workfile item data. + + Returns: + PublishedWorkfileInfo: File item. + + """ + return PublishedWorkfileInfo(**data) + + +class IWorkfileHost(AbstractHost): + """Implementation requirements to be able to use workfiles utils and tool. + + Some of the methods are pre-implemented as they generally do the same in + all host integrations. + + """ + @abstractmethod + def save_workfile(self, dst_path: Optional[str] = None) -> None: + """Save the currently opened scene. + + Args: + dst_path (str): Where the current scene should be saved. Or use + the current path if 'None' is passed. + + """ + pass + + @abstractmethod + def open_workfile(self, filepath: str) -> None: + """Open passed filepath in the host. + + Args: + filepath (str): Path to workfile. + + """ + pass + + @abstractmethod + def get_current_workfile(self) -> Optional[str]: + """Retrieve a path to current opened file. + + Returns: + Optional[str]: Path to the file which is currently opened. None if + nothing is opened or the current workfile is unsaved. + + """ + return None + + def workfile_has_unsaved_changes(self) -> Optional[bool]: + """Currently opened scene is saved. + + Not all hosts can know if the current scene is saved because the API + of DCC does not support it. + + Returns: + Optional[bool]: True if scene is saved and False if has unsaved + modifications. None if can't tell if workfiles has + modifications. + + """ + return None + + def get_workfile_extensions(self) -> list[str]: + """Extensions that can be used to save the workfile to. + + Notes: + Method may not be used if 'list_workfiles' and + 'list_published_workfiles' are re-implemented with different + logic. + + Returns: + list[str]: List of extensions that can be used for saving. + + """ + return [] + + def save_workfile_with_context( + self, + filepath: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + version: Optional[int] = None, + comment: Optional[str] = None, + description: Optional[str] = None, + prepared_data: Optional[SaveWorkfileOptionalData] = None, + ) -> None: + """Save the current workfile with context. + + Arguments 'rootless_path', 'workfile_entities', 'project_entity' + and 'anatomy' can be filled to enhance efficiency if you already + have access to the values. + + Argument 'project_settings' is used to calculate 'rootless_path' + if it is not provided. + + Notes: + Should this method care about context change? + + Args: + filepath (str): Where the current scene should be saved. + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + version (Optional[int]): Version of the workfile. Information + for workfile entity. Recommended to fill. + comment (Optional[str]): Comment for the workfile. + Usually used in the filename template. + description (Optional[str]): Artist note for the workfile entity. + prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data + for speed enhancements. + + """ + project_name = self.get_current_project_name() + save_workfile_context = get_save_workfile_context( + project_name, + filepath, + folder_entity, + task_entity, + host_name=self.name, + prepared_data=prepared_data, + ) + + self._before_workfile_save(save_workfile_context) + event_data = self._get_workfile_event_data( + project_name, + folder_entity, + task_entity, + filepath, + ) + self._emit_workfile_save_event(event_data, after_save=False) + + workdir = os.path.dirname(filepath) + if not os.path.exists(workdir): + os.makedirs(workdir, exist_ok=True) + + # Set 'AYON_WORKDIR' environment variable + os.environ["AYON_WORKDIR"] = workdir + + self.set_current_context( + folder_entity, + task_entity, + reason=ContextChangeReason.workfile_save, + project_entity=save_workfile_context.project_entity, + anatomy=save_workfile_context.anatomy, + ) + + self.save_workfile(filepath) + + self._save_workfile_entity( + save_workfile_context, + version, + comment, + description, + ) + self._after_workfile_save(save_workfile_context) + self._emit_workfile_save_event(event_data) + + def open_workfile_with_context( + self, + filepath: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + prepared_data: Optional[OpenWorkfileOptionalData] = None, + ) -> None: + """Open passed filepath in the host with context. + + This function should be used to open workfile in different context. + + Notes: + Should this method care about context change? + + Args: + filepath (str): Path to workfile. + folder_entity (dict[str, Any]): Folder id. + task_entity (dict[str, Any]): Task id. + prepared_data (Optional[WorkfileOptionalData]): Prepared data + for speed enhancements. + + """ + context = self.get_current_context() + project_name = context["project_name"] + + open_workfile_context = get_open_workfile_context( + project_name, + filepath, + folder_entity, + task_entity, + prepared_data=prepared_data, + ) + + workdir = os.path.dirname(filepath) + # Set 'AYON_WORKDIR' environment variable + os.environ["AYON_WORKDIR"] = workdir + + event_data = self._get_workfile_event_data( + project_name, folder_entity, task_entity, filepath + ) + self._before_workfile_open(open_workfile_context) + self._emit_workfile_open_event(event_data, after_open=False) + + self.set_current_context( + folder_entity, + task_entity, + reason=ContextChangeReason.workfile_open, + project_entity=open_workfile_context.project_entity, + anatomy=open_workfile_context.anatomy, + ) + + self.open_workfile(filepath) + + self._after_workfile_open(open_workfile_context) + self._emit_workfile_open_event(event_data) + + def list_workfiles( + self, + project_name: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + prepared_data: Optional[ListWorkfilesOptionalData] = None, + ) -> list[WorkfileInfo]: + """List workfiles in the given task. + + The method should also return workfiles that are not available on + disk, but are in the AYON database. + + Notes: + - Better method name? + - This method is pre-implemented as the logic can be shared across + 95% of host integrations. Ad-hoc implementation to give host + integration workfile api functionality. + + Args: + project_name (str): Project name. + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + prepared_data (Optional[ListWorkfilesOptionalData]): Prepared + data for speed enhancements. + + Returns: + list[WorkfileInfo]: List of workfiles. + + """ + from ayon_core.pipeline.template_data import get_template_data + from ayon_core.pipeline.workfile.path_resolving import ( + get_workdir_with_workdir_data, + WorkfileDataParser, + ) + + extensions = self.get_workfile_extensions() + if not extensions: + return [] + + list_workfiles_context = get_list_workfiles_context( + project_name, + folder_entity, + task_entity, + host_name=self.name, + prepared_data=prepared_data, + ) + + workfile_entities_by_path = {} + for workfile_entity in list_workfiles_context.workfile_entities: + rootless_path = workfile_entity["path"] + path = os.path.normpath( + list_workfiles_context.anatomy.fill_root(rootless_path) + ) + workfile_entities_by_path[path] = workfile_entity + + workdir_data = get_template_data( + list_workfiles_context.project_entity, + folder_entity, + task_entity, + host_name=self.name, + ) + workdir = get_workdir_with_workdir_data( + workdir_data, + project_name, + anatomy=list_workfiles_context.anatomy, + template_key=list_workfiles_context.template_key, + project_settings=list_workfiles_context.project_settings, + ) + + file_template = list_workfiles_context.anatomy.get_template_item( + "work", list_workfiles_context.template_key, "file" + ) + rootless_workdir = workdir.rootless + if platform.system().lower() == "windows": + rootless_workdir = rootless_workdir.replace("\\", "/") + + filenames = [] + if os.path.exists(workdir): + filenames = list(os.listdir(workdir)) + + data_parser = WorkfileDataParser(file_template, workdir_data) + items = [] + for filename in filenames: + # TODO add 'default' support for folders + ext = os.path.splitext(filename)[1].lower() + if ext not in extensions: + continue + + filepath = os.path.join(workdir, filename) + + rootless_path = f"{rootless_workdir}/{filename}" + workfile_entity = workfile_entities_by_path.pop( + filepath, None + ) + version = comment = None + if workfile_entity is not None: + _data = workfile_entity["data"] + version = _data.get("version") + comment = _data.get("comment") + + if version is None: + parsed_data = data_parser.parse_data(filename) + version = parsed_data.version + comment = parsed_data.comment + + item = WorkfileInfo.new( + filepath, + rootless_path, + version=version, + comment=comment, + available=True, + workfile_entity=workfile_entity, + ) + items.append(item) + + for filepath, workfile_entity in workfile_entities_by_path.items(): + # Workfile entity is not in the filesystem + # but it is in the database + rootless_path = workfile_entity["path"] + ext = os.path.splitext(rootless_path)[1].lower() + if ext not in extensions: + continue + + _data = workfile_entity["data"] + version = _data.get("version") + comment = _data.get("comment") + if version is None: + filename = os.path.basename(rootless_path) + parsed_data = data_parser.parse_data(filename) + version = parsed_data.version + comment = parsed_data.comment + + available = os.path.exists(filepath) + items.append(WorkfileInfo.new( + filepath, + rootless_path, + version=version, + comment=comment, + available=available, + workfile_entity=workfile_entity, + )) + + return items + + def list_published_workfiles( + self, + project_name: str, + folder_id: str, + *, + prepared_data: Optional[ListPublishedWorkfilesOptionalData] = None, + ) -> list[PublishedWorkfileInfo]: + """List published workfiles for the given folder. + + The default implementation looks for products with the 'workfile' + product type. + + Pre-fetched entities have mandatory fields to be fetched: + - Version: 'id', 'author', 'taskId' + - Representation: 'id', 'versionId', 'files' + + Args: + project_name (str): Project name. + folder_id (str): Folder id. + prepared_data (Optional[ListPublishedWorkfilesOptionalData]): + Prepared data for speed enhancements. + + Returns: + list[PublishedWorkfileInfo]: Published workfile information for + the given context. + + """ + list_workfiles_context = get_list_published_workfiles_context( + project_name, + folder_id, + prepared_data=prepared_data, + ) + if not list_workfiles_context.repre_entities: + return [] + + versions_by_id = { + version_entity["id"]: version_entity + for version_entity in list_workfiles_context.version_entities + } + extensions = { + ext.lstrip(".") + for ext in self.get_workfile_extensions() + } + items = [] + for repre_entity in list_workfiles_context.repre_entities: + version_id = repre_entity["versionId"] + version_entity = versions_by_id[version_id] + task_id = version_entity["taskId"] + + # Filter by extension + workfile_path = None + for repre_file in repre_entity["files"]: + ext = ( + os.path.splitext(repre_file["name"])[1] + .lower() + .lstrip(".") + ) + if ext in extensions: + workfile_path = repre_file["path"] + break + + if not workfile_path: + continue + + try: + workfile_path = workfile_path.format( + root=list_workfiles_context.anatomy.roots + ) + except Exception: + self.log.warning( + "Failed to format workfile path.", exc_info=True + ) + + is_available = False + file_size = file_modified = file_created = None + if workfile_path and os.path.exists(workfile_path): + filestat = os.stat(workfile_path) + is_available = True + file_size = filestat.st_size + file_created = filestat.st_ctime + file_modified = filestat.st_mtime + + workfile_item = PublishedWorkfileInfo.new( + project_name, + folder_id, + task_id, + repre_entity, + filepath=workfile_path, + author=version_entity["author"], + available=is_available, + file_size=file_size, + file_created=file_created, + file_modified=file_modified, + ) + items.append(workfile_item) + + return items + + def copy_workfile( + self, + src_path: str, + dst_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + version: Optional[int] = None, + comment: Optional[str] = None, + description: Optional[str] = None, + open_workfile: bool = True, + prepared_data: Optional[CopyWorkfileOptionalData] = None, + ) -> None: + """Save workfile path with target folder and task context. + + It is expected that workfile is saved to the current project, but + can be copied from the other project. + + Arguments 'rootless_path', 'workfile_entities', 'project_entity' + and 'anatomy' can be filled to enhance efficiency if you already + have access to the values. + + Argument 'project_settings' is used to calculate 'rootless_path' + if it is not provided. + + Args: + src_path (str): Path to the source scene. + dst_path (str): Where the scene should be saved. + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + version (Optional[int]): Version of the workfile. Information + for workfile entity. Recommended to fill. + comment (Optional[str]): Comment for the workfile. + description (Optional[str]): Artist note for the workfile entity. + open_workfile (bool): Open workfile when copied. + prepared_data (Optional[CopyWorkfileOptionalData]): Prepared data + for speed enhancements. + + """ + project_name = self.get_current_project_name() + copy_workfile_context: CopyWorkfileContext = get_copy_workfile_context( + project_name, + src_path, + dst_path, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + open_workfile=open_workfile, + host_name=self.name, + prepared_data=prepared_data, + ) + self._copy_workfile( + copy_workfile_context, + version=version, + comment=comment, + description=description, + open_workfile=open_workfile, + ) + + def copy_workfile_representation( + self, + src_project_name: str, + src_representation_entity: dict[str, Any], + dst_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + version: Optional[int] = None, + comment: Optional[str] = None, + description: Optional[str] = None, + open_workfile: bool = True, + prepared_data: Optional[CopyPublishedWorkfileOptionalData] = None, + ) -> None: + """Copy workfile representation. + + Use representation as a source for the workfile. + + Arguments 'rootless_path', 'workfile_entities', 'project_entity' + and 'anatomy' can be filled to enhance efficiency if you already + have access to the values. + + Argument 'project_settings' is used to calculate 'rootless_path' + if it is not provided. + + Args: + src_project_name (str): Project name. + src_representation_entity (dict[str, Any]): Representation + entity. + dst_path (str): Where the scene should be saved. + folder_entity (dict[str, Any): Folder entity. + task_entity (dict[str, Any]): Task entity. + version (Optional[int]): Version of the workfile. Information + for workfile entity. Recommended to fill. + comment (Optional[str]): Comment for the workfile. + description (Optional[str]): Artist note for the workfile entity. + open_workfile (bool): Open workfile when copied. + prepared_data (Optional[CopyPublishedWorkfileOptionalData]): + Prepared data for speed enhancements. + + """ + project_name = self.get_current_project_name() + copy_repre_workfile_context: CopyPublishedWorkfileContext = ( + get_copy_repre_workfile_context( + project_name, + src_project_name, + src_representation_entity, + dst_path, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + open_workfile=open_workfile, + host_name=self.name, + prepared_data=prepared_data, + ) + ) + self._copy_workfile( + copy_repre_workfile_context, + version=version, + comment=comment, + description=description, + open_workfile=open_workfile, + ) + + # --- Deprecated method names --- + @deprecated("Use 'get_workfile_extensions' instead") + def file_extensions(self): + """Deprecated variant of 'get_workfile_extensions'. + + Todo: + Remove when all usages are replaced. + + """ + return self.get_workfile_extensions() + + @deprecated("Use 'save_workfile' instead") + def save_file(self, dst_path=None): + """Deprecated variant of 'save_workfile'. + + Todo: + Remove when all usages are replaced + + """ + self.save_workfile(dst_path) + + @deprecated("Use 'open_workfile' instead") + def open_file(self, filepath): + """Deprecated variant of 'open_workfile'. + + Todo: + Remove when all usages are replaced. + + """ + return self.open_workfile(filepath) + + @deprecated("Use 'get_current_workfile' instead") + def current_file(self): + """Deprecated variant of 'get_current_workfile'. + + Todo: + Remove when all usages are replaced. + + """ + return self.get_current_workfile() + + @deprecated("Use 'workfile_has_unsaved_changes' instead") + def has_unsaved_changes(self): + """Deprecated variant of 'workfile_has_unsaved_changes'. + + Todo: + Remove when all usages are replaced. + + """ + return self.workfile_has_unsaved_changes() + + def _copy_workfile( + self, + copy_workfile_context: CopyWorkfileContext, + *, + version: Optional[int], + comment: Optional[str], + description: Optional[str], + open_workfile: bool, + ) -> None: + """Save workfile path with target folder and task context. + + It is expected that workfile is saved to the current project, but + can be copied from the other project. + + Arguments 'rootless_path', 'workfile_entities', 'project_entity' + and 'anatomy' can be filled to enhance efficiency if you already + have access to the values. + + Argument 'project_settings' is used to calculate 'rootless_path' + if it is not provided. + + Args: + copy_workfile_context (CopyWorkfileContext): Prepared data + for speed enhancements. + version (Optional[int]): Version of the workfile. Information + for workfile entity. Recommended to fill. + comment (Optional[str]): Comment for the workfile. + description (Optional[str]): Artist note for the workfile entity. + open_workfile (bool): Open workfile when copied. + + """ + self._before_workfile_copy(copy_workfile_context) + event_data = self._get_workfile_event_data( + copy_workfile_context.project_name, + copy_workfile_context.folder_entity, + copy_workfile_context.task_entity, + copy_workfile_context.dst_path, + ) + self._emit_workfile_save_event(event_data, after_save=False) + + dst_dir = os.path.dirname(copy_workfile_context.dst_path) + if not os.path.exists(dst_dir): + os.makedirs(dst_dir, exist_ok=True) + shutil.copy( + copy_workfile_context.src_path, + copy_workfile_context.dst_path + ) + + self._save_workfile_entity( + copy_workfile_context, + version, + comment, + description, + ) + self._after_workfile_copy(copy_workfile_context) + self._emit_workfile_save_event(event_data) + + if not open_workfile: + return + + self.open_workfile_with_context( + copy_workfile_context.dst_path, + copy_workfile_context.folder_entity, + copy_workfile_context.task_entity, + ) + + def _save_workfile_entity( + self, + save_workfile_context: SaveWorkfileContext, + version: Optional[int], + comment: Optional[str], + description: Optional[str], + ) -> Optional[dict[str, Any]]: + """Create of update workfile entity to AYON based on provided data. + + Args: + save_workfile_context (SaveWorkfileContext): Save workfile + context with all prepared data. + version (Optional[int]): Version of the workfile. + comment (Optional[str]): Comment for the workfile. + description (Optional[str]): Artist note for the workfile entity. + + Returns: + Optional[dict[str, Any]]: Workfile entity. + + """ + from ayon_core.pipeline.workfile.utils import ( + save_workfile_info + ) + + project_name = self.get_current_project_name() + if not description: + description = None + + if not comment: + comment = None + + rootless_path = save_workfile_context.rootless_path + # It is not possible to create workfile infor without rootless path + workfile_info = None + if not rootless_path: + return workfile_info + + if platform.system().lower() == "windows": + rootless_path = rootless_path.replace("\\", "/") + + workfile_info = save_workfile_info( + project_name, + save_workfile_context.task_entity["id"], + rootless_path, + self.name, + version, + comment, + description, + workfile_entities=save_workfile_context.workfile_entities, + ) + return workfile_info + + def _create_extra_folders( + self, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + workdir: str, + ) -> None: + """Create extra folders in the workdir. + + This method should be called when workfile is saved or copied. + + Args: + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + workdir (str): Workdir where workfile/s will be stored. + + """ + from ayon_core.pipeline.workfile.path_resolving import ( + create_workdir_extra_folders + ) + + project_name = self.get_current_project_name() + + # Create extra folders + create_workdir_extra_folders( + workdir, + self.name, + task_entity["taskType"], + task_entity["name"], + project_name + ) + + def _get_workfile_event_data( + self, + project_name: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + filepath: str, + ) -> dict[str, Optional[str]]: + """Prepare workfile event data. + + Args: + project_name (str): Name of the project where workfile lives. + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + filepath (str): Path to the workfile. + + Returns: + dict[str, Optional[str]]: Data for workfile event. + + """ + workdir, filename = os.path.split(filepath) + return { + "project_name": project_name, + "folder_id": folder_entity["id"], + "folder_path": folder_entity["path"], + "task_id": task_entity["id"], + "task_name": task_entity["name"], + "host_name": self.name, + "filepath": filepath, + "filename": filename, + "workdir_path": workdir, + } + + def _before_workfile_open( + self, open_workfile_context: OpenWorkfileContext + ) -> None: + """Before workfile is opened. + + This method is called before the workfile is opened in the host. + + Can be overridden to implement host specific logic. + + Args: + open_workfile_context (OpenWorkfileContext): Context and path of + workfile to open. + + """ + pass + + def _after_workfile_open( + self, open_workfile_context: OpenWorkfileContext + ) -> None: + """After workfile is opened. + + This method is called after the workfile is opened in the host. + + Can be overridden to implement host specific logic. + + Args: + open_workfile_context (OpenWorkfileContext): Context and path of + opened workfile. + + """ + pass + + def _before_workfile_save( + self, save_workfile_context: SaveWorkfileContext + ) -> None: + """Before workfile is saved. + + This method is called before the workfile is saved in the host. + + Can be overridden to implement host specific logic. + + Args: + save_workfile_context (SaveWorkfileContext): Workfile path with + target folder and task context. + + """ + pass + + def _after_workfile_save( + self, save_workfile_context: SaveWorkfileContext + ) -> None: + """After workfile is saved. + + This method is called after the workfile is saved in the host. + + Can be overridden to implement host specific logic. + + Args: + save_workfile_context (SaveWorkfileContext): Workfile path with + target folder and task context. + + """ + workdir = os.path.dirname(save_workfile_context.dst_path) + self._create_extra_folders( + save_workfile_context.folder_entity, + save_workfile_context.task_entity, + workdir + ) + + def _before_workfile_copy( + self, copy_workfile_context: CopyWorkfileContext + ) -> None: + """Before workfile is copied. + + This method is called before the workfile is copied by host + integration. + + Can be overridden to implement host specific logic. + + Args: + copy_workfile_context (CopyWorkfileContext): Source and destination + path with context before workfile is copied. + + """ + pass + + def _after_workfile_copy( + self, copy_workfile_context: CopyWorkfileContext + ) -> None: + """After workfile is copied. + + This method is called after the workfile is copied by host + integration. + + Can be overridden to implement host specific logic. + + Args: + copy_workfile_context (CopyWorkfileContext): Source and destination + path with context after workfile is copied. + + """ + workdir = os.path.dirname(copy_workfile_context.dst_path) + self._create_extra_folders( + copy_workfile_context.folder_entity, + copy_workfile_context.task_entity, + workdir, + ) + + def _emit_workfile_open_event( + self, + event_data: dict[str, Optional[str]], + after_open: bool = True, + ) -> None: + """Emit workfile save event. + + Emit event before and after workfile is opened. + + This method is not meant to be overridden. + + Other addons can listen to this event and do additional steps. + + Args: + event_data (dict[str, Optional[str]]): Prepare event data. + after_open (bool): Emit event after workfile is opened. + + """ + topics = [] + topic_end = "before" + if after_open: + topics.append("workfile.opened") + topic_end = "after" + + # Keep backwards compatible event topic + topics.append(f"workfile.open.{topic_end}") + + for topic in topics: + emit_event(topic, event_data) + + def _emit_workfile_save_event( + self, + event_data: dict[str, Optional[str]], + after_save: bool = True, + ) -> None: + """Emit workfile save event. + + Emit event before and after workfile is saved or copied. + + This method is not meant to be overridden. + + Other addons can listen to this event and do additional steps. + + Args: + event_data (dict[str, Optional[str]]): Prepare event data. + after_save (bool): Emit event after workfile is saved. + + """ + topics = [] + topic_end = "before" + if after_save: + topics.append("workfile.saved") + topic_end = "after" + + # Keep backwards compatible event topic + topics.append(f"workfile.save.{topic_end}") + + for topic in topics: + emit_event(topic, event_data) diff --git a/client/ayon_core/host/typing.py b/client/ayon_core/host/typing.py new file mode 100644 index 0000000000..a51460713b --- /dev/null +++ b/client/ayon_core/host/typing.py @@ -0,0 +1,7 @@ +from typing import Optional, TypedDict + + +class HostContextData(TypedDict): + project_name: str + folder_path: Optional[str] + task_name: Optional[str] diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 477eb29c28..5ccc8d03e5 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -50,8 +50,10 @@ from .attribute_definitions import ( ) from .env_tools import ( + compute_env_variables_structure, env_value_to_bool, get_paths_from_environ, + merge_env_variables, ) from .terminal import Terminal @@ -166,8 +168,10 @@ __all__ = [ "path_to_subprocess_arg", "CREATE_NO_WINDOW", + "compute_env_variables_structure", "env_value_to_bool", "get_paths_from_environ", + "merge_env_variables", "ToolNotFoundError", "find_executable", diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index d994145d4b..1edfc3c1b6 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -8,6 +8,7 @@ import warnings from datetime import datetime from abc import ABC, abstractmethod from functools import lru_cache +from typing import Optional, Any import platformdirs import ayon_api @@ -15,22 +16,31 @@ import ayon_api _PLACEHOLDER = object() -def _get_ayon_appdirs(*args): +# TODO should use 'KeyError' or 'Exception' as base +class RegistryItemNotFound(ValueError): + """Raised when the item is not found in the keyring.""" + + +class _Cache: + username = None + + +def _get_ayon_appdirs(*args: str) -> str: return os.path.join( platformdirs.user_data_dir("AYON", "Ynput"), *args ) -def get_ayon_appdirs(*args): +def get_ayon_appdirs(*args: str) -> str: """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). + a use-case. Deprecation added 24/08/09 (0.4.4-dev.1). Args: - *args (Iterable[str]): Subdirectories/files in local app data dir. + *args (Iterable[str]): Subdirectories/files in the local app data dir. Returns: str: Path to directory/file in local app data dir. @@ -48,7 +58,7 @@ def get_ayon_appdirs(*args): def get_launcher_storage_dir(*subdirs: str) -> str: - """Get storage directory for launcher. + """Get a storage directory for launcher. Storage directory is used for storing shims, addons, dependencies, etc. @@ -73,14 +83,14 @@ def get_launcher_storage_dir(*subdirs: str) -> str: def get_launcher_local_dir(*subdirs: str) -> str: - """Get local directory for launcher. + """Get a local directory for launcher. - Local directory is used for storing machine or user specific data. + Local directory is used for storing machine or user-specific data. - The location is user specific. + The location is user-specific. Note: - This function should be called at least once on bootstrap. + This function should be called at least once on the bootstrap. Args: *subdirs (str): Subdirectories relative to local dir. @@ -97,7 +107,7 @@ def get_launcher_local_dir(*subdirs: str) -> str: def get_addons_resources_dir(addon_name: str, *args) -> str: - """Get directory for storing resources for addons. + """Get a directory for storing resources for addons. Some addons might need to store ad-hoc resources that are not part of addon client package (e.g. because of size). Studio might define @@ -107,7 +117,7 @@ def get_addons_resources_dir(addon_name: str, *args) -> str: Args: addon_name (str): Addon name. - *args (str): Subfolders in resources directory. + *args (str): Subfolders in the resources directory. Returns: str: Path to resources directory. @@ -120,6 +130,10 @@ def get_addons_resources_dir(addon_name: str, *args) -> str: return os.path.join(addons_resources_dir, addon_name, *args) +class _FakeException(Exception): + """Placeholder exception used if real exception is not available.""" + + class AYONSecureRegistry: """Store information using keyring. @@ -130,9 +144,10 @@ class AYONSecureRegistry: identify which data were created by AYON. Args: - name(str): Name of registry used as identifier for data. + name(str): Name of registry used as the identifier for data. + """ - def __init__(self, name): + def __init__(self, name: str) -> None: try: import keyring @@ -148,13 +163,12 @@ class AYONSecureRegistry: keyring.set_keyring(Windows.WinVaultKeyring()) # Force "AYON" prefix - self._name = "/".join(("AYON", name)) + self._name = f"AYON/{name}" - def set_item(self, name, value): - # type: (str, str) -> None - """Set sensitive item into system's keyring. + def set_item(self, name: str, value: str) -> None: + """Set sensitive item into the system's keyring. - This uses `Keyring module`_ to save sensitive stuff into system's + This uses `Keyring module`_ to save sensitive stuff into the system's keyring. Args: @@ -168,22 +182,26 @@ class AYONSecureRegistry: import keyring keyring.set_password(self._name, name, value) + self.get_item.cache_clear() @lru_cache(maxsize=32) - def get_item(self, name, default=_PLACEHOLDER): - """Get value of sensitive item from system's keyring. + def get_item( + self, name: str, default: Any = _PLACEHOLDER + ) -> Optional[str]: + """Get value of sensitive item from the system's keyring. See also `Keyring module`_ Args: name (str): Name of the item. - default (Any): Default value if item is not available. + default (Any): Default value if the item is not available. Returns: value (str): Value of the item. Raises: - ValueError: If item doesn't exist and default is not defined. + RegistryItemNotFound: If the item doesn't exist and default + is not defined. .. _Keyring module: https://github.com/jaraco/keyring @@ -191,21 +209,29 @@ class AYONSecureRegistry: """ import keyring - value = keyring.get_password(self._name, name) + # Capture 'ItemNotFoundException' exception (on linux) + try: + from secretstorage.exceptions import ItemNotFoundException + except ImportError: + ItemNotFoundException = _FakeException + + try: + value = keyring.get_password(self._name, name) + except ItemNotFoundException: + value = None + if value is not None: return value if default is not _PLACEHOLDER: return default - # NOTE Should raise `KeyError` - raise ValueError( - "Item {}:{} does not exist in keyring.".format(self._name, name) + raise RegistryItemNotFound( + f"Item {self._name}:{name} not found in keyring." ) - def delete_item(self, name): - # type: (str) -> None - """Delete value stored in system's keyring. + def delete_item(self, name: str) -> None: + """Delete value stored in the system's keyring. See also `Keyring module`_ @@ -223,47 +249,38 @@ class AYONSecureRegistry: class ASettingRegistry(ABC): - """Abstract class defining structure of **SettingRegistry** class. - - It is implementing methods to store secure items into keyring, otherwise - mechanism for storing common items must be implemented in abstract - methods. - - Attributes: - _name (str): Registry names. + """Abstract class to defining structure of registry class. """ - - def __init__(self, name): - # type: (str) -> ASettingRegistry - super(ASettingRegistry, self).__init__() - + def __init__(self, name: str) -> None: self._name = name - self._items = {} - - def set_item(self, name, value): - # type: (str, str) -> None - """Set item to settings registry. - - Args: - name (str): Name of the item. - value (str): Value of the item. - - """ - self._set_item(name, value) @abstractmethod - def _set_item(self, name, value): - # type: (str, str) -> None - # Implement it - pass + def _get_item(self, name: str) -> Any: + """Get item value from registry.""" - def __setitem__(self, name, value): - self._items[name] = value + @abstractmethod + def _set_item(self, name: str, value: str) -> None: + """Set item value to registry.""" + + @abstractmethod + def _delete_item(self, name: str) -> None: + """Delete item from registry.""" + + def __getitem__(self, name: str) -> Any: + return self._get_item(name) + + def __setitem__(self, name: str, value: str) -> None: self._set_item(name, value) - def get_item(self, name): - # type: (str) -> str + def __delitem__(self, name: str) -> None: + self._delete_item(name) + + @property + def name(self) -> str: + return self._name + + def get_item(self, name: str) -> str: """Get item from settings registry. Args: @@ -273,22 +290,22 @@ class ASettingRegistry(ABC): value (str): Value of the item. Raises: - ValueError: If item doesn't exist. + RegistryItemNotFound: If the item doesn't exist. """ return self._get_item(name) - @abstractmethod - def _get_item(self, name): - # type: (str) -> str - # Implement it - pass + def set_item(self, name: str, value: str) -> None: + """Set item to settings registry. - def __getitem__(self, name): - return self._get_item(name) + Args: + name (str): Name of the item. + value (str): Value of the item. - def delete_item(self, name): - # type: (str) -> None + """ + self._set_item(name, value) + + def delete_item(self, name: str) -> None: """Delete item from settings registry. Args: @@ -297,16 +314,6 @@ class ASettingRegistry(ABC): """ self._delete_item(name) - @abstractmethod - def _delete_item(self, name): - # type: (str) -> None - """Delete item from settings.""" - pass - - def __delitem__(self, name): - del self._items[name] - self._delete_item(name) - class IniSettingRegistry(ASettingRegistry): """Class using :mod:`configparser`. @@ -314,20 +321,17 @@ class IniSettingRegistry(ASettingRegistry): This class is using :mod:`configparser` (ini) files to store items. """ - - def __init__(self, name, path): - # type: (str, str) -> IniSettingRegistry - super(IniSettingRegistry, self).__init__(name) + def __init__(self, name: str, path: str) -> None: + super().__init__(name) # get registry file - self._registry_file = os.path.join(path, "{}.ini".format(name)) + self._registry_file = os.path.join(path, f"{name}.ini") if not os.path.exists(self._registry_file): with open(self._registry_file, mode="w") as cfg: print("# Settings registry", cfg) now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") - print("# {}".format(now), cfg) + print(f"# {now}", cfg) - def set_item_section(self, section, name, value): - # type: (str, str, str) -> None + def set_item_section(self, section: str, name: str, value: str) -> None: """Set item to specific section of ini registry. If section doesn't exists, it is created. @@ -350,12 +354,10 @@ class IniSettingRegistry(ASettingRegistry): with open(self._registry_file, mode="w") as cfg: config.write(cfg) - def _set_item(self, name, value): - # type: (str, str) -> None + def _set_item(self, name: str, value: str) -> None: self.set_item_section("MAIN", name, value) - def set_item(self, name, value): - # type: (str, str) -> None + def set_item(self, name: str, value: str) -> None: """Set item to settings ini file. This saves item to ``DEFAULT`` section of ini as each item there @@ -368,10 +370,9 @@ class IniSettingRegistry(ASettingRegistry): """ # this does the some, overridden just for different docstring. # we cast value to str as ini options values must be strings. - super(IniSettingRegistry, self).set_item(name, str(value)) + super().set_item(name, str(value)) - def get_item(self, name): - # type: (str) -> str + def get_item(self, name: str) -> str: """Gets item from settings ini file. This gets settings from ``DEFAULT`` section of ini file as each item @@ -384,19 +385,18 @@ class IniSettingRegistry(ASettingRegistry): str: Value of item. Raises: - ValueError: If value doesn't exist. + RegistryItemNotFound: If value doesn't exist. """ - return super(IniSettingRegistry, self).get_item(name) + return super().get_item(name) @lru_cache(maxsize=32) - def get_item_from_section(self, section, name): - # type: (str, str) -> str + def get_item_from_section(self, section: str, name: str) -> str: """Get item from section of ini file. This will read ini file and try to get item value from specified - section. If that section or item doesn't exist, :exc:`ValueError` - is risen. + section. If that section or item doesn't exist, + :exc:`RegistryItemNotFound` is risen. Args: section (str): Name of ini section. @@ -406,7 +406,7 @@ class IniSettingRegistry(ASettingRegistry): str: Item value. Raises: - ValueError: If value doesn't exist. + RegistryItemNotFound: If value doesn't exist. """ config = configparser.ConfigParser() @@ -414,16 +414,15 @@ class IniSettingRegistry(ASettingRegistry): try: value = config[section][name] except KeyError: - raise ValueError( - "Registry doesn't contain value {}:{}".format(section, name)) + raise RegistryItemNotFound( + f"Registry doesn't contain value {section}:{name}" + ) return value - def _get_item(self, name): - # type: (str) -> str + def _get_item(self, name: str) -> str: return self.get_item_from_section("MAIN", name) - def delete_item_from_section(self, section, name): - # type: (str, str) -> None + def delete_item_from_section(self, section: str, name: str) -> None: """Delete item from section in ini file. Args: @@ -431,7 +430,7 @@ class IniSettingRegistry(ASettingRegistry): name (str): Name of the item. Raises: - ValueError: If item doesn't exist. + RegistryItemNotFound: If the item doesn't exist. """ self.get_item_from_section.cache_clear() @@ -440,8 +439,9 @@ class IniSettingRegistry(ASettingRegistry): try: _ = config[section][name] except KeyError: - raise ValueError( - "Registry doesn't contain value {}:{}".format(section, name)) + raise RegistryItemNotFound( + f"Registry doesn't contain value {section}:{name}" + ) config.remove_option(section, name) # if section is empty, delete it @@ -457,29 +457,28 @@ class IniSettingRegistry(ASettingRegistry): class JSONSettingRegistry(ASettingRegistry): - """Class using json file as storage.""" + """Class using a json file as storage.""" - def __init__(self, name, path): - # type: (str, str) -> JSONSettingRegistry - super(JSONSettingRegistry, self).__init__(name) - #: str: name of registry file - self._registry_file = os.path.join(path, "{}.json".format(name)) + def __init__(self, name: str, path: str) -> None: + super().__init__(name) + self._registry_file = os.path.join(path, f"{name}.json") now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") header = { "__metadata__": {"generated": now}, "registry": {} } - if not os.path.exists(os.path.dirname(self._registry_file)): - os.makedirs(os.path.dirname(self._registry_file), exist_ok=True) + # Use 'os.path.dirname' in case someone uses slashes in 'name' + dirpath = os.path.dirname(self._registry_file) + if not os.path.exists(dirpath): + os.makedirs(dirpath, exist_ok=True) if not os.path.exists(self._registry_file): with open(self._registry_file, mode="w") as cfg: json.dump(header, cfg, indent=4) @lru_cache(maxsize=32) - def _get_item(self, name): - # type: (str) -> object - """Get item value from registry json. + def _get_item(self, name: str) -> str: + """Get item value from the registry. Note: See :meth:`ayon_core.lib.JSONSettingRegistry.get_item` @@ -490,29 +489,13 @@ class JSONSettingRegistry(ASettingRegistry): try: value = data["registry"][name] except KeyError: - raise ValueError( - "Registry doesn't contain value {}".format(name)) + raise RegistryItemNotFound( + f"Registry doesn't contain value {name}" + ) return value - def get_item(self, name): - # type: (str) -> object - """Get item value from registry json. - - Args: - name (str): Name of the item. - - Returns: - value of the item - - Raises: - ValueError: If item is not found in registry file. - - """ - return self._get_item(name) - - def _set_item(self, name, value): - # type: (str, object) -> None - """Set item value to registry json. + def _set_item(self, name: str, value: str) -> None: + """Set item value to the registry. Note: See :meth:`ayon_core.lib.JSONSettingRegistry.set_item` @@ -524,41 +507,39 @@ class JSONSettingRegistry(ASettingRegistry): cfg.truncate(0) cfg.seek(0) json.dump(data, cfg, indent=4) - - def set_item(self, name, value): - # type: (str, object) -> None - """Set item and its value into json registry file. - - Args: - name (str): name of the item. - value (Any): value of the item. - - """ - self._set_item(name, value) - - def _delete_item(self, name): - # type: (str) -> None self._get_item.cache_clear() + + def _delete_item(self, name: str) -> None: with open(self._registry_file, "r+") as cfg: data = json.load(cfg) del data["registry"][name] cfg.truncate(0) cfg.seek(0) json.dump(data, cfg, indent=4) + self._get_item.cache_clear() class AYONSettingsRegistry(JSONSettingRegistry): """Class handling AYON general settings registry. Args: - name (Optional[str]): Name of the registry. - """ + name (Optional[str]): Name of the registry. Using 'None' or not + passing name is deprecated. - def __init__(self, name=None): + """ + def __init__(self, name: Optional[str] = None) -> None: if not name: name = "AYON_settings" + warnings.warn( + ( + "Used 'AYONSettingsRegistry' without 'name' argument." + " The argument will be required in future versions." + ), + DeprecationWarning, + stacklevel=2, + ) path = get_launcher_storage_dir() - super(AYONSettingsRegistry, self).__init__(name, path) + super().__init__(name, path) def get_local_site_id(): @@ -591,10 +572,26 @@ def get_local_site_id(): def get_ayon_username(): """AYON username used for templates and publishing. - Uses curet ayon api username. + Uses current ayon api username. Returns: str: Username. """ - return ayon_api.get_user()["name"] + # Look for username in the connection stack + # - this is used when service is working as other user + # (e.g. in background sync) + # TODO @iLLiCiTiT - do not use private attribute of 'ServerAPI', rather + # use public method to get username from connection stack. + con = ayon_api.get_server_api_connection() + user_stack = getattr(con, "_as_user_stack", None) + if user_stack is not None: + username = user_stack.username + if username is not None: + return username + + # Cache the username to avoid multiple API calls + # - it is not expected that user would change + if _Cache.username is None: + _Cache.username = ayon_api.get_user()["name"] + return _Cache.username diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index 9e3e455a6c..c6e9e14eac 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -3,6 +3,7 @@ import re import copy import numbers import warnings +import platform from string import Formatter import typing from typing import List, Dict, Any, Set @@ -12,6 +13,7 @@ if typing.TYPE_CHECKING: SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)") OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") +_IS_WINDOWS = platform.system().lower() == "windows" class TemplateUnsolved(Exception): @@ -277,8 +279,11 @@ class TemplateResult(str): """Convert to normalized path.""" cls = self.__class__ + path = str(self) + if _IS_WINDOWS: + path = path.replace("\\", "/") return cls( - os.path.normpath(self.replace("\\", "/")), + os.path.normpath(path), self.template, self.solved, self.used_values, diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 137736c302..f2ec952cd6 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -19,11 +19,7 @@ from .create import ( CreatedInstance, CreatorError, - LegacyCreator, - legacy_create, - discover_creator_plugins, - discover_legacy_creator_plugins, register_creator_plugin, deregister_creator_plugin, register_creator_plugin_path, @@ -141,12 +137,7 @@ __all__ = ( "CreatorError", - # - legacy creation - "LegacyCreator", - "legacy_create", - "discover_creator_plugins", - "discover_legacy_creator_plugins", "register_creator_plugin", "deregister_creator_plugin", "register_creator_plugin_path", diff --git a/client/ayon_core/pipeline/anatomy/__init__.py b/client/ayon_core/pipeline/anatomy/__init__.py index 7000f51495..36bc2a138d 100644 --- a/client/ayon_core/pipeline/anatomy/__init__.py +++ b/client/ayon_core/pipeline/anatomy/__init__.py @@ -6,6 +6,7 @@ from .exceptions import ( AnatomyTemplateUnsolved, ) from .anatomy import Anatomy +from .templates import AnatomyTemplateResult, AnatomyStringTemplate __all__ = ( @@ -16,4 +17,7 @@ __all__ = ( "AnatomyTemplateUnsolved", "Anatomy", + + "AnatomyTemplateResult", + "AnatomyStringTemplate", ) diff --git a/client/ayon_core/pipeline/anatomy/templates.py b/client/ayon_core/pipeline/anatomy/templates.py index d89b70719e..e3ec005089 100644 --- a/client/ayon_core/pipeline/anatomy/templates.py +++ b/client/ayon_core/pipeline/anatomy/templates.py @@ -1,6 +1,7 @@ import os import re import copy +import platform import collections import numbers @@ -15,6 +16,7 @@ from .exceptions import ( AnatomyTemplateUnsolved, ) +_IS_WINDOWS = platform.system().lower() == "windows" _PLACEHOLDER = object() @@ -526,6 +528,14 @@ class AnatomyTemplates: root_key = "{" + root_key + "}" output = output.replace(str(used_value), root_key) + # Make sure rootless path is with forward slashes + if _IS_WINDOWS: + output.replace("\\", "/") + + # Make sure there are no double slashes + while "//" in output: + output = output.replace("//", "/") + return output def format(self, data, strict=True): diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 4b1d14d570..a7d1d80b0a 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -1403,7 +1403,12 @@ def _get_display_view_colorspace_name(config_path, display, view): """ config = _get_ocio_config(config_path) - return config.getDisplayViewColorSpaceName(display, view) + colorspace = config.getDisplayViewColorSpaceName(display, view) + # Special token. See https://opencolorio.readthedocs.io/en/latest/guides/authoring/authoring.html#shared-views # noqa + if colorspace == "": + colorspace = display + + return colorspace def _get_ocio_config_colorspaces(config_path): diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 66556bbb35..0589eeb49f 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -1,21 +1,22 @@ """Core pipeline functionality""" +from __future__ import annotations import os import logging import platform import uuid +import warnings +from typing import Optional, Any import ayon_api import pyblish.api from pyblish.lib import MessageHandler from ayon_core import AYON_CORE_ROOT -from ayon_core.host import HostBase +from ayon_core.host import AbstractHost from ayon_core.lib import ( is_in_tests, initialize_ayon_connection, - emit_event, - version_up ) from ayon_core.addon import load_addons, AddonsManager from ayon_core.settings import get_project_settings @@ -23,13 +24,7 @@ from ayon_core.settings import get_project_settings from .publish.lib import filter_pyblish_plugins from .anatomy import Anatomy from .template_data import get_template_data_with_names -from .workfile import ( - get_workdir, - get_custom_workfile_template_by_string_context, - get_workfile_template_key_from_context, - get_last_workfile, - MissingWorkdirError, -) +from .workfile import get_custom_workfile_template_by_string_context from . import ( register_loader_plugin_path, register_inventory_action_path, @@ -75,7 +70,7 @@ def _get_addons_manager(): def register_root(path): - """Register currently active root""" + """DEPRECATED Register currently active root.""" log.info("Registering root: %s" % path) _registered_root["_"] = path @@ -94,18 +89,29 @@ def registered_root(): Returns: dict[str, str]: Root paths. - """ + """ + warnings.warn( + "Used deprecated function 'registered_root'. Please use 'Anatomy'" + " to get roots.", + DeprecationWarning, + stacklevel=2, + ) return _registered_root["_"] -def install_host(host): +def install_host(host: AbstractHost) -> None: """Install `host` into the running Python session. Args: - host (HostBase): A host interface object. + host (AbstractHost): A host interface object. """ + if not isinstance(host, AbstractHost): + log.error( + f"Host must be a subclass of 'AbstractHost', got '{type(host)}'." + ) + global _is_installed _is_installed = True @@ -183,7 +189,7 @@ def install_ayon_plugins(project_name=None, host_name=None): register_inventory_action_path(INVENTORY_PATH) if host_name is None: - host_name = os.environ.get("AYON_HOST_NAME") + host_name = get_current_host_name() addons_manager = _get_addons_manager() publish_plugin_dirs = addons_manager.collect_publish_plugin_paths( @@ -304,7 +310,7 @@ def get_current_host_name(): """ host = registered_host() - if isinstance(host, HostBase): + if isinstance(host, AbstractHost): return host.name return os.environ.get("AYON_HOST_NAME") @@ -340,32 +346,50 @@ def get_global_context(): def get_current_context(): host = registered_host() - if isinstance(host, HostBase): + if isinstance(host, AbstractHost): return host.get_current_context() return get_global_context() def get_current_project_name(): host = registered_host() - if isinstance(host, HostBase): + if isinstance(host, AbstractHost): return host.get_current_project_name() return get_global_context()["project_name"] def get_current_folder_path(): host = registered_host() - if isinstance(host, HostBase): + if isinstance(host, AbstractHost): return host.get_current_folder_path() return get_global_context()["folder_path"] def get_current_task_name(): host = registered_host() - if isinstance(host, HostBase): + if isinstance(host, AbstractHost): return host.get_current_task_name() return get_global_context()["task_name"] +def get_current_project_settings() -> dict[str, Any]: + """Project settings for the current context project. + + Returns: + dict[str, Any]: Project settings for the current context project. + + Raises: + ValueError: If current project is not set. + + """ + project_name = get_current_project_name() + if not project_name: + raise ValueError( + "Current project is not set. Can't get project settings." + ) + return get_project_settings(project_name) + + def get_current_project_entity(fields=None): """Helper function to get project document based on global Session. @@ -505,66 +529,64 @@ def get_current_context_custom_workfile_template(project_settings=None): ) -def change_current_context(folder_entity, task_entity, template_key=None): +_PLACEHOLDER = object() + + +def change_current_context( + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + template_key: Optional[str] = _PLACEHOLDER, + reason: Optional[str] = None, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional[Anatomy] = None, +) -> dict[str, str]: """Update active Session to a new task work area. - This updates the live Session to a different task under folder. + This updates the live Session to a different task under a folder. + + Notes: + * This function does a lot of things related to workfiles which + extends arguments options a lot. + * We might want to implement 'set_current_context' on host integration + instead. But `AYON_WORKDIR`, which is related to 'IWorkfileHost', + would not be available in that case which might break some + logic. Args: folder_entity (Dict[str, Any]): Folder entity to set. task_entity (Dict[str, Any]): Task entity to set. - template_key (Union[str, None]): Prepared template key to be used for - workfile template in Anatomy. + template_key (Optional[str]): DEPRECATED: Prepared template key to + be used for workfile template in Anatomy. + reason (Optional[str]): Reason for changing context. + anatomy (Optional[Anatomy]): Anatomy object used for workdir + calculation. + project_entity (Optional[dict[str, Any]]): Project entity used for + workdir calculation. Returns: - Dict[str, str]: The changed key, values in the current Session. - """ + dict[str, str]: New context data. - project_name = get_current_project_name() - workdir = None - folder_path = None - task_name = None - if folder_entity: - folder_path = folder_entity["path"] - if task_entity: - task_name = task_entity["name"] - project_entity = ayon_api.get_project(project_name) - host_name = get_current_host_name() - workdir = get_workdir( - project_entity, - folder_entity, - task_entity, - host_name, - template_key=template_key + """ + if template_key is not _PLACEHOLDER: + warnings.warn( + ( + "Used deprecated argument 'template_key' in" + " 'change_current_context'." + " It is not necessary to pass it in anymore." + ), + DeprecationWarning, + stacklevel=2, ) - envs = { - "AYON_PROJECT_NAME": project_name, - "AYON_FOLDER_PATH": folder_path, - "AYON_TASK_NAME": task_name, - "AYON_WORKDIR": workdir, - } - - # Update the Session and environments. Pop from environments all keys with - # value set to None. - for key, value in envs.items(): - if value is None: - os.environ.pop(key, None) - else: - os.environ[key] = value - - data = envs.copy() - - # Convert env keys to human readable keys - data["project_name"] = project_name - data["folder_path"] = folder_path - data["task_name"] = task_name - data["workdir_path"] = workdir - - # Emit session change - emit_event("taskChanged", data) - - return data + host = registered_host() + return host.set_current_context( + folder_entity, + task_entity, + reason=reason, + project_entity=project_entity, + anatomy=anatomy, + ) def get_process_id(): @@ -583,53 +605,16 @@ def get_process_id(): def version_up_current_workfile(): - """Function to increment and save workfile + """DEPRECATED Function to increment and save workfile. + + Please use 'save_next_version' from 'ayon_core.pipeline.workfile' instead. + """ - host = registered_host() - - project_name = get_current_project_name() - folder_path = get_current_folder_path() - task_name = get_current_task_name() - host_name = get_current_host_name() - - template_key = get_workfile_template_key_from_context( - project_name, - folder_path, - task_name, - host_name, + warnings.warn( + "Used deprecated 'version_up_current_workfile' please use" + " 'save_next_version' from 'ayon_core.pipeline.workfile' instead.", + DeprecationWarning, + stacklevel=2, ) - anatomy = Anatomy(project_name) - - data = get_template_data_with_names( - project_name, folder_path, task_name, host_name - ) - data["root"] = anatomy.roots - - work_template = anatomy.get_template_item("work", template_key) - - # Define saving file extension - extensions = host.get_workfile_extensions() - current_file = host.get_current_workfile() - if current_file: - extensions = [os.path.splitext(current_file)[-1]] - - work_root = work_template["directory"].format_strict(data) - file_template = work_template["file"].template - last_workfile_path = get_last_workfile( - work_root, file_template, data, extensions, True - ) - # `get_last_workfile` will return the first expected file version - # if no files exist yet. In that case, if they do not exist we will - # want to save v001 - new_workfile_path = last_workfile_path - if os.path.exists(new_workfile_path): - new_workfile_path = version_up(new_workfile_path) - - # Raise an error if the parent folder doesn't exist as `host.save_workfile` - # is not supposed/able to create missing folders. - parent_folder = os.path.dirname(new_workfile_path) - if not os.path.exists(parent_folder): - raise MissingWorkdirError( - f"Work area directory '{parent_folder}' does not exist.") - - host.save_workfile(new_workfile_path) + from ayon_core.pipeline.workfile import save_next_version + save_next_version() diff --git a/client/ayon_core/pipeline/create/__init__.py b/client/ayon_core/pipeline/create/__init__.py index ced43528eb..edb1b12cd4 100644 --- a/client/ayon_core/pipeline/create/__init__.py +++ b/client/ayon_core/pipeline/create/__init__.py @@ -21,12 +21,14 @@ from .exceptions import ( TemplateFillError, ) from .structures import ( + ParentFlags, CreatedInstance, ConvertorItem, AttributeValues, CreatorAttributeValues, PublishAttributeValues, PublishAttributes, + InstanceContextInfo, ) from .utils import ( get_last_versions_for_instances, @@ -44,9 +46,6 @@ from .creator_plugins import ( AutoCreator, HiddenCreator, - discover_legacy_creator_plugins, - get_legacy_creator_by_name, - discover_creator_plugins, register_creator_plugin, deregister_creator_plugin, @@ -58,11 +57,6 @@ from .creator_plugins import ( from .context import CreateContext -from .legacy_create import ( - LegacyCreator, - legacy_create, -) - __all__ = ( "PRODUCT_NAME_ALLOWED_SYMBOLS", @@ -85,12 +79,14 @@ __all__ = ( "TaskNotSetError", "TemplateFillError", + "ParentFlags", "CreatedInstance", "ConvertorItem", "AttributeValues", "CreatorAttributeValues", "PublishAttributeValues", "PublishAttributes", + "InstanceContextInfo", "get_last_versions_for_instances", "get_next_versions_for_instances", @@ -105,9 +101,6 @@ __all__ = ( "AutoCreator", "HiddenCreator", - "discover_legacy_creator_plugins", - "get_legacy_creator_by_name", - "discover_creator_plugins", "register_creator_plugin", "deregister_creator_plugin", @@ -117,7 +110,4 @@ __all__ = ( "cache_and_get_instances", "CreateContext", - - "LegacyCreator", - "legacy_create", ) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 17a5dea7dc..6df437202e 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import sys import copy @@ -10,13 +12,8 @@ import typing from typing import ( Optional, Iterable, - Tuple, - List, - Set, - Dict, Any, Callable, - Union, ) from warnings import warn @@ -28,7 +25,7 @@ from ayon_core.settings import get_project_settings from ayon_core.lib import is_func_signature_supported from ayon_core.lib.events import QueuedEventSystem from ayon_core.lib.attribute_definitions import get_default_values -from ayon_core.host import IPublishHost, IWorkfileHost +from ayon_core.host import IWorkfileHost, IPublishHost from ayon_core.pipeline import Anatomy from ayon_core.pipeline.template_data import get_template_data from ayon_core.pipeline.plugin_discover import DiscoverResult @@ -46,7 +43,12 @@ from .exceptions import ( HostMissRequiredMethod, ) from .changes import TrackChangesItem -from .structures import PublishAttributes, ConvertorItem, InstanceContextInfo +from .structures import ( + PublishAttributes, + ConvertorItem, + InstanceContextInfo, + ParentFlags, +) from .creator_plugins import ( Creator, AutoCreator, @@ -54,7 +56,12 @@ from .creator_plugins import ( discover_convertor_plugins, ) if typing.TYPE_CHECKING: + from ayon_core.lib import AbstractAttrDef + from ayon_core.lib.events import EventCallback, Event + from .structures import CreatedInstance + from .creator_plugins import BaseCreator + # 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 @@ -76,6 +83,8 @@ _NOT_SET = object() INSTANCE_ADDED_TOPIC = "instances.added" INSTANCE_REMOVED_TOPIC = "instances.removed" VALUE_CHANGED_TOPIC = "values.changed" +INSTANCE_REQUIREMENT_CHANGED_TOPIC = "instance.requirement.changed" +INSTANCE_PARENT_CHANGED_TOPIC = "instance.parent.changed" PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC = "pre.create.attr.defs.changed" CREATE_ATTR_DEFS_CHANGED_TOPIC = "create.attr.defs.changed" PUBLISH_ATTR_DEFS_CHANGED_TOPIC = "publish.attr.defs.changed" @@ -159,16 +168,20 @@ class CreateContext: context which should be handled by host. Args: - host(ModuleType): Host implementation which handles implementation and - global metadata. - headless(bool): Context is created out of UI (Current not used). - reset(bool): Reset context on initialization. - discover_publish_plugins(bool): Discover publish plugins during reset + host (IPublishHost): Host implementation which handles implementation + and global metadata. + headless (bool): Context is created out of UI (Current not used). + reset (bool): Reset context on initialization. + discover_publish_plugins (bool): Discover publish plugins during reset phase. """ def __init__( - self, host, headless=False, reset=True, discover_publish_plugins=True + self, + host: IPublishHost, + headless: bool = False, + reset: bool = True, + discover_publish_plugins: bool = True, ): self.host = host @@ -250,6 +263,12 @@ class CreateContext: "create_attrs_change": BulkInfo(), # Publish attribute definitions changed "publish_attrs_change": BulkInfo(), + # Instance requirement changed + # - right now used only for 'mandatory' but can be extended + # in future + "requirement_change": BulkInfo(), + # Instance parent changed + "parent_change": BulkInfo(), } self._bulk_order = [] @@ -269,15 +288,15 @@ class CreateContext: self.reset(discover_publish_plugins) @property - def instances(self): + def instances(self) -> Iterable["CreatedInstance"]: return self._instances_by_id.values() @property - def instances_by_id(self): + def instances_by_id(self) -> dict[str, "CreatedInstance"]: return self._instances_by_id @property - def publish_attributes(self): + def publish_attributes(self) -> PublishAttributes: """Access to global publish attributes.""" return self._publish_attributes @@ -296,15 +315,17 @@ class CreateContext: """ return self._instances_by_id.get(instance_id) - def get_sorted_creators(self, identifiers=None): + def get_sorted_creators( + self, identifiers: Optional[Iterable[str]] = None + ) -> list["BaseCreator"]: """Sorted creators by 'order' attribute. Args: - identifiers (Iterable[str]): Filter creators by identifiers. All - creators are returned if 'None' is passed. + identifiers (Optional[Iterable[str]]): Filter creators by + identifiers. All creators are returned if 'None' is passed. Returns: - List[BaseCreator]: Sorted creator plugins by 'order' value. + list[BaseCreator]: Sorted creator plugins by 'order' value. """ if identifiers is not None: @@ -322,21 +343,21 @@ class CreateContext: ) @property - def sorted_creators(self): + def sorted_creators(self) -> list["BaseCreator"]: """Sorted creators by 'order' attribute. Returns: - List[BaseCreator]: Sorted creator plugins by 'order' value. + list[BaseCreator]: Sorted creator plugins by 'order' value. """ return self.get_sorted_creators() @property - def sorted_autocreators(self): + def sorted_autocreators(self) -> list["AutoCreator"]: """Sorted auto-creators by 'order' attribute. Returns: - List[AutoCreator]: Sorted plugins by 'order' value. + list[AutoCreator]: Sorted plugins by 'order' value. """ return sorted( @@ -367,38 +388,38 @@ class CreateContext: return self.host.name return os.environ["AYON_HOST_NAME"] - def get_current_project_name(self) -> Optional[str]: + def get_current_project_name(self) -> str: """Project name which was used as current context on context reset. Returns: - Union[str, None]: Project name. - """ + Optional[str]: Project name. + """ return self._current_project_name def get_current_folder_path(self) -> Optional[str]: """Folder path which was used as current context on context reset. Returns: - Union[str, None]: Folder path. - """ + Optional[str]: Folder path. + """ return self._current_folder_path def get_current_task_name(self) -> Optional[str]: """Task name which was used as current context on context reset. Returns: - Union[str, None]: Task name. - """ + Optional[str]: Task name. + """ return self._current_task_name def get_current_task_type(self) -> Optional[str]: """Task type which was used as current context on context reset. Returns: - Union[str, None]: Task type. + Optional[str]: Task type. """ if self._current_task_type is _NOT_SET: @@ -409,11 +430,11 @@ class CreateContext: self._current_task_type = task_type return self._current_task_type - def get_current_project_entity(self) -> Optional[Dict[str, Any]]: + def get_current_project_entity(self) -> Optional[dict[str, Any]]: """Project entity for current context project. Returns: - Union[dict[str, Any], None]: Folder entity. + Optional[dict[str, Any]]: Folder entity. """ if self._current_project_entity is not _NOT_SET: @@ -425,7 +446,7 @@ class CreateContext: self._current_project_entity = project_entity return copy.deepcopy(self._current_project_entity) - def get_current_folder_entity(self) -> Optional[Dict[str, Any]]: + def get_current_folder_entity(self) -> Optional[dict[str, Any]]: """Folder entity for current context folder. Returns: @@ -439,11 +460,11 @@ class CreateContext: self._current_folder_entity = self.get_folder_entity(folder_path) return copy.deepcopy(self._current_folder_entity) - def get_current_task_entity(self) -> Optional[Dict[str, Any]]: + def get_current_task_entity(self) -> Optional[dict[str, Any]]: """Task entity for current context task. Returns: - Union[dict[str, Any], None]: Task entity. + Optional[dict[str, Any]]: Task entity. """ if self._current_task_entity is not _NOT_SET: @@ -456,16 +477,16 @@ class CreateContext: ) return copy.deepcopy(self._current_task_entity) - def get_current_workfile_path(self): + def get_current_workfile_path(self) -> Optional[str]: """Workfile path which was opened on context reset. Returns: - Union[str, None]: Workfile path. - """ + Optional[str]: Workfile path. + """ return self._current_workfile_path - def get_current_project_anatomy(self): + def get_current_project_anatomy(self) -> Anatomy: """Project anatomy for current project. Returns: @@ -477,7 +498,7 @@ class CreateContext: self._current_project_name) return self._current_project_anatomy - def get_current_project_settings(self): + def get_current_project_settings(self) -> dict[str, Any]: if self._current_project_settings is None: self._current_project_settings = get_project_settings( self.get_current_project_name()) @@ -485,7 +506,7 @@ class CreateContext: def get_template_data( self, folder_path: Optional[str], task_name: Optional[str] - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Prepare template data for given context. Method is using cached entities and settings to prepare template data. @@ -514,7 +535,7 @@ class CreateContext: ) @property - def context_has_changed(self): + def context_has_changed(self) -> bool: """Host context has changed. As context is used project, folder, task name and workfile path if @@ -522,8 +543,8 @@ class CreateContext: Returns: bool: Context changed. - """ + """ project_name, folder_path, task_name, workfile_path = ( self._get_current_host_context() ) @@ -534,17 +555,17 @@ class CreateContext: or self._current_workfile_path != workfile_path ) - project_name = property(get_current_project_name) - project_anatomy = property(get_current_project_anatomy) + project_name: str = property(get_current_project_name) + project_anatomy: Anatomy = property(get_current_project_anatomy) @property - def log(self): + def log(self) -> logging.Logger: """Dynamic access to logger.""" if self._log is None: self._log = logging.getLogger(self.__class__.__name__) return self._log - def reset(self, discover_publish_plugins=True): + def reset(self, discover_publish_plugins: bool = True) -> None: """Reset context with all plugins and instances. All changes will be lost if were not saved explicitely. @@ -563,7 +584,7 @@ class CreateContext: self.reset_finalization() - def refresh_thumbnails(self): + def refresh_thumbnails(self) -> None: """Cleanup thumbnail paths. Remove all thumbnail filepaths that are empty or lead to files which @@ -586,7 +607,7 @@ class CreateContext: for instance_id in invalid: self.thumbnail_paths_by_instance_id.pop(instance_id) - def reset_preparation(self): + def reset_preparation(self) -> None: """Prepare attributes that must be prepared/cleaned before reset.""" # Give ability to store shared data for collection phase @@ -600,14 +621,16 @@ class CreateContext: self._event_hub.clear_callbacks() - def reset_finalization(self): + def reset_finalization(self) -> None: """Cleanup of attributes after reset.""" # Stop access to collection shared data self._collection_shared_data = None self.refresh_thumbnails() - def _get_current_host_context(self): + def _get_current_host_context( + self + ) -> tuple[str, Optional[str], Optional[str], Optional[str]]: project_name = folder_path = task_name = workfile_path = None if hasattr(self.host, "get_current_context"): host_context = self.host.get_current_context() @@ -621,7 +644,7 @@ class CreateContext: return project_name, folder_path, task_name, workfile_path - def reset_current_context(self): + def reset_current_context(self) -> None: """Refresh current context. Reset is based on optional host implementation of `get_current_context` @@ -655,7 +678,7 @@ class CreateContext: self._current_project_anatomy = None self._current_project_settings = None - def reset_plugins(self, discover_publish_plugins=True): + def reset_plugins(self, discover_publish_plugins: bool = True) -> None: """Reload plugins. Reloads creators from preregistered paths and can load publish plugins @@ -666,7 +689,7 @@ class CreateContext: self._reset_creator_plugins() self._reset_convertor_plugins() - def _reset_publish_plugins(self, discover_publish_plugins): + def _reset_publish_plugins(self, discover_publish_plugins: bool) -> None: from ayon_core.pipeline import AYONPyblishPluginMixin from ayon_core.pipeline.publish import ( publish_plugins_discover @@ -720,7 +743,7 @@ class CreateContext: self.publish_plugins = plugins_by_targets self.plugins_with_defs = plugins_with_defs - def _reset_creator_plugins(self): + def _reset_creator_plugins(self) -> None: # Prepare settings project_settings = self.get_current_project_settings() @@ -785,7 +808,7 @@ class CreateContext: self.creators = creators self.disabled_creators = disabled_creators - def _reset_convertor_plugins(self): + def _reset_convertor_plugins(self) -> None: convertors_plugins = {} report = discover_convertor_plugins(return_report=True) self.convertor_discover_result = report @@ -808,7 +831,7 @@ class CreateContext: self.convertors_plugins = convertors_plugins - def reset_context_data(self): + def reset_context_data(self) -> None: """Reload context data using host implementation. These data are not related to any instance but may be needed for whole @@ -847,13 +870,15 @@ class CreateContext: plugin.__name__, attr_defs ) - def add_instances_added_callback(self, callback): + def add_instances_added_callback( + self, callback: Callable + ) -> "EventCallback": """Register callback for added instances. Event is triggered when instances are already available in context and have set create/publish attribute definitions. - Data structure of event:: + Data structure of event: ```python { @@ -873,12 +898,14 @@ class CreateContext: """ return self._event_hub.add_callback(INSTANCE_ADDED_TOPIC, callback) - def add_instances_removed_callback(self, callback): + def add_instances_removed_callback( + self, callback: Callable + ) -> "EventCallback": """Register callback for removed instances. Event is triggered when instances are already removed from context. - Data structure of event:: + Data structure of event: ```python { @@ -896,15 +923,17 @@ class CreateContext: stop listening. """ - self._event_hub.add_callback(INSTANCE_REMOVED_TOPIC, callback) + return self._event_hub.add_callback(INSTANCE_REMOVED_TOPIC, callback) - def add_value_changed_callback(self, callback): + def add_value_changed_callback( + self, callback: Callable + ) -> "EventCallback": """Register callback to listen value changes. Event is triggered when any value changes on any instance or context data. - Data structure of event:: + Data structure of event: ```python { @@ -932,15 +961,17 @@ class CreateContext: stop listening. """ - self._event_hub.add_callback(VALUE_CHANGED_TOPIC, callback) + return self._event_hub.add_callback(VALUE_CHANGED_TOPIC, callback) - def add_pre_create_attr_defs_change_callback(self, callback): + def add_pre_create_attr_defs_change_callback( + self, callback: Callable + ) -> "EventCallback": """Register callback to listen pre-create attribute changes. Create plugin can trigger refresh of pre-create attributes. Usage of this event is mainly for publisher UI. - Data structure of event:: + Data structure of event: ```python { @@ -958,16 +989,18 @@ class CreateContext: stop listening. """ - self._event_hub.add_callback( + return self._event_hub.add_callback( PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC, callback ) - def add_create_attr_defs_change_callback(self, callback): + def add_create_attr_defs_change_callback( + self, callback: Callable + ) -> "EventCallback": """Register callback to listen create attribute changes. Create plugin changed attribute definitions of instance. - Data structure of event:: + Data structure of event: ```python { @@ -985,14 +1018,18 @@ class CreateContext: stop listening. """ - self._event_hub.add_callback(CREATE_ATTR_DEFS_CHANGED_TOPIC, callback) + return self._event_hub.add_callback( + CREATE_ATTR_DEFS_CHANGED_TOPIC, callback + ) - def add_publish_attr_defs_change_callback(self, callback): + def add_publish_attr_defs_change_callback( + self, callback: Callable + ) -> "EventCallback": """Register callback to listen publish attribute changes. Publish plugin changed attribute definitions of instance of context. - Data structure of event:: + Data structure of event: ```python { @@ -1019,11 +1056,69 @@ class CreateContext: stop listening. """ - self._event_hub.add_callback( + return self._event_hub.add_callback( PUBLISH_ATTR_DEFS_CHANGED_TOPIC, callback ) - def context_data_to_store(self): + def add_instance_requirement_change_callback( + self, callback: Callable + ) -> "EventCallback": + """Register callback to listen to instance requirement changes. + + Instance changed requirement of active state. + + Data structure of event: + + ```python + { + "instances": [CreatedInstance, ...], + "create_context": CreateContext + } + ``` + + Args: + callback (Callable): Callback function that will be called when + instance requirement changed. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ + return self._event_hub.add_callback( + INSTANCE_REQUIREMENT_CHANGED_TOPIC, callback + ) + + def add_instance_parent_change_callback( + self, callback: Callable + ) -> "EventCallback": + """Register callback to listen to instance parent changes. + + Instance changed parent or parent flags. + + Data structure of event: + + ```python + { + "instances": [CreatedInstance, ...], + "create_context": CreateContext + } + ``` + + Args: + callback (Callable): Callback function that will be called when + instance requirement changed. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ + return self._event_hub.add_callback( + INSTANCE_PARENT_CHANGED_TOPIC, callback + ) + + def context_data_to_store(self) -> dict[str, Any]: """Data that should be stored by host function. The same data should be returned on loading. @@ -1032,19 +1127,21 @@ class CreateContext: "publish_attributes": self._publish_attributes.data_to_store() } - def context_data_changes(self): + def context_data_changes(self) -> TrackChangesItem: """Changes of attributes.""" return TrackChangesItem( self._original_context_data, self.context_data_to_store() ) - def set_context_publish_plugin_attr_defs(self, plugin_name, attr_defs): + def set_context_publish_plugin_attr_defs( + self, plugin_name: str, attr_defs: list["AbstractAttrDef"] + ) -> None: """Set attribute definitions for CreateContext publish plugin. Args: plugin_name(str): Name of publish plugin. - attr_defs(List[AbstractAttrDef]): Attribute definitions. + attr_defs(list[AbstractAttrDef]): Attribute definitions. """ self.publish_attributes.set_publish_plugin_attr_defs( @@ -1054,7 +1151,7 @@ class CreateContext: None, plugin_name ) - def creator_adds_instance(self, instance: "CreatedInstance"): + def creator_adds_instance(self, instance: "CreatedInstance") -> None: """Creator adds new instance to context. Instances should be added only from creators. @@ -1079,7 +1176,7 @@ class CreateContext: with self.bulk_add_instances() as bulk_info: bulk_info.append(instance) - def _get_creator_in_create(self, identifier): + def _get_creator_in_create(self, identifier: str) -> "BaseCreator": """Creator by identifier with unified error. Helper method to get creator by identifier with same error when creator @@ -1105,13 +1202,13 @@ class CreateContext: def create( self, - creator_identifier, - variant, - folder_entity=None, - task_entity=None, - pre_create_data=None, - active=None - ): + creator_identifier: str, + variant: str, + folder_entity: Optional[dict[str, Any]] = None, + task_entity: Optional[dict[str, Any]] = None, + pre_create_data: Optional[dict[str, Any]] = None, + active: Optional[bool] = None, + ) -> Any: """Trigger create of plugins with standartized arguments. Arguments 'folder_entity' and 'task_name' use current context as @@ -1124,10 +1221,10 @@ class CreateContext: Args: creator_identifier (str): Identifier of creator plugin. variant (str): Variant used for product name. - folder_entity (Dict[str, Any]): Folder entity which define context + folder_entity (dict[str, Any]): Folder entity which define context of creation (possible context of created instance/s). - task_entity (Dict[str, Any]): Task entity. - pre_create_data (Dict[str, Any]): Pre-create attribute values. + task_entity (dict[str, Any]): Task entity. + pre_create_data (dict[str, Any]): Pre-create attribute values. active (Optional[bool]): Whether the created instance defaults to be active or not. @@ -1225,7 +1322,9 @@ class CreateContext: _pre_create_data ) - def create_with_unified_error(self, identifier, *args, **kwargs): + def create_with_unified_error( + self, identifier: str, *args, **kwargs + ) -> Any: """Trigger create but raise only one error if anything fails. Added to raise unified exception. Capture any possible issues and @@ -1233,8 +1332,8 @@ class CreateContext: Args: identifier (str): Identifier of creator. - *args (Tuple[Any]): Arguments for create method. - **kwargs (Dict[Any, Any]): Keyword argument for create method. + *args (tuple[Any]): Arguments for create method. + **kwargs (dict[Any, Any]): Keyword argument for create method. Raises: CreatorsCreateFailed: When creation fails due to any possible @@ -1249,7 +1348,7 @@ class CreateContext: raise CreatorsCreateFailed([fail_info]) return result - def creator_removed_instance(self, instance: "CreatedInstance"): + def creator_removed_instance(self, instance: "CreatedInstance") -> None: """When creator removes instance context should be acknowledged. If creator removes instance context should know about it to avoid @@ -1262,57 +1361,75 @@ class CreateContext: self._remove_instances([instance]) - def add_convertor_item(self, convertor_identifier, label): + def add_convertor_item( + self, convertor_identifier: str, label: str + ) -> None: self.convertor_items_by_id[convertor_identifier] = ConvertorItem( convertor_identifier, label ) - def remove_convertor_item(self, convertor_identifier): + def remove_convertor_item(self, convertor_identifier: str) -> None: self.convertor_items_by_id.pop(convertor_identifier, None) @contextmanager - def bulk_add_instances(self, sender=None): + def bulk_add_instances(self, sender: Optional[str] = None): with self._bulk_context("add", sender) as bulk_info: yield bulk_info @contextmanager - def bulk_instances_collection(self, sender=None): + def bulk_instances_collection(self, sender: Optional[str] = None): """DEPRECATED use 'bulk_add_instances' instead.""" # TODO add warning with self.bulk_add_instances(sender) as bulk_info: yield bulk_info @contextmanager - def bulk_remove_instances(self, sender=None): + def bulk_remove_instances(self, sender: Optional[str] = None): with self._bulk_context("remove", sender) as bulk_info: yield bulk_info @contextmanager - def bulk_value_changes(self, sender=None): + def bulk_value_changes(self, sender: Optional[str] = None): with self._bulk_context("change", sender) as bulk_info: yield bulk_info @contextmanager - def bulk_pre_create_attr_defs_change(self, sender=None): + def bulk_pre_create_attr_defs_change(self, sender: Optional[str] = None): with self._bulk_context( "pre_create_attrs_change", sender ) as bulk_info: yield bulk_info @contextmanager - def bulk_create_attr_defs_change(self, sender=None): + def bulk_create_attr_defs_change(self, sender: Optional[str] = None): with self._bulk_context( "create_attrs_change", sender ) as bulk_info: yield bulk_info @contextmanager - def bulk_publish_attr_defs_change(self, sender=None): + def bulk_instance_requirement_change(self, sender: Optional[str] = None): + with self._bulk_context( + "requirement_change", sender + ) as bulk_info: + yield bulk_info + + @contextmanager + def bulk_instance_parent_change(self, sender: Optional[str] = None): + with self._bulk_context( + "parent_change", sender + ) as bulk_info: + yield bulk_info + + @contextmanager + def bulk_publish_attr_defs_change(self, sender: Optional[str] = None): with self._bulk_context("publish_attrs_change", sender) as bulk_info: yield bulk_info # --- instance change callbacks --- - def create_plugin_pre_create_attr_defs_changed(self, identifier: str): + def create_plugin_pre_create_attr_defs_changed( + self, identifier: str + ) -> None: """Create plugin pre-create attributes changed. Triggered by 'Creator'. @@ -1324,7 +1441,7 @@ class CreateContext: with self.bulk_pre_create_attr_defs_change() as bulk_item: bulk_item.append(identifier) - def instance_create_attr_defs_changed(self, instance_id: str): + def instance_create_attr_defs_changed(self, instance_id: str) -> None: """Instance attribute definitions changed. Triggered by instance 'CreatorAttributeValues' on instance. @@ -1339,7 +1456,7 @@ class CreateContext: def instance_publish_attr_defs_changed( self, instance_id: Optional[str], plugin_name: str - ): + ) -> None: """Instance attribute definitions changed. Triggered by instance 'PublishAttributeValues' on instance. @@ -1355,8 +1472,8 @@ class CreateContext: bulk_item.append((instance_id, plugin_name)) def instance_values_changed( - self, instance_id: Optional[str], new_values: Dict[str, Any] - ): + self, instance_id: Optional[str], new_values: dict[str, Any] + ) -> None: """Instance value changed. Triggered by `CreatedInstance, 'CreatorAttributeValues' @@ -1364,24 +1481,50 @@ class CreateContext: Args: instance_id (Optional[str]): Instance id or None for context. - new_values (Dict[str, Any]): Changed values. + new_values (dict[str, Any]): Changed values. """ if self._is_instance_events_ready(instance_id): with self.bulk_value_changes() as bulk_item: bulk_item.append((instance_id, new_values)) + def instance_requirement_changed(self, instance_id: str) -> None: + """Instance requirement changed. + + Triggered by `CreatedInstance`. + + Args: + instance_id (Optional[str]): Instance id. + + """ + if self._is_instance_events_ready(instance_id): + with self.bulk_instance_requirement_change() as bulk_item: + bulk_item.append(instance_id) + + def instance_parent_changed(self, instance_id: str) -> None: + """Instance parent changed. + + Triggered by `CreatedInstance`. + + Args: + instance_id (Optional[str]): Instance id. + + """ + if self._is_instance_events_ready(instance_id): + with self.bulk_instance_parent_change() as bulk_item: + bulk_item.append(instance_id) + # --- context change callbacks --- def publish_attribute_value_changed( - self, plugin_name: str, value: Dict[str, Any] - ): + self, plugin_name: str, value: dict[str, Any] + ) -> None: """Context publish attribute values changed. Triggered by instance 'PublishAttributeValues' on context. Args: plugin_name (str): Plugin name which changed value. - value (Dict[str, Any]): Changed values. + value (dict[str, Any]): Changed values. """ self.instance_values_changed( @@ -1393,7 +1536,7 @@ class CreateContext: }, ) - def reset_instances(self): + def reset_instances(self) -> None: """Reload instances""" self._instances_by_id = collections.OrderedDict() @@ -1433,7 +1576,7 @@ class CreateContext: if failed_info: raise CreatorsCollectionFailed(failed_info) - def find_convertor_items(self): + def find_convertor_items(self) -> None: """Go through convertor plugins to look for items to convert. Raises: @@ -1464,7 +1607,7 @@ class CreateContext: if failed_info: raise ConvertorsFindFailed(failed_info) - def execute_autocreators(self): + def execute_autocreators(self) -> None: """Execute discovered AutoCreator plugins. Reset instances if any autocreator executed properly. @@ -1480,14 +1623,16 @@ class CreateContext: if failed_info: raise CreatorsCreateFailed(failed_info) - def get_folder_entities(self, folder_paths: Iterable[str]): + def get_folder_entities( + self, folder_paths: Iterable[str] + ) -> dict[str, Optional[dict[str, Any]]]: """Get folder entities by paths. Args: folder_paths (Iterable[str]): Folder paths. Returns: - Dict[str, Optional[Dict[str, Any]]]: Folder entities by path. + dict[str, Optional[dict[str, Any]]]: Folder entities by path. """ output = { @@ -1527,18 +1672,18 @@ class CreateContext: def get_task_entities( self, - task_names_by_folder_paths: Dict[str, Set[str]] - ) -> Dict[str, Dict[str, Optional[Dict[str, Any]]]]: + task_names_by_folder_paths: dict[str, set[str]] + ) -> dict[str, dict[str, Optional[dict[str, Any]]]]: """Get task entities by folder path and task name. Entities are cached until reset. Args: - task_names_by_folder_paths (Dict[str, Set[str]]): Task names by + task_names_by_folder_paths (dict[str, set[str]]): Task names by folder path. Returns: - Dict[str, Dict[str, Dict[str, Any]]]: Task entities by folder path + dict[str, dict[str, dict[str, Any]]]: Task entities by folder path and task name. """ @@ -1625,7 +1770,7 @@ class CreateContext: def get_folder_entity( self, folder_path: Optional[str], - ) -> Optional[Dict[str, Any]]: + ) -> Optional[dict[str, Any]]: """Get folder entity by path. Entities are cached until reset. @@ -1634,7 +1779,7 @@ class CreateContext: folder_path (Optional[str]): Folder path. Returns: - Optional[Dict[str, Any]]: Folder entity. + Optional[dict[str, Any]]: Folder entity. """ if not folder_path: @@ -1645,7 +1790,7 @@ class CreateContext: self, folder_path: Optional[str], task_name: Optional[str], - ) -> Optional[Dict[str, Any]]: + ) -> Optional[dict[str, Any]]: """Get task entity by name and folder path. Entities are cached until reset. @@ -1655,7 +1800,7 @@ class CreateContext: task_name (Optional[str]): Task name. Returns: - Optional[Dict[str, Any]]: Task entity. + Optional[dict[str, Any]]: Task entity. """ if not folder_path or not task_name: @@ -1666,7 +1811,7 @@ class CreateContext: def get_instances_folder_entities( self, instances: Optional[Iterable["CreatedInstance"]] = None - ) -> Dict[str, Optional[Dict[str, Any]]]: + ) -> dict[str, Optional[dict[str, Any]]]: if instances is None: instances = self._instances_by_id.values() instances = list(instances) @@ -1690,7 +1835,7 @@ class CreateContext: def get_instances_task_entities( self, instances: Optional[Iterable["CreatedInstance"]] = None - ): + ) -> dict[str, Optional[dict[str, Any]]]: """Get task entities for instances. Args: @@ -1698,7 +1843,7 @@ class CreateContext: get task entities. If not provided all instances are used. Returns: - Dict[str, Optional[Dict[str, Any]]]: Task entity by instance id. + dict[str, Optional[dict[str, Any]]]: Task entity by instance id. """ if instances is None: @@ -1736,7 +1881,7 @@ class CreateContext: def get_instances_context_info( self, instances: Optional[Iterable["CreatedInstance"]] = None - ) -> Dict[str, InstanceContextInfo]: + ) -> dict[str, InstanceContextInfo]: """Validate 'folder' and 'task' instance context. Args: @@ -1744,7 +1889,7 @@ class CreateContext: validate. If not provided all instances are validated. Returns: - Dict[str, InstanceContextInfo]: Validation results by instance id. + dict[str, InstanceContextInfo]: Validation results by instance id. """ # Use all instances from context if 'instances' are not passed @@ -1878,7 +2023,7 @@ class CreateContext: context_info.task_is_valid = True return info_by_instance_id - def save_changes(self): + def save_changes(self) -> None: """Save changes. Update all changed values.""" if not self.host_is_valid: missing_methods = self.get_host_misssing_methods(self.host) @@ -1887,14 +2032,14 @@ class CreateContext: self._save_context_changes() self._save_instance_changes() - def _save_context_changes(self): + def _save_context_changes(self) -> None: """Save global context values.""" changes = self.context_data_changes() if changes: data = self.context_data_to_store() self.host.update_context_data(data, changes) - def _save_instance_changes(self): + def _save_instance_changes(self) -> None: """Save instance specific values.""" instances_by_identifier = collections.defaultdict(list) for instance in self._instances_by_id.values(): @@ -1954,86 +2099,124 @@ class CreateContext: if failed_info: raise CreatorsSaveFailed(failed_info) - def remove_instances(self, instances, sender=None): + def remove_instances( + self, + instances: list["CreatedInstance"], + sender: Optional[str] = None, + ) -> None: """Remove instances from context. All instances that don't have creator identifier leading to existing creator are just removed from context. Args: - instances (List[CreatedInstance]): Instances that should be + instances (list[CreatedInstance]): Instances that should be removed. Remove logic is done using creator, which may require to do other cleanup than just remove instance from context. sender (Optional[str]): Sender of the event. """ + instance_ids_by_parent_id = collections.defaultdict(set) + for instance in self.instances: + instance_ids_by_parent_id[instance.parent_instance_id].add( + instance.id + ) + + instances_to_remove = list(instances) + ids_to_remove = { + instance.id + for instance in instances_to_remove + } + _queue = collections.deque() + _queue.extend(instances_to_remove) + # Add children with parent lifetime flag + while _queue: + instance = _queue.popleft() + ids_to_remove.add(instance.id) + children_ids = instance_ids_by_parent_id[instance.id] + for children_id in children_ids: + if children_id in ids_to_remove: + continue + instance = self._instances_by_id[children_id] + if instance.parent_flags & ParentFlags.parent_lifetime: + instances_to_remove.append(instance) + ids_to_remove.add(instance.id) + _queue.append(instance) + instances_by_identifier = collections.defaultdict(list) - for instance in instances: + for instance in instances_to_remove: identifier = instance.creator_identifier instances_by_identifier[identifier].append(instance) # Just remove instances from context if creator is not available missing_creators = set(instances_by_identifier) - set(self.creators) - instances = [] + miss_creator_instances = [] for identifier in missing_creators: - instances.extend( - instance - for instance in instances_by_identifier[identifier] - ) + miss_creator_instances.extend(instances_by_identifier[identifier]) - self._remove_instances(instances, sender) + with self.bulk_remove_instances(sender): + self._remove_instances(miss_creator_instances, sender) - error_message = "Instances removement of creator \"{}\" failed. {}" - failed_info = [] - # Remove instances by creator plugin order - for creator in self.get_sorted_creators( - instances_by_identifier.keys() - ): - identifier = creator.identifier - creator_instances = instances_by_identifier[identifier] + error_message = "Instances removement of creator \"{}\" failed. {}" + failed_info = [] + # Remove instances by creator plugin order + for creator in self.get_sorted_creators( + instances_by_identifier.keys() + ): + identifier = creator.identifier + # Filter instances by current state of 'CreateContext' + # - in case instances were already removed as subroutine of + # previous create plugin. + creator_instances = [ + instance + for instance in instances_by_identifier[identifier] + if instance.id in self._instances_by_id + ] + if not creator_instances: + continue - label = creator.label - failed = False - add_traceback = False - exc_info = None - try: - creator.remove_instances(creator_instances) + label = creator.label + failed = False + add_traceback = False + exc_info = None + try: + creator.remove_instances(creator_instances) - except CreatorError: - failed = True - exc_info = sys.exc_info() - self.log.warning( - error_message.format(identifier, exc_info[1]) - ) - - except (KeyboardInterrupt, SystemExit): - raise - - except: # noqa: E722 - failed = True - add_traceback = True - exc_info = sys.exc_info() - self.log.warning( - error_message.format(identifier, ""), - exc_info=True - ) - - if failed: - failed_info.append( - prepare_failed_creator_operation_info( - identifier, label, exc_info, add_traceback + except CreatorError: + failed = True + exc_info = sys.exc_info() + self.log.warning( + error_message.format(identifier, exc_info[1]) + ) + + except (KeyboardInterrupt, SystemExit): + raise + + except: # noqa: E722 + failed = True + add_traceback = True + exc_info = sys.exc_info() + self.log.warning( + error_message.format(identifier, ""), + exc_info=True + ) + + if failed: + failed_info.append( + prepare_failed_creator_operation_info( + identifier, label, exc_info, add_traceback + ) ) - ) if failed_info: raise CreatorsRemoveFailed(failed_info) @property - def collection_shared_data(self): + def collection_shared_data(self) -> dict[str, Any]: """Access to shared data that can be used during creator's collection. Returns: - Dict[str, Any]: Shared data. + dict[str, Any]: Shared data. Raises: UnavailableSharedData: When called out of collection phase. @@ -2045,7 +2228,7 @@ class CreateContext: ) return self._collection_shared_data - def run_convertor(self, convertor_identifier): + def run_convertor(self, convertor_identifier: str) -> None: """Run convertor plugin by identifier. Conversion is skipped if convertor is not available. @@ -2058,14 +2241,14 @@ class CreateContext: if convertor is not None: convertor.convert() - def run_convertors(self, convertor_identifiers): + def run_convertors(self, convertor_identifiers: Iterable[str]) -> None: """Run convertor plugins by identifiers. Conversion is skipped if convertor is not available. It is recommended to trigger reset after conversion to reload instances. Args: - convertor_identifiers (Iterator[str]): Identifiers of convertors + convertor_identifiers (Iterable[str]): Identifiers of convertors to run. Raises: @@ -2093,21 +2276,27 @@ class CreateContext: if failed_info: raise ConvertorsConversionFailed(failed_info) - def _register_event_callback(self, topic: str, callback: Callable): + def _register_event_callback( + self, topic: str, callback: Callable + ) -> "EventCallback": return self._event_hub.add_callback(topic, callback) def _emit_event( self, topic: str, - data: Optional[Dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, sender: Optional[str] = None, - ): + ) -> "Event": if data is None: data = {} data.setdefault("create_context", self) return self._event_hub.emit(topic, data, sender) - def _remove_instances(self, instances, sender=None): + def _remove_instances( + self, + instances: Iterable[CreatedInstance], + sender: Optional[str] = None, + ) -> None: with self.bulk_remove_instances(sender) as bulk_info: for instance in instances: obj = self._instances_by_id.pop(instance.id, None) @@ -2115,8 +2304,12 @@ class CreateContext: bulk_info.append(obj) def _create_with_unified_error( - self, identifier, creator, *args, **kwargs - ): + self, + identifier: str, + creator: Optional["BaseCreator"], + *args, + **kwargs + ) -> tuple[Optional[Any], Optional[dict[str, Any]]]: error_message = "Failed to run Creator with identifier \"{}\". {}" label = None @@ -2184,7 +2377,7 @@ class CreateContext: if bulk_info: self._bulk_finished(key) - def _bulk_finished(self, key: str): + def _bulk_finished(self, key: str) -> None: if self._bulk_order[0] != key: return @@ -2198,7 +2391,7 @@ class CreateContext: self._bulk_order.pop(0) self._bulk_finish(key) - def _bulk_finish(self, key: str): + def _bulk_finish(self, key: str) -> None: bulk_info = self._bulk_info[key] sender = bulk_info.get_sender() data = bulk_info.pop_data() @@ -2214,12 +2407,16 @@ class CreateContext: self._bulk_create_attrs_change_finished(data, sender) elif key == "publish_attrs_change": self._bulk_publish_attrs_change_finished(data, sender) + elif key == "requirement_change": + self._bulk_instance_requirement_change_finished(data, sender) + elif key == "parent_change": + self._bulk_instance_parent_change_finished(data, sender) def _bulk_add_instances_finished( self, - instances_to_validate: List["CreatedInstance"], + instances_to_validate: list["CreatedInstance"], sender: Optional[str] - ): + ) -> None: if not instances_to_validate: return @@ -2280,9 +2477,9 @@ class CreateContext: def _bulk_remove_instances_finished( self, - instances_to_remove: List["CreatedInstance"], + instances_to_remove: list["CreatedInstance"], sender: Optional[str] - ): + ) -> None: if not instances_to_remove: return @@ -2296,9 +2493,9 @@ class CreateContext: def _bulk_values_change_finished( self, - changes: Tuple[Union[str, None], Dict[str, Any]], + changes: list[tuple[Optional[str], dict[str, Any]]], sender: Optional[str], - ): + ) -> None: if not changes: return item_data_by_id = {} @@ -2351,8 +2548,8 @@ class CreateContext: ) def _bulk_pre_create_attrs_change_finished( - self, identifiers: List[str], sender: Optional[str] - ): + self, identifiers: list[str], sender: Optional[str] + ) -> None: if not identifiers: return identifiers = list(set(identifiers)) @@ -2365,8 +2562,8 @@ class CreateContext: ) def _bulk_create_attrs_change_finished( - self, instance_ids: List[str], sender: Optional[str] - ): + self, instance_ids: list[str], sender: Optional[str] + ) -> None: if not instance_ids: return @@ -2384,9 +2581,9 @@ class CreateContext: def _bulk_publish_attrs_change_finished( self, - attr_info: Tuple[str, Union[str, None]], + attr_info: list[tuple[str, Optional[str]]], sender: Optional[str], - ): + ) -> None: if not attr_info: return @@ -2408,3 +2605,41 @@ class CreateContext: {"instance_changes": instance_changes}, sender, ) + + def _bulk_instance_requirement_change_finished( + self, + instance_ids: list[str], + sender: Optional[str], + ) -> None: + if not instance_ids: + return + + instances = [ + self.get_instance_by_id(instance_id) + for instance_id in set(instance_ids) + ] + + self._emit_event( + INSTANCE_REQUIREMENT_CHANGED_TOPIC, + {"instances": instances}, + sender, + ) + + def _bulk_instance_parent_change_finished( + self, + instance_ids: list[str], + sender: Optional[str], + ): + if not instance_ids: + return + + instances = [ + self.get_instance_by_id(instance_id) + for instance_id in set(instance_ids) + ] + + self._emit_event( + INSTANCE_PARENT_CHANGED_TOPIC, + {"instances": instances}, + sender, + ) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 78fb723567..95db3f260f 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -19,10 +19,9 @@ from ayon_core.pipeline.staging_dir import StagingDir, get_staging_dir_info from ayon_core.settings import get_project_settings from .constants import DEFAULT_VARIANT_VALUE -from .legacy_create import LegacyCreator from .product_name import get_product_name -from .structures import CreatedInstance from .utils import get_next_versions_for_instances +from .structures import CreatedInstance if TYPE_CHECKING: from ayon_core.lib import AbstractAttrDef @@ -1028,9 +1027,6 @@ def register_creator_plugin(plugin): if issubclass(plugin, BaseCreator): register_plugin(BaseCreator, plugin) - elif issubclass(plugin, LegacyCreator): - register_plugin(LegacyCreator, plugin) - elif issubclass(plugin, ProductConvertorPlugin): register_plugin(ProductConvertorPlugin, plugin) @@ -1039,22 +1035,17 @@ def deregister_creator_plugin(plugin): if issubclass(plugin, BaseCreator): deregister_plugin(BaseCreator, plugin) - elif issubclass(plugin, LegacyCreator): - deregister_plugin(LegacyCreator, plugin) - elif issubclass(plugin, ProductConvertorPlugin): deregister_plugin(ProductConvertorPlugin, plugin) def register_creator_plugin_path(path): register_plugin_path(BaseCreator, path) - register_plugin_path(LegacyCreator, path) register_plugin_path(ProductConvertorPlugin, path) def deregister_creator_plugin_path(path): deregister_plugin_path(BaseCreator, path) - deregister_plugin_path(LegacyCreator, path) deregister_plugin_path(ProductConvertorPlugin, path) diff --git a/client/ayon_core/pipeline/create/legacy_create.py b/client/ayon_core/pipeline/create/legacy_create.py deleted file mode 100644 index f6427d9bd1..0000000000 --- a/client/ayon_core/pipeline/create/legacy_create.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Create workflow moved from avalon-core repository. - -Renamed classes and functions -- 'Creator' -> 'LegacyCreator' -- 'create' -> 'legacy_create' -""" - -import os -import logging -import collections - -from ayon_core.pipeline.constants import AYON_INSTANCE_ID - -from .product_name import get_product_name - - -class LegacyCreator: - """Determine how assets are created""" - label = None - product_type = None - defaults = None - maintain_selection = True - enabled = True - - dynamic_product_name_keys = [] - - log = logging.getLogger("LegacyCreator") - log.propagate = True - - def __init__(self, name, folder_path, options=None, data=None): - self.name = name # For backwards compatibility - self.options = options - - # Default data - self.data = collections.OrderedDict() - # TODO use 'AYON_INSTANCE_ID' when all hosts support it - self.data["id"] = AYON_INSTANCE_ID - self.data["productType"] = self.product_type - self.data["folderPath"] = folder_path - self.data["productName"] = name - self.data["active"] = True - - self.data.update(data or {}) - - @classmethod - def apply_settings(cls, project_settings): - """Apply AYON settings to a plugin class.""" - - host_name = os.environ.get("AYON_HOST_NAME") - plugin_type = "create" - plugin_type_settings = ( - project_settings - .get(host_name, {}) - .get(plugin_type, {}) - ) - global_type_settings = ( - project_settings - .get("core", {}) - .get(plugin_type, {}) - ) - if not global_type_settings and not plugin_type_settings: - return - - plugin_name = cls.__name__ - - plugin_settings = None - # Look for plugin settings in host specific settings - if plugin_name in plugin_type_settings: - plugin_settings = plugin_type_settings[plugin_name] - - # Look for plugin settings in global settings - elif plugin_name in global_type_settings: - plugin_settings = global_type_settings[plugin_name] - - if not plugin_settings: - return - - cls.log.debug(">>> We have preset for {}".format(plugin_name)) - for option, value in plugin_settings.items(): - if option == "enabled" and value is False: - cls.log.debug(" - is disabled by preset") - else: - cls.log.debug(" - setting `{}`: `{}`".format(option, value)) - setattr(cls, option, value) - - def process(self): - pass - - @classmethod - def get_dynamic_data( - cls, project_name, folder_entity, task_entity, variant, host_name - ): - """Return dynamic data for current Creator plugin. - - By default return keys from `dynamic_product_name_keys` attribute - as mapping to keep formatted template unchanged. - - ``` - dynamic_product_name_keys = ["my_key"] - --- - output = { - "my_key": "{my_key}" - } - ``` - - Dynamic keys may override default Creator keys (productType, task, - folderPath, ...) but do it wisely if you need. - - All of keys will be converted into 3 variants unchanged, capitalized - and all upper letters. Because of that are all keys lowered. - - This method can be modified to prefill some values just keep in mind it - is class method. - - Args: - project_name (str): Context's project name. - folder_entity (dict[str, Any]): Folder entity. - task_entity (dict[str, Any]): Task entity. - variant (str): What is entered by user in creator tool. - host_name (str): Name of host. - - Returns: - dict: Fill data for product name template. - """ - dynamic_data = {} - for key in cls.dynamic_product_name_keys: - key = key.lower() - dynamic_data[key] = "{" + key + "}" - return dynamic_data - - @classmethod - def get_product_name( - cls, project_name, folder_entity, task_entity, variant, host_name=None - ): - """Return product name created with entered arguments. - - Logic extracted from Creator tool. This method should give ability - to get product name without the tool. - - TODO: Maybe change `variant` variable. - - By default is output concatenated product type with variant. - - Args: - project_name (str): Context's project name. - folder_entity (dict[str, Any]): Folder entity. - task_entity (dict[str, Any]): Task entity. - variant (str): What is entered by user in creator tool. - host_name (str): Name of host. - - Returns: - str: Formatted product name with entered arguments. Should match - config's logic. - """ - - dynamic_data = cls.get_dynamic_data( - project_name, folder_entity, task_entity, variant, host_name - ) - task_name = task_type = None - if task_entity: - task_name = task_entity["name"] - task_type = task_entity["taskType"] - return get_product_name( - project_name, - task_name, - task_type, - host_name, - cls.product_type, - variant, - dynamic_data=dynamic_data - ) - - -def legacy_create( - Creator, product_name, folder_path, options=None, data=None -): - """Create a new instance - - Associate nodes with a product name and type. These nodes are later - validated, according to their `product type`, and integrated into the - shared environment, relative their `productName`. - - Data relative each product type, along with default data, are imprinted - into the resulting objectSet. This data is later used by extractors - and finally asset browsers to help identify the origin of the asset. - - Arguments: - Creator (Creator): Class of creator. - product_name (str): Name of product. - folder_path (str): Folder path. - options (dict, optional): Additional options from GUI. - data (dict, optional): Additional data from GUI. - - Raises: - NameError on `productName` already exists - KeyError on invalid dynamic property - RuntimeError on host error - - Returns: - Name of instance - - """ - from ayon_core.pipeline import registered_host - - host = registered_host() - plugin = Creator(product_name, folder_path, options, data) - - if plugin.maintain_selection is True: - with host.maintained_selection(): - print("Running %s with maintained selection" % plugin) - instance = plugin.process() - return instance - - print("Running %s" % plugin) - instance = plugin.process() - return instance diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index aad85a546a..2b603722a9 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -1,6 +1,7 @@ import copy import collections from uuid import uuid4 +from enum import Enum import typing from typing import Optional, Dict, List, Any from warnings import warn @@ -26,6 +27,23 @@ if typing.TYPE_CHECKING: from .creator_plugins import BaseCreator +class IntEnum(int, Enum): + """An int-based Enum class that allows for int comparison.""" + + def __int__(self) -> int: + return self.value + + +class ParentFlags(IntEnum): + # Delete instance if parent is deleted + parent_lifetime = 1 + # Active state is propagated from parent to children + # - the active state is propagated in collection phase + # NOTE It might be helpful to have a function that would return "real" + # active state for instances + share_active = 1 << 1 + + class ConvertorItem: """Item representing convertor plugin. @@ -530,6 +548,9 @@ class CreatedInstance: if transient_data is None: transient_data = {} self._transient_data = transient_data + self._is_mandatory: bool = False + self._parent_instance_id: Optional[str] = None + self._parent_flags: int = 0 # Create a copy of passed data to avoid changing them on the fly data = copy.deepcopy(data or {}) @@ -633,6 +654,12 @@ class CreatedInstance: if key in self._data and self._data[key] == value: return + if self.is_mandatory and key == "active" and value is not True: + raise ImmutableKeyError( + key, + "Instance is mandatory and can't be disabled." + ) + self._data[key] = value self._create_context.instance_values_changed( self.id, {key: value} @@ -746,6 +773,66 @@ class CreatedInstance: return self._transient_data + @property + def is_mandatory(self) -> bool: + """Check if instance is mandatory. + + Returns: + bool: True if instance is mandatory, False otherwise. + + """ + return self._is_mandatory + + def set_mandatory(self, value: bool) -> None: + """Set instance as mandatory or not. + + Mandatory instance can't be disabled in UI. + + Args: + value (bool): True if instance should be mandatory, False + otherwise. + + """ + if value is self._is_mandatory: + return + self._is_mandatory = value + if value is True: + self["active"] = True + self._create_context.instance_requirement_changed(self.id) + + @property + def parent_instance_id(self) -> Optional[str]: + return self._parent_instance_id + + @property + def parent_flags(self) -> int: + return self._parent_flags + + def set_parent( + self, instance_id: Optional[str], flags: int + ) -> None: + """Set parent instance id and parenting flags. + + Args: + instance_id (Optional[str]): Parent instance id. + flags (int): Parenting flags. + + """ + changed = False + if instance_id != self._parent_instance_id: + changed = True + self._parent_instance_id = instance_id + + if flags is None: + flags = 0 + + if self._parent_flags != flags: + self._parent_flags = flags + changed = True + + if changed: + self._create_context.instance_parent_changed(self.id) + def changes(self): """Calculate and return changes.""" diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 8b6cfc52f1..b553fae3fb 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -7,6 +7,10 @@ import opentimelineio as otio from opentimelineio import opentime as _ot +# https://github.com/AcademySoftwareFoundation/OpenTimelineIO/issues/1822 +OTIO_EPSILON = 1e-9 + + def otio_range_to_frame_range(otio_range): start = _ot.to_frames( otio_range.start_time, otio_range.start_time.rate) diff --git a/client/ayon_core/pipeline/load/__init__.py b/client/ayon_core/pipeline/load/__init__.py index bdc5ece620..2a33fa119b 100644 --- a/client/ayon_core/pipeline/load/__init__.py +++ b/client/ayon_core/pipeline/load/__init__.py @@ -49,6 +49,11 @@ from .plugins import ( deregister_loader_plugin_path, register_loader_plugin_path, deregister_loader_plugin, + + register_loader_hook_plugin, + deregister_loader_hook_plugin, + register_loader_hook_plugin_path, + deregister_loader_hook_plugin_path, ) @@ -103,4 +108,10 @@ __all__ = ( "deregister_loader_plugin_path", "register_loader_plugin_path", "deregister_loader_plugin", + + "register_loader_hook_plugin", + "deregister_loader_hook_plugin", + "register_loader_hook_plugin_path", + "deregister_loader_hook_plugin_path", + ) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 4a11b929cc..48e860e834 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,21 +1,28 @@ -import os -import logging +"""Plugins for loading representations and products into host applications.""" +from __future__ import annotations + +from abc import abstractmethod +import logging +import os +from typing import Any, Optional, Type -from ayon_core.settings import get_project_settings from ayon_core.pipeline.plugin_discover import ( + deregister_plugin, + deregister_plugin_path, discover, register_plugin, register_plugin_path, - deregister_plugin, - deregister_plugin_path ) +from ayon_core.settings import get_project_settings + from .utils import get_representation_path_from_context class LoaderPlugin(list): """Load representation into host application""" - product_types = set() + product_types: set[str] = set() + product_base_types: Optional[set[str]] = None representations = set() extensions = {"*"} order = 0 @@ -58,12 +65,12 @@ class LoaderPlugin(list): if not plugin_settings: return - print(">>> We have preset for {}".format(plugin_name)) + print(f">>> We have preset for {plugin_name}") for option, value in plugin_settings.items(): if option == "enabled" and value is False: print(" - is disabled by preset") else: - print(" - setting `{}`: `{}`".format(option, value)) + print(f" - setting `{option}`: `{value}`") setattr(cls, option, value) @classmethod @@ -76,7 +83,6 @@ class LoaderPlugin(list): Returns: bool: Representation has valid extension """ - if "*" in cls.extensions: return True @@ -121,18 +127,34 @@ class LoaderPlugin(list): """ plugin_repre_names = cls.get_representations() - plugin_product_types = cls.product_types + + # If the product base type isn't defined on the loader plugin, + # then we will use the product types. + plugin_product_filter = cls.product_base_types + if plugin_product_filter is None: + plugin_product_filter = cls.product_types + + if plugin_product_filter: + plugin_product_filter = set(plugin_product_filter) + + repre_entity = context.get("representation") + product_entity = context["product"] + + # If no representation names, product types or extensions are defined + # then loader is not compatible with any context. if ( not plugin_repre_names - or not plugin_product_types + or not plugin_product_filter or not cls.extensions ): return False - repre_entity = context.get("representation") + # If no representation entity is provided then loader is not + # compatible with context. if not repre_entity: return False + # Check the compatibility with the representation names. plugin_repre_names = set(plugin_repre_names) if ( "*" not in plugin_repre_names @@ -140,17 +162,34 @@ class LoaderPlugin(list): ): return False + # Check the compatibility with the extension of the representation. if not cls.has_valid_extension(repre_entity): return False - plugin_product_types = set(plugin_product_types) - if "*" in plugin_product_types: + product_type = product_entity.get("productType") + product_base_type = product_entity.get("productBaseType") + + # Use product base type if defined, otherwise use product type. + product_filter = product_base_type + # If there is no product base type defined in the product entity, + # then we will use the product type. + if product_filter is None: + product_filter = product_type + + # If wildcard is used in product types or base types, + # then we will consider the loader compatible with any product type. + if "*" in plugin_product_filter: return True - product_entity = context["product"] - product_type = product_entity["productType"] + # compatibility with legacy loader + if cls.product_base_types is None and product_base_type: + cls.log.error( + f"Loader {cls.__name__} is doesn't specify " + "`product_base_types` but product entity has " + f"`productBaseType` defined as `{product_base_type}`. " + ) - return product_type in plugin_product_types + return product_filter in plugin_product_filter @classmethod def get_representations(cls): @@ -205,19 +244,17 @@ class LoaderPlugin(list): bool: Whether the container was deleted """ - raise NotImplementedError("Loader.remove() must be " "implemented by subclass") @classmethod def get_options(cls, contexts): - """ - Returns static (cls) options or could collect from 'contexts'. + """Returns static (cls) options or could collect from 'contexts'. - Args: - contexts (list): of repre or product contexts - Returns: - (list) + Args: + contexts (list): of repre or product contexts + Returns: + (list) """ return cls.options or [] @@ -251,28 +288,152 @@ class ProductLoaderPlugin(LoaderPlugin): """ +class LoaderHookPlugin: + """Plugin that runs before and post specific Loader in 'loaders' + + Should be used as non-invasive method to enrich core loading process. + Any studio might want to modify loaded data before or after + they are loaded without need to override existing core plugins. + + The post methods are called after the loader's methods and receive the + return value of the loader's method as `result` argument. + """ + order = 0 + + @classmethod + @abstractmethod + def is_compatible(cls, Loader: Type[LoaderPlugin]) -> bool: + pass + + @abstractmethod + def pre_load( + self, + plugin: LoaderPlugin, + context: dict, + name: Optional[str], + namespace: Optional[str], + options: Optional[dict], + ): + pass + + @abstractmethod + def post_load( + self, + plugin: LoaderPlugin, + result: Any, + context: dict, + name: Optional[str], + namespace: Optional[str], + options: Optional[dict], + ): + pass + + @abstractmethod + def pre_update( + self, + plugin: LoaderPlugin, + container: dict, # (ayon:container-3.0) + context: dict, + ): + pass + + @abstractmethod + def post_update( + self, + plugin: LoaderPlugin, + result: Any, + container: dict, # (ayon:container-3.0) + context: dict, + ): + pass + + @abstractmethod + def pre_remove( + self, + plugin: LoaderPlugin, + container: dict, # (ayon:container-3.0) + ): + pass + + @abstractmethod + def post_remove( + self, + plugin: LoaderPlugin, + result: Any, + container: dict, # (ayon:container-3.0) + ): + pass + + def discover_loader_plugins(project_name=None): from ayon_core.lib import Logger from ayon_core.pipeline import get_current_project_name log = Logger.get_logger("LoaderDiscover") - plugins = discover(LoaderPlugin) if not project_name: project_name = get_current_project_name() project_settings = get_project_settings(project_name) + plugins = discover(LoaderPlugin, allow_duplicates=False) + hooks = discover(LoaderHookPlugin) + sorted_hooks = sorted(hooks, key=lambda hook: hook.order) for plugin in plugins: try: plugin.apply_settings(project_settings) except Exception: log.warning( - "Failed to apply settings to loader {}".format( - plugin.__name__ - ), + f"Failed to apply settings to loader {plugin.__name__}", exc_info=True ) + compatible_hooks = [] + for hook_cls in sorted_hooks: + if hook_cls.is_compatible(plugin): + compatible_hooks.append(hook_cls) + add_hooks_to_loader(plugin, compatible_hooks) return plugins +def add_hooks_to_loader( + loader_class: LoaderPlugin, compatible_hooks: list[Type[LoaderHookPlugin]] +) -> None: + """Monkey patch method replacing Loader.load|update|remove methods + + It wraps applicable loaders with pre/post hooks. Discovery is called only + once per loaders discovery. + """ + loader_class._load_hooks = compatible_hooks + + def wrap_method(method_name: str): + original_method = getattr(loader_class, method_name) + + def wrapped_method(self, *args, **kwargs): + # Call pre_ on all hooks + pre_hook_name = f"pre_{method_name}" + + hooks: list[LoaderHookPlugin] = [] + for cls in loader_class._load_hooks: + hook = cls() # Instantiate the hook + hooks.append(hook) + pre_hook = getattr(hook, pre_hook_name, None) + if callable(pre_hook): + pre_hook(self, *args, **kwargs) + # Call original method + result = original_method(self, *args, **kwargs) + # Call post_ on all hooks + post_hook_name = f"post_{method_name}" + for hook in hooks: + post_hook = getattr(hook, post_hook_name, None) + if callable(post_hook): + post_hook(self, result, *args, **kwargs) + + return result + + setattr(loader_class, method_name, wrapped_method) + + for method in ("load", "update", "remove"): + if hasattr(loader_class, method): + wrap_method(method) + + def register_loader_plugin(plugin): return register_plugin(LoaderPlugin, plugin) @@ -287,3 +448,19 @@ def deregister_loader_plugin_path(path): def register_loader_plugin_path(path): return register_plugin_path(LoaderPlugin, path) + + +def register_loader_hook_plugin(plugin): + return register_plugin(LoaderHookPlugin, plugin) + + +def deregister_loader_hook_plugin(plugin): + deregister_plugin(LoaderHookPlugin, plugin) + + +def register_loader_hook_plugin_path(path): + return register_plugin_path(LoaderHookPlugin, path) + + +def deregister_loader_hook_plugin_path(path): + deregister_plugin_path(LoaderHookPlugin, path) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index b130161190..836fc5e096 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -288,7 +288,12 @@ def get_representation_context(project_name, representation): def load_with_repre_context( - Loader, repre_context, namespace=None, name=None, options=None, **kwargs + Loader, + repre_context, + namespace=None, + name=None, + options=None, + **kwargs ): # Ensure the Loader is compatible for the representation @@ -320,7 +325,12 @@ def load_with_repre_context( def load_with_product_context( - Loader, product_context, namespace=None, name=None, options=None, **kwargs + Loader, + product_context, + namespace=None, + name=None, + options=None, + **kwargs ): # Ensure options is a dictionary when no explicit options provided @@ -343,7 +353,12 @@ def load_with_product_context( def load_with_product_contexts( - Loader, product_contexts, namespace=None, name=None, options=None, **kwargs + Loader, + product_contexts, + namespace=None, + name=None, + options=None, + **kwargs ): # Ensure options is a dictionary when no explicit options provided @@ -553,15 +568,20 @@ def update_container(container, version=-1): return Loader().update(container, context) -def switch_container(container, representation, loader_plugin=None): +def switch_container( + container, + representation, + loader_plugin=None, +): """Switch a container to representation Args: container (dict): container information representation (dict): representation entity + loader_plugin (LoaderPlugin) Returns: - function call + return from function call """ from ayon_core.pipeline import get_current_project_name @@ -700,11 +720,13 @@ def get_representation_path(representation, root=None): str: fullpath of the representation """ - if root is None: - from ayon_core.pipeline import registered_root + from ayon_core.pipeline import get_current_project_name, Anatomy - root = registered_root() + anatomy = Anatomy(get_current_project_name()) + return get_representation_path_with_anatomy( + representation, anatomy + ) def path_from_representation(): try: @@ -752,7 +774,7 @@ def get_representation_path(representation, root=None): dir_path, file_name = os.path.split(path) if not os.path.exists(dir_path): - return + return None base_name, ext = os.path.splitext(file_name) file_name_items = None @@ -762,7 +784,7 @@ def get_representation_path(representation, root=None): file_name_items = base_name.split("%") if not file_name_items: - return + return None filename_start = file_name_items[0] diff --git a/client/ayon_core/pipeline/plugin_discover.py b/client/ayon_core/pipeline/plugin_discover.py index f531600276..03da7fce79 100644 --- a/client/ayon_core/pipeline/plugin_discover.py +++ b/client/ayon_core/pipeline/plugin_discover.py @@ -51,7 +51,7 @@ class DiscoverResult: "*** Discovered {} plugins".format(len(self.plugins)) ) for cls in self.plugins: - lines.append("- {}".format(cls.__class__.__name__)) + lines.append("- {}".format(cls.__name__)) # Plugin that were defined to be ignored if self.ignored_plugins or full_report: diff --git a/client/ayon_core/pipeline/publish/__init__.py b/client/ayon_core/pipeline/publish/__init__.py index 5363e0b378..ede7fc3a35 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -46,6 +46,11 @@ from .lib import ( get_publish_instance_families, main_cli_publish, + + add_trait_representations, + get_trait_representations, + has_trait_representations, + set_trait_representations, ) from .abstract_expected_files import ExpectedFiles @@ -104,4 +109,9 @@ __all__ = ( "RenderInstance", "AbstractCollectRender", + + "add_trait_representations", + "get_trait_representations", + "has_trait_representations", + "set_trait_representations", ) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 49ecab2221..3b82d961f8 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -5,8 +5,9 @@ import sys import inspect import copy import warnings +import hashlib import xml.etree.ElementTree -from typing import Optional, Union, List +from typing import TYPE_CHECKING, Optional, Union, List import ayon_api import pyblish.util @@ -27,6 +28,12 @@ from .constants import ( DEFAULT_HERO_PUBLISH_TEMPLATE, ) +if TYPE_CHECKING: + from ayon_core.pipeline.traits import Representation + + +TRAIT_INSTANCE_KEY: str = "representations_with_traits" + def get_template_name_profiles( project_name, project_settings=None, logger=None @@ -237,32 +244,38 @@ def publish_plugins_discover( for path in paths: path = os.path.normpath(path) - if not os.path.isdir(path): - continue + filenames = [] + if os.path.isdir(path): + filenames.extend( + name + for name in os.listdir(path) + if ( + os.path.isfile(os.path.join(path, name)) + and not name.startswith("_") + ) + ) + else: + filenames.append(os.path.basename(path)) + path = os.path.dirname(path) - for fname in os.listdir(path): - if fname.startswith("_"): - continue - - abspath = os.path.join(path, fname) - - if not os.path.isfile(abspath): - continue - - mod_name, mod_ext = os.path.splitext(fname) - - if mod_ext != ".py": + dirpath_hash = hashlib.md5(path.encode("utf-8")).hexdigest() + for filename in filenames: + basename, ext = os.path.splitext(filename) + if ext.lower() != ".py": continue + filepath = os.path.join(path, filename) + module_name = f"{dirpath_hash}.{basename}" try: module = import_filepath( - abspath, mod_name, sys_module_name=mod_name) + filepath, module_name, sys_module_name=module_name + ) except Exception as err: # noqa: BLE001 # we need broad exception to catch all possible errors. - result.crashed_file_paths[abspath] = sys.exc_info() + result.crashed_file_paths[filepath] = sys.exc_info() - log.debug('Skipped: "%s" (%s)', mod_name, err) + log.debug('Skipped: "%s" (%s)', filepath, err) continue for plugin in pyblish.plugin.plugins_from_module(module): @@ -348,12 +361,18 @@ def get_plugin_settings(plugin, project_settings, log, category=None): # Use project settings based on a category name if category: try: - return ( + output = ( project_settings [category] ["publish"] [plugin.__name__] ) + warnings.warn( + "Please fill 'settings_category'" + f" for plugin '{plugin.__name__}'.", + DeprecationWarning + ) + return output except KeyError: pass @@ -378,12 +397,18 @@ def get_plugin_settings(plugin, project_settings, log, category=None): category_from_file = "core" try: - return ( + output = ( project_settings [category_from_file] [plugin_kind] [plugin.__name__] ) + warnings.warn( + "Please fill 'settings_category'" + f" for plugin '{plugin.__name__}'.", + DeprecationWarning + ) + return output except KeyError: pass return {} @@ -1016,12 +1041,6 @@ def main_cli_publish( 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() @@ -1046,19 +1065,81 @@ def main_cli_publish( log.info("Running publish ...") - plugins = pyblish.api.discover() - print("Using plugins:") - for plugin in plugins: - print(plugin) + discover_result = publish_plugins_discover() + publish_plugins = discover_result.plugins + print(discover_result.get_report(only_errors=False)) # Error exit as soon as any error occurs. error_format = ("Failed {plugin.__name__}: " "{error} -- {error.traceback}") - for result in pyblish.util.publish_iter(): + for result in pyblish.util.publish_iter(plugins=publish_plugins): if result["error"]: log.error(error_format.format(**result)) # uninstall() sys.exit(1) log.info("Publish finished.") + + +def has_trait_representations( + instance: pyblish.api.Instance) -> bool: + """Check if instance has trait representation. + + Args: + instance (pyblish.api.Instance): Instance to check. + + Returns: + True: Instance has trait representation. + False: Instance does not have trait representation. + + """ + return TRAIT_INSTANCE_KEY in instance.data + + +def add_trait_representations( + instance: pyblish.api.Instance, + representations: list[Representation] +) -> None: + """Add trait representations to instance. + + Args: + instance (pyblish.api.Instance): Instance to add trait + representations to. + representations (list[Representation]): List of representation + trait based representations to add. + + """ + repres = instance.data.setdefault(TRAIT_INSTANCE_KEY, []) + repres.extend(representations) + + +def set_trait_representations( + instance: pyblish.api.Instance, + representations: list[Representation] +) -> None: + """Set trait representations to instance. + + Args: + instance (pyblish.api.Instance): Instance to set trait + representations to. + representations (list[Representation]): List of trait + based representations. + + """ + instance.data[TRAIT_INSTANCE_KEY] = representations + + +def get_trait_representations( + instance: pyblish.api.Instance) -> list[Representation]: + """Get trait representations from instance. + + Args: + instance (pyblish.api.Instance): Instance to get trait + representations from. + + Returns: + list[Representation]: List of representation names. + + """ + return instance.data.get(TRAIT_INSTANCE_KEY, []) diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md new file mode 100644 index 0000000000..96ced3692c --- /dev/null +++ b/client/ayon_core/pipeline/traits/README.md @@ -0,0 +1,453 @@ +# Representations and traits + +## Introduction + +The Representation is the lowest level entity, describing the concrete data chunk that +pipeline can act on. It can be a specific file or just a set of metadata. Idea is that one +product version can have multiple representations - **Image** product can be jpeg or tiff, both formats are representation of the same source. + +### Brief look into the past (and current state) + +So far, representation was defined as a dict-like structure: +```python +{ + "name": "foo", + "ext": "exr", + "files": ["foo_001.exr", "foo_002.exr"], + "stagingDir": "/bar/dir" +} +``` + +This is minimal form, but it can have additional keys like `frameStart`, `fps`, `resolutionWidth`, and more. Thare is also `tags` key that can hold `review`, `thumbnail`, `delete`, `toScanline` and other tags that are controlling the processing. + +This will be *"translated"* to the similar structure in the database: + +```python +{ + "name": "foo", + "version_id": "...", + "files": [ + { + "id": ..., + "hash": ..., + "name": "foo_001.exr", + "path": "{root[work]}/bar/dir/foo_001.exr", + "size": 1234, + "hash_type": "...", + }, + ... + ], + "attrib": { + "path": "root/bar/dir/foo_001.exr", + "template": "{root[work]}/{project[name]}...", + }, + "data": { + "context": { + "ext": "exr", + "root": {...}, + ... + }, + "active": True + ... + +} +``` + +There are also some assumptions and limitations - like that if `files` in the +representation are list they need to be sequence of files (it can't be a bunch of +unrelated files). + +This system is very flexible in one way, but it lacks a few very important things: + +- it is not clearly defined — you can add easily keys, values, tags but without +unforeseeable +consequences +- it cannot handle "bundles" — multiple files that need to be versioned together and +belong together +- it cannot describe important information that you can't get from the file itself, or +it is very expensive (like axis orientation and units from alembic files) + + +### New Representation model + +The idea about a new representation model is about solving points mentioned +above and also adding some benefits, like consistent IDE hints, typing, built-in + validators and much more. + +### Design + +The new representation is "just" a dictionary of traits. Trait can be anything provided +it is based on `TraitBase`. It shouldn't really duplicate information that is +available at the moment of loading (or any usage) by other means. It should contain +information that couldn't be determined by the file, or the AYON context. Some of +those traits are aligned with [OpenAssetIO Media Creation](https://github.com/OpenAssetIO/OpenAssetIO-MediaCreation) with hopes of maintained compatibility (it +should be easy enough to convert between OpenAssetIO Traits and AYON Traits). + +#### Details: Representation + +`Representation` has methods to deal with adding, removing, getting +traits. It has all the usual stuff like `get_trait()`, `add_trait()`, +`remove_trait()`, etc. But it also has plural forms so you can get/set +several traits at the same time with `get_traits()` and so on. +`Representation` also behaves like dictionary. so you can access/set +traits in the same way as you would do with dict: + +```python +# import Image trait +from ayon_core.pipeline.traits import Image, Tagged, Representation + + +# create new representation with name "foo" and add Image trait to it +rep = Representation(name="foo", traits=[Image()]) + +# you can add another trait like so +rep.add_trait(Tagged(tags=["tag"])) + +# or you can +rep[Tagged.id] = Tagged(tags=["tag"]) + +# and getting them in analogous +image = rep.get_trait(Image) + +# or +image = rep[Image.id] +``` + +> [!NOTE] +> Trait and their ids — every Trait has its id as a string with a +> version appended - so **Image** has `ayon.2d.Image.v1`. This is used on +> several places (you see its use above for indexing traits). When querying, +> you can also omit the version at the end, and it will try its best to find +> the latest possible version. More on that in [Traits]() + +You can construct the `Representation` from dictionary (for example, +serialized as JSON) using `Representation.from_dict()`, or you can +serialize `Representation` to dict to store with `Representation.traits_as_dict()`. + +Every time representation is created, a new id is generated. You can pass existing +id when creating the new representation instance. + +##### Equality + +Two Representations are equal if: +- their names are the same +- their IDs are the same +- they have the same traits +- the traits have the same values + +##### Validation + +Representation has `validate()` method that will run `validate()` on +all it's traits. + +#### Details: Traits + +As mentioned there are several traits defined directly in **ayon-core**. They are namespaced +to different packages based on their use: + +| namespace | trait | description | +|-------------------|----------------------|----------------------------------------------------------------------------------------------------------| +| color | ColorManaged | hold color management information | +| content | MimeType | use MIME type (RFC 2046) to describe content (like image/jpeg) | +| | LocatableContent | describe some location (file or URI) | +| | FileLocation | path to file, with size and checksum | +| | FileLocations | list of `FileLocation` | +| | RootlessLocation | Path where root is replaced with AYON root token | +| | Compressed | describes compression (of file or other) | +| | Bundle | list of list of Traits - compound of inseparable "sub-representations" | +| | Fragment | compound type marking the representation as a part of larger group of representations | +| cryptography | DigitallySigned | Type traits marking data to be digitally signed | +| | PGPSigned | Representation is signed by [PGP](https://www.openpgp.org/) | +| lifecycle | Transient | Marks the representation to be temporary - not to be stored. | +| | Persistent | Representation should be integrated (stored). Opposite of Transient. | +| meta | Tagged | holds list of tag strings. | +| | TemplatePath | Template consisted of tokens/keys and data to be used to resolve the template into string | +| | Variant | Used to differentiate between data variants of the same output (mp4 as h.264 and h.265 for example) | +| | KeepOriginalLocation | Marks the representation to keep the original location of the file | +| | KeepOriginalName | Marks the representation to keep the original name of the file | +| | SourceApplication | Holds information about producing application, about it's version, variant and platform. | +| | IntendedUse | For specifying the intended use of the representation if it cannot be easily determined by other traits. | +| three dimensional | Spatial | Spatial information like up-axis, units and handedness. | +| | Geometry | Type trait to mark the representation as a geometry. | +| | Shader | Type trait to mark the representation as a Shader. | +| | Lighting | Type trait to mark the representation as Lighting. | +| | IESProfile | States that the representation is IES Profile. | +| time | FrameRanged | Contains start and end frame information with in and out. | +| | Handless | define additional frames at the end or beginning and if those frames are inclusive of the range or not. | +| | Sequence | Describes sequence of frames and how the frames are defined in that sequence. | +| | SMPTETimecode | Adds timecode information in SMPTE format. | +| | Static | Marks the content as not time-variant. | +| two dimensional | Image | Type traits of image. | +| | PixelBased | Defines resolution and pixel aspect for the image data. | +| | Planar | Whether pixel data is in planar configuration or packed. | +| | Deep | Image encodes deep pixel data. | +| | Overscan | holds overscan/underscan information (added pixels to bottom/sides). | +| | UDIM | Representation is UDIM tile set. | + +Traits are Python data classes with optional +validation and helper methods. If they implement `TraitBase.validate(Representation)` method, they can validate against all other traits +in the representation if needed. + +> [!NOTE] +> They could be easily converted to [Pydantic models](https://docs.pydantic.dev/latest/) but since this must run in diverse Python environments inside DCC, we cannot +> easily resolve pydantic-core dependency (as it is binary written in Rust). + +> [!NOTE] +> Every trait has id, name and some human-readable description. Every trait +> also has `persistent` property that is by default set to True. This +> Controls whether this trait should be stored with the persistent representation +> or not. Useful for traits to be used just to control the publishing process. + +## Examples + +Create a simple image representation to be integrated by AYON: + +```python +from pathlib import Path +from ayon_core.pipeline.traits import ( + FileLocation, + Image, + PixelBased, + Persistent, + Representation, + Static, + + TraitValidationError, +) + +rep = Representation(name="reference image", traits=[ + FileLocation( + file_path=Path("/foo/bar/baz.exr"), + file_size=1234, + file_hash="sha256:...", + ), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0, + ), + Persistent(), + Static() +]) + +# validate the representation + +try: + rep.validate() +except TraitValidationError as e: + print(f"Representation {rep.name} is invalid: {e}") + +``` + +To work with the resolution of such representation: + +```python + +try: + width = rep.get_trait(PixelBased).display_window_width + # or like this: + height = rep[PixelBased.id].display_window_height +except MissingTraitError: + print(f"resolution isn't set on {rep.name}") +``` + +Accessing non-existent traits will result in an exception. To test if +the representation has some specific trait, you can use `.contains_trait()` method. + + +You can also prepare the whole representation data as a dict and +create it from it: + +```python +rep_dict = { + "ayon.content.FileLocation.v1": { + "file_path": Path("/path/to/file"), + "file_size": 1024, + "file_hash": None, + }, + "ayon.two_dimensional.Image": {}, + "ayon.two_dimensional.PixelBased": { + "display_window_width": 1920, + "display_window_height": 1080, + "pixel_aspect_ratio": 1.0, + }, + "ayon.two_dimensional.Planar": { + "planar_configuration": "RGB", + } +} + +rep = Representation.from_dict(name="image", rep_dict) + +``` + + +## Addon specific traits + +Addon can define its own traits. To do so, it needs to implement `ITraits` interface: + +```python +from ayon_core.pipeline.traits import TraitBase +from ayon_core.addon import ( + AYONAddon, + ITraits, +) + +class MyTraitFoo(TraitBase): + id = "myaddon.mytrait.foo.v1" + name = "My Trait Foo" + description = "This is my trait foo" + persistent = True + + +class MyTraitBar(TraitBase): + id = "myaddon.mytrait.bar.v1" + name = "My Trait Bar" + description = "This is my trait bar" + persistent = True + + +class MyAddon(AYONAddon, ITraits): + def __init__(self): + super().__init__() + + def get_addon_traits(self): + return [ + MyTraitFoo, + MyTraitBar, + ] +``` +## Usage in Loaders + +In loaders, you can implement `is_compatible_loader()` method to check if the +representation is compatible with the loader. You can use `Representation.from_dict()` to +create the representation from the context. You can also use `Representation.contains_traits()` +to check if the representation contains the required traits. You can even check for specific +values in the traits. + +You can use similar concepts directly in the `load()` method to get the traits. Here is +an example of how to use the traits in the hypothetical Maya loader: + +```python +"""Alembic loader using traits.""" +from __future__ import annotations +import json +from typing import Any, TypeVar, Type +from ayon_maya.api.plugin import MayaLoader +from ayon_core.pipeline.traits import ( + FileLocation, + Spatial, + + Representation, + TraitBase, +) + +T = TypeVar("T", bound=TraitBase) + + +class AlembicTraitLoader(MayaLoader): + """Alembic loader using traits.""" + label = "Alembic Trait Loader" + ... + + required_traits: list[T] = [ + FileLocation, + Spatial, + ] + + @staticmethod + def is_compatible_loader(context: dict[str, Any]) -> bool: + traits_raw = context["representation"].get("traits") + if not traits_raw: + return False + + # construct Representation object from the context + representation = Representation.from_dict( + name=context["representation"]["name"], + representation_id=context["representation"]["id"], + trait_data=json.loads(traits_raw), + ) + + # check if the representation is compatible with this loader + if representation.contains_traits(AlembicTraitLoader.required_traits): + # you can also check for specific values in traits here + return True + return False + + ... +``` + +## Usage Publishing plugins + +You can create the representations in the same way as mentioned in the examples above. +Straightforward way is to use `Representation` class and add the traits to it. Collect +traits in the list and then pass them to the `Representation` constructor. You should add +the new Representation to the instance data using `add_trait_representations()` function. + +```python +class SomeExtractor(Extractor): + """Some extractor.""" + ... + + def extract(self, instance: Instance) -> None: + """Extract the data.""" + # get the path to the file + path = self.get_path(instance) + + # create the representation + traits: list[TraitBase] = [ + Geometry(), + MimeType(mime_type="application/abc"), + Persistent(), + Spatial( + up_axis=cmds.upAxis(q=True, axis=True), + meters_per_unit=maya_units_to_meters_per_unit( + instance.context.data["linearUnits"]), + handedness="right", + ), + ] + + if instance.data.get("frameStart"): + traits.append( + FrameRanged( + frame_start=instance.data["frameStart"], + frame_end=instance.data["frameEnd"], + frames_per_second=instance.context.data["fps"], + ) + ) + + representation = Representation( + name="alembic", + traits=[ + FileLocation( + file_path=Path(path), + file_size=os.path.getsize(path), + file_hash=get_file_hash(Path(path)) + ), + *traits], + ) + + add_trait_representations( + instance, + [representation], + ) + ... +``` + +## Developer notes + +Adding new trait-based representations in to the publishing Instance and working with them is using +a set of helper function defined in `ayon_core.pipeline.publish` module. These are: + +* add_trait_representations +* get_trait_representations +* has_trait_representations +* set_trait_representations + +And their main purpose is to handle the key under which the representation +is stored in the instance data. This is done to avoid name clashes with +other representations. The key is defined in the `AYON_PUBLISH_REPRESENTATION_KEY`. +It is strongly recommended to use those functions instead of +directly accessing the instance data. This is to ensure that the +code will work even if the key is changed in the future. + diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py new file mode 100644 index 0000000000..645064d59f --- /dev/null +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -0,0 +1,112 @@ +"""Trait classes for the pipeline.""" +from .color import ColorManaged +from .content import ( + Bundle, + Compressed, + FileLocation, + FileLocations, + Fragment, + LocatableContent, + MimeType, + RootlessLocation, +) +from .cryptography import DigitallySigned, PGPSigned +from .lifecycle import Persistent, Transient +from .meta import ( + IntendedUse, + KeepOriginalLocation, + SourceApplication, + Tagged, + TemplatePath, + Variant, +) +from .representation import Representation +from .temporal import ( + FrameRanged, + GapPolicy, + Handles, + Sequence, + SMPTETimecode, + Static, +) +from .three_dimensional import Geometry, IESProfile, Lighting, Shader, Spatial +from .trait import ( + MissingTraitError, + TraitBase, + TraitValidationError, +) +from .two_dimensional import ( + UDIM, + Deep, + Image, + Overscan, + PixelBased, + Planar, +) +from .utils import ( + get_sequence_from_files, +) + +__all__ = [ # noqa: RUF022 + # base + "Representation", + "TraitBase", + "MissingTraitError", + "TraitValidationError", + + # color + "ColorManaged", + + # content + "Bundle", + "Compressed", + "FileLocation", + "FileLocations", + "Fragment", + "LocatableContent", + "MimeType", + "RootlessLocation", + + # cryptography + "DigitallySigned", + "PGPSigned", + + # life cycle + "Persistent", + "Transient", + + # meta + "IntendedUse", + "KeepOriginalLocation", + "SourceApplication", + "Tagged", + "TemplatePath", + "Variant", + + # temporal + "FrameRanged", + "GapPolicy", + "Handles", + "Sequence", + "SMPTETimecode", + "Static", + + # three-dimensional + "Geometry", + "IESProfile", + "Lighting", + "Shader", + "Spatial", + + # two-dimensional + "Compressed", + "Deep", + "Image", + "Overscan", + "PixelBased", + "Planar", + "UDIM", + + # utils + "get_sequence_from_files", +] diff --git a/client/ayon_core/pipeline/traits/color.py b/client/ayon_core/pipeline/traits/color.py new file mode 100644 index 0000000000..6da7b86ae7 --- /dev/null +++ b/client/ayon_core/pipeline/traits/color.py @@ -0,0 +1,30 @@ +"""Color-management-related traits.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar, Optional + +from .trait import TraitBase + + +@dataclass +class ColorManaged(TraitBase): + """Color managed trait. + + Holds color management information. Can be used with Image-related + traits to define color space and config. + + Sync with OpenAssetIO MediaCreation Traits. + + Attributes: + color_space (str): An OCIO colorspace name available + in the "current" OCIO context. + config (str): An OCIO config name defining color space. + """ + + id: ClassVar[str] = "ayon.color.ColorManaged.v1" + name: ClassVar[str] = "ColorManaged" + color_space: str + description: ClassVar[str] = "Color Managed trait." + persistent: ClassVar[bool] = True + config: Optional[str] = None diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py new file mode 100644 index 0000000000..42c162d28f --- /dev/null +++ b/client/ayon_core/pipeline/traits/content.py @@ -0,0 +1,485 @@ +"""Content traits for the pipeline.""" +from __future__ import annotations + +import contextlib +import re +from dataclasses import dataclass + +# TCH003 is there because Path in TYPECHECKING will fail in tests +from pathlib import Path # noqa: TCH003 +from typing import ClassVar, Generator, Optional + +from .representation import Representation +from .temporal import FrameRanged, Handles, Sequence +from .trait import ( + MissingTraitError, + TraitBase, + TraitValidationError, +) +from .two_dimensional import UDIM +from .utils import get_sequence_from_files + + +@dataclass +class MimeType(TraitBase): + """MimeType trait model. + + This model represents a mime type trait. For example, image/jpeg. + It is used to describe the type of content in a representation regardless + of the file extension. + + For more information, see RFC 2046 and RFC 4288 (and related RFCs). + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + mime_type (str): Mime type like image/jpeg. + """ + + name: ClassVar[str] = "MimeType" + description: ClassVar[str] = "MimeType Trait Model" + id: ClassVar[str] = "ayon.content.MimeType.v1" + persistent: ClassVar[bool] = True + mime_type: str + + +@dataclass +class LocatableContent(TraitBase): + """LocatableContent trait model. + + This model represents a locatable content trait. Locatable content + is content that has a location. It doesn't have to be a file - it could + be a URL or some other location. + + Sync with OpenAssetIO MediaCreation Traits. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + location (str): Location. + is_templated (Optional[bool]): Is the location templated? + Default is None. + """ + + name: ClassVar[str] = "LocatableContent" + description: ClassVar[str] = "LocatableContent Trait Model" + id: ClassVar[str] = "ayon.content.LocatableContent.v1" + persistent: ClassVar[bool] = True + location: str + is_templated: Optional[bool] = None + + +@dataclass +class FileLocation(TraitBase): + """FileLocation trait model. + + This model represents a file path. It is a specialization of the + LocatableContent trait. It is adding optional file size and file hash + for easy access to file information. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + file_path (str): File path. + file_size (Optional[int]): File size in bytes. + file_hash (Optional[str]): File hash. + """ + + name: ClassVar[str] = "FileLocation" + description: ClassVar[str] = "FileLocation Trait Model" + id: ClassVar[str] = "ayon.content.FileLocation.v1" + persistent: ClassVar[bool] = True + file_path: Path + file_size: Optional[int] = None + file_hash: Optional[str] = None + + +@dataclass +class FileLocations(TraitBase): + """FileLocation trait model. + + This model represents a file path. It is a specialization of the + LocatableContent trait. It is adding optional file size and file hash + for easy access to file information. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + file_paths (list of FileLocation): File locations. + + """ + + name: ClassVar[str] = "FileLocations" + description: ClassVar[str] = "FileLocations Trait Model" + id: ClassVar[str] = "ayon.content.FileLocations.v1" + persistent: ClassVar[bool] = True + file_paths: list[FileLocation] + + def get_files(self) -> Generator[Path, None, None]: + """Get all file paths from the trait. + + This method will return all file paths from the trait. + + Yields: + Path: List of file paths. + + """ + for file_location in self.file_paths: + yield file_location.file_path + + def get_file_location_for_frame( + self, + frame: int, + sequence_trait: Optional[Sequence] = None, + ) -> Optional[FileLocation]: + """Get a file location for a frame. + + This method will return the file location for a given frame. If the + frame is not found in the file paths, it will return None. + + Args: + frame (int): Frame to get the file location for. + sequence_trait (Sequence): Sequence trait to get the + frame range specs from. + + Returns: + Optional[FileLocation]: File location for the frame. + + """ + frame_regex = re.compile(r"\.(?P(?P0*)\d+)\.\D+\d?$") + if sequence_trait and sequence_trait.frame_regex: + frame_regex = sequence_trait.get_frame_pattern() + + for location in self.file_paths: + result = re.search(frame_regex, location.file_path.name) + if result: + frame_index = int(result.group("index")) + if frame_index == frame: + return location + return None + + def validate_trait(self, representation: Representation) -> None: + """Validate the trait. + + This method validates the trait against others in the representation. + In particular, it checks that the sequence trait is present, and if + so, it will compare the frame range to the file paths. + + Args: + representation (Representation): Representation to validate. + + Raises: + TraitValidationError: If the trait is invalid within the + representation. + + """ + super().validate_trait(representation) + if len(self.file_paths) == 0: + # If there are no file paths, we can't validate + msg = "No file locations defined (empty list)" + raise TraitValidationError(self.name, msg) + if representation.contains_trait(FrameRanged): + self._validate_frame_range(representation) + if not representation.contains_trait(Sequence) \ + and not representation.contains_trait(UDIM): + # we have multiple files, but it is not a sequence + # or UDIM tile set what is it then? If the files are not related + # to each other, then this representation is invalid. + msg = ( + "Multiple file locations defined, but no Sequence " + "or UDIM trait defined. If the files are not related to " + "each other, the representation is invalid." + ) + raise TraitValidationError(self.name, msg) + + def _validate_frame_range(self, representation: Representation) -> None: + """Validate the frame range against the file paths. + + If the representation contains a FrameRanged trait, this method will + validate the frame range against the file paths. If the frame range + does not match the file paths, the trait is invalid. It takes into + account the Handles and Sequence traits. + + Args: + representation (Representation): Representation to validate. + + Raises: + TraitValidationError: If the trait is invalid within the + representation. + + """ + tmp_frame_ranged: FrameRanged = get_sequence_from_files( + [f.file_path for f in self.file_paths]) + + frames_from_spec: list[int] = [] + with contextlib.suppress(MissingTraitError): + sequence: Sequence = representation.get_trait(Sequence) + frame_regex = sequence.get_frame_pattern() + if sequence.frame_spec: + frames_from_spec = sequence.get_frame_list( + self, frame_regex) + + frame_start_with_handles, frame_end_with_handles = \ + self._get_frame_info_with_handles(representation, frames_from_spec) + + if frame_start_with_handles \ + and tmp_frame_ranged.frame_start != frame_start_with_handles: + # If the detected frame range does not match the combined + # FrameRanged and Handles trait, the + # trait is invalid. + msg = ( + f"Frame range defined by {self.name} " + f"({tmp_frame_ranged.frame_start}-" + f"{tmp_frame_ranged.frame_end}) " + "in files does not match " + "frame range " + f"({frame_start_with_handles}-" + f"{frame_end_with_handles}) defined in FrameRanged trait." + ) + + raise TraitValidationError(self.name, msg) + + if frames_from_spec: + if len(frames_from_spec) != len(self.file_paths): + # If the number of file paths does not match the frame range, + # the trait is invalid + msg = ( + f"Number of file locations ({len(self.file_paths)}) " + "does not match frame range defined by frame spec " + "on Sequence trait: " + f"({len(frames_from_spec)})" + ) + raise TraitValidationError(self.name, msg) + # if there is a frame spec on the Sequence trait, + # we should not validate the frame range from the files. + # the rest is validated by Sequence validators. + return + + length_with_handles: int = ( + frame_end_with_handles - frame_start_with_handles + 1 + ) + + if len(self.file_paths) != length_with_handles: + # If the number of file paths does not match the frame range, + # the trait is invalid + msg = ( + f"Number of file locations ({len(self.file_paths)}) " + "does not match frame range " + f"({length_with_handles})" + ) + raise TraitValidationError(self.name, msg) + + frame_ranged: FrameRanged = representation.get_trait(FrameRanged) + + if frame_start_with_handles != tmp_frame_ranged.frame_start or \ + frame_end_with_handles != tmp_frame_ranged.frame_end: + # If the frame range does not match the FrameRanged trait, the + # trait is invalid. Note that we don't check the frame rate + # because it is not stored in the file paths and is not + # determined by `get_sequence_from_files`. + msg = ( + "Frame range " + f"({frame_ranged.frame_start}-{frame_ranged.frame_end}) " + "in sequence trait does not match " + "frame range " + f"({tmp_frame_ranged.frame_start}-" + f"{tmp_frame_ranged.frame_end}) " + ) + raise TraitValidationError(self.name, msg) + + @staticmethod + def _get_frame_info_with_handles( + representation: Representation, + frames_from_spec: list[int]) -> tuple[int, int]: + """Get the frame range with handles from the representation. + + This will return frame start and frame end with handles calculated + in if there actually is the Handles trait in the representation. + + Args: + representation (Representation): Representation to get the frame + range from. + frames_from_spec (list[int]): List of frames from the frame spec. + This list is modified in place to take into + account the handles. + + Mutates: + frames_from_spec: List of frames from the frame spec. + + Returns: + tuple[int, int]: Start and end frame with handles. + + """ + frame_start = frame_end = 0 + frame_start_handle = frame_end_handle = 0 + # If there is no sequence trait, we can't validate it + if frames_from_spec and representation.contains_trait(FrameRanged): + # if there is no FrameRanged trait (but really there should be) + # we can use the frame range from the frame spec + frame_start = min(frames_from_spec) + frame_end = max(frames_from_spec) + + # Handle the frame range + with contextlib.suppress(MissingTraitError): + frame_start = representation.get_trait(FrameRanged).frame_start + frame_end = representation.get_trait(FrameRanged).frame_end + + # Handle the handles :P + with contextlib.suppress(MissingTraitError): + handles: Handles = representation.get_trait(Handles) + if not handles.inclusive: + # if handless are exclusive, we need to adjust the frame range + frame_start_handle = handles.frame_start_handle or 0 + frame_end_handle = handles.frame_end_handle or 0 + if frames_from_spec: + frames_from_spec.extend( + range(frame_start - frame_start_handle, frame_start) + ) + frames_from_spec.extend( + range(frame_end + 1, frame_end_handle + frame_end + 1) + ) + + frame_start_with_handles = frame_start - frame_start_handle + frame_end_with_handles = frame_end + frame_end_handle + + return frame_start_with_handles, frame_end_with_handles + + +@dataclass +class RootlessLocation(TraitBase): + """RootlessLocation trait model. + + RootlessLocation trait is a trait that represents a file path that is + without a specific root. To get the absolute path, the root needs to be + resolved by AYON. Rootless path can be used on multiple platforms. + + Example:: + + RootlessLocation( + rootless_path="{root[work]}/project/asset/asset.jpg" + ) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + rootless_path (str): Rootless path. + """ + + name: ClassVar[str] = "RootlessLocation" + description: ClassVar[str] = "RootlessLocation Trait Model" + id: ClassVar[str] = "ayon.content.RootlessLocation.v1" + persistent: ClassVar[bool] = True + rootless_path: str + + +@dataclass +class Compressed(TraitBase): + """Compressed trait model. + + This trait can hold information about compressed content. What type + of compression is used. + + Example:: + + Compressed("gzip") + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + compression_type (str): Compression type. + """ + + name: ClassVar[str] = "Compressed" + description: ClassVar[str] = "Compressed Trait" + id: ClassVar[str] = "ayon.content.Compressed.v1" + persistent: ClassVar[bool] = True + compression_type: str + + +@dataclass +class Bundle(TraitBase): + """Bundle trait model. + + This model list of independent Representation traits + that are bundled together. This is useful for representing + a collection of sub-entities that are part of a single + entity. You can easily reconstruct representations from + the bundle. + + Example:: + + Bundle( + items=[ + [ + MimeType(mime_type="image/jpeg"), + FileLocation(file_path="/path/to/file.jpg") + ], + [ + + MimeType(mime_type="image/png"), + FileLocation(file_path="/path/to/file.png") + ] + ] + ) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + items (list[list[TraitBase]]): List of representations. + """ + + name: ClassVar[str] = "Bundle" + description: ClassVar[str] = "Bundle Trait" + id: ClassVar[str] = "ayon.content.Bundle.v1" + persistent: ClassVar[bool] = True + items: list[list[TraitBase]] + + def to_representations(self) -> Generator[Representation]: + """Convert a bundle to representations. + + Yields: + Representation: Representation of the bundle. + + """ + for idx, item in enumerate(self.items): + yield Representation(name=f"{self.name} {idx}", traits=item) + + +@dataclass +class Fragment(TraitBase): + """Fragment trait model. + + This model represents a fragment trait. A fragment is a part of + a larger entity that is represented by another representation. + + Example:: + + main_representation = Representation(name="parent", + traits=[], + ) + fragment_representation = Representation( + name="fragment", + traits=[ + Fragment(parent=main_representation.id), + ] + ) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + parent (str): Parent representation id. + """ + + name: ClassVar[str] = "Fragment" + description: ClassVar[str] = "Fragment Trait" + id: ClassVar[str] = "ayon.content.Fragment.v1" + persistent: ClassVar[bool] = True + parent: str diff --git a/client/ayon_core/pipeline/traits/cryptography.py b/client/ayon_core/pipeline/traits/cryptography.py new file mode 100644 index 0000000000..7fcbb1b387 --- /dev/null +++ b/client/ayon_core/pipeline/traits/cryptography.py @@ -0,0 +1,42 @@ +"""Cryptography traits.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar, Optional + +from .trait import TraitBase + + +@dataclass +class DigitallySigned(TraitBase): + """Digitally signed trait. + + This type trait means that the data is digitally signed. + + Attributes: + signature (str): Digital signature. + """ + + id: ClassVar[str] = "ayon.cryptography.DigitallySigned.v1" + name: ClassVar[str] = "DigitallySigned" + description: ClassVar[str] = "Digitally signed trait." + persistent: ClassVar[bool] = True + + +@dataclass +class PGPSigned(DigitallySigned): + """PGP signed trait. + + This trait holds PGP (RFC-4880) signed data. + + Attributes: + signed_data (str): Signed data. + clear_text (str): Clear text. + """ + + id: ClassVar[str] = "ayon.cryptography.PGPSigned.v1" + name: ClassVar[str] = "PGPSigned" + description: ClassVar[str] = "PGP signed trait." + persistent: ClassVar[bool] = True + signed_data: str + clear_text: Optional[str] = None diff --git a/client/ayon_core/pipeline/traits/lifecycle.py b/client/ayon_core/pipeline/traits/lifecycle.py new file mode 100644 index 0000000000..4845f04779 --- /dev/null +++ b/client/ayon_core/pipeline/traits/lifecycle.py @@ -0,0 +1,77 @@ +"""Lifecycle traits.""" +from dataclasses import dataclass +from typing import ClassVar + +from .trait import TraitBase, TraitValidationError + + +@dataclass +class Transient(TraitBase): + """Transient trait model. + + Transient trait marks representation as transient. Such representations + are not persisted in the system. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with the version + """ + + name: ClassVar[str] = "Transient" + description: ClassVar[str] = "Transient Trait Model" + id: ClassVar[str] = "ayon.lifecycle.Transient.v1" + persistent: ClassVar[bool] = True # see note in Persistent + + def validate_trait(self, representation) -> None: # noqa: ANN001 + """Validate representation is not Persistent. + + Args: + representation (Representation): Representation model. + + Raises: + TraitValidationError: If representation is marked as both + Persistent and Transient. + + """ + if representation.contains_trait(Persistent): + msg = "Representation is marked as both Persistent and Transient." + raise TraitValidationError(self.name, msg) + + +@dataclass +class Persistent(TraitBase): + """Persistent trait model. + + Persistent trait is opposite to transient trait. It marks representation + as persistent. Such representations are persisted in the system (e.g. in + the database). + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with the version + """ + + name: ClassVar[str] = "Persistent" + description: ClassVar[str] = "Persistent Trait Model" + id: ClassVar[str] = "ayon.lifecycle.Persistent.v1" + # Note that this affects the persistence of the trait itself, not + # the representation. This is a class variable, so it is shared + # among all instances of the class. + persistent: bool = True + + def validate_trait(self, representation) -> None: # noqa: ANN001 + """Validate representation is not Transient. + + Args: + representation (Representation): Representation model. + + Raises: + TraitValidationError: If representation is marked + as both Persistent and Transient. + + """ + if representation.contains_trait(Transient): + msg = "Representation is marked as both Persistent and Transient." + raise TraitValidationError(self.name, msg) diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py new file mode 100644 index 0000000000..26edf3ffb6 --- /dev/null +++ b/client/ayon_core/pipeline/traits/meta.py @@ -0,0 +1,162 @@ +"""Metadata traits.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar, List, Optional + +from .trait import TraitBase + + +@dataclass +class Tagged(TraitBase): + """Tagged trait model. + + This trait can hold a list of tags. + + Example:: + + Tagged(tags=["tag1", "tag2"]) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + tags (List[str]): Tags. + """ + + name: ClassVar[str] = "Tagged" + description: ClassVar[str] = "Tagged Trait Model" + id: ClassVar[str] = "ayon.meta.Tagged.v1" + persistent: ClassVar[bool] = True + tags: List[str] + + +@dataclass +class TemplatePath(TraitBase): + """TemplatePath trait model. + + This model represents a template path with formatting data. + Template path can be an Anatomy template and data is used to format it. + + Example:: + + TemplatePath(template="path/{key}/file", data={"key": "to"}) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + template (str): Template path. + data (dict[str]): Formatting data. + """ + + name: ClassVar[str] = "TemplatePath" + description: ClassVar[str] = "Template Path Trait Model" + id: ClassVar[str] = "ayon.meta.TemplatePath.v1" + persistent: ClassVar[bool] = True + template: str + data: dict + + +@dataclass +class Variant(TraitBase): + """Variant trait model. + + This model represents a variant of the representation. + + Example:: + + Variant(variant="high") + Variant(variant="prores444) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + variant (str): Variant name. + """ + + name: ClassVar[str] = "Variant" + description: ClassVar[str] = "Variant Trait Model" + id: ClassVar[str] = "ayon.meta.Variant.v1" + persistent: ClassVar[bool] = True + variant: str + + +@dataclass +class KeepOriginalLocation(TraitBase): + """Keep files in its original location. + + Note: + This is not a persistent trait. + + """ + name: ClassVar[str] = "KeepOriginalLocation" + description: ClassVar[str] = "Keep Original Location Trait Model" + id: ClassVar[str] = "ayon.meta.KeepOriginalLocation.v1" + persistent: ClassVar[bool] = False + + +@dataclass +class KeepOriginalName(TraitBase): + """Keep files in its original name. + + Note: + This is not a persistent trait. + """ + + name: ClassVar[str] = "KeepOriginalName" + description: ClassVar[str] = "Keep Original Name Trait Model" + id: ClassVar[str] = "ayon.meta.KeepOriginalName.v1" + persistent: ClassVar[bool] = False + + +@dataclass +class SourceApplication(TraitBase): + """Metadata about the source (producing) application. + + This can be useful in cases where this information is + needed, but it cannot be determined from other means - like + .txt files used for various motion tracking applications that + must be interpreted by the loader. + + Note that this is not really connected to any logic in + ayon-applications addon. + + Attributes: + application (str): Application name. + variant (str): Application variant. + version (str): Application version. + platform (str): Platform name (Windows, darwin, etc.). + host_name (str): AYON host name if applicable. + """ + + name: ClassVar[str] = "SourceApplication" + description: ClassVar[str] = "Source Application Trait Model" + id: ClassVar[str] = "ayon.meta.SourceApplication.v1" + persistent: ClassVar[bool] = True + application: str + variant: Optional[str] = None + version: Optional[str] = None + platform: Optional[str] = None + host_name: Optional[str] = None + + +@dataclass +class IntendedUse(TraitBase): + """Intended use of the representation. + + This trait describes the intended use of the representation. It + can be used in cases where the other traits are not enough to + describe the intended use. For example, a txt file with tracking + points can be used as a corner pin in After Effect but not in Nuke. + + Attributes: + use (str): Intended use description. + + """ + name: ClassVar[str] = "IntendedUse" + description: ClassVar[str] = "Intended Use Trait Model" + id: ClassVar[str] = "ayon.meta.IntendedUse.v1" + persistent: ClassVar[bool] = True + use: str diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py new file mode 100644 index 0000000000..f76d5df99f --- /dev/null +++ b/client/ayon_core/pipeline/traits/representation.py @@ -0,0 +1,713 @@ +"""Defines the base trait model and representation.""" +from __future__ import annotations + +import contextlib +import inspect +import re +import sys +import uuid +from functools import lru_cache +from types import GenericAlias +from typing import ( + ClassVar, + Generic, + ItemsView, + Optional, + Type, + TypeVar, + Union, +) + +from .trait import ( + IncompatibleTraitVersionError, + LooseMatchingTraitError, + MissingTraitError, + TraitBase, + TraitValidationError, + UpgradableTraitError, +) + +T = TypeVar("T", bound="TraitBase") + + +def _get_version_from_id(_id: str) -> Optional[int]: + """Get the version from ID. + + Args: + _id (str): ID. + + Returns: + int: Version. + + """ + match = re.search(r"v(\d+)$", _id) + return int(match[1]) if match else None + + +class Representation(Generic[T]): # noqa: PLR0904 + """Representation of products. + + Representation defines a collection of individual properties that describe + the specific "form" of the product. A trait represents a set of + properties therefore, the Representation is a collection of traits. + + It holds methods to add, remove, get, and check for the existence of a + trait in the representation. + + Note: + `PLR0904` is the rule for checking the number of public methods + in a class. + + Arguments: + name (str): Representation name. Must be unique within instance. + representation_id (str): Representation ID. + """ + + _data: dict[str, T] + _module_blacklist: ClassVar[list[str]] = [ + "_", "builtins", "pydantic", + ] + name: str + representation_id: str + + def __hash__(self): + """Return hash of the representation ID.""" + return hash(self.representation_id) + + def __getitem__(self, key: str) -> T: + """Get the trait by ID. + + Args: + key (str): Trait ID. + + Returns: + TraitBase: Trait instance. + + """ + return self.get_trait_by_id(key) + + def __setitem__(self, key: str, value: T) -> None: + """Set the trait by ID. + + Args: + key (str): Trait ID. + value (TraitBase): Trait instance. + + """ + with contextlib.suppress(KeyError): + self._data.pop(key) + + self.add_trait(value) + + def __delitem__(self, key: str) -> None: + """Remove the trait by ID. + + Args: + key (str): Trait ID. + + + """ + self.remove_trait_by_id(key) + + def __contains__(self, key: str) -> bool: + """Check if the trait exists by ID. + + Args: + key (str): Trait ID. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + return self.contains_trait_by_id(key) + + def __iter__(self): + """Return the trait ID iterator.""" + return iter(self._data) + + def __str__(self): + """Return the representation name.""" + return self.name + + def items(self) -> ItemsView[str, T]: + """Return the traits as items.""" + return ItemsView(self._data) + + def add_trait(self, trait: T, *, exists_ok: bool = False) -> None: + """Add a trait to the Representation. + + Args: + trait (TraitBase): Trait to add. + exists_ok (bool, optional): If True, do not raise an error if the + trait already exists. Defaults to False. + + Raises: + ValueError: If the trait ID is not provided, or the trait already + exists. + + """ + if not hasattr(trait, "id"): + error_msg = f"Invalid trait {trait} - ID is required." + raise ValueError(error_msg) + if trait.id in self._data and not exists_ok: + error_msg = f"Trait with ID {trait.id} already exists." + raise ValueError(error_msg) + self._data[trait.id] = trait + + def add_traits( + self, traits: list[T], *, exists_ok: bool = False) -> None: + """Add a list of traits to the Representation. + + Args: + traits (list[TraitBase]): List of traits to add. + exists_ok (bool, optional): If True, do not raise an error if the + trait already exists. Defaults to False. + + """ + for trait in traits: + self.add_trait(trait, exists_ok=exists_ok) + + def remove_trait(self, trait: Type[TraitBase]) -> None: + """Remove a trait from the data. + + Args: + trait (TraitBase, optional): Trait class. + + Raises: + ValueError: If the trait is not found. + + """ + try: + self._data.pop(str(trait.id)) + except KeyError as e: + error_msg = f"Trait with ID {trait.id} not found." + raise ValueError(error_msg) from e + + def remove_trait_by_id(self, trait_id: str) -> None: + """Remove a trait from the data by its ID. + + Args: + trait_id (str): Trait ID. + + Raises: + ValueError: If the trait is not found. + + """ + try: + self._data.pop(trait_id) + except KeyError as e: + error_msg = f"Trait with ID {trait_id} not found." + raise ValueError(error_msg) from e + + def remove_traits(self, traits: list[Type[T]]) -> None: + """Remove a list of traits from the Representation. + + If no trait IDs or traits are provided, all traits will be removed. + + Args: + traits (list[TraitBase]): List of trait classes. + + """ + if not traits: + self._data = {} + return + + for trait in traits: + self.remove_trait(trait) + + def remove_traits_by_id(self, trait_ids: list[str]) -> None: + """Remove a list of traits from the Representation by their ID. + + If no trait IDs or traits are provided, all traits will be removed. + + Args: + trait_ids (list[str], optional): List of trait IDs. + + """ + for trait_id in trait_ids: + self.remove_trait_by_id(trait_id) + + def has_traits(self) -> bool: + """Check if the Representation has any traits. + + Returns: + bool: True if the Representation has any traits, False otherwise. + + """ + return bool(self._data) + + def contains_trait(self, trait: Type[T]) -> bool: + """Check if the trait exists in the Representation. + + Args: + trait (TraitBase): Trait class. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + return bool(self._data.get(str(trait.id))) + + def contains_trait_by_id(self, trait_id: str) -> bool: + """Check if the trait exists using trait id. + + Args: + trait_id (str): Trait ID. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + return bool(self._data.get(trait_id)) + + def contains_traits(self, traits: list[Type[T]]) -> bool: + """Check if the traits exist. + + Args: + traits (list[TraitBase], optional): List of trait classes. + + Returns: + bool: True if all traits exist, False otherwise. + + """ + return all(self.contains_trait(trait=trait) for trait in traits) + + def contains_traits_by_id(self, trait_ids: list[str]) -> bool: + """Check if the traits exist by id. + + If no trait IDs or traits are provided, it will check if the + representation has any traits. + + Args: + trait_ids (list[str]): List of trait IDs. + + Returns: + bool: True if all traits exist, False otherwise. + + """ + return all( + self.contains_trait_by_id(trait_id) for trait_id in trait_ids + ) + + def get_trait(self, trait: Type[T]) -> T: + """Get a trait from the representation. + + Args: + trait (TraitBase, optional): Trait class. + + Returns: + TraitBase: Trait instance. + + Raises: + MissingTraitError: If the trait is not found. + + """ + try: + return self._data[str(trait.id)] + except KeyError as e: + msg = f"Trait with ID {trait.id} not found." + raise MissingTraitError(msg) from e + + def get_trait_by_id(self, trait_id: str) -> T: + # sourcery skip: use-named-expression + """Get a trait from the representation by id. + + Args: + trait_id (str): Trait ID. + + Returns: + TraitBase: Trait instance. + + Raises: + MissingTraitError: If the trait is not found. + + """ + version = _get_version_from_id(trait_id) + if version: + try: + return self._data[trait_id] + except KeyError as e: + msg = f"Trait with ID {trait_id} not found." + raise MissingTraitError(msg) from e + + result = next( + ( + self._data.get(trait_id) + for trait_id in self._data + if trait_id.startswith(trait_id) + ), + None, + ) + if result is None: + msg = f"Trait with ID {trait_id} not found." + raise MissingTraitError(msg) + return result + + def get_traits(self, + traits: Optional[list[Type[T]]] = None + ) -> dict[str, T]: + """Get a list of traits from the representation. + + If no trait IDs or traits are provided, all traits will be returned. + + Args: + traits (list[TraitBase], optional): List of trait classes. + + Returns: + dict: Dictionary of traits. + + """ + result: dict[str, T] = {} + if not traits: + for trait_id in self._data: + result[trait_id] = self.get_trait_by_id(trait_id=trait_id) + return result + + for trait in traits: + result[str(trait.id)] = self.get_trait(trait=trait) + return result + + def get_traits_by_ids(self, trait_ids: list[str]) -> dict[str, T]: + """Get a list of traits from the representation by their id. + + If no trait IDs or traits are provided, all traits will be returned. + + Args: + trait_ids (list[str]): List of trait IDs. + + Returns: + dict: Dictionary of traits. + + """ + return { + trait_id: self.get_trait_by_id(trait_id) + for trait_id in trait_ids + } + + def traits_as_dict(self) -> dict: + """Return the traits from Representation data as a dictionary. + + Returns: + dict: Traits data dictionary. + + """ + return { + trait_id: trait.as_dict() + for trait_id, trait in self._data.items() + if trait and trait_id + } + + def __len__(self): + """Return the length of the data.""" + return len(self._data) + + def __init__( + self, + name: str, + representation_id: Optional[str] = None, + traits: Optional[list[T]] = None): + """Initialize the data. + + Args: + name (str): Representation name. Must be unique within instance. + representation_id (str, optional): Representation ID. + traits (list[TraitBase], optional): List of traits. + + """ + self.name = name + self.representation_id = representation_id or uuid.uuid4().hex + self._data = {} + if traits: + for trait in traits: + self.add_trait(trait) + + @staticmethod + def _get_version_from_id(trait_id: str) -> Union[int, None]: + # sourcery skip: use-named-expression + """Check if the trait has a version specified. + + Args: + trait_id (str): Trait ID. + + Returns: + int: Trait version. + None: If the trait id does not have a version. + + """ + version_regex = r"v(\d+)$" + match = re.search(version_regex, trait_id) + return int(match[1]) if match else None + + def __eq__(self, other: object) -> bool: # noqa: PLR0911 + """Check if the representation is equal to another. + + Args: + other (Representation): Representation to compare. + + Returns: + bool: True if the representations are equal, False otherwise. + + """ + if not isinstance(other, Representation): + return False + + if self.representation_id != other.representation_id: + return False + + if self.name != other.name: + return False + + # number of traits + if len(self) != len(other): + return False + + for trait_id, trait in self._data.items(): + if trait_id not in other._data: + return False + if trait != other._data[trait_id]: + return False + + return True + + @classmethod + @lru_cache(maxsize=64) + def _get_possible_trait_classes_from_modules( + cls, + trait_id: str) -> set[type[T]]: + """Get possible trait classes from modules. + + Args: + trait_id (str): Trait ID. + + Returns: + set[type[T]]: Set of trait classes. + + """ + modules = sys.modules.copy() + filtered_modules = modules.copy() + for module_name in modules: + for bl_module in cls._module_blacklist: + if module_name.startswith(bl_module): + filtered_modules.pop(module_name) + + trait_candidates = set() + for module in filtered_modules.values(): + if not module: + continue + + for attr_name in dir(module): + klass = getattr(module, attr_name) + if not inspect.isclass(klass): + continue + # This needs to be done because of the bug? In + # python ABCMeta, where ``issubclass`` is not working + # if it hits the GenericAlias (that is in fact + # tuple[int, int]). This is added to the scope by + # the ``types`` module. + if type(klass) is GenericAlias: + continue + if issubclass(klass, TraitBase) \ + and str(klass.id).startswith(trait_id): + trait_candidates.add(klass) + # I + return trait_candidates # type: ignore[return-value] + + @classmethod + @lru_cache(maxsize=64) + def _get_trait_class( + cls, trait_id: str) -> Union[Type[T], None]: + """Get the trait class with corresponding to given ID. + + This method will search for the trait class in all the modules except + the blocklisted modules. There is some issue in Pydantic where + ``issubclass`` is not working properly, so we are excluding explicit + modules with offending classes. This list can be updated as needed to + speed up the search. + + Args: + trait_id (str): Trait ID. + + Returns: + Type[TraitBase]: Trait class. + + """ + version = cls._get_version_from_id(trait_id) + + trait_candidates = cls._get_possible_trait_classes_from_modules( + trait_id + ) + if not trait_candidates: + return None + + for trait_class in trait_candidates: + if trait_class.id == trait_id: + # we found a direct match + return trait_class + + # if we didn't find direct match, we will search for the highest + # version of the trait. + if not version: + # sourcery skip: use-named-expression + trait_versions = [ + trait_class for trait_class in trait_candidates + if re.match( + rf"{trait_id}.v(\d+)$", str(trait_class.id)) + ] + if trait_versions: + def _get_version_by_id(trait_klass: Type[T]) -> int: + match = re.search(r"v(\d+)$", str(trait_klass.id)) + return int(match[1]) if match else 0 + + error: LooseMatchingTraitError = LooseMatchingTraitError( + "Found trait that might match.") + error.found_trait = max( + trait_versions, key=_get_version_by_id) + error.expected_id = trait_id + raise error + + return None + + @classmethod + def get_trait_class_by_trait_id(cls, trait_id: str) -> Type[T]: + """Get the trait class for the given trait ID. + + Args: + trait_id (str): Trait ID. + + Returns: + type[TraitBase]: Trait class. + + Raises: + IncompatibleTraitVersionError: If the trait version is incompatible + with the current version of the trait. + + """ + try: + trait_class = cls._get_trait_class(trait_id=trait_id) + except LooseMatchingTraitError as e: + requested_version = _get_version_from_id(trait_id) + found_version = _get_version_from_id(e.found_trait.id) + if found_version is None and not requested_version: + msg = ( + "Trait found with no version and requested version " + "is not specified." + ) + raise IncompatibleTraitVersionError(msg) from e + + if found_version is None: + msg = ( + f"Trait {e.found_trait.id} found with no version, " + "but requested version is specified." + ) + raise IncompatibleTraitVersionError(msg) from e + + if requested_version is None: + trait_class = e.found_trait + requested_version = found_version + + if requested_version > found_version: + error_msg = ( + f"Requested trait version {requested_version} is " + f"higher than the found trait version {found_version}." + ) + raise IncompatibleTraitVersionError(error_msg) from e + + if requested_version < found_version and hasattr( + e.found_trait, "upgrade"): + error_msg = ( + "Requested trait version " + f"{requested_version} is lower " + f"than the found trait version {found_version}." + ) + error: UpgradableTraitError = UpgradableTraitError(error_msg) + error.trait = e.found_trait + raise error from e + return trait_class # type: ignore[return-value] + + @classmethod + def from_dict( + cls: Type[Representation], + name: str, + representation_id: Optional[str] = None, + trait_data: Optional[dict] = None) -> Representation: + """Create a representation from a dictionary. + + Args: + name (str): Representation name. + representation_id (str, optional): Representation ID. + trait_data (dict): Representation data. Dictionary with keys + as trait ids and values as trait data. Example:: + + { + "ayon.2d.PixelBased.v1": { + "display_window_width": 1920, + "display_window_height": 1080 + }, + "ayon.2d.Planar.v1": { + "channels": 3 + } + } + + Returns: + Representation: Representation instance. + + Raises: + ValueError: If the trait model with ID is not found. + TypeError: If the trait data is not a dictionary. + IncompatibleTraitVersionError: If the trait version is incompatible + + """ + if not trait_data: + trait_data = {} + traits = [] + for trait_id, value in trait_data.items(): + if not isinstance(value, dict): + msg = ( + f"Invalid trait data for trait ID {trait_id}. " + "Trait data must be a dictionary." + ) + raise TypeError(msg) + + try: + trait_class = cls.get_trait_class_by_trait_id(trait_id) + except UpgradableTraitError as e: + # we found a newer version of trait, we will upgrade the data + if hasattr(e.trait, "upgrade"): + traits.append(e.trait.upgrade(value)) + else: + msg = ( + f"Newer version of trait {e.trait.id} found " + f"for requested {trait_id} but without " + "upgrade method." + ) + raise IncompatibleTraitVersionError(msg) from e + else: + if not trait_class: + error_msg = f"Trait model with ID {trait_id} not found." + raise ValueError(error_msg) + + traits.append(trait_class(**value)) + + return cls( + name=name, representation_id=representation_id, traits=traits) + + def validate(self) -> None: + """Validate the representation. + + This method will validate all the traits in the representation. + + Raises: + TraitValidationError: If the trait is invalid within representation + + """ + errors = [] + for trait in self._data.values(): + # we do this in the loop to catch all the errors + try: + trait.validate_trait(self) + except TraitValidationError as e: # noqa: PERF203 + errors.append(str(e)) + if errors: + msg = "\n".join(errors) + scope = self.name + raise TraitValidationError(scope, msg) diff --git a/client/ayon_core/pipeline/traits/temporal.py b/client/ayon_core/pipeline/traits/temporal.py new file mode 100644 index 0000000000..9ad5424eee --- /dev/null +++ b/client/ayon_core/pipeline/traits/temporal.py @@ -0,0 +1,457 @@ +"""Temporal (time related) traits.""" +from __future__ import annotations + +import contextlib +import re +from dataclasses import dataclass +from enum import Enum, auto +from re import Pattern +from typing import TYPE_CHECKING, ClassVar, Optional + +import clique + +from .trait import MissingTraitError, TraitBase, TraitValidationError + +if TYPE_CHECKING: + + from .content import FileLocations + from .representation import Representation + + +class GapPolicy(Enum): + """Gap policy enumeration. + + This type defines how to handle gaps in a sequence. + + Attributes: + forbidden (int): Gaps are forbidden. + missing (int): Gaps are interpreted as missing frames. + hold (int): Gaps are interpreted as hold frames (last existing frames). + black (int): Gaps are interpreted as black frames. + """ + + forbidden = auto() + missing = auto() + hold = auto() + black = auto() + + +@dataclass +class FrameRanged(TraitBase): + """Frame ranged trait model. + + Model representing a frame-ranged trait. + + Sync with OpenAssetIO MediaCreation Traits. For compatibility with + OpenAssetIO, we'll need to handle different names of attributes: + + * frame_start -> start_frame + * frame_end -> end_frame + ... + + Note: frames_per_second is a string to allow various precision + formats. FPS is a floating point number, but it can be also + represented as a fraction (e.g. "30000/1001") or as a decimal + or even as an irrational number. We need to support all these + formats. To work with FPS, we'll need some helper function + to convert FPS to Decimal from string. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + frame_start (int): Frame start. + frame_end (int): Frame end. + frame_in (int): Frame in. + frame_out (int): Frame out. + frames_per_second (str): Frames per second. + step (int): Step. + """ + + name: ClassVar[str] = "FrameRanged" + description: ClassVar[str] = "Frame Ranged Trait" + id: ClassVar[str] = "ayon.time.FrameRanged.v1" + persistent: ClassVar[bool] = True + frame_start: int + frame_end: int + frame_in: Optional[int] = None + frame_out: Optional[int] = None + frames_per_second: str = None + step: Optional[int] = None + + +@dataclass +class Handles(TraitBase): + """Handles trait model. + + Handles define the range of frames that are included or excluded + from the sequence. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + inclusive (bool): Handles are inclusive. + frame_start_handle (int): Frame start handle. + frame_end_handle (int): Frame end handle. + """ + + name: ClassVar[str] = "Handles" + description: ClassVar[str] = "Handles Trait" + id: ClassVar[str] = "ayon.time.Handles.v1" + persistent: ClassVar[bool] = True + inclusive: Optional[bool] = False + frame_start_handle: Optional[int] = None + frame_end_handle: Optional[int] = None + + +@dataclass +class Sequence(TraitBase): + """Sequence trait model. + + This model represents a sequence trait. Based on the FrameRanged trait + and Handles, adding support for gaps policy, frame padding and frame + list specification. Regex is used to match frame numbers. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + gaps_policy (GapPolicy): Gaps policy - how to handle gaps in + sequence. + frame_padding (int): Frame padding. + frame_regex (str): Frame regex - regular expression to match + frame numbers. Must include 'index' named group and 'padding' + named group. + frame_spec (str): Frame list specification of frames. This takes + string like "1-10,20-30,40-50" etc. + """ + + name: ClassVar[str] = "Sequence" + description: ClassVar[str] = "Sequence Trait Model" + id: ClassVar[str] = "ayon.time.Sequence.v1" + persistent: ClassVar[bool] = True + frame_padding: int + gaps_policy: Optional[GapPolicy] = GapPolicy.forbidden + frame_regex: Optional[Pattern] = None + frame_spec: Optional[str] = None + + @classmethod + def validate_frame_regex( + cls, v: Optional[Pattern] + ) -> Optional[Pattern]: + """Validate frame regex. + + Frame regex must have index and padding named groups. + + Returns: + Optional[Pattern]: Compiled regex pattern. + + Raises: + ValueError: If frame regex does not include 'index' and 'padding' + + """ + if v is None: + return v + if v and any(s not in v.pattern for s in ["?P", "?P"]): + msg = "Frame regex must include 'index' and `padding named groups" + raise ValueError(msg) + return v + + def validate_trait(self, representation: Representation) -> None: + """Validate the trait.""" + super().validate_trait(representation) + + # if there is a FileLocations trait, run validation + # on it as well + + with contextlib.suppress(MissingTraitError): + self._validate_file_locations(representation) + + def _validate_file_locations(self, representation: Representation) -> None: + """Validate file locations trait. + + If along with the Sequence trait, there is a FileLocations trait, + then we need to validate if the file locations match the frame + list specification. + + Args: + representation (Representation): Representation instance. + + """ + from .content import FileLocations + file_locs: FileLocations = representation.get_trait( + FileLocations) + # Validate if the file locations on representation + # match the frame list (if any). + # We need to extend the expected frames with Handles. + frame_start = None + frame_end = None + handles_frame_start = None + handles_frame_end = None + with contextlib.suppress(MissingTraitError): + handles: Handles = representation.get_trait(Handles) + # if handles are inclusive, they should be already + # accounted for in the FrameRaged frame spec + if not handles.inclusive: + handles_frame_start = handles.frame_start_handle + handles_frame_end = handles.frame_end_handle + with contextlib.suppress(MissingTraitError): + frame_ranged: FrameRanged = representation.get_trait( + FrameRanged) + frame_start = frame_ranged.frame_start + frame_end = frame_ranged.frame_end + if self.frame_spec is not None: + self.validate_frame_list( + file_locs, + frame_start, + frame_end, + handles_frame_start, + handles_frame_end) + + self.validate_frame_padding(file_locs) + + def validate_frame_list( + self, + file_locations: FileLocations, + frame_start: Optional[int] = None, + frame_end: Optional[int] = None, + handles_frame_start: Optional[int] = None, + handles_frame_end: Optional[int] = None) -> None: + """Validate a frame list. + + This will take FileLocations trait and validate if the + file locations match the frame list specification. + + For example, if the frame list is "1-10,20-30,40-50", then + the frame numbers in the file locations should match + these frames. + + It will skip the validation if the frame list is not provided. + + Args: + file_locations (FileLocations): File locations trait. + frame_start (Optional[int]): Frame start. + frame_end (Optional[int]): Frame end. + handles_frame_start (Optional[int]): Frame start handle. + handles_frame_end (Optional[int]): Frame end handle. + + Raises: + TraitValidationError: If the frame list does not match + the expected frames. + + """ + if self.frame_spec is None: + return + + frames: list[int] = [] + if self.frame_regex: + frames = self.get_frame_list( + file_locations, self.frame_regex) + else: + frames = self.get_frame_list( + file_locations) + + expected_frames = self.list_spec_to_frames(self.frame_spec) + if frame_start is None or frame_end is None: + if min(expected_frames) != frame_start: + msg = ( + "Frame start does not match the expected frame start. " + f"Expected: {frame_start}, Found: {min(expected_frames)}" + ) + raise TraitValidationError(self.name, msg) + + if max(expected_frames) != frame_end: + msg = ( + "Frame end does not match the expected frame end. " + f"Expected: {frame_end}, Found: {max(expected_frames)}" + ) + raise TraitValidationError(self.name, msg) + + # we need to extend the expected frames with Handles + if handles_frame_start is not None: + expected_frames.extend( + range( + min(frames) - handles_frame_start, min(frames) + 1)) + + if handles_frame_end is not None: + expected_frames.extend( + range( + max(frames), max(frames) + handles_frame_end + 1)) + + if set(frames) != set(expected_frames): + msg = ( + "Frame list does not match the expected frames. " + f"Expected: {expected_frames}, Found: {frames}" + ) + raise TraitValidationError(self.name, msg) + + def validate_frame_padding( + self, file_locations: FileLocations) -> None: + """Validate frame padding. + + This will take FileLocations trait and validate if the + frame padding matches the expected frame padding. + + Args: + file_locations (FileLocations): File locations trait. + + Raises: + TraitValidationError: If frame padding does not match + the expected frame padding. + + """ + expected_padding = self.get_frame_padding(file_locations) + if self.frame_padding != expected_padding: + msg = ( + "Frame padding does not match the expected frame padding. " + f"Expected: {expected_padding}, Found: {self.frame_padding}" + ) + raise TraitValidationError(self.name, msg) + + @staticmethod + def list_spec_to_frames(list_spec: str) -> list[int]: + """Convert list specification to frames. + + Returns: + list[int]: List of frame numbers. + + Raises: + ValueError: If invalid frame number in the list. + + """ + frames = [] + segments = list_spec.split(",") + for segment in segments: + ranges = segment.split("-") + if len(ranges) == 1: + if not ranges[0].isdigit(): + msg = ( + "Invalid frame number " + f"in the list: {ranges[0]}" + ) + raise ValueError(msg) + frames.append(int(ranges[0])) + continue + start, end = segment.split("-") + frames.extend(range(int(start), int(end) + 1)) + return frames + + @staticmethod + def _get_collection( + file_locations: FileLocations, + regex: Optional[Pattern] = None) -> clique.Collection: + r"""Get the collection from file locations. + + Args: + file_locations (FileLocations): File locations trait. + regex (Optional[Pattern]): Regular expression to match + frame numbers. This is passed to ``clique.assemble()``. + Default clique pattern is:: + + \.(?P(?P0*)\d+)\.\D+\d?$ + + Returns: + clique.Collection: Collection instance. + + Raises: + ValueError: If zero or multiple of collections are found. + + """ + patterns = [regex] if regex else None + files: list[str] = [ + file.file_path.as_posix() + for file in file_locations.file_paths + ] + src_collections, _ = clique.assemble(files, patterns=patterns) + if len(src_collections) != 1: + msg = ( + f"Zero or multiple collections found: {len(src_collections)} " + "expected 1" + ) + raise ValueError(msg) + return src_collections[0] + + @staticmethod + def get_frame_padding(file_locations: FileLocations) -> int: + """Get frame padding. + + Returns: + int: Frame padding. + + """ + src_collection = Sequence._get_collection(file_locations) + padding = src_collection.padding + # sometimes Clique doesn't get the padding right, so + # we need to calculate it manually + if padding == 0: + padding = len(str(max(src_collection.indexes))) + + return padding + + @staticmethod + def get_frame_list( + file_locations: FileLocations, + regex: Optional[Pattern] = None, + ) -> list[int]: + r"""Get the frame list. + + Args: + file_locations (FileLocations): File locations trait. + regex (Optional[Pattern]): Regular expression to match + frame numbers. This is passed to ``clique.assemble()``. + Default clique pattern is:: + + \.(?P(?P0*)\d+)\.\D+\d?$ + + Returns: + list[int]: List of frame numbers. + + """ + src_collection = Sequence._get_collection(file_locations, regex) + return list(src_collection.indexes) + + def get_frame_pattern(self) -> Pattern: + """Get frame regex as a pattern. + + If the regex is a string, it will compile it to the pattern. + + Returns: + Pattern: Compiled regex pattern. + + """ + if self.frame_regex: + if isinstance(self.frame_regex, str): + return re.compile(self.frame_regex) + return self.frame_regex + return re.compile( + r"\.(?P(?P0*)\d+)\.\D+\d?$") + + +# Do we need one for drop and non-drop frame? +@dataclass +class SMPTETimecode(TraitBase): + """SMPTE Timecode trait model. + + Attributes: + timecode (str): SMPTE Timecode HH:MM:SS:FF + """ + + name: ClassVar[str] = "Timecode" + description: ClassVar[str] = "SMPTE Timecode Trait" + id: ClassVar[str] = "ayon.time.SMPTETimecode.v1" + persistent: ClassVar[bool] = True + timecode: str + + +@dataclass +class Static(TraitBase): + """Static time trait. + + Used to define static time (single frame). + """ + + name: ClassVar[str] = "Static" + description: ClassVar[str] = "Static Time Trait" + id: ClassVar[str] = "ayon.time.Static.v1" + persistent: ClassVar[bool] = True diff --git a/client/ayon_core/pipeline/traits/three_dimensional.py b/client/ayon_core/pipeline/traits/three_dimensional.py new file mode 100644 index 0000000000..d68fb99e61 --- /dev/null +++ b/client/ayon_core/pipeline/traits/three_dimensional.py @@ -0,0 +1,93 @@ +"""3D traits.""" +from dataclasses import dataclass +from typing import ClassVar + +from .trait import TraitBase + + +@dataclass +class Spatial(TraitBase): + """Spatial trait model. + + Trait describing spatial information. Up axis valid strings are + "Y", "Z", "X". Handedness valid strings are "left", "right". Meters per + unit is a float value. + + Example:: + + Spatial(up_axis="Y", handedness="right", meters_per_unit=1.0) + + Todo: + * Add value validation for up_axis and handedness. + + Attributes: + up_axis (str): Up axis. + handedness (str): Handedness. + meters_per_unit (float): Meters per unit. + """ + + id: ClassVar[str] = "ayon.3d.Spatial.v1" + name: ClassVar[str] = "Spatial" + description: ClassVar[str] = "Spatial trait model." + persistent: ClassVar[bool] = True + up_axis: str + handedness: str + meters_per_unit: float + + +@dataclass +class Geometry(TraitBase): + """Geometry type trait model. + + Type trait for geometry data. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.Geometry.v1" + name: ClassVar[str] = "Geometry" + description: ClassVar[str] = "Geometry trait model." + persistent: ClassVar[bool] = True + + +@dataclass +class Shader(TraitBase): + """Shader trait model. + + Type trait for shader data. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.Shader.v1" + name: ClassVar[str] = "Shader" + description: ClassVar[str] = "Shader trait model." + persistent: ClassVar[bool] = True + + +@dataclass +class Lighting(TraitBase): + """Lighting trait model. + + Type trait for lighting data. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.Lighting.v1" + name: ClassVar[str] = "Lighting" + description: ClassVar[str] = "Lighting trait model." + persistent: ClassVar[bool] = True + + +@dataclass +class IESProfile(TraitBase): + """IES profile (IES-LM-64) type trait model. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.IESProfile.v1" + name: ClassVar[str] = "IESProfile" + description: ClassVar[str] = "IES profile trait model." + persistent: ClassVar[bool] = True diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py new file mode 100644 index 0000000000..85f8e07630 --- /dev/null +++ b/client/ayon_core/pipeline/traits/trait.py @@ -0,0 +1,147 @@ +"""Defines the base trait model and representation.""" +from __future__ import annotations + +import re +from abc import ABC, abstractmethod +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Generic, Optional, TypeVar + +if TYPE_CHECKING: + from .representation import Representation + + +T = TypeVar("T", bound="TraitBase") + + +@dataclass +class TraitBase(ABC): + """Base trait model. + + This model must be used as a base for all trait models. + ``id``, ``name``, and ``description`` are abstract attributes that must be + implemented in the derived classes. + """ + + @property + @abstractmethod + def id(self) -> str: + """Abstract attribute for ID.""" + ... + + @property + @abstractmethod + def name(self) -> str: + """Abstract attribute for name.""" + ... + + @property + @abstractmethod + def description(self) -> str: + """Abstract attribute for description.""" + ... + + def validate_trait(self, representation: Representation) -> None: # noqa: PLR6301 + """Validate the trait. + + This method should be implemented in the derived classes to validate + the trait data. It can be used by traits to validate against other + traits in the representation. + + Args: + representation (Representation): Representation instance. + + """ + return + + @classmethod + def get_version(cls) -> Optional[int]: + # sourcery skip: use-named-expression + """Get a trait version from ID. + + This assumes Trait ID ends with `.v{version}`. If not, it will + return None. + + Returns: + Optional[int]: Trait version + + """ + version_regex = r"v(\d+)$" + match = re.search(version_regex, str(cls.id)) + return int(match[1]) if match else None + + @classmethod + def get_versionless_id(cls) -> str: + """Get a trait ID without a version. + + Returns: + str: Trait ID without a version. + + """ + return re.sub(r"\.v\d+$", "", str(cls.id)) + + def as_dict(self) -> dict: + """Return a trait as a dictionary. + + Returns: + dict: Trait as dictionary. + + """ + return asdict(self) + + +class IncompatibleTraitVersionError(Exception): + """Incompatible trait version exception. + + This exception is raised when the trait version is incompatible with the + current version of the trait. + """ + + +class UpgradableTraitError(Exception, Generic[T]): + """Upgradable trait version exception. + + This exception is raised when the trait can upgrade existing data + meant for older versions of the trait. It must implement an `upgrade` + method that will take old trait data as an argument to handle the upgrade. + """ + + trait: T + old_data: dict + + +class LooseMatchingTraitError(Exception, Generic[T]): + """Loose matching trait exception. + + This exception is raised when the trait is found with a loose matching + criteria. + """ + + found_trait: T + expected_id: str + + +class TraitValidationError(Exception): + """Trait validation error exception. + + This exception is raised when the trait validation fails. + """ + + def __init__(self, scope: str, message: str): + """Initialize the exception. + + We could determine the scope from the stack in the future, + provided the scope is always Trait name. + + Args: + scope (str): Scope of the error. + message (str): Error message. + + """ + super().__init__(f"{scope}: {message}") + + +class MissingTraitError(TypeError): + """Missing trait error exception. + + This exception is raised when the trait is missing. + """ diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py new file mode 100644 index 0000000000..d94294bf74 --- /dev/null +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -0,0 +1,208 @@ +"""Two-dimensional image traits.""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar, Optional + +from .trait import TraitBase + +if TYPE_CHECKING: + from .content import FileLocation, FileLocations + + +@dataclass +class Image(TraitBase): + """Image trait model. + + Type trait model for image. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + """ + + name: ClassVar[str] = "Image" + description: ClassVar[str] = "Image Trait" + id: ClassVar[str] = "ayon.2d.Image.v1" + persistent: ClassVar[bool] = True + + +@dataclass +class PixelBased(TraitBase): + """PixelBased trait model. + + The pixel-related trait for image data. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + display_window_width (int): Width of the image display window. + display_window_height (int): Height of the image display window. + pixel_aspect_ratio (float): Pixel aspect ratio. + """ + + name: ClassVar[str] = "PixelBased" + description: ClassVar[str] = "PixelBased Trait Model" + id: ClassVar[str] = "ayon.2d.PixelBased.v1" + persistent: ClassVar[bool] = True + display_window_width: int + display_window_height: int + pixel_aspect_ratio: float + + +@dataclass +class Planar(TraitBase): + """Planar trait model. + + This model represents an Image with planar configuration. + + Todo: + * (antirotor): Is this really a planar configuration? As with + bit planes and everything? If it serves as differentiator for + Deep images, should it be named differently? Like Raster? + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + planar_configuration (str): Planar configuration. + """ + + name: ClassVar[str] = "Planar" + description: ClassVar[str] = "Planar Trait Model" + id: ClassVar[str] = "ayon.2d.Planar.v1" + persistent: ClassVar[bool] = True + planar_configuration: str + + +@dataclass +class Deep(TraitBase): + """Deep trait model. + + Type trait model for deep EXR images. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + """ + + name: ClassVar[str] = "Deep" + description: ClassVar[str] = "Deep Trait Model" + id: ClassVar[str] = "ayon.2d.Deep.v1" + persistent: ClassVar[bool] = True + + +@dataclass +class Overscan(TraitBase): + """Overscan trait model. + + This model represents an overscan (or underscan) trait. Defines the + extra pixels around the image. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + left (int): Left overscan/underscan. + right (int): Right overscan/underscan. + top (int): Top overscan/underscan. + bottom (int): Bottom overscan/underscan. + """ + + name: ClassVar[str] = "Overscan" + description: ClassVar[str] = "Overscan Trait" + id: ClassVar[str] = "ayon.2d.Overscan.v1" + persistent: ClassVar[bool] = True + left: int + right: int + top: int + bottom: int + + +@dataclass +class UDIM(TraitBase): + """UDIM trait model. + + This model represents a UDIM trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + udim (int): UDIM value. + udim_regex (str): UDIM regex. + """ + + name: ClassVar[str] = "UDIM" + description: ClassVar[str] = "UDIM Trait" + id: ClassVar[str] = "ayon.2d.UDIM.v1" + persistent: ClassVar[bool] = True + udim: list[int] + udim_regex: Optional[str] = r"(?:\.|_)(?P\d+)\.\D+\d?$" + + # Field validator for udim_regex - this works in the pydantic model v2 + # but not with the pure data classes. + @classmethod + def validate_frame_regex(cls, v: Optional[str]) -> Optional[str]: + """Validate udim regex. + + Returns: + Optional[str]: UDIM regex. + + Raises: + ValueError: UDIM regex must include 'udim' named group. + + """ + if v is not None and "?P" not in v: + msg = "UDIM regex must include 'udim' named group" + raise ValueError(msg) + return v + + def get_file_location_for_udim( + self, + file_locations: FileLocations, + udim: int, + ) -> Optional[FileLocation]: + """Get file location for UDIM. + + Args: + file_locations (FileLocations): File locations. + udim (int): UDIM value. + + Returns: + Optional[FileLocation]: File location. + + """ + if not self.udim_regex: + return None + pattern = re.compile(self.udim_regex) + for location in file_locations.file_paths: + result = re.search(pattern, location.file_path.name) + if result: + udim_index = int(result.group("udim")) + if udim_index == udim: + return location + return None + + def get_udim_from_file_location( + self, file_location: FileLocation) -> Optional[int]: + """Get UDIM from the file location. + + Args: + file_location (FileLocation): File location. + + Returns: + Optional[int]: UDIM value. + + """ + if not self.udim_regex: + return None + pattern = re.compile(self.udim_regex) + result = re.search(pattern, file_location.file_path.name) + if result: + return int(result.group("udim")) + return None diff --git a/client/ayon_core/pipeline/traits/utils.py b/client/ayon_core/pipeline/traits/utils.py new file mode 100644 index 0000000000..4cb9a643fa --- /dev/null +++ b/client/ayon_core/pipeline/traits/utils.py @@ -0,0 +1,90 @@ +"""Utility functions for traits.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from clique import assemble + +from ayon_core.addon import AddonsManager, ITraits +from ayon_core.pipeline.traits.temporal import FrameRanged + +if TYPE_CHECKING: + from pathlib import Path + from ayon_core.pipeline.traits.trait import TraitBase + + +def get_sequence_from_files(paths: list[Path]) -> FrameRanged: + """Get the original frame range from files. + + Note that this cannot guess frame rate, so it's set to 25. + This will also fail on paths that cannot be assembled into + one collection without any reminders. + + Args: + paths (list[Path]): List of file paths. + + Returns: + FrameRanged: FrameRanged trait. + + Raises: + ValueError: If paths cannot be assembled into one collection + + """ + cols, rems = assemble([path.as_posix() for path in paths]) + if rems: + msg = "Cannot assemble paths into one collection" + raise ValueError(msg) + if len(cols) != 1: + msg = "More than one collection found" + raise ValueError(msg) + col = cols[0] + + sorted_frames = sorted(col.indexes) + # First frame used for end value + first_frame = sorted_frames[0] + # Get last frame for padding + last_frame = sorted_frames[-1] + # Use padding from a collection of the last frame lengths as string + # padding = max(col.padding, len(str(last_frame))) + + return FrameRanged( + frame_start=first_frame, frame_end=last_frame, + frames_per_second="25.0" + ) + + +def get_available_traits( + addons_manager: Optional[AddonsManager] = None +) -> Optional[list[TraitBase]]: + """Get available traits from active addons. + + Args: + addons_manager (Optional[AddonsManager]): Addons manager instance. + If not provided, a new one will be created. Within pyblish + plugins, you can use an already collected instance of + AddonsManager from context `context.data["ayonAddonsManager"]`. + + Returns: + list[TraitBase]: List of available traits. + + """ + if addons_manager is None: + # Create a new instance of AddonsManager + addons_manager = AddonsManager() + + # Get active addons + enabled_addons = addons_manager.get_enabled_addons() + traits = [] + for addon in enabled_addons: + if not issubclass(type(addon), ITraits): + # Skip addons not providing traits + continue + # Get traits from addon + addon_traits = addon.get_addon_traits() + if addon_traits: + # Add traits to a list + for trait in addon_traits: + if trait not in traits: + traits.append(trait) + + return traits diff --git a/client/ayon_core/pipeline/workfile/__init__.py b/client/ayon_core/pipeline/workfile/__init__.py index aa7e150bca..7acaf69a7c 100644 --- a/client/ayon_core/pipeline/workfile/__init__.py +++ b/client/ayon_core/pipeline/workfile/__init__.py @@ -4,6 +4,8 @@ from .path_resolving import ( get_workdir_with_workdir_data, get_workdir, + get_last_workfile_with_version_from_paths, + get_last_workfile_from_paths, get_last_workfile_with_version, get_last_workfile, @@ -11,12 +13,21 @@ from .path_resolving import ( get_custom_workfile_template_by_string_context, create_workdir_extra_folders, + + get_comments_from_workfile_paths, ) from .utils import ( should_use_last_workfile_on_launch, should_open_workfiles_tool_on_launch, MissingWorkdirError, + + save_workfile_info, + save_current_workfile_to, + save_workfile_with_current_context, + save_next_version, + copy_workfile_to_context, + find_workfile_rootless_path, ) from .build_workfile import BuildWorkfile @@ -37,18 +48,29 @@ __all__ = ( "get_workdir_with_workdir_data", "get_workdir", + "get_last_workfile_with_version_from_paths", + "get_last_workfile_from_paths", "get_last_workfile_with_version", "get_last_workfile", + "find_workfile_rootless_path", "get_custom_workfile_template", "get_custom_workfile_template_by_string_context", "create_workdir_extra_folders", + "get_comments_from_workfile_paths", + "should_use_last_workfile_on_launch", "should_open_workfiles_tool_on_launch", "MissingWorkdirError", + "save_workfile_info", + "save_current_workfile_to", + "save_workfile_with_current_context", + "save_next_version", + "copy_workfile_to_context", + "BuildWorkfile", "discover_workfile_build_plugins", diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 9b2fe25199..b806f1ebf0 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -1,8 +1,12 @@ +from __future__ import annotations import os import re import copy import platform +import warnings +import typing from typing import Optional, Dict, Any +from dataclasses import dataclass import ayon_api @@ -15,6 +19,9 @@ from ayon_core.lib import ( from ayon_core.pipeline import version_start, Anatomy from ayon_core.pipeline.template_data import get_template_data +if typing.TYPE_CHECKING: + from ayon_core.pipeline.anatomy import AnatomyTemplateResult + def get_workfile_template_key_from_context( project_name: str, @@ -111,7 +118,7 @@ def get_workdir_with_workdir_data( anatomy=None, template_key=None, project_settings=None -): +) -> "AnatomyTemplateResult": """Fill workdir path from entered data and project's anatomy. It is possible to pass only project's name instead of project's anatomy but @@ -130,9 +137,9 @@ def get_workdir_with_workdir_data( if 'template_key' is not passed. Returns: - TemplateResult: Workdir path. - """ + AnatomyTemplateResult: Workdir path. + """ if not anatomy: anatomy = Anatomy(project_name) @@ -147,7 +154,7 @@ def get_workdir_with_workdir_data( template_obj = anatomy.get_template_item( "work", template_key, "directory" ) - # Output is TemplateResult object which contain useful data + # Output is AnatomyTemplateResult object which contain useful data output = template_obj.format_strict(workdir_data) if output: return output.normalized() @@ -155,14 +162,14 @@ def get_workdir_with_workdir_data( def get_workdir( - project_entity, - folder_entity, - task_entity, - host_name, + project_entity: dict[str, Any], + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + host_name: str, anatomy=None, template_key=None, project_settings=None -): +) -> "AnatomyTemplateResult": """Fill workdir path from entered data and project's anatomy. Args: @@ -174,8 +181,8 @@ def get_workdir( is stored under `AYON_HOST_NAME` key. anatomy (Anatomy): Optional argument. Anatomy object is created using project name from `project_entity`. It is preferred to pass this - argument as initialization of a new Anatomy object may be time - consuming. + argument as initialization of a new Anatomy object may be + time-consuming. template_key (str): Key of work templates in anatomy templates. Default value is defined in `get_workdir_with_workdir_data`. project_settings(Dict[str, Any]): Prepared project settings for @@ -183,9 +190,9 @@ def get_workdir( if 'template_key' is not passed. Returns: - TemplateResult: Workdir path. - """ + AnatomyTemplateResult: Workdir path. + """ if not anatomy: anatomy = Anatomy( project_entity["name"], project_entity=project_entity @@ -197,7 +204,7 @@ def get_workdir( task_entity, host_name, ) - # Output is TemplateResult object which contain useful data + # Output is AnatomyTemplateResult object which contain useful data return get_workdir_with_workdir_data( workdir_data, anatomy.project_name, @@ -207,12 +214,141 @@ def get_workdir( ) -def get_last_workfile_with_version( - workdir, file_template, fill_data, extensions -): +@dataclass +class WorkfileParsedData: + version: Optional[int] = None + comment: Optional[str] = None + ext: Optional[str] = None + + +class WorkfileDataParser: + """Parse dynamic data from existing filenames based on template. + + Args: + file_template (str): Workfile file template. + data (dict[str, Any]): Data to fill the template with. + + """ + def __init__( + self, + file_template: str, + data: dict[str, Any], + ): + data = copy.deepcopy(data) + file_template = str(file_template) + # Use placeholders that will never be in the filename + ext_replacement = "CIextID" + version_replacement = "CIversionID" + comment_replacement = "CIcommentID" + data["version"] = version_replacement + data["comment"] = comment_replacement + for pattern, replacement in ( + # Replace `.{ext}` with `{ext}` so we are sure dot is not + # at the end + (r"\.?{ext}", ext_replacement), + ): + file_template = re.sub(pattern, replacement, file_template) + + file_template = StringTemplate(file_template) + # Prepare template that does contain 'comment' + comment_template = re.escape(str(file_template.format_strict(data))) + # Prepare template that does not contain 'comment' + # - comment is usually marked as optional and in that case the regex + # to find the comment is different based on the filename + # - if filename contains comment then 'comment_template' will match + # - if filename does not contain comment then 'file_template' will + # match + data.pop("comment") + file_template = re.escape(str(file_template.format_strict(data))) + for src, replacement in ( + (ext_replacement, r"(?P\..*)"), + (version_replacement, r"(?P[0-9]+)"), + (comment_replacement, r"(?P.+?)"), + ): + comment_template = comment_template.replace(src, replacement) + file_template = file_template.replace(src, replacement) + + kwargs = {} + if platform.system().lower() == "windows": + kwargs["flags"] = re.IGNORECASE + + # Match from beginning to end of string to be safe + self._comment_template = re.compile(f"^{comment_template}$", **kwargs) + self._file_template = re.compile(f"^{file_template}$", **kwargs) + + def parse_data(self, filename: str) -> WorkfileParsedData: + """Parse the dynamic data from a filename.""" + match = self._comment_template.match(filename) + if not match: + match = self._file_template.match(filename) + + if not match: + return WorkfileParsedData() + + kwargs = match.groupdict() + version = kwargs.get("version") + if version is not None: + kwargs["version"] = int(version) + return WorkfileParsedData(**kwargs) + + +def parse_dynamic_data_from_workfile( + filename: str, + file_template: str, + template_data: dict[str, Any], +) -> WorkfileParsedData: + """Parse dynamic data from a workfile filename. + + Dynamic data are 'version', 'comment' and 'ext'. + + Args: + filename (str): Workfile filename. + file_template (str): Workfile file template. + template_data (dict[str, Any]): Data to fill the template with. + + Returns: + WorkfileParsedData: Dynamic data parsed from the filename. + + """ + parser = WorkfileDataParser(file_template, template_data) + return parser.parse_data(filename) + + +def parse_dynamic_data_from_workfiles( + filenames: list[str], + file_template: str, + template_data: dict[str, Any], +) -> dict[str, WorkfileParsedData]: + """Parse dynamic data from a workfiles filenames. + + Dynamic data are 'version', 'comment' and 'ext'. + + Args: + filenames (list[str]): Workfiles filenames. + file_template (str): Workfile file template. + template_data (dict[str, Any]): Data to fill the template with. + + Returns: + dict[str, WorkfileParsedData]: Dynamic data parsed from the filenames + by filename. + + """ + parser = WorkfileDataParser(file_template, template_data) + return { + filename: parser.parse_data(filename) + for filename in filenames + } + + +def get_last_workfile_with_version_from_paths( + filepaths: list[str], + file_template: str, + template_data: dict[str, Any], + extensions: set[str], +) -> tuple[Optional[str], Optional[int]]: """Return last workfile version. - Usign workfile template and it's filling data find most possible last + Using the workfile template and its template data find most possible last version of workfile which was created for the context. Functionality is fully based on knowing which keys are optional or what @@ -222,50 +358,43 @@ def get_last_workfile_with_version( last workfile. Args: - workdir (str): Path to dir where workfiles are stored. + filepaths (list[str]): Workfile paths. file_template (str): Template of file name. - fill_data (Dict[str, Any]): Data for filling template. - extensions (Iterable[str]): All allowed file extensions of workfile. + template_data (Dict[str, Any]): Data for filling template. + extensions (set[str]): All allowed file extensions of workfile. Returns: - Tuple[Union[str, None], Union[int, None]]: Last workfile with version + tuple[Optional[str], Optional[int]]: Last workfile with version if there is any workfile otherwise None for both. - """ - if not os.path.exists(workdir): + """ + if not filepaths: return None, None dotted_extensions = set() for ext in extensions: if not ext.startswith("."): - ext = ".{}".format(ext) - dotted_extensions.add(ext) - - # Fast match on extension - filenames = [ - filename - for filename in os.listdir(workdir) - if os.path.splitext(filename)[-1] in dotted_extensions - ] + ext = f".{ext}" + dotted_extensions.add(re.escape(ext)) # Build template without optionals, version to digits only regex # and comment to any definable value. # Escape extensions dot for regex - regex_exts = [ - "\\" + ext - for ext in dotted_extensions - ] - ext_expression = "(?:" + "|".join(regex_exts) + ")" + ext_expression = "(?:" + "|".join(dotted_extensions) + ")" + + for pattern, replacement in ( + # Replace `.{ext}` with `{ext}` so we are sure dot is not at the end + (r"\.?{ext}", ext_expression), + # Replace optional keys with optional content regex + (r"<.*?>", r".*?"), + # Replace `{version}` with group regex + (r"{version.*?}", r"([0-9]+)"), + (r"{comment.*?}", r".+?"), + ): + file_template = re.sub(pattern, replacement, file_template) - # Replace `.{ext}` with `{ext}` so we are sure there is not dot at the end - file_template = re.sub(r"\.?{ext}", ext_expression, file_template) - # Replace optional keys with optional content regex - file_template = re.sub(r"<.*?>", r".*?", file_template) - # Replace `{version}` with group regex - file_template = re.sub(r"{version.*?}", r"([0-9]+)", file_template) - file_template = re.sub(r"{comment.*?}", r".+?", file_template) file_template = StringTemplate.format_strict_template( - file_template, fill_data + file_template, template_data ) # Match with ignore case on Windows due to the Windows @@ -278,64 +407,189 @@ def get_last_workfile_with_version( # Get highest version among existing matching files version = None - output_filenames = [] - for filename in sorted(filenames): + output_filepaths = [] + for filepath in sorted(filepaths): + filename = os.path.basename(filepath) match = re.match(file_template, filename, **kwargs) if not match: continue if not match.groups(): - output_filenames.append(filename) + output_filepaths.append(filename) continue file_version = int(match.group(1)) if version is None or file_version > version: - output_filenames[:] = [] + output_filepaths.clear() version = file_version if file_version == version: - output_filenames.append(filename) + output_filepaths.append(filepath) - output_filename = None - if output_filenames: - if len(output_filenames) == 1: - output_filename = output_filenames[0] - else: - last_time = None - for _output_filename in output_filenames: - full_path = os.path.join(workdir, _output_filename) - mod_time = os.path.getmtime(full_path) - if last_time is None or last_time < mod_time: - output_filename = _output_filename - last_time = mod_time + # Use file modification time to use most recent file if there are + # multiple workfiles with the same version + output_filepath = None + last_time = None + for _output_filepath in output_filepaths: + mod_time = None + if os.path.exists(_output_filepath): + mod_time = os.path.getmtime(_output_filepath) + if ( + last_time is None + or (mod_time is not None and last_time < mod_time) + ): + output_filepath = _output_filepath + last_time = mod_time - return output_filename, version + return output_filepath, version -def get_last_workfile( - workdir, file_template, fill_data, extensions, full_path=False -): - """Return last workfile filename. +def get_last_workfile_from_paths( + filepaths: list[str], + file_template: str, + template_data: dict[str, Any], + extensions: set[str], +) -> Optional[str]: + """Return the last workfile filename. - Returns file with version 1 if there is not workfile yet. + Returns the file with version 1 if there is not workfile yet. + + Args: + filepaths (list[str]): Paths to workfiles. + file_template (str): Template of file name. + template_data (dict[str, Any]): Data for filling template. + extensions (set[str]): All allowed file extensions of workfile. + + Returns: + Optional[str]: Last workfile path. + + """ + filepath, _version = get_last_workfile_with_version_from_paths( + filepaths, file_template, template_data, extensions + ) + return filepath + + +def _filter_dir_files_by_ext( + dirpath: str, + extensions: set[str], +) -> tuple[list[str], set[str]]: + """Filter files by extensions. + + Args: + dirpath (str): List of file paths. + extensions (set[str]): Set of file extensions. + + Returns: + tuple[list[str], set[str]]: Filtered list of file paths. + + """ + dotted_extensions = set() + for ext in extensions: + if not ext.startswith("."): + ext = f".{ext}" + dotted_extensions.add(ext) + + if not os.path.exists(dirpath): + return [], dotted_extensions + + filtered_paths = [ + os.path.join(dirpath, filename) + for filename in os.listdir(dirpath) + if os.path.splitext(filename)[-1] in dotted_extensions + ] + return filtered_paths, dotted_extensions + + +def get_last_workfile_with_version( + workdir: str, + file_template: str, + template_data: dict[str, Any], + extensions: set[str], +) -> tuple[Optional[str], Optional[int]]: + """Return last workfile version. + + Using the workfile template and its filling data to find the most possible + last version of workfile which was created for the context. + + Functionality is fully based on knowing which keys are optional or what + values are expected as value. + + The last modified file is used if more files can be considered as + last workfile. Args: workdir (str): Path to dir where workfiles are stored. file_template (str): Template of file name. - fill_data (Dict[str, Any]): Data for filling template. - extensions (Iterable[str]): All allowed file extensions of workfile. - full_path (Optional[bool]): Full path to file is returned if - set to True. + template_data (dict[str, Any]): Data for filling template. + extensions (set[str]): All allowed file extensions of workfile. Returns: - str: Last or first workfile as filename of full path to filename. + tuple[Optional[str], Optional[int]]: Last workfile with version + if there is any workfile otherwise None for both. """ - filename, _version = get_last_workfile_with_version( - workdir, file_template, fill_data, extensions + if not os.path.exists(workdir): + return None, None + + filepaths, dotted_extensions = _filter_dir_files_by_ext( + workdir, extensions ) - if filename is None: - data = copy.deepcopy(fill_data) + + return get_last_workfile_with_version_from_paths( + filepaths, + file_template, + template_data, + dotted_extensions, + ) + + +def get_last_workfile( + workdir: str, + file_template: str, + template_data: dict[str, Any], + extensions: set[str], + full_path: bool = False, +) -> str: + """Return last the workfile filename. + + Returns first file name/path if there are not workfiles yet. + + Args: + workdir (str): Path to dir where workfiles are stored. + file_template (str): Template of file name. + template_data (Dict[str, Any]): Data for filling template. + extensions (Iterable[str]): All allowed file extensions of workfile. + full_path (bool): Return full path to the file or only filename. + + Returns: + str: Last or first workfile file name or path based on + 'full_path' value. + + """ + # TODO (iLLiCiTiT): Remove the argument 'full_path' and return only full + # path. As far as I can tell it is always called with 'full_path' set + # to 'True'. + # - it has to be 2 step operation, first warn about having it 'False', and + # then warn about having it filled. + if full_path is False: + warnings.warn( + "Argument 'full_path' will be removed and will return" + " only full path in future.", + DeprecationWarning, + ) + + filepaths, dotted_extensions = _filter_dir_files_by_ext( + workdir, extensions + ) + filepath = get_last_workfile_from_paths( + filepaths, + file_template, + template_data, + dotted_extensions + ) + if filepath is None: + data = copy.deepcopy(template_data) data["version"] = version_start.get_versioning_start( data["project"]["name"], data["app"], @@ -344,15 +598,15 @@ def get_last_workfile( product_type="workfile" ) data.pop("comment", None) - if not data.get("ext"): - data["ext"] = extensions[0] + if data.get("ext") is None: + data["ext"] = next(iter(extensions), "") data["ext"] = data["ext"].lstrip(".") filename = StringTemplate.format_strict_template(file_template, data) + filepath = os.path.join(workdir, filename) if full_path: - return os.path.normpath(os.path.join(workdir, filename)) - - return filename + return os.path.normpath(filepath) + return os.path.basename(filepath) def get_custom_workfile_template( @@ -389,11 +643,10 @@ def get_custom_workfile_template( project_settings(Dict[str, Any]): Preloaded project settings. Returns: - str: Path to template or None if none of profiles match current - context. Existence of formatted path is not validated. - None: If no profile is matching context. - """ + Optional[str]: Path to template or None if none of profiles match + current context. Existence of formatted path is not validated. + """ log = Logger.get_logger("CustomWorkfileResolve") project_name = project_entity["name"] @@ -562,3 +815,112 @@ def create_workdir_extra_folders( fullpath = os.path.join(workdir, subfolder) if not os.path.exists(fullpath): os.makedirs(fullpath) + + +class CommentMatcher: + """Use anatomy and work file data to parse comments from filenames. + + Args: + extensions (set[str]): Set of extensions. + file_template (StringTemplate): Workfile file template. + data (dict[str, Any]): Data to fill the template with. + + """ + def __init__( + self, + extensions: set[str], + file_template: StringTemplate, + data: dict[str, Any] + ): + warnings.warn( + "Class 'CommentMatcher' is deprecated. Please" + " use 'parse_dynamic_data_from_workfiles' instead.", + DeprecationWarning, + stacklevel=2, + ) + self._fname_regex = None + + if "{comment}" not in file_template: + # Don't look for comment if template doesn't allow it + return + + # Create a regex group for extensions + any_extension = "(?:{})".format( + "|".join(re.escape(ext.lstrip(".")) for ext in extensions) + ) + + # Use placeholders that will never be in the filename + temp_data = copy.deepcopy(data) + temp_data["comment"] = "<>" + temp_data["version"] = "<>" + temp_data["ext"] = "<>" + + fname_pattern = re.escape( + file_template.format_strict(temp_data) + ) + + # Replace comment and version with something we can match with regex + replacements = ( + ("<>", r"(?P.+)"), + ("<>", r"[0-9]+"), + ("<>", any_extension), + ) + for src, dest in replacements: + fname_pattern = fname_pattern.replace(re.escape(src), dest) + + # Match from beginning to end of string to be safe + self._fname_regex = re.compile(f"^{fname_pattern}$") + + def parse_comment(self, filename: str) -> Optional[str]: + """Parse the {comment} part from a filename.""" + if self._fname_regex: + match = self._fname_regex.match(filename) + if match: + return match.group("comment") + return None + + +def get_comments_from_workfile_paths( + filepaths: list[str], + extensions: set[str], + file_template: StringTemplate, + template_data: dict[str, Any], + current_filename: Optional[str] = None, +) -> tuple[list[str], str]: + """DEPRECATED Collect comments from workfile filenames. + + Based on 'current_filename' is also returned "current comment". + + Args: + filepaths (list[str]): List of filepaths to parse. + extensions (set[str]): Set of file extensions. + file_template (StringTemplate): Workfile file template. + template_data (dict[str, Any]): Data to fill the template with. + current_filename (str): Filename to check for the current comment. + + Returns: + tuple[list[str], str]: List of comments and the current comment. + + """ + warnings.warn( + "Function 'get_comments_from_workfile_paths' is deprecated. Please" + " use 'parse_dynamic_data_from_workfiles' instead.", + DeprecationWarning, + stacklevel=2, + ) + current_comment = "" + if not filepaths: + return [], current_comment + + matcher = CommentMatcher(extensions, file_template, template_data) + + comment_hints = set() + for filepath in filepaths: + filename = os.path.basename(filepath) + comment = matcher.parse_comment(filename) + if comment: + comment_hints.add(comment) + if filename == current_filename: + current_comment = comment + + return list(comment_hints), current_comment diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 25be061dec..6666853998 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -1,5 +1,30 @@ -from ayon_core.lib import filter_profiles +from __future__ import annotations +import os +import platform +import uuid +import typing +from typing import Optional, Any + +import ayon_api +from ayon_api.operations import OperationsSession + +from ayon_core.lib import filter_profiles, get_ayon_username from ayon_core.settings import get_project_settings +from ayon_core.host.interfaces import ( + SaveWorkfileOptionalData, + ListWorkfilesOptionalData, + CopyWorkfileOptionalData, +) +from ayon_core.pipeline.version_start import get_versioning_start +from ayon_core.pipeline.template_data import get_template_data + +from .path_resolving import ( + get_workdir, + get_workfile_template_key, +) + +if typing.TYPE_CHECKING: + from ayon_core.pipeline import Anatomy class MissingWorkdirError(Exception): @@ -7,14 +32,61 @@ class MissingWorkdirError(Exception): pass +def get_workfiles_info( + workfile_path: str, + project_name: str, + task_id: str, + *, + anatomy: Optional["Anatomy"] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, +) -> Optional[dict[str, Any]]: + """Find workfile info entity for a workfile path. + + Args: + workfile_path (str): Workfile path. + project_name (str): The name of the project. + task_id (str): Task id under which is workfile created. + anatomy (Optional[Anatomy]): Project anatomy used to get roots. + workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched + workfile entities related to the task. + + Returns: + Optional[dict[str, Any]]: Workfile info entity if found, otherwise + `None`. + + """ + if anatomy is None: + anatomy = Anatomy(project_name) + + if workfile_entities is None: + workfile_entities = list(ayon_api.get_workfiles_info( + project_name, + task_ids=[task_id], + )) + + if platform.system().lower() == "windows": + workfile_path = workfile_path.replace("\\", "/") + workfile_path = workfile_path.lower() + + for workfile_entity in workfile_entities: + path = workfile_entity["path"] + filled_path = anatomy.fill_root(path) + if platform.system().lower() == "windows": + filled_path = filled_path.replace("\\", "/") + filled_path = filled_path.lower() + if filled_path == workfile_path: + return workfile_entity + return None + + def should_use_last_workfile_on_launch( - project_name, - host_name, - task_name, - task_type, - default_output=False, - project_settings=None, -): + project_name: str, + host_name: str, + task_name: str, + task_type: str, + default_output: bool = False, + project_settings: Optional[dict[str, Any]] = None, +) -> bool: """Define if host should start last version workfile if possible. Default output is `False`. Can be overridden with environment variable @@ -124,3 +196,608 @@ def should_open_workfiles_tool_on_launch( if output is None: return default_output return output + + +def save_workfile_info( + project_name: str, + task_id: str, + rootless_path: str, + host_name: str, + version: Optional[int] = None, + comment: Optional[str] = None, + description: Optional[str] = None, + username: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, +) -> dict[str, Any]: + """Save workfile info entity for a workfile path. + + Args: + project_name (str): The name of the project. + task_id (str): Task id under which is workfile created. + rootless_path (str): Rootless path of the workfile. + host_name (str): Name of host which is saving the workfile. + version (Optional[int]): Workfile version. + comment (Optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + username (Optional[str]): Username of user who saves the workfile. + If not provided, current user is used. + workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched + workfile entities related to task. + + Returns: + dict[str, Any]: Workfile info entity. + + """ + if workfile_entities is None: + workfile_entities = list(ayon_api.get_workfiles_info( + project_name, + task_ids=[task_id], + )) + + workfile_entity = next( + ( + _ent + for _ent in workfile_entities + if _ent["path"] == rootless_path + ), + None + ) + + if username is None: + username = get_ayon_username() + + if not workfile_entity: + return _create_workfile_info_entity( + project_name, + task_id, + host_name, + rootless_path, + username, + version, + comment, + description, + ) + + data = { + key: value + for key, value in ( + ("host_name", host_name), + ("version", version), + ("comment", comment), + ) + if value is not None + } + + old_data = workfile_entity["data"] + + changed_data = {} + for key, value in data.items(): + if key not in old_data or old_data[key] != value: + changed_data[key] = value + + update_data = {} + if changed_data: + update_data["data"] = changed_data + + old_description = workfile_entity["attrib"].get("description") + if description is not None and old_description != description: + update_data["attrib"] = {"description": description} + workfile_entity["attrib"]["description"] = description + + # Automatically fix 'createdBy' and 'updatedBy' fields + # NOTE both fields were not automatically filled by server + # until 1.1.3 release. + if workfile_entity.get("createdBy") is None: + update_data["createdBy"] = username + workfile_entity["createdBy"] = username + + if workfile_entity.get("updatedBy") != username: + update_data["updatedBy"] = username + workfile_entity["updatedBy"] = username + + if not update_data: + return workfile_entity + + session = OperationsSession() + session.update_entity( + project_name, + "workfile", + workfile_entity["id"], + update_data, + ) + session.commit() + return workfile_entity + + +def save_current_workfile_to( + workfile_path: str, + folder_path: str, + task_name: str, + *, + version: Optional[int] = None, + comment: Optional[str] = None, + description: Optional[str] = None, + prepared_data: Optional[SaveWorkfileOptionalData] = None, +) -> None: + """Save current workfile to new location or context. + + Args: + workfile_path (str): Destination workfile path. + folder_path (str): Target folder path. + task_name (str): Target task name. + version (Optional[int]): Workfile version. + comment (optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data + for speed enhancements. + + """ + from ayon_core.pipeline.context_tools import registered_host + + host = registered_host() + context = host.get_current_context() + project_name = context["project_name"] + folder_entity = ayon_api.get_folder_by_path( + project_name, folder_path + ) + task_entity = ayon_api.get_task_by_name( + project_name, folder_entity["id"], task_name + ) + host.save_workfile_with_context( + workfile_path, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + prepared_data=prepared_data, + ) + + +def save_workfile_with_current_context( + workfile_path: str, + *, + version: Optional[int] = None, + comment: Optional[str] = None, + description: Optional[str] = None, + prepared_data: Optional[SaveWorkfileOptionalData] = None, +) -> None: + """Save current workfile to new location using current context. + + Helper function to save workfile using current context. Calls + 'save_current_workfile_to' at the end. + + Args: + workfile_path (str): Destination workfile path. + version (Optional[int]): Workfile version. + comment (optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data + for speed enhancements. + + """ + from ayon_core.pipeline.context_tools import registered_host + + host = registered_host() + context = host.get_current_context() + project_name = context["project_name"] + folder_path = context["folder_path"] + task_name = context["task_name"] + folder_entity = task_entity = None + if folder_path: + folder_entity = ayon_api.get_folder_by_path(project_name, folder_path) + if folder_entity and task_name: + task_entity = ayon_api.get_task_by_name( + project_name, folder_entity["id"], task_name + ) + + host.save_workfile_with_context( + workfile_path, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + prepared_data=prepared_data, + ) + + +def save_next_version( + version: Optional[int] = None, + comment: Optional[str] = None, + description: Optional[str] = None, + *, + prepared_data: Optional[SaveWorkfileOptionalData] = None, +) -> None: + """Save workfile using current context, version and comment. + + Helper function to save a workfile using the current context. Last + workfile version + 1 is used if is not passed in. + + Args: + version (Optional[int]): Workfile version that will be used. Last + version + 1 is used if is not passed in. + comment (optional[str]): Workfile comment. Pass '""' to clear comment. + The current workfile comment is used if it is not passed. + description (Optional[str]): Workfile description. + prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data + for speed enhancements. + + """ + from ayon_core.pipeline import Anatomy + from ayon_core.pipeline.context_tools import registered_host + + host = registered_host() + current_path = host.get_current_workfile() + if not current_path: + current_path = None + else: + current_path = os.path.normpath(current_path) + + context = host.get_current_context() + project_name = context["project_name"] + folder_path = context["folder_path"] + task_name = context["task_name"] + if prepared_data is None: + prepared_data = SaveWorkfileOptionalData() + + project_entity = prepared_data.project_entity + anatomy = prepared_data.anatomy + project_settings = prepared_data.project_settings + + if project_entity is None: + project_entity = ayon_api.get_project(project_name) + prepared_data.project_entity = project_entity + + if project_settings is None: + project_settings = get_project_settings(project_name) + prepared_data.project_settings = project_settings + + if anatomy is None: + anatomy = Anatomy(project_name, project_entity=project_entity) + prepared_data.anatomy = anatomy + + folder_entity = ayon_api.get_folder_by_path(project_name, folder_path) + task_entity = ayon_api.get_task_by_name( + project_name, folder_entity["id"], task_name + ) + + template_key = get_workfile_template_key( + project_name, + task_entity["taskType"], + host.name, + project_settings=project_settings + ) + file_template = anatomy.get_template_item("work", template_key, "file") + template_data = get_template_data( + project_entity, + folder_entity, + task_entity, + host.name, + project_settings, + ) + workdir = get_workdir( + project_entity, + folder_entity, + task_entity, + host.name, + anatomy=anatomy, + template_key=template_key, + project_settings=project_settings, + ) + rootless_dir = workdir.rootless + last_workfile = None + current_workfile = None + if version is None or comment is None: + workfiles = host.list_workfiles( + project_name, folder_entity, task_entity, + prepared_data=ListWorkfilesOptionalData( + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + template_key=template_key, + ) + ) + for workfile in workfiles: + if current_workfile is None and workfile.filepath == current_path: + current_workfile = workfile + + if workfile.version is None: + continue + + if ( + last_workfile is None + or last_workfile.version < workfile.version + ): + last_workfile = workfile + + if version is None and last_workfile is not None: + version = last_workfile.version + 1 + + if version is None: + version = get_versioning_start( + project_name, + host.name, + task_name=task_entity["name"], + task_type=task_entity["taskType"], + product_type="workfile" + ) + + # Re-use comment from the current workfile if is not passed in + if comment is None and current_workfile is not None: + comment = current_workfile.comment + + template_data["version"] = version + if comment: + template_data["comment"] = comment + + # Resolve extension + # - Don't fill any if the host does not have defined any -> e.g. if host + # uses directory instead of a file. + # 1. Use the current file extension. + # 2. Use the last known workfile extension. + # 3. Use the first extensions from 'get_workfile_extensions'. + ext = None + workfile_extensions = host.get_workfile_extensions() + if workfile_extensions: + if current_path: + ext = os.path.splitext(current_path)[1] + elif last_workfile is not None: + ext = os.path.splitext(last_workfile.filepath)[1] + else: + ext = next(iter(workfile_extensions)) + ext = ext.lstrip(".") + + if ext: + template_data["ext"] = ext + + filename = file_template.format_strict(template_data) + workfile_path = os.path.join(workdir, filename) + rootless_path = f"{rootless_dir}/{filename}" + if platform.system().lower() == "windows": + rootless_path = rootless_path.replace("\\", "/") + prepared_data.rootless_path = rootless_path + + host.save_workfile_with_context( + workfile_path, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + prepared_data=prepared_data, + ) + + +def copy_workfile_to_context( + src_workfile_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + version: Optional[int] = None, + comment: Optional[str] = None, + description: Optional[str] = None, + open_workfile: bool = True, + prepared_data: Optional[CopyWorkfileOptionalData] = None, +) -> None: + """Copy workfile to a context. + + Copy workfile to a specified folder and task. Destination path is + calculated based on passed information. + + Args: + src_workfile_path (str): Source workfile path. + folder_entity (dict[str, Any]): Target folder entity. + task_entity (dict[str, Any]): Target task entity. + version (Optional[int]): Workfile version. Use next version if not + passed. + comment (optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + prepared_data (Optional[CopyWorkfileOptionalData]): Prepared data + for speed enhancements. Rootless path is calculated in this + function. + + """ + from ayon_core.pipeline import Anatomy + from ayon_core.pipeline.context_tools import registered_host + + host = registered_host() + project_name = host.get_current_project_name() + + anatomy = prepared_data.anatomy + if anatomy is None: + if prepared_data.project_entity is None: + prepared_data.project_entity = ayon_api.get_project( + project_name + ) + anatomy = Anatomy( + project_name, project_entity=prepared_data.project_entity + ) + prepared_data.anatomy = anatomy + + project_settings = prepared_data.project_settings + if project_settings is None: + project_settings = get_project_settings(project_name) + prepared_data.project_settings = project_settings + + if version is None: + list_prepared_data = None + if prepared_data is not None: + list_prepared_data = ListWorkfilesOptionalData( + project_entity=prepared_data.project_entity, + anatomy=prepared_data.anatomy, + project_settings=prepared_data.project_settings, + workfile_entities=prepared_data.workfile_entities, + ) + + workfiles = host.list_workfiles( + project_name, + folder_entity, + task_entity, + prepared_data=list_prepared_data + ) + if workfiles: + version = max( + workfile.version + for workfile in workfiles + ) + 1 + else: + version = get_versioning_start( + project_name, + host.name, + task_name=task_entity["name"], + task_type=task_entity["taskType"], + product_type="workfile" + ) + + task_type = task_entity["taskType"] + template_key = get_workfile_template_key( + project_name, + task_type, + host.name, + project_settings=prepared_data.project_settings + ) + + template_data = get_template_data( + prepared_data.project_entity, + folder_entity, + task_entity, + host.name, + prepared_data.project_settings, + ) + template_data["version"] = version + if comment: + template_data["comment"] = comment + + workfile_extensions = host.get_workfile_extensions() + if workfile_extensions: + ext = os.path.splitext(src_workfile_path)[1].lstrip(".") + template_data["ext"] = ext + + workfile_template = anatomy.get_template_item( + "work", template_key, "path" + ) + workfile_path = workfile_template.format_strict(template_data) + prepared_data.rootless_path = workfile_path.rootless + host.copy_workfile( + src_workfile_path, + workfile_path, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + open_workfile=open_workfile, + prepared_data=prepared_data, + ) + + +def find_workfile_rootless_path( + workfile_path: str, + project_name: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + host_name: str, + *, + project_entity: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, +) -> str: + """Find rootless workfile path.""" + if anatomy is None: + from ayon_core.pipeline import Anatomy + + anatomy = Anatomy(project_name, project_entity=project_entity) + + task_type = task_entity["taskType"] + template_key = get_workfile_template_key( + project_name, + task_type, + host_name, + project_settings=project_settings + ) + dir_template = anatomy.get_template_item( + "work", template_key, "directory" + ) + result = dir_template.format({"root": anatomy.roots}) + used_root = result.used_values.get("root") + rootless_path = str(workfile_path) + if platform.system().lower() == "windows": + rootless_path = rootless_path.replace("\\", "/") + + root_key = root_value = None + if used_root is not None: + root_key, root_value = next(iter(used_root.items())) + if platform.system().lower() == "windows": + root_value = root_value.replace("\\", "/") + + if root_value and rootless_path.startswith(root_value): + rootless_path = rootless_path[len(root_value):].lstrip("/") + rootless_path = f"{{root[{root_key}]}}/{rootless_path}" + else: + success, result = anatomy.find_root_template_from_path(rootless_path) + if success: + rootless_path = result + return rootless_path + + +def _create_workfile_info_entity( + project_name: str, + task_id: str, + host_name: str, + rootless_path: str, + username: str, + version: Optional[int], + comment: Optional[str], + description: Optional[str], +) -> dict[str, Any]: + """Create workfile entity data. + + Args: + project_name (str): Project name. + task_id (str): Task id. + host_name (str): Host name. + rootless_path (str): Rootless workfile path. + username (str): Username. + version (Optional[int]): Workfile version. + comment (Optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + + Returns: + dict[str, Any]: Created workfile entity data. + + """ + extension = os.path.splitext(rootless_path)[1] + + attrib = {} + for key, value in ( + ("extension", extension), + ("description", description), + ): + if value is not None: + attrib[key] = value + + data = { + "host_name": host_name, + "version": version, + "comment": comment, + } + + workfile_info = { + "id": uuid.uuid4().hex, + "path": rootless_path, + "taskId": task_id, + "attrib": attrib, + "data": data, + # TODO remove 'createdBy' and 'updatedBy' fields when server is + # or above 1.1.3 . + "createdBy": username, + "updatedBy": username, + } + + session = OperationsSession() + session.create_entity( + project_name, "workfile", workfile_info + ) + session.commit() + return workfile_info diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 27da278c5e..52e27baa80 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -8,7 +8,7 @@ targeted by task types and names. Placeholders are created using placeholder plugins which should care about logic and data of placeholder items. 'PlaceholderItem' is used to keep track -about it's progress. +about its progress. """ import os @@ -16,7 +16,9 @@ import re import collections import copy from abc import ABC, abstractmethod +from typing import Optional +import ayon_api from ayon_api import ( get_folders, get_folder_by_path, @@ -28,7 +30,7 @@ from ayon_api import ( ) from ayon_core.settings import get_project_settings -from ayon_core.host import IWorkfileHost, HostBase +from ayon_core.host import IWorkfileHost, AbstractHost from ayon_core.lib import ( Logger, StringTemplate, @@ -52,7 +54,6 @@ from ayon_core.pipeline.plugin_discover import ( ) from ayon_core.pipeline.create import ( - discover_legacy_creator_plugins, CreateContext, HiddenCreator, ) @@ -60,6 +61,32 @@ from ayon_core.pipeline.create import ( _NOT_SET = object() +class EntityResolutionError(Exception): + """Exception raised when entity URI resolution fails.""" + + +def resolve_entity_uri(entity_uri: str) -> str: + """Resolve AYON entity URI to a filesystem path for local system.""" + response = ayon_api.post( + "resolve", + resolveRoots=True, + uris=[entity_uri] + ) + if response.status_code != 200: + raise RuntimeError( + f"Unable to resolve AYON entity URI filepath for " + f"'{entity_uri}': {response.text}" + ) + + entities = response.data[0]["entities"] + if len(entities) != 1: + raise EntityResolutionError( + f"Unable to resolve AYON entity URI '{entity_uri}' to a " + f"single filepath. Received data: {response.data}" + ) + return entities[0]["filePath"] + + class TemplateNotFound(Exception): """Exception raised when template does not exist.""" pass @@ -99,15 +126,14 @@ class AbstractTemplateBuilder(ABC): placeholder population. Args: - host (Union[HostBase, ModuleType]): Implementation of host. + host (Union[AbstractHost, ModuleType]): Implementation of host. """ _log = None - use_legacy_creators = False def __init__(self, host): # Get host name - if isinstance(host, HostBase): + if isinstance(host, AbstractHost): host_name = host.name else: host_name = os.environ.get("AYON_HOST_NAME") @@ -135,24 +161,24 @@ class AbstractTemplateBuilder(ABC): @property def project_name(self): - if isinstance(self._host, HostBase): + if isinstance(self._host, AbstractHost): return self._host.get_current_project_name() return os.getenv("AYON_PROJECT_NAME") @property def current_folder_path(self): - if isinstance(self._host, HostBase): + if isinstance(self._host, AbstractHost): return self._host.get_current_folder_path() return os.getenv("AYON_FOLDER_PATH") @property def current_task_name(self): - if isinstance(self._host, HostBase): + if isinstance(self._host, AbstractHost): return self._host.get_current_task_name() return os.getenv("AYON_TASK_NAME") def get_current_context(self): - if isinstance(self._host, HostBase): + if isinstance(self._host, AbstractHost): return self._host.get_current_context() return { "project_name": self.project_name, @@ -174,12 +200,6 @@ class AbstractTemplateBuilder(ABC): ) return self._current_folder_entity - @property - def linked_folder_entities(self): - if self._linked_folder_entities is _NOT_SET: - self._linked_folder_entities = self._get_linked_folder_entities() - return self._linked_folder_entities - @property def current_task_entity(self): if self._current_task_entity is _NOT_SET: @@ -234,7 +254,7 @@ class AbstractTemplateBuilder(ABC): """Access to host implementation. Returns: - Union[HostBase, ModuleType]: Implementation of host. + Union[AbstractHost, ModuleType]: Implementation of host. """ return self._host @@ -280,13 +300,16 @@ class AbstractTemplateBuilder(ABC): self._loaders_by_name = get_loaders_by_name() return self._loaders_by_name - def _get_linked_folder_entities(self): + def get_linked_folder_entities(self, link_type: Optional[str]): + if not link_type: + return [] project_name = self.project_name folder_entity = self.current_folder_entity if not folder_entity: return [] links = get_folder_links( - project_name, folder_entity["id"], link_direction="in" + project_name, + folder_entity["id"], link_types=[link_type], link_direction="in" ) linked_folder_ids = { link["entityId"] @@ -296,19 +319,6 @@ class AbstractTemplateBuilder(ABC): return list(get_folders(project_name, folder_ids=linked_folder_ids)) - def _collect_legacy_creators(self): - creators_by_name = {} - for creator in discover_legacy_creator_plugins(): - if not creator.enabled: - continue - creator_name = creator.__name__ - if creator_name in creators_by_name: - raise KeyError( - "Duplicated creator name {} !".format(creator_name) - ) - creators_by_name[creator_name] = creator - self._creators_by_name = creators_by_name - def _collect_creators(self): self._creators_by_name = { identifier: creator @@ -320,10 +330,7 @@ class AbstractTemplateBuilder(ABC): def get_creators_by_name(self): if self._creators_by_name is None: - if self.use_legacy_creators: - self._collect_legacy_creators() - else: - self._collect_creators() + self._collect_creators() return self._creators_by_name @@ -604,7 +611,7 @@ class AbstractTemplateBuilder(ABC): """Open template file with registered host.""" template_preset = self.get_template_preset() template_path = template_preset["path"] - self.host.open_file(template_path) + self.host.open_workfile(template_path) @abstractmethod def import_template(self, template_path): @@ -823,7 +830,6 @@ class AbstractTemplateBuilder(ABC): """ host_name = self.host_name - project_name = self.project_name task_name = self.current_task_name task_type = self.current_task_type @@ -835,7 +841,6 @@ class AbstractTemplateBuilder(ABC): "task_names": task_name } ) - if not profile: raise TemplateProfileNotFound(( "No matching profile found for task '{}' of type '{}' " @@ -843,6 +848,22 @@ class AbstractTemplateBuilder(ABC): ).format(task_name, task_type, host_name)) path = profile["path"] + if not path: + raise TemplateLoadFailed(( + "Template path is not set.\n" + "Path need to be set in {}\\Template Workfile Build " + "Settings\\Profiles" + ).format(host_name.title())) + + resolved_path = self.resolve_template_path(path) + if not resolved_path or not os.path.exists(resolved_path): + raise TemplateNotFound( + "Template file found in AYON settings for task '{}' with host " + "'{}' does not exists. (Not found : {})".format( + task_name, host_name, resolved_path) + ) + + self.log.info(f"Found template at: '{resolved_path}'") # switch to remove placeholders after they are used keep_placeholder = profile.get("keep_placeholder") @@ -852,44 +873,86 @@ class AbstractTemplateBuilder(ABC): if keep_placeholder is None: keep_placeholder = True - if not path: - raise TemplateLoadFailed(( - "Template path is not set.\n" - "Path need to be set in {}\\Template Workfile Build " - "Settings\\Profiles" - ).format(host_name.title())) - - # Try to fill path with environments and anatomy roots - anatomy = Anatomy(project_name) - fill_data = { - key: value - for key, value in os.environ.items() + return { + "path": resolved_path, + "keep_placeholder": keep_placeholder, + "create_first_version": create_first_version } - fill_data["root"] = anatomy.roots - fill_data["project"] = { - "name": project_name, - "code": anatomy.project_code, - } + def resolve_template_path(self, path, fill_data=None) -> str: + """Resolve the template path. - path = self.resolve_template_path(path, fill_data) + By default, this: + - Resolves AYON entity URI to a filesystem path + - Returns path directly if it exists on disk. + - Resolves template keys through anatomy and environment variables. + This can be overridden in host integrations to perform additional + resolving over the template. Like, `hou.text.expandString` in Houdini. + It's recommended to still call the super().resolve_template_path() + to ensure the basic resolving is done across all integrations. + + Arguments: + path (str): The input path. + fill_data (dict[str, str]): Deprecated. This is computed inside + the method using the current environment and project settings. + Used to be the data to use for template formatting. + + Returns: + str: The resolved path. + + """ + + # If the path is an AYON entity URI, then resolve the filepath + # through the backend + if path.startswith("ayon+entity://") or path.startswith("ayon://"): + # This is a special case where the path is an AYON entity URI + # We need to resolve it to a filesystem path + resolved_path = resolve_entity_uri(path) + return resolved_path + + # If the path is set and it's found on disk, return it directly if path and os.path.exists(path): - self.log.info("Found template at: '{}'".format(path)) - return { - "path": path, - "keep_placeholder": keep_placeholder, - "create_first_version": create_first_version + return path + + # We may have path for another platform, like C:/path/to/file + # or a path with template keys, like {project[code]} or both. + # Try to fill path with environments and anatomy roots + project_name = self.project_name + anatomy = Anatomy(project_name) + + # Simple check whether the path contains any template keys + if "{" in path: + fill_data = { + key: value + for key, value in os.environ.items() + } + fill_data["root"] = anatomy.roots + fill_data["project"] = { + "name": project_name, + "code": anatomy.project_code, } - solved_path = None + # Format the template using local fill data + result = StringTemplate.format_template(path, fill_data) + if not result.solved: + return path + + path = result.normalized() + if os.path.exists(path): + return path + + # If the path were set in settings using a Windows path and we + # are now on a Linux system, we try to convert the solved path to + # the current platform. while True: try: solved_path = anatomy.path_remapper(path) except KeyError as missing_key: raise KeyError( - "Could not solve key '{}' in template path '{}'".format( - missing_key, path)) + f"Could not solve key '{missing_key}'" + f" in template path '{path}'" + ) if solved_path is None: solved_path = path @@ -898,40 +961,7 @@ class AbstractTemplateBuilder(ABC): path = solved_path solved_path = os.path.normpath(solved_path) - if not os.path.exists(solved_path): - raise TemplateNotFound( - "Template found in AYON settings for task '{}' with host " - "'{}' does not exists. (Not found : {})".format( - task_name, host_name, solved_path)) - - self.log.info("Found template at: '{}'".format(solved_path)) - - return { - "path": solved_path, - "keep_placeholder": keep_placeholder, - "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 + return solved_path def emit_event(self, topic, data=None, source=None) -> Event: return self._event_system.emit(topic, data, source) @@ -1379,10 +1409,27 @@ class PlaceholderLoadMixin(object): builder_type_enum_items = [ {"label": "Current folder", "value": "context_folder"}, - # TODO implement linked folders - # {"label": "Linked folders", "value": "linked_folders"}, + {"label": "Linked folders", "value": "linked_folders"}, {"label": "All folders", "value": "all_folders"}, ] + + link_types = ayon_api.get_link_types(self.builder.project_name) + + # Filter link types for folder to folder links + link_types_enum_items = [ + {"label": link_type["name"], "value": link_type["linkType"]} + for link_type in link_types + if ( + link_type["inputType"] == "folder" + and link_type["outputType"] == "folder" + ) + ] + + if not link_types_enum_items: + link_types_enum_items.append( + {"label": "", "value": None} + ) + build_type_label = "Folder Builder Type" build_type_help = ( "Folder Builder Type\n" @@ -1411,6 +1458,16 @@ class PlaceholderLoadMixin(object): items=builder_type_enum_items, tooltip=build_type_help ), + attribute_definitions.EnumDef( + "link_type", + label="Link Type", + items=link_types_enum_items, + tooltip=( + "Link Type\n" + "\nDefines what type of link will be used to" + " link the asset to the current folder." + ) + ), attribute_definitions.EnumDef( "product_type", label="Product type", @@ -1557,10 +1614,7 @@ class PlaceholderLoadMixin(object): builder_type = placeholder.data["builder_type"] folder_ids = [] - if builder_type == "context_folder": - folder_ids = [current_folder_entity["id"]] - - elif builder_type == "all_folders": + if builder_type == "all_folders": folder_ids = { folder_entity["id"] for folder_entity in get_folders( @@ -1570,6 +1624,23 @@ class PlaceholderLoadMixin(object): ) } + elif builder_type == "context_folder": + folder_ids = [current_folder_entity["id"]] + + elif builder_type == "linked_folders": + # link type from placeholder data or default to "template" + link_type = placeholder.data.get("link_type", "template") + # Get all linked folders for the current folder + if hasattr(self, "builder") and isinstance( + self.builder, AbstractTemplateBuilder): + # self.builder: AbstractTemplateBuilder + folder_ids = [ + linked_folder_entity["id"] + for linked_folder_entity in ( + self.builder.get_linked_folder_entities( + link_type=link_type)) + ] + if not folder_ids: return [] @@ -1849,8 +1920,6 @@ class PlaceholderCreateMixin(object): pre_create_data (dict): dictionary of configuration from Creator configuration in UI """ - - legacy_create = self.builder.use_legacy_creators creator_name = placeholder.data["creator"] create_variant = placeholder.data["create_variant"] active = placeholder.data.get("active") @@ -1890,20 +1959,14 @@ class PlaceholderCreateMixin(object): # compile product name from variant try: - if legacy_create: - creator_instance = creator_plugin( - product_name, - folder_path - ).process() - else: - creator_instance = self.builder.create_context.create( - creator_plugin.identifier, - create_variant, - folder_entity, - task_entity, - pre_create_data=pre_create_data, - active=active - ) + creator_instance = self.builder.create_context.create( + creator_plugin.identifier, + create_variant, + folder_entity, + task_entity, + pre_create_data=pre_create_data, + active=active + ) except: # noqa: E722 failed = True diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py new file mode 100644 index 0000000000..aef0cf8863 --- /dev/null +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -0,0 +1,630 @@ +"""Plugin to create hero version from selected context.""" +from __future__ import annotations +import os +import copy +import shutil +import errno +import itertools +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Optional + +from speedcopy import copyfile +import clique +import ayon_api +from ayon_api.operations import OperationsSession, new_version_entity +from ayon_api.utils import create_entity_id +from qtpy import QtWidgets, QtCore +from ayon_core import style +from ayon_core.pipeline import load, Anatomy +from ayon_core.lib import create_hard_link, source_hash, StringTemplate +from ayon_core.lib.file_transaction import wait_for_future_errors +from ayon_core.pipeline.publish import get_publish_template_name +from ayon_core.pipeline.template_data import get_template_data + + +def prepare_changes(old_entity: dict, new_entity: dict) -> dict: + """Prepare changes dict for update entity operation. + + Args: + old_entity (dict): Existing entity data from database. + new_entity (dict): New entity data to compare against old. + + Returns: + dict: Changes to apply to old entity to make it like new entity. + + """ + changes = {} + for key in set(new_entity.keys()): + if key == "attrib": + continue + if key in new_entity and new_entity[key] != old_entity.get(key): + changes[key] = new_entity[key] + attrib_changes = {} + if "attrib" in new_entity: + for key, value in new_entity["attrib"].items(): + if value != old_entity["attrib"].get(key): + attrib_changes[key] = value + if attrib_changes: + changes["attrib"] = attrib_changes + return changes + + +class CreateHeroVersion(load.ProductLoaderPlugin): + """Create hero version from selected context.""" + + is_multiple_contexts_compatible = False + representations = {"*"} + product_types = {"*"} + label = "Create Hero Version" + order = 36 + icon = "star" + color = "#ffd700" + + ignored_representation_names: list[str] = [] + db_representation_context_keys = [ + "project", "folder", "asset", "hierarchy", "task", "product", + "subset", "family", "representation", "username", "user", "output" + ] + use_hardlinks = False + + @staticmethod + def message(text: str) -> None: + """Show message box with text.""" + msgBox = QtWidgets.QMessageBox() + msgBox.setText(text) + msgBox.setStyleSheet(style.load_stylesheet()) + msgBox.setWindowFlags( + msgBox.windowFlags() | QtCore.Qt.WindowType.FramelessWindowHint + ) + msgBox.exec_() + + def load(self, context, name=None, namespace=None, options=None) -> None: + """Load hero version from context (dict as in context.py).""" + success = True + errors = [] + + # Extract project, product, version, folder from context + project = context.get("project") + product = context.get("product") + version = context.get("version") + folder = context.get("folder") + task_entity = ayon_api.get_task_by_id( + task_id=version.get("taskId"), project_name=project["name"] + ) + + anatomy = Anatomy(project["name"]) + + version_id = version["id"] + project_name = project["name"] + repres = list( + ayon_api.get_representations( + project_name, version_ids={version_id} + ) + ) + anatomy_data = get_template_data( + project_entity=project, + folder_entity=folder, + task_entity=task_entity, + ) + anatomy_data["product"] = { + "name": product["name"], + "type": product["productType"], + } + anatomy_data["version"] = version["version"] + published_representations = {} + for repre in repres: + repre_anatomy = copy.deepcopy(anatomy_data) + if "ext" not in repre_anatomy: + repre_anatomy["ext"] = repre.get("context", {}).get("ext", "") + published_representations[repre["id"]] = { + "representation": repre, + "published_files": [f["path"] for f in repre.get("files", [])], + "anatomy_data": repre_anatomy + } + # get the publish directory + publish_template_key = get_publish_template_name( + project_name, + context.get("hostName"), + product["productType"], + task_name=anatomy_data.get("task", {}).get("name"), + task_type=anatomy_data.get("task", {}).get("type"), + project_settings=context.get("project_settings", {}), + logger=self.log + ) + published_template_obj = anatomy.get_template_item( + "publish", publish_template_key, "directory" + ) + published_dir = os.path.normpath( + published_template_obj.format_strict(anatomy_data) + ) + instance_data = { + "productName": product["name"], + "productType": product["productType"], + "anatomyData": anatomy_data, + "publishDir": published_dir, + "published_representations": published_representations, + "versionEntity": version, + } + + try: + self.create_hero_version(instance_data, anatomy, context) + except Exception as exc: + success = False + errors.append(str(exc)) + if success: + self.message("Hero version created successfully.") + else: + self.message( + f"Failed to create hero version:\n{chr(10).join(errors)}") + + def create_hero_version( + self, + instance_data: dict[str, Any], + anatomy: Anatomy, + context: dict[str, Any]) -> None: + """Create hero version from instance data. + + Args: + instance_data (dict): Instance data with keys: + - productName (str): Name of the product. + - productType (str): Type of the product. + - anatomyData (dict): Anatomy data for templates. + - publishDir (str): Directory where the product is published. + - published_representations (dict): Published representations. + - versionEntity (dict, optional): Source version entity. + anatomy (Anatomy): Anatomy object for the project. + context (dict): Context data with keys: + - hostName (str): Name of the host application. + - project_settings (dict): Project settings. + + Raises: + RuntimeError: If any required data is missing or an error occurs + during the hero version creation process. + + """ + published_repres = instance_data.get("published_representations") + if not published_repres: + raise RuntimeError("No published representations found.") + + project_name = anatomy.project_name + template_key = get_publish_template_name( + project_name, + context.get("hostName"), + instance_data.get("productType"), + instance_data.get("anatomyData", {}).get("task", {}).get("name"), + instance_data.get("anatomyData", {}).get("task", {}).get("type"), + project_settings=context.get("project_settings", {}), + hero=True, + ) + hero_template = anatomy.get_template_item( + "hero", template_key, "path", default=None + ) + if hero_template is None: + raise RuntimeError("Project anatomy does not have hero " + f"template key: {template_key}") + + self.log.info(f"Hero template: {hero_template.template}") + + hero_publish_dir = self.get_publish_dir( + instance_data, anatomy, template_key + ) + + self.log.info(f"Hero publish dir: {hero_publish_dir}") + + src_version_entity = instance_data.get("versionEntity") + filtered_repre_ids = [] + for repre_id, repre_info in published_repres.items(): + repre = repre_info["representation"] + if repre["name"].lower() in self.ignored_representation_names: + filtered_repre_ids.append(repre_id) + for repre_id in filtered_repre_ids: + published_repres.pop(repre_id, None) + if not published_repres: + raise RuntimeError( + "All published representations were filtered by name." + ) + + if src_version_entity is None: + src_version_entity = self.version_from_representations( + project_name, published_repres) + if not src_version_entity: + raise RuntimeError("Can't find origin version in database.") + if src_version_entity["version"] == 0: + raise RuntimeError("Version 0 cannot have hero version.") + + all_copied_files = [] + transfers = instance_data.get("transfers", []) + for _src, dst in transfers: + dst = os.path.normpath(dst) + if dst not in all_copied_files: + all_copied_files.append(dst) + hardlinks = instance_data.get("hardlinks", []) + for _src, dst in hardlinks: + dst = os.path.normpath(dst) + if dst not in all_copied_files: + all_copied_files.append(dst) + + all_repre_file_paths = [] + for repre_info in published_repres.values(): + published_files = repre_info.get("published_files") or [] + for file_path in published_files: + file_path = os.path.normpath(file_path) + if file_path not in all_repre_file_paths: + all_repre_file_paths.append(file_path) + + publish_dir = instance_data.get("publishDir", "") + if not publish_dir: + raise RuntimeError( + "publishDir is empty in instance_data, cannot continue." + ) + instance_publish_dir = os.path.normpath(publish_dir) + other_file_paths_mapping = [] + for file_path in all_copied_files: + if not file_path.startswith(instance_publish_dir): + continue + if file_path in all_repre_file_paths: + continue + dst_filepath = file_path.replace( + instance_publish_dir, hero_publish_dir + ) + other_file_paths_mapping.append((file_path, dst_filepath)) + + old_version, old_repres = self.current_hero_ents( + project_name, src_version_entity + ) + inactive_old_repres_by_name = {} + old_repres_by_name = {} + for repre in old_repres: + low_name = repre["name"].lower() + if repre["active"]: + old_repres_by_name[low_name] = repre + else: + inactive_old_repres_by_name[low_name] = repre + + op_session = OperationsSession() + entity_id = old_version["id"] if old_version else None + new_hero_version = new_version_entity( + -src_version_entity["version"], + src_version_entity["productId"], + task_id=src_version_entity.get("taskId"), + data=copy.deepcopy(src_version_entity["data"]), + attribs=copy.deepcopy(src_version_entity["attrib"]), + entity_id=entity_id, + ) + if old_version: + update_data = prepare_changes(old_version, new_hero_version) + op_session.update_entity( + project_name, "version", old_version["id"], update_data + ) + else: + op_session.create_entity(project_name, "version", new_hero_version) + + # Store hero entity to instance_data + instance_data["heroVersionEntity"] = new_hero_version + + old_repres_to_replace = {} + for repre_info in published_repres.values(): + repre = repre_info["representation"] + repre_name_low = repre["name"].lower() + if repre_name_low in old_repres_by_name: + old_repres_to_replace[repre_name_low] = ( + old_repres_by_name.pop(repre_name_low) + ) + old_repres_to_delete = old_repres_by_name or {} + backup_hero_publish_dir = None + if os.path.exists(hero_publish_dir): + base_backup_dir = f"{hero_publish_dir}.BACKUP" + max_idx = 10 + # Find the first available backup directory name + for idx in range(max_idx + 1): + if idx == 0: + candidate_backup_dir = base_backup_dir + else: + candidate_backup_dir = f"{base_backup_dir}{idx}" + if not os.path.exists(candidate_backup_dir): + backup_hero_publish_dir = candidate_backup_dir + break + else: + raise AssertionError( + f"Backup folders are fully occupied to max index {max_idx}" + ) + + try: + os.rename(hero_publish_dir, backup_hero_publish_dir) + except PermissionError as e: + raise AssertionError( + "Could not create hero version because it is " + "not possible to replace current hero files." + ) from e + + try: + src_to_dst_file_paths = [] + repre_integrate_data = [] + path_template_obj = anatomy.get_template_item( + "hero", template_key, "path") + anatomy_root = {"root": anatomy.roots} + for repre_info in published_repres.values(): + published_files = repre_info["published_files"] + if len(published_files) == 0: + continue + anatomy_data = copy.deepcopy(repre_info["anatomy_data"]) + anatomy_data.pop("version", None) + template_filled = path_template_obj.format_strict(anatomy_data) + repre_context = template_filled.used_values + for key in self.db_representation_context_keys: + value = anatomy_data.get(key) + if value is not None: + repre_context[key] = value + repre_entity = copy.deepcopy(repre_info["representation"]) + repre_entity.pop("id", None) + repre_entity["versionId"] = new_hero_version["id"] + repre_entity["context"] = repre_context + repre_entity["attrib"] = { + "path": str(template_filled), + "template": hero_template.template + } + dst_paths = [] + + if len(published_files) == 1: + dst_paths.append(str(template_filled)) + mapped_published_file = StringTemplate( + published_files[0]).format_strict( + anatomy_root + ) + src_to_dst_file_paths.append( + (mapped_published_file, template_filled) + ) + self.log.info( + f"Single published file: {mapped_published_file} -> " + f"{template_filled}" + ) + else: + collections, remainders = clique.assemble(published_files) + if remainders or not collections or len(collections) > 1: + raise RuntimeError( + ( + "Integrity error. Files of published " + "representation is combination of frame " + "collections and single files." + ) + ) + src_col = collections[0] + frame_splitter = "_-_FRAME_SPLIT_-_" + anatomy_data["frame"] = frame_splitter + _template_filled = path_template_obj.format_strict( + anatomy_data + ) + head, tail = _template_filled.split(frame_splitter) + padding = anatomy.templates_obj.frame_padding + dst_col = clique.Collection( + head=head, padding=padding, tail=tail + ) + dst_col.indexes.clear() + dst_col.indexes.update(src_col.indexes) + for src_file, dst_file in zip(src_col, dst_col): + src_file = StringTemplate(src_file).format_strict( + anatomy_root + ) + src_to_dst_file_paths.append((src_file, dst_file)) + dst_paths.append(dst_file) + self.log.info( + f"Collection published file: {src_file} " + f"-> {dst_file}" + ) + repre_integrate_data.append((repre_entity, dst_paths)) + + # Copy files + with ThreadPoolExecutor(max_workers=8) as executor: + futures = [ + executor.submit(self.copy_file, src_path, dst_path) + for src_path, dst_path in itertools.chain( + src_to_dst_file_paths, other_file_paths_mapping) + ] + wait_for_future_errors(executor, futures) + + # Update/create representations + for repre_entity, dst_paths in repre_integrate_data: + repre_files = self.get_files_info(dst_paths, anatomy) + repre_entity["files"] = repre_files + repre_name_low = repre_entity["name"].lower() + if repre_name_low in old_repres_to_replace: + old_repre = old_repres_to_replace.pop(repre_name_low) + repre_entity["id"] = old_repre["id"] + update_data = prepare_changes(old_repre, repre_entity) + op_session.update_entity( + project_name, + "representation", + old_repre["id"], + update_data + ) + elif repre_name_low in inactive_old_repres_by_name: + inactive_repre = inactive_old_repres_by_name.pop( + repre_name_low + ) + repre_entity["id"] = inactive_repre["id"] + update_data = prepare_changes(inactive_repre, repre_entity) + op_session.update_entity( + project_name, + "representation", + inactive_repre["id"], + update_data + ) + else: + op_session.create_entity( + project_name, + "representation", + repre_entity + ) + + for repre in old_repres_to_delete.values(): + op_session.update_entity( + project_name, + "representation", + repre["id"], + {"active": False} + ) + + op_session.commit() + + if backup_hero_publish_dir is not None and os.path.exists( + backup_hero_publish_dir + ): + shutil.rmtree(backup_hero_publish_dir) + + except Exception: + if backup_hero_publish_dir is not None and os.path.exists( + backup_hero_publish_dir): + if os.path.exists(hero_publish_dir): + shutil.rmtree(hero_publish_dir) + os.rename(backup_hero_publish_dir, hero_publish_dir) + raise + + def get_files_info( + self, filepaths: list[str], anatomy: Anatomy) -> list[dict]: + """Get list of file info dictionaries for given file paths. + + Args: + filepaths (list[str]): List of absolute file paths. + anatomy (Anatomy): Anatomy object for the project. + + Returns: + list[dict]: List of file info dictionaries. + + """ + file_infos = [] + for filepath in filepaths: + file_info = self.prepare_file_info(filepath, anatomy) + file_infos.append(file_info) + return file_infos + + def prepare_file_info(self, path: str, anatomy: Anatomy) -> dict: + """Prepare file info dictionary for given path. + + Args: + path (str): Absolute file path. + anatomy (Anatomy): Anatomy object for the project. + + Returns: + dict: File info dictionary with keys: + - id (str): Unique identifier for the file. + - name (str): Base name of the file. + - path (str): Rootless file path. + - size (int): Size of the file in bytes. + - hash (str): Hash of the file content. + - hash_type (str): Type of the hash used. + + """ + return { + "id": create_entity_id(), + "name": os.path.basename(path), + "path": self.get_rootless_path(anatomy, path), + "size": os.path.getsize(path), + "hash": source_hash(path), + "hash_type": "op3", + } + + @staticmethod + def get_publish_dir( + instance_data: dict, + anatomy: Anatomy, + template_key: str) -> str: + """Get publish directory from instance data and anatomy. + + Args: + instance_data (dict): Instance data with "anatomyData" key. + anatomy (Anatomy): Anatomy object for the project. + template_key (str): Template key for the hero template. + + Returns: + str: Normalized publish directory path. + + """ + template_data = copy.deepcopy(instance_data.get("anatomyData", {})) + if "originalBasename" in instance_data: + template_data["originalBasename"] = ( + instance_data["originalBasename"] + ) + template_obj = anatomy.get_template_item( + "hero", template_key, "directory" + ) + return os.path.normpath(template_obj.format_strict(template_data)) + + @staticmethod + def get_rootless_path(anatomy: Anatomy, path: str) -> str: + """Get rootless path from absolute path. + + Args: + anatomy (Anatomy): Anatomy object for the project. + path (str): Absolute file path. + + Returns: + str: Rootless file path if root found, else original path. + + """ + success, rootless_path = anatomy.find_root_template_from_path(path) + if success: + path = rootless_path + return path + + def copy_file(self, src_path: str, dst_path: str) -> None: + """Copy file from src to dst with creating directories. + + Args: + src_path (str): Source file path. + dst_path (str): Destination file path. + + Raises: + OSError: If copying or linking fails. + + """ + dirname = os.path.dirname(dst_path) + try: + os.makedirs(dirname) + except OSError as exc: + if exc.errno != errno.EEXIST: + raise + if self.use_hardlinks: + try: + create_hard_link(src_path, dst_path) + return + except OSError as exc: + if exc.errno not in [errno.EXDEV, errno.EINVAL]: + raise + copyfile(src_path, dst_path) + + @staticmethod + def version_from_representations( + project_name: str, repres: dict) -> Optional[dict[str, Any]]: + """Find version from representations. + + Args: + project_name (str): Name of the project. + repres (dict): Dictionary of representations info. + + Returns: + Optional[dict]: Version entity if found, else None. + + """ + for repre_info in repres.values(): + version = ayon_api.get_version_by_id( + project_name, repre_info["representation"]["versionId"] + ) + if version: + return version + return None + + @staticmethod + def current_hero_ents( + project_name: str, + version: dict[str, Any]) -> tuple[Any, list[dict[str, Any]]]: + hero_version = ayon_api.get_hero_version_by_product_id( + project_name, version["productId"] + ) + if not hero_version: + return None, [] + hero_repres = list( + ayon_api.get_representations( + project_name, version_ids={hero_version["id"]} + ) + ) + return hero_version, hero_repres diff --git a/client/ayon_core/plugins/load/push_to_library.py b/client/ayon_core/plugins/load/push_to_project.py similarity index 63% rename from client/ayon_core/plugins/load/push_to_library.py rename to client/ayon_core/plugins/load/push_to_project.py index 981028d734..0b218d6ea1 100644 --- a/client/ayon_core/plugins/load/push_to_library.py +++ b/client/ayon_core/plugins/load/push_to_project.py @@ -6,15 +6,15 @@ from ayon_core.pipeline import load from ayon_core.pipeline.load import LoadError -class PushToLibraryProject(load.ProductLoaderPlugin): - """Export selected versions to folder structure from Template""" +class PushToProject(load.ProductLoaderPlugin): + """Export selected versions to different project""" is_multiple_contexts_compatible = True representations = {"*"} product_types = {"*"} - label = "Push to Library project" + label = "Push to project" order = 35 icon = "send" color = "#d8d8d8" @@ -28,10 +28,12 @@ class PushToLibraryProject(load.ProductLoaderPlugin): if not filtered_contexts: raise LoadError("Nothing to push for your selection") - if len(filtered_contexts) > 1: - raise LoadError("Please select only one item") - - context = tuple(filtered_contexts)[0] + folder_ids = set( + context["folder"]["id"] + for context in filtered_contexts + ) + if len(folder_ids) > 1: + raise LoadError("Please select products from single folder") push_tool_script_path = os.path.join( AYON_CORE_ROOT, @@ -39,14 +41,16 @@ class PushToLibraryProject(load.ProductLoaderPlugin): "push_to_project", "main.py" ) + project_name = filtered_contexts[0]["project"]["name"] - project_name = context["project"]["name"] - version_id = context["version"]["id"] + version_ids = { + context["version"]["id"] + for context in filtered_contexts + } args = get_ayon_launcher_args( - "run", push_tool_script_path, "--project", project_name, - "--version", version_id + "--versions", ",".join(version_ids) ) run_detached_process(args) diff --git a/client/ayon_core/plugins/publish/cleanup.py b/client/ayon_core/plugins/publish/cleanup.py index 681fe700a3..03eaaf9c6e 100644 --- a/client/ayon_core/plugins/publish/cleanup.py +++ b/client/ayon_core/plugins/publish/cleanup.py @@ -38,6 +38,8 @@ class CleanUp(pyblish.api.InstancePlugin): "webpublisher", "shell" ] + settings_category = "core" + exclude_families = ["clip"] optional = True active = True diff --git a/client/ayon_core/plugins/publish/cleanup_farm.py b/client/ayon_core/plugins/publish/cleanup_farm.py index e655437ced..8d1c8de425 100644 --- a/client/ayon_core/plugins/publish/cleanup_farm.py +++ b/client/ayon_core/plugins/publish/cleanup_farm.py @@ -13,6 +13,8 @@ class CleanUpFarm(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder + 11 label = "Clean Up Farm" + + settings_category = "core" enabled = True # Keep "filesequence" for backwards compatibility of older jobs 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 2fcf562dd0..2cb2297bf7 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py @@ -46,6 +46,8 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.49 label = "Collect Anatomy Instance data" + settings_category = "core" + follow_workfile_version = False def process(self, context): diff --git a/client/ayon_core/plugins/publish/collect_audio.py b/client/ayon_core/plugins/publish/collect_audio.py index 57c69ef2b2..2949ff1196 100644 --- a/client/ayon_core/plugins/publish/collect_audio.py +++ b/client/ayon_core/plugins/publish/collect_audio.py @@ -39,8 +39,9 @@ class CollectAudio(pyblish.api.ContextPlugin): "blender", "houdini", "max", - "circuit", + "batchdelivery", ] + settings_category = "core" audio_product_name = "audioMain" diff --git a/client/ayon_core/plugins/publish/collect_frames_fix.py b/client/ayon_core/plugins/publish/collect_frames_fix.py index 0f7d5b692a..4270af5541 100644 --- a/client/ayon_core/plugins/publish/collect_frames_fix.py +++ b/client/ayon_core/plugins/publish/collect_frames_fix.py @@ -23,6 +23,7 @@ class CollectFramesFixDef( targets = ["local"] hosts = ["nuke"] families = ["render", "prerender"] + settings_category = "core" rewrite_version_enable = False diff --git a/client/ayon_core/plugins/publish/collect_from_create_context.py b/client/ayon_core/plugins/publish/collect_from_create_context.py index b99866fed9..5e0ecbdff4 100644 --- a/client/ayon_core/plugins/publish/collect_from_create_context.py +++ b/client/ayon_core/plugins/publish/collect_from_create_context.py @@ -2,11 +2,13 @@ """ import os +import collections + import pyblish.api from ayon_core.host import IPublishHost from ayon_core.pipeline import registered_host -from ayon_core.pipeline.create import CreateContext +from ayon_core.pipeline.create import CreateContext, ParentFlags class CollectFromCreateContext(pyblish.api.ContextPlugin): @@ -36,18 +38,51 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): if project_name: context.data["projectName"] = project_name + # Separate root instances and parented instances + instances_by_parent_id = collections.defaultdict(list) + root_instances = [] for created_instance in create_context.instances: + parent_id = created_instance.parent_instance_id + if parent_id is None: + root_instances.append(created_instance) + else: + instances_by_parent_id[parent_id].append(created_instance) + + # Traverse instances from top to bottom + # - All instances without an existing parent are automatically + # eliminated + filtered_instances = [] + _queue = collections.deque() + _queue.append((root_instances, True)) + while _queue: + created_instances, parent_is_active = _queue.popleft() + for created_instance in created_instances: + is_active = created_instance["active"] + # Use a parent's active state if parent flags defines that + if ( + created_instance.parent_flags & ParentFlags.share_active + and is_active + ): + is_active = parent_is_active + + if is_active: + filtered_instances.append(created_instance) + + children = instances_by_parent_id[created_instance.id] + if children: + _queue.append((children, is_active)) + + for created_instance in filtered_instances: instance_data = created_instance.data_to_store() - if instance_data["active"]: - thumbnail_path = thumbnail_paths_by_instance_id.get( - created_instance.id - ) - self.create_instance( - context, - instance_data, - created_instance.transient_data, - thumbnail_path - ) + thumbnail_path = thumbnail_paths_by_instance_id.get( + created_instance.id + ) + self.create_instance( + context, + instance_data, + created_instance.transient_data, + thumbnail_path + ) # Update global data to context context.data.update(create_context.context_data_to_store()) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index 0a4efc2172..d68970d428 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -8,13 +8,7 @@ This module contains a unified plugin that handles: from pprint import pformat -import opentimelineio as otio import pyblish.api -from ayon_core.pipeline.editorial import ( - get_media_range_with_retimes, - otio_range_to_frame_range, - otio_range_with_handles, -) def validate_otio_clip(instance, logger): @@ -74,6 +68,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): if not validate_otio_clip(instance, self.log): return + import opentimelineio as otio + otio_clip = instance.data["otioClip"] # Collect timeline ranges if workfile start frame is available @@ -100,6 +96,11 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): def _collect_timeline_ranges(self, instance, otio_clip): """Collect basic timeline frame ranges.""" + from ayon_core.pipeline.editorial import ( + otio_range_to_frame_range, + otio_range_with_handles, + ) + workfile_start = instance.data["workfileFrameStart"] # Get timeline ranges @@ -129,6 +130,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): def _collect_source_ranges(self, instance, otio_clip): """Collect source media frame ranges.""" + import opentimelineio as otio + # Get source ranges otio_src_range = otio_clip.source_range otio_available_range = otio_clip.available_range() @@ -178,6 +181,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): def _collect_retimed_ranges(self, instance, otio_clip): """Handle retimed clip frame ranges.""" + from ayon_core.pipeline.editorial import get_media_range_with_retimes + retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0) self.log.debug(f"Retimed attributes: {retimed_attributes}") diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index 1abb8e29d2..524381f656 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -1,7 +1,9 @@ import ayon_api import ayon_api.utils +from ayon_core.host import ILoadHost from ayon_core.pipeline import registered_host + import pyblish.api @@ -27,16 +29,23 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): def process(self, context): host = registered_host() if host is None: - self.log.warn("No registered host.") + self.log.warning("No registered host.") return - if not hasattr(host, "ls"): - host_name = host.__name__ - self.log.warn("Host %r doesn't have ls() implemented." % host_name) + if not isinstance(host, ILoadHost): + host_name = host.name + self.log.warning( + f"Host {host_name} does not implement ILoadHost. " + "Skipping querying of loaded versions in scene." + ) + return + + containers = list(host.get_containers()) + if not containers: + # Opt out early if there are no containers + self.log.debug("No loaded containers found in scene.") return - loaded_versions = [] - containers = list(host.ls()) repre_ids = { container["representation"] for container in containers @@ -61,6 +70,7 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): # QUESTION should we add same representation id when loaded multiple # times? + loaded_versions = [] for con in containers: repre_id = con["representation"] repre_entity = repre_entities_by_id.get(repre_id) @@ -80,4 +90,5 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): } loaded_versions.append(version) + self.log.debug(f"Collected {len(loaded_versions)} loaded versions.") context.data["loadedVersions"] = loaded_versions diff --git a/client/ayon_core/plugins/publish/collect_scene_version.py b/client/ayon_core/plugins/publish/collect_scene_version.py index 7979b66abe..e6e81ea074 100644 --- a/client/ayon_core/plugins/publish/collect_scene_version.py +++ b/client/ayon_core/plugins/publish/collect_scene_version.py @@ -12,9 +12,10 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): """ order = pyblish.api.CollectorOrder - label = 'Collect Scene Version' + label = "Collect Scene Version" # configurable in Settings hosts = ["*"] + settings_category = "core" # in some cases of headless publishing (for example webpublisher using PS) # you want to ignore version from name and let integrate use next version diff --git a/client/ayon_core/plugins/publish/extract_burnin.py b/client/ayon_core/plugins/publish/extract_burnin.py index 3f7c2f4cba..351d85a97f 100644 --- a/client/ayon_core/plugins/publish/extract_burnin.py +++ b/client/ayon_core/plugins/publish/extract_burnin.py @@ -55,8 +55,9 @@ class ExtractBurnin(publish.Extractor): "max", "blender", "unreal", - "circuit", + "batchdelivery", ] + settings_category = "core" optional = True @@ -757,6 +758,15 @@ class ExtractBurnin(publish.Extractor): ) }) + # burnin source resolution which might be different than on review + repre_source_resolution_width = repre.get("source_resolution_width") + repre_source_resolution_height = repre.get("source_resolution_height") + if repre_source_resolution_width and repre_source_resolution_height: + burnin_data.update({ + "source_resolution_width": repre_source_resolution_width, + "source_resolution_height": repre_source_resolution_height + }) + def filter_burnins_defs(self, profile, instance): """Filter outputs by their values from settings. diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 1e86b91484..bbb6f9585b 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -55,10 +55,12 @@ class ExtractOIIOTranscode(publish.Extractor): label = "Transcode color spaces" order = pyblish.api.ExtractorOrder + 0.019 + settings_category = "core" + optional = True # Supported extensions - supported_exts = ["exr", "jpg", "jpeg", "png", "dpx"] + supported_exts = {"exr", "jpg", "jpeg", "png", "dpx"} # Configurable by Settings profiles = None diff --git a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py index 472694d334..3a450a4f33 100644 --- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py +++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py @@ -158,6 +158,7 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): """ # Not all hosts can import this module. import opentimelineio as otio + from ayon_core.pipeline.editorial import OTIO_EPSILON output = [] # go trough all audio tracks @@ -172,6 +173,14 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): clip_start = otio_clip.source_range.start_time fps = clip_start.rate conformed_av_start = media_av_start.rescaled_to(fps) + + # Avoid rounding issue on media available range. + if clip_start.almost_equal( + conformed_av_start, + OTIO_EPSILON + ): + conformed_av_start = clip_start + # ffmpeg ignores embedded tc start = clip_start - conformed_av_start duration = otio_clip.source_range.duration diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index f217be551c..90215bd2c9 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -23,7 +23,10 @@ from ayon_core.lib import ( get_ffmpeg_tool_args, run_subprocess, ) -from ayon_core.pipeline import publish +from ayon_core.pipeline import ( + KnownPublishError, + publish, +) class ExtractOTIOReview( @@ -97,8 +100,11 @@ class ExtractOTIOReview( # skip instance if no reviewable data available if ( - not isinstance(otio_review_clips[0], otio.schema.Clip) - and len(otio_review_clips) == 1 + len(otio_review_clips) == 1 + and ( + not isinstance(otio_review_clips[0], otio.schema.Clip) + or otio_review_clips[0].media_reference.is_missing_reference + ) ): self.log.warning( "Instance `{}` has nothing to process".format(instance)) @@ -248,7 +254,7 @@ class ExtractOTIOReview( # Single video way. # Extraction via FFmpeg. - else: + elif hasattr(media_ref, "target_url"): path = media_ref.target_url # Set extract range from 0 (FFmpeg ignores # embedded timecode). @@ -352,6 +358,7 @@ class ExtractOTIOReview( import opentimelineio as otio from ayon_core.pipeline.editorial import ( trim_media_range, + OTIO_EPSILON, ) def _round_to_frame(rational_time): @@ -370,6 +377,13 @@ class ExtractOTIOReview( avl_start = avl_range.start_time + # Avoid rounding issue on media available range. + if start.almost_equal( + avl_start, + OTIO_EPSILON + ): + avl_start = start + # An additional gap is required before the available # range to conform source start point and head handles. if start < avl_start: @@ -388,6 +402,14 @@ class ExtractOTIOReview( # (media duration is shorter then clip requirement). end_point = start + duration avl_end_point = avl_range.end_time_exclusive() + + # Avoid rounding issue on media available range. + if end_point.almost_equal( + avl_end_point, + OTIO_EPSILON + ): + avl_end_point = end_point + if end_point > avl_end_point: gap_duration = end_point - avl_end_point duration -= gap_duration @@ -444,7 +466,7 @@ class ExtractOTIOReview( command = get_ffmpeg_tool_args("ffmpeg") input_extension = None - if sequence: + if sequence is not None: input_dir, collection, sequence_fps = sequence in_frame_start = min(collection.indexes) @@ -478,7 +500,7 @@ class ExtractOTIOReview( "-i", input_path ]) - elif video: + elif video is not None: video_path, otio_range = video frame_start = otio_range.start_time.value input_fps = otio_range.start_time.rate @@ -496,7 +518,7 @@ class ExtractOTIOReview( "-i", video_path ]) - elif gap: + elif gap is not None: sec_duration = frames_to_seconds(gap, self.actual_fps) # form command for rendering gap files @@ -510,6 +532,9 @@ class ExtractOTIOReview( "-tune", "stillimage" ]) + else: + raise KnownPublishError("Sequence, video or gap is required.") + if video or sequence: command.extend([ "-vf", f"scale={self.to_width}:{self.to_height}:flags=lanczos", diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 3fc2185d1a..04e534054e 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -7,7 +7,6 @@ import shutil import subprocess from abc import ABC, abstractmethod from typing import Any, Optional -from dataclasses import dataclass, field import tempfile import clique @@ -37,37 +36,68 @@ from ayon_core.pipeline.publish import ( from ayon_core.pipeline.publish.lib import add_repre_files_for_cleanup -@dataclass class TempData: """Temporary data used across extractor's process.""" - fps: float - frame_start: int - frame_end: int - handle_start: int - handle_end: int - frame_start_handle: int - frame_end_handle: int - output_frame_start: int - output_frame_end: int - pixel_aspect: float - resolution_width: int - resolution_height: int - origin_repre: dict[str, Any] - input_is_sequence: bool - first_sequence_frame: int - input_allow_bg: bool - with_audio: bool - without_handles: bool - handles_are_set: bool - input_ext: str - explicit_input_paths: list[str] - paths_to_remove: list[str] + def __init__( + self, + fps: float, + frame_start: int, + frame_end: int, + handle_start: int, + handle_end: int, + frame_start_handle: int, + frame_end_handle: int, + output_frame_start: int, + output_frame_end: int, + pixel_aspect: float, + resolution_width: int, + resolution_height: int, + origin_repre: dict[str, Any], + input_is_sequence: bool, + first_sequence_frame: int, + input_allow_bg: bool, + with_audio: bool, + without_handles: bool, + handles_are_set: bool, + input_ext: str, + explicit_input_paths: list[str], + paths_to_remove: list[str], - # Set later - full_output_path: str = "" - filled_files: dict[int, str] = field(default_factory=dict) - output_ext_is_image: bool = True - output_is_sequence: bool = True + # Set later + full_output_path: str = "", + filled_files: dict[int, str] = None, + output_ext_is_image: bool = True, + output_is_sequence: bool = True, + ): + if filled_files is None: + filled_files = {} + self.fps = fps + self.frame_start = frame_start + self.frame_end = frame_end + self.handle_start = handle_start + self.handle_end = handle_end + self.frame_start_handle = frame_start_handle + self.frame_end_handle = frame_end_handle + self.output_frame_start = output_frame_start + self.output_frame_end = output_frame_end + self.pixel_aspect = pixel_aspect + self.resolution_width = resolution_width + self.resolution_height = resolution_height + self.origin_repre = origin_repre + self.input_is_sequence = input_is_sequence + self.first_sequence_frame = first_sequence_frame + self.input_allow_bg = input_allow_bg + self.with_audio = with_audio + self.without_handles = without_handles + self.handles_are_set = handles_are_set + self.input_ext = input_ext + self.explicit_input_paths = explicit_input_paths + self.paths_to_remove = paths_to_remove + + self.full_output_path = full_output_path + self.filled_files = filled_files + self.output_ext_is_image = output_ext_is_image + self.output_is_sequence = output_is_sequence def frame_to_timecode(frame: int, fps: float) -> str: @@ -131,15 +161,17 @@ class ExtractReview(pyblish.api.InstancePlugin): "aftereffects", "flame", "unreal", - "circuit", + "batchdelivery", + "photoshop" ] + settings_category = "core" # Supported extensions - image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"] - video_exts = ["mov", "mp4"] - supported_exts = image_exts + video_exts + image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"} + video_exts = {"mov", "mp4"} + supported_exts = image_exts | video_exts - alpha_exts = ["exr", "png", "dpx"] + alpha_exts = {"exr", "png", "dpx"} # Preset attributes profiles = [] @@ -172,15 +204,21 @@ class ExtractReview(pyblish.api.InstancePlugin): def _get_outputs_for_instance(self, instance): host_name = instance.context.data["hostName"] product_type = instance.data["productType"] + task_type = None + task_entity = instance.data.get("taskEntity") + if task_entity: + task_type = task_entity["taskType"] self.log.debug("Host: \"{}\"".format(host_name)) self.log.debug("Product type: \"{}\"".format(product_type)) + self.log.debug("Task type: \"{}\"".format(task_type)) profile = filter_profiles( self.profiles, { "hosts": host_name, "product_types": product_type, + "task_types": task_type }, logger=self.log) if not profile: @@ -1598,6 +1636,12 @@ class ExtractReview(pyblish.api.InstancePlugin): "FFprobe couldn't read resolution from input file: \"{}\"" ).format(full_input_path_single_file)) + # collect source values to be potentially used in burnins later + if "source_resolution_width" not in new_repre: + new_repre["source_resolution_width"] = input_width + if "source_resolution_height" not in new_repre: + new_repre["source_resolution_height"] = input_height + # NOTE Setting only one of `width` or `height` is not allowed # - settings value can't have None but has value of 0 output_width = output_def["width"] or output_width or None diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 69bb9007f9..705fea1f72 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -38,10 +38,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): "substancedesigner", "nuke", "aftereffects", + "photoshop", "unreal", "houdini", - "circuit", + "batchdelivery", ] + settings_category = "core" enabled = False integrate_thumbnail = False 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 ec1fddc6b1..0dc9a5e34d 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -256,6 +256,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, label = "Collect USD Layer Contributions (Asset/Shot)" families = ["usd"] enabled = True + settings_category = "core" # A contribution defines a contribution into a (department) layer which # will get layered into the target product, usually the asset or shot. @@ -633,6 +634,8 @@ class ExtractUSDLayerContribution(publish.Extractor): label = "Extract USD Layer Contributions (Asset/Shot)" order = pyblish.api.ExtractorOrder + 0.45 + settings_category = "core" + use_ayon_entity_uri = False def process(self, instance): @@ -795,6 +798,8 @@ class ExtractUSDAssetContribution(publish.Extractor): label = "Extract USD Asset/Shot Contributions" order = ExtractUSDLayerContribution.order + 0.01 + settings_category = "core" + use_ayon_entity_uri = False def process(self, instance): diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 43f93da293..90e6f15568 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -61,6 +61,8 @@ class IntegrateHeroVersion( # Must happen after IntegrateNew order = pyblish.api.IntegratorOrder + 0.1 + settings_category = "core" + optional = True active = True diff --git a/client/ayon_core/plugins/publish/integrate_inputlinks.py b/client/ayon_core/plugins/publish/integrate_inputlinks.py index a3b6a228d6..be399a95fc 100644 --- a/client/ayon_core/plugins/publish/integrate_inputlinks.py +++ b/client/ayon_core/plugins/publish/integrate_inputlinks.py @@ -105,7 +105,7 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin): created links by its type """ if workfile_instance is None: - self.log.warn("No workfile in this publish session.") + self.log.warning("No workfile in this publish session.") return workfile_version_id = workfile_instance.data["versionEntity"]["id"] diff --git a/client/ayon_core/plugins/publish/integrate_product_group.py b/client/ayon_core/plugins/publish/integrate_product_group.py index 90887a359d..8904d21d69 100644 --- a/client/ayon_core/plugins/publish/integrate_product_group.py +++ b/client/ayon_core/plugins/publish/integrate_product_group.py @@ -24,6 +24,8 @@ class IntegrateProductGroup(pyblish.api.InstancePlugin): order = pyblish.api.IntegratorOrder - 0.1 label = "Product Group" + settings_category = "core" + # Attributes set by settings product_grouping_profiles = None diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py new file mode 100644 index 0000000000..45f32be4a0 --- /dev/null +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -0,0 +1,1273 @@ +"""Integrate representations with traits.""" +from __future__ import annotations +import contextlib +import copy +import hashlib +import json +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pyblish.api +from ayon_api import ( + get_attributes_for_type, + get_product_by_name, + # get_representations, + get_version_by_name, +) +from ayon_api.operations import ( + OperationsSession, + new_product_entity, + new_representation_entity, + new_version_entity, +) +from ayon_api.utils import create_entity_id +from ayon_core.lib import source_hash +from ayon_core.lib.file_transaction import ( + FileTransaction, +) +from ayon_core.pipeline.publish import ( + PublishError, + get_publish_template_name, + has_trait_representations, + get_trait_representations, + set_trait_representations, +) +from ayon_core.pipeline.traits import ( + UDIM, + Bundle, + ColorManaged, + FileLocation, + FileLocations, + FrameRanged, + MissingTraitError, + Persistent, + PixelBased, + Representation, + Sequence, + TemplatePath, + TraitValidationError, + Transient, + Variant, +) + +if TYPE_CHECKING: + import logging + + from ayon_core.pipeline import Anatomy + from ayon_core.pipeline.anatomy.templates import ( + AnatomyStringTemplate, + ) + from ayon_core.pipeline.anatomy.templates import ( + TemplateItem as AnatomyTemplateItem, + ) + + +class TransferItem: + """Represents a single transfer item. + + Source file path, destination file path, template that was used to + construct the destination path, template data that was used in the + template, size of the file, checksum of the file. + + Attributes: + source (Path): Source file path. + destination (Path): Destination file path. + size (int): Size of the file. + checksum (str): Checksum of the file. + template (str): Template path. + template_data (dict[str, Any]): Template data. + representation (Representation): Reference to representation + + """ + source: Path + destination: Path + size: int + checksum: str + template: str + template_data: dict[str, Any] + representation: Representation + related_trait: FileLocation + + def __init__(self, + source: Path, + destination: Path, + size: int, + checksum: str, + template: str, + template_data: dict[str, Any], + representation: Representation, + related_trait: FileLocation): + + self.source = source + self.destination = destination + self.size = size + self.checksum = checksum + self.template = template + self.template_data = template_data + self.representation = representation + self.related_trait = related_trait + + @staticmethod + def get_size(file_path: Path) -> int: + """Get the size of the file. + + Args: + file_path (Path): File path. + + Returns: + int: Size of the file. + + """ + return file_path.stat().st_size + + @staticmethod + def get_checksum(file_path: Path) -> str: + """Get checksum of the file. + + Args: + file_path (Path): File path. + + Returns: + str: Checksum of the file. + + """ + return hashlib.sha256( + file_path.read_bytes() + ).hexdigest() + + +class TemplateItem: + """Represents single template item. + + Template path, template data that was used in the template. + + Attributes: + anatomy (Anatomy): Anatomy object. + template (str): Template path. + template_data (dict[str, Any]): Template data. + template_object (AnatomyTemplateItem): Template object + """ + anatomy: Anatomy + template: str + template_data: dict[str, Any] + template_object: "AnatomyTemplateItem" + + def __init__(self, + anatomy: "Anatomy", + template: str, + template_data: dict[str, Any], + template_object: "AnatomyTemplateItem"): + """Initialize TemplateItem. + + Args: + anatomy (Anatomy): Anatomy object. + template (str): Template path. + template_data (dict[str, Any]): Template data. + template_object (AnatomyTemplateItem): Template object. + + """ + self.anatomy = anatomy + self.template = template + self.template_data = template_data + self.template_object = template_object + + +class RepresentationEntity: + """Representation entity data.""" + id: str + versionId: str # noqa: N815 + name: str + files: dict[str, Any] + attrib: dict[str, Any] + data: str + tags: list[str] + status: str + + def __init__(self, + id: str, + versionId: str, # noqa: N815 + name: str, + files: dict[str, Any], + attrib: dict[str, Any], + data: str, + tags: list[str], + status: str): + """Initialize RepresentationEntity. + + Args: + id (str): Entity ID. + versionId (str): Version ID. + name (str): Representation name. + files (dict[str, Any]): Files in the representation. + attrib (dict[str, Any]): Attributes of the representation. + data (str): Data of the representation. + tags (list[str]): Tags of the representation. + status (str): Status of the representation. + + """ + self.id = id + self.versionId = versionId + self.name = name + self.files = files + self.attrib = attrib + self.data = data + self.tags = tags + self.status = status + + +def get_instance_families(instance: pyblish.api.Instance) -> list[str]: + """Get all families of the instance. + + Todo: + Move to the library. + + Args: + instance (pyblish.api.Instance): Instance to get families from. + + Returns: + list[str]: List of families. + + """ + family = instance.data.get("family") + families = [] + if family: + families.append(family) + + for _family in (instance.data.get("families") or []): + if _family not in families: + families.append(_family) + + return families + + +def get_changed_attributes( + old_entity: dict, new_entity: dict) -> dict[str, Any]: + """Prepare changes for entity update. + + Todo: + Move to the library. + + Args: + old_entity (dict[str, Any]): Existing entity. + new_entity (dict[str, Any]): New entity. + + Returns: + dict[str, Any]: Changes that have new entity. + + """ + changes = {} + for key in set(new_entity.keys()): + if key == "attrib": + continue + + if key in new_entity and new_entity[key] != old_entity.get(key): + changes[key] = new_entity[key] + continue + + attrib_changes = {} + if "attrib" in new_entity: + attrib_changes = { + key: value + for key, value in new_entity["attrib"].items() + if value != old_entity["attrib"].get(key) + } + if attrib_changes: + changes["attrib"] = attrib_changes + return changes + + +def prepare_for_json(data: dict[str, Any]) -> dict[str, Any]: + """Prepare data for JSON serialization. + + If there are values that json cannot serialize, this function will + convert them to strings. + + Args: + data (dict[str, Any]): Data to prepare. + + Returns: + dict[str, Any]: Prepared data. + + Raises: + TypeError: If the data cannot be converted to JSON. + + """ + prepared = {} + for key, value in data.items(): + if isinstance(value, dict): + value = prepare_for_json(value) + try: + json.dumps(value) + except TypeError: + value = value.as_posix() if issubclass( + value.__class__, Path) else str(value) + prepared[key] = value + return prepared + + +class IntegrateTraits(pyblish.api.InstancePlugin): + """Integrate representations with traits.""" + + label = "Integrate Traits of an Asset" + order = pyblish.api.IntegratorOrder + log: "logging.Logger" + + def process(self, instance: pyblish.api.Instance) -> None: + """Integrate representations with traits. + + Todo: + Refactor this method to be more readable and maintainable. + + Args: + instance (pyblish.api.Instance): Instance to process. + + """ + # 1) skip farm and integrate == False + + if instance.data.get("integrate", True) is False: + self.log.debug("Instance is marked to skip integrating. Skipping") + return + + if instance.data.get("farm"): + self.log.debug( + "Instance is marked to be processed on farm. Skipping") + return + + # TODO (antirotor): Find better name for the key + if not has_trait_representations(instance): + self.log.debug( + "Instance has no representations with traits. Skipping") + return + + # 2) filter representations based on LifeCycle traits + set_trait_representations( + instance, + self.filter_lifecycle(get_trait_representations(instance)) + ) + + representations: list[Representation] = get_trait_representations( + instance + ) + if not representations: + self.log.debug( + "Instance has no persistent representations. Skipping") + return + + op_session = OperationsSession() + + product_entity = self.prepare_product(instance, op_session) + + version_entity = self.prepare_version( + instance, op_session, product_entity + ) + instance.data["versionEntity"] = version_entity + + transfers = self.get_transfers_from_representations( + instance, representations) + + # 8) Transfer files + file_transactions = FileTransaction( + log=self.log, + # Enforce unique transfers + allow_queue_replacements=False) + for transfer in transfers: + self.log.debug( + "Transferring file: %s -> %s", + transfer.source, + transfer.destination + ) + file_transactions.add( + transfer.source.as_posix(), + transfer.destination.as_posix(), + mode=FileTransaction.MODE_COPY, + ) + file_transactions.process() + self.log.debug( + "Transferred files %s", [file_transactions.transferred]) + + # replace original paths with the destination in traits. + for transfer in transfers: + transfer.related_trait.file_path = transfer.destination + + # 9) Create representation entities + for representation in representations: + representation_entity = new_representation_entity( + representation.name, + version_entity["id"], + files=self._get_legacy_files_for_representation( + transfers, + representation, + anatomy=instance.context.data["anatomy"]), + attribs={}, + data="", + tags=[], + status="", + ) + # add traits to representation entity + representation_entity["traits"] = representation.traits_as_dict() + op_session.create_entity( + project_name=instance.context.data["projectName"], + entity_type="representation", + data=prepare_for_json(representation_entity), + ) + + # 10) Commit the session to AYON + self.log.debug("{}".format(op_session.to_data())) + op_session.commit() + + def get_transfers_from_representations( + self, + instance: pyblish.api.Instance, + representations: list[Representation]) -> list[TransferItem]: + """Get transfers from representations. + + This method will go through all representations and prepare transfers + based on the traits they contain. First it will validate the + representation, and then it will prepare template data for the + representation. It specifically handles FileLocations, FileLocation, + Bundle, Sequence and UDIM traits. + + Args: + instance (pyblish.api.Instance): Instance to process. + representations (list[Representation]): List of representations. + + Returns: + list[TransferItem]: List of transfers. + + Raises: + PublishError: If representation is invalid. + + """ + template: str = self.get_publish_template(instance) + instance_template_data: dict[str, str] = {} + transfers: list[TransferItem] = [] + # prepare template and data to format it + for representation in representations: + + # validate representation first, this will go through all traits + # and check if they are valid + try: + representation.validate() + except TraitValidationError as e: + msg = f"Representation '{representation.name}' is invalid: {e}" + raise PublishError(msg) from e + + template_data = self.get_template_data_from_representation( + representation, instance) + # add instance based template data + + template_data.update(instance_template_data) + + # treat Variant as `output` in template data + with contextlib.suppress(MissingTraitError): + template_data["output"] = ( + representation.get_trait(Variant).variant + ) + + template_item = TemplateItem( + anatomy=instance.context.data["anatomy"], + template=template, + template_data=copy.deepcopy(template_data), + template_object=self.get_publish_template_object(instance), + ) + + if representation.contains_trait(FileLocations): + # If representation has FileLocations trait (list of files) + # it can be either Sequence or UDIM tile set. + # We do not allow unrelated files in the single representation. + # Note: we do not support yet frame sequence of multiple UDIM + # tiles in the same representation + self.get_transfers_from_file_locations( + representation, template_item, transfers + ) + elif representation.contains_trait(FileLocation): + # This is just a single file representation + self.get_transfers_from_file_location( + representation, template_item, transfers + ) + + elif representation.contains_trait(Bundle): + # Bundle groups multiple "sub-representations" together. + # It has a list of lists with traits, some might be + # FileLocations,but some might be "file-less" representations + # or even other bundles. + self.get_transfers_from_bundle( + representation, template_item, transfers + ) + return transfers + + def _get_relative_to_root_original_dirname( + self, instance: pyblish.api.Instance) -> str: + """Get path stripped of root of the original directory name. + + If `originalDirname` or `stagingDir` is set in instance data, + this will return it as rootless path. The path must reside + within the project directory. + + Returns: + str: Relative path to the root of the project directory. + + Raises: + PublishError: If the path is not within the project directory. + + """ + original_directory = ( + instance.data.get("originalDirname") or + instance.data.get("stagingDir")) + anatomy = instance.context.data["anatomy"] + + rootless = self.get_rootless_path(anatomy, original_directory) + # this check works because _rootless will be the same as + # original_directory if the original_directory cannot be transformed + # to the rootless path. + if rootless == original_directory: + msg = ( + f"Destination path '{original_directory}' must " + "be in project directory.") + raise PublishError(msg) + # the root is at the beginning - {root[work]}/rest/of/the/path + relative_path_start = rootless.rfind("}") + 2 + return rootless[relative_path_start:] + + # 8) Transfer files + # 9) Commit the session to AYON + # 10) Finalize represetations - add integrated path Trait etc. + + @staticmethod + def filter_lifecycle( + representations: list[Representation] + ) -> list[Representation]: + """Filter representations based on LifeCycle traits. + + Args: + representations (list): List of representations. + + Returns: + list: Filtered representations. + + """ + return [ + representation + for representation in representations + if representation.contains_trait(Persistent) + ] + + def get_template_name(self, instance: pyblish.api.Instance) -> str: + """Return anatomy template name to use for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + + Returns: + str: Anatomy template name + + """ + # Anatomy data is pre-filled by Collectors + context = instance.context + project_name = context.data["projectName"] + + # Task can be optional in anatomy data + host_name = context.data["hostName"] + anatomy_data = instance.data["anatomyData"] + product_type = instance.data["productType"] + task_info = anatomy_data.get("task") or {} + + return get_publish_template_name( + project_name, + host_name, + product_type, + task_name=task_info.get("name"), + task_type=task_info.get("type"), + project_settings=context.data["project_settings"], + logger=self.log + ) + + def get_publish_template(self, instance: pyblish.api.Instance) -> str: + """Return anatomy template name to use for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + + Returns: + str: Anatomy template name + + """ + # Anatomy data is pre-filled by Collectors + template_name = self.get_template_name(instance) + anatomy = instance.context.data["anatomy"] + publish_template = anatomy.get_template_item("publish", template_name) + path_template_obj = publish_template["path"] + return path_template_obj.template.replace("\\", "/") + + def get_publish_template_object( + self, instance: pyblish.api.Instance) -> "AnatomyTemplateItem": + """Return anatomy template object to use for integration. + + Note: What is the actual type of the object? + + Args: + instance (pyblish.api.Instance): Instance to process. + + Returns: + AnatomyTemplateItem: Anatomy template object + + """ + # Anatomy data is pre-filled by Collectors + template_name = self.get_template_name(instance) + anatomy = instance.context.data["anatomy"] + return anatomy.get_template_item("publish", template_name) + + def prepare_product( + self, + instance: pyblish.api.Instance, + op_session: OperationsSession) -> dict: + """Prepare product for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + op_session (OperationsSession): Operations session. + + Returns: + dict: Product entity. + + """ + project_name = instance.context.data["projectName"] + folder_entity = instance.data["folderEntity"] + product_name = instance.data["productName"] + product_type = instance.data["productType"] + self.log.debug("Product: %s", product_name) + + # Get existing product if it exists + existing_product_entity = get_product_by_name( + project_name, product_name, folder_entity["id"] + ) + + # Define product data + data = { + "families": get_instance_families(instance) + } + attributes = {} + + product_group = instance.data.get("productGroup") + if product_group: + attributes["productGroup"] = product_group + elif existing_product_entity: + # Preserve previous product group if new version does not set it + product_group = existing_product_entity.get("attrib", {}).get( + "productGroup" + ) + if product_group is not None: + attributes["productGroup"] = product_group + + product_id = existing_product_entity["id"] if existing_product_entity else None # noqa: E501 + product_entity = new_product_entity( + product_name, + product_type, + folder_entity["id"], + data=data, + attribs=attributes, + entity_id=product_id + ) + + if existing_product_entity is None: + # Create a new product + self.log.info( + "Product '%s' not found, creating ...", + product_name + ) + op_session.create_entity( + project_name, "product", product_entity + ) + + else: + # Update existing product data with new data and set in database. + # We also change the found product in-place so we don't need to + # re-query the product afterward + update_data = get_changed_attributes( + existing_product_entity, product_entity + ) + op_session.update_entity( + project_name, + "product", + product_entity["id"], + update_data + ) + + self.log.debug("Prepared product: %s", product_name) + return product_entity + + def prepare_version( + self, + instance: pyblish.api.Instance, + op_session: OperationsSession, + product_entity: dict) -> dict: + """Prepare version for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + op_session (OperationsSession): Operations session. + product_entity (dict): Product entity. + + Returns: + dict: Version entity. + + """ + project_name = instance.context.data["projectName"] + version_number = instance.data["version"] + task_entity = instance.data.get("taskEntity") + task_id = task_entity["id"] if task_entity else None + existing_version = get_version_by_name( + project_name, + version_number, + product_entity["id"] + ) + version_id = existing_version["id"] if existing_version else None + all_version_data = self.get_version_data_from_instance(instance) + version_data = {} + version_attributes = {} + attr_defs = self.get_attributes_for_type(instance.context, "version") + for key, value in all_version_data.items(): + if key in attr_defs: + version_attributes[key] = value + else: + version_data[key] = value + + version_entity = new_version_entity( + version_number, + product_entity["id"], + task_id=task_id, + status=instance.data.get("status"), + data=version_data, + attribs=version_attributes, + entity_id=version_id, + ) + + if existing_version: + self.log.debug("Updating existing version ...") + update_data = get_changed_attributes( + existing_version, version_entity) + op_session.update_entity( + project_name, + "version", + version_entity["id"], + update_data + ) + else: + self.log.debug("Creating new version ...") + op_session.create_entity( + project_name, "version", version_entity + ) + + self.log.debug( + "Prepared version: v%s", + "{:03d}".format(version_entity["version"]) + ) + + return version_entity + + def get_version_data_from_instance( + self, instance: pyblish.api.Instance) -> dict: + """Get version data from the Instance. + + Args: + instance (pyblish.api.Instance): the current instance + being published. + + Returns: + dict: the required information for ``version["data"]`` + + """ + context = instance.context + + # create relative source path for DB + if "source" in instance.data: + source = instance.data["source"] + else: + source = context.data["currentFile"] + anatomy = instance.context.data["anatomy"] + source = self.get_rootless_path(anatomy, source) + self.log.debug("Source: %s", source) + + version_data = { + "families": get_instance_families(instance), + "time": context.data["time"], + "author": context.data["user"], + "source": source, + "comment": instance.data["comment"], + "machine": context.data.get("machine"), + "fps": instance.data.get("fps", context.data.get("fps")) + } + + intent_value = context.data.get("intent") + if intent_value and isinstance(intent_value, dict): + intent_value = intent_value.get("value") + + if intent_value: + version_data["intent"] = intent_value + + # Include optional data if present in + optionals = [ + "frameStart", "frameEnd", "step", + "handleEnd", "handleStart", "sourceHashes" + ] + for key in optionals: + if key in instance.data: + version_data[key] = instance.data[key] + + # Include instance.data[versionData] directly + version_data_instance = instance.data.get("versionData") + if version_data_instance: + version_data.update(version_data_instance) + + return version_data + + def get_rootless_path(self, anatomy: "Anatomy", path: str) -> str: + r"""Get rootless variant of the path. + + Returns, if possible, a path without an absolute portion from the root + (e.g. 'c:\' or '/opt/..'). This is basically a wrapper for the + meth:`Anatomy.find_root_template_from_path` method that displays + a warning if the root path is not found. + + This information is platform-dependent and shouldn't be captured. + For example:: + + 'c:/projects/MyProject1/Assets/publish...' + will be transformed to: + '{root}/MyProject1/Assets...' + + Args: + anatomy (Anatomy): Project anatomy. + path (str): Absolute path. + + Returns: + str: Path where root path is replaced by formatting string. + + """ + success, rootless_path = anatomy.find_root_template_from_path(path) + if success: + path = rootless_path + else: + self.log.warning(( + 'Could not find root path for remapping "%s".' + " This may cause issues on farm." + ), path) + return path + + def get_attributes_for_type( + self, + context: pyblish.api.Context, + entity_type: str) -> dict: + """Get AYON attributes for the given entity type. + + Args: + context (pyblish.api.Context): Context to get attributes from. + entity_type (str): Entity type to get attributes for. + + Returns: + dict: AYON attributes for the given entity type. + + """ + return self.get_attributes_by_type(context)[entity_type] + + @staticmethod + def get_attributes_by_type( + context: pyblish.api.Context) -> dict: + """Gets AYON attributes from the given context. + + Args: + context (pyblish.api.Context): Context to get attributes from. + + Returns: + dict: AYON attributes. + + """ + attributes = context.data.get("ayonAttributes") + if attributes is None: + attributes = { + key: get_attributes_for_type(key) + for key in ( + "project", + "folder", + "product", + "version", + "representation", + ) + } + context.data["ayonAttributes"] = attributes + return attributes + + def get_template_data_from_representation( + self, + representation: Representation, + instance: pyblish.api.Instance) -> dict: + """Get template data from representation. + + Using representation traits and data on instance + prepare data for formatting template. + + Args: + representation (Representation): Representation to process. + instance (pyblish.api.Instance): Instance to process. + + Returns: + dict: Template data. + + """ + template_data = copy.deepcopy(instance.data["anatomyData"]) + template_data["representation"] = representation.name + template_data["version"] = instance.data["version"] + # template_data["hierarchy"] = instance.data["hierarchy"] + + # add colorspace data to template data + if representation.contains_trait(ColorManaged): + colorspace_data: ColorManaged = representation.get_trait( + ColorManaged) + + template_data["colorspace"] = { + "colorspace": colorspace_data.color_space, + "config": colorspace_data.config + } + + # add explicit list of traits properties to template data + # there must be some better way to handle this + try: + # resolution from PixelBased trait + template_data["resolution_width"] = representation.get_trait( + PixelBased).display_window_width + template_data["resolution_height"] = representation.get_trait( + PixelBased).display_window_height + # get fps from representation traits + template_data["fps"] = representation.get_trait( + FrameRanged).frames_per_second + + # Note: handle "output" and "originalBasename" + + except MissingTraitError as e: + self.log.debug("Missing traits: %s", e) + + return template_data + + @staticmethod + def get_transfers_from_file_locations( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem]) -> None: + """Get transfers from FileLocations trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + Raises: + PublishError: If representation is invalid. + + """ + if representation.contains_trait(Sequence): + IntegrateTraits.get_transfers_from_sequence( + representation, template_item, transfers + ) + + elif representation.contains_trait(UDIM) and \ + not representation.contains_trait(Sequence): + # handle UDIM not in sequence + IntegrateTraits.get_transfers_from_udim( + representation, template_item, transfers + ) + + else: + # This should never happen because the representation + # validation should catch this. + msg = ( + "Representation contains FileLocations trait, but " + "is not a Sequence or UDIM." + ) + raise PublishError(msg) + + @staticmethod + def get_transfers_from_sequence( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from Sequence trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + sequence: Sequence = representation.get_trait(Sequence) + path_template_object = template_item.template_object["path"] + + # get the padding from the sequence if the padding on the + # template is higher, us the one from the template + dst_padding = representation.get_trait( + Sequence).frame_padding + frames: list[int] = sequence.get_frame_list( + representation.get_trait(FileLocations), + regex=sequence.frame_regex) + template_padding = template_item.anatomy.templates_obj.frame_padding + dst_padding = max(template_padding, dst_padding) + + # Go through all frames in the sequence and + # find their corresponding file locations, then + # format their template and add them to transfers. + for frame in frames: + file_loc: FileLocation = representation.get_trait( + FileLocations).get_file_location_for_frame( + frame, sequence) + + template_item.template_data["frame"] = frame + template_item.template_data["ext"] = ( + file_loc.file_path.suffix.lstrip(".")) + template_filled = path_template_object.format_strict( + template_item.template_data + ) + + # add used values to the template data + used_values: dict = template_filled.used_values + template_item.template_data.update(used_values) + + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size or TransferItem.get_size( + file_loc.file_path), + checksum=file_loc.file_hash or TransferItem.get_checksum( + file_loc.file_path), + template=template_item.template, + template_data=template_item.template_data, + representation=representation, + related_trait=file_loc + ) + ) + + # add template path and the data to resolve it + if not representation.contains_trait(TemplatePath): + representation.add_trait(TemplatePath( + template=template_item.template, + data=template_item.template_data + )) + + @staticmethod + def get_transfers_from_udim( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from UDIM trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + udim: UDIM = representation.get_trait(UDIM) + path_template_object: "AnatomyStringTemplate" = ( + template_item.template_object["path"] + ) + for file_loc in representation.get_trait( + FileLocations).file_paths: + template_item.template_data["udim"] = ( + udim.get_udim_from_file_location(file_loc) + ) + + template_filled = path_template_object.format_strict( + template_item.template_data + ) + + # add used values to the template data + used_values: dict = template_filled.used_values + template_item.template_data.update(used_values) + + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size or TransferItem.get_size( + file_loc.file_path), + checksum=file_loc.file_hash or TransferItem.get_checksum( + file_loc.file_path), + template=template_item.template, + template_data=template_item.template_data, + representation=representation, + related_trait=file_loc + ) + ) + # add template path and the data to resolve it + representation.add_trait(TemplatePath( + template=template_item.template, + data=template_item.template_data + )) + + @staticmethod + def get_transfers_from_file_location( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from FileLocation trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + path_template_object: "AnatomyStringTemplate" = ( + template_item.template_object["path"] + ) + template_item.template_data["ext"] = ( + representation.get_trait(FileLocation).file_path.suffix.lstrip(".") + ) + template_item.template_data.pop("frame", None) + with contextlib.suppress(MissingTraitError): + udim = representation.get_trait(UDIM) + template_item.template_data["udim"] = udim.udim[0] + + template_filled = path_template_object.format_strict( + template_item.template_data + ) + + # add used values to the template data + used_values: dict = template_filled.used_values + template_item.template_data.update(used_values) + + file_loc: FileLocation = representation.get_trait(FileLocation) + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size or TransferItem.get_size( + file_loc.file_path), + checksum=file_loc.file_hash or TransferItem.get_checksum( + file_loc.file_path), + template=template_item.template, + template_data=template_item.template_data, + representation=representation, + related_trait=file_loc + ) + ) + # add template path and the data to resolve it + representation.add_trait(TemplatePath( + template=template_item.template, + data=template_item.template_data + )) + + @staticmethod + def get_transfers_from_bundle( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from Bundle trait. + + This will be called recursively for each sub-representation in the + bundle that is a Bundle itself. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + bundle: Bundle = representation.get_trait(Bundle) + for idx, sub_representation_traits in enumerate(bundle.items): + sub_representation = Representation( + name=f"{representation.name}_{idx}", + traits=sub_representation_traits) + # sub presentation transient: + sub_representation.add_trait(Transient()) + if sub_representation.contains_trait(FileLocations): + IntegrateTraits.get_transfers_from_file_locations( + sub_representation, template_item, transfers + ) + elif sub_representation.contains_trait(FileLocation): + IntegrateTraits.get_transfers_from_file_location( + sub_representation, template_item, transfers + ) + elif sub_representation.contains_trait(Bundle): + IntegrateTraits.get_transfers_from_bundle( + sub_representation, template_item, transfers + ) + + def _prepare_file_info( + self, path: Path, anatomy: "Anatomy") -> dict[str, Any]: + """Prepare information for one file (asset or resource). + + Arguments: + path (Path): Destination url of published file. + anatomy (Anatomy): Project anatomy part from instance. + + Raises: + PublishError: If file does not exist. + + Returns: + dict[str, Any]: Representation file info dictionary. + + """ + if not path.exists(): + msg = f"File '{path}' does not exist." + raise PublishError(msg) + + return { + "id": create_entity_id(), + "name": path.name, + "path": self.get_rootless_path(anatomy, path.as_posix()), + "size": path.stat().st_size, + "hash": source_hash(path.as_posix()), + "hash_type": "op3", + } + + def _get_legacy_files_for_representation( + self, + transfer_items: list[TransferItem], + representation: Representation, + anatomy: "Anatomy", + ) -> list[dict[str, str]]: + """Get legacy files for a given representation. + + This expects the file to exist - it must run after the transfer + is done. + + Returns: + list: List of legacy files. + + """ + selected: list[TransferItem] = [] + selected.extend( + item + for item in transfer_items + if item.representation == representation + ) + files: list[dict[str, str]] = [] + files.extend( + self._prepare_file_info(item.destination, anatomy) + for item in selected + ) + return files diff --git a/client/ayon_core/plugins/publish/preintegrate_thumbnail_representation.py b/client/ayon_core/plugins/publish/preintegrate_thumbnail_representation.py index 8bd67c0183..900febc236 100644 --- a/client/ayon_core/plugins/publish/preintegrate_thumbnail_representation.py +++ b/client/ayon_core/plugins/publish/preintegrate_thumbnail_representation.py @@ -22,6 +22,8 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin): label = "Override Integrate Thumbnail Representations" order = pyblish.api.IntegratorOrder - 0.1 + settings_category = "core" + integrate_profiles = [] def process(self, instance): diff --git a/client/ayon_core/plugins/publish/validate_containers.py b/client/ayon_core/plugins/publish/validate_containers.py index 520e7a7ce9..fda3d93627 100644 --- a/client/ayon_core/plugins/publish/validate_containers.py +++ b/client/ayon_core/plugins/publish/validate_containers.py @@ -31,6 +31,7 @@ class ValidateOutdatedContainers( label = "Validate Outdated Containers" order = pyblish.api.ValidatorOrder + settings_category = "core" optional = True actions = [ShowInventory] diff --git a/client/ayon_core/plugins/publish/validate_file_saved.py b/client/ayon_core/plugins/publish/validate_file_saved.py index 4f9e84aee0..28734ba714 100644 --- a/client/ayon_core/plugins/publish/validate_file_saved.py +++ b/client/ayon_core/plugins/publish/validate_file_saved.py @@ -37,7 +37,7 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): label = "Validate File Saved" order = pyblish.api.ValidatorOrder - 0.1 hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter", - "cinema4d", "silhouette"] + "cinema4d", "silhouette", "gaffer", "blender", "loki"] actions = [SaveByVersionUpAction, ShowWorkfilesAction] def process(self, context): diff --git a/client/ayon_core/plugins/publish/validate_intent.py b/client/ayon_core/plugins/publish/validate_intent.py index 71df652e92..fa5e5af093 100644 --- a/client/ayon_core/plugins/publish/validate_intent.py +++ b/client/ayon_core/plugins/publish/validate_intent.py @@ -14,6 +14,8 @@ class ValidateIntent(pyblish.api.ContextPlugin): order = pyblish.api.ValidatorOrder label = "Validate Intent" + settings_category = "core" + enabled = False # Can be modified by settings diff --git a/client/ayon_core/plugins/publish/validate_unique_subsets.py b/client/ayon_core/plugins/publish/validate_unique_subsets.py index 4067dd75a5..26c9ada116 100644 --- a/client/ayon_core/plugins/publish/validate_unique_subsets.py +++ b/client/ayon_core/plugins/publish/validate_unique_subsets.py @@ -34,7 +34,11 @@ class ValidateProductUniqueness(pyblish.api.ContextPlugin): for instance in context: # Ignore disabled instances - if not instance.data.get('publish', True): + if not instance.data.get("publish", True): + continue + + # Ignore instances not marked to integrate + if not instance.data.get("integrate", True): continue # Ignore instance without folder data diff --git a/client/ayon_core/plugins/publish/validate_version.py b/client/ayon_core/plugins/publish/validate_version.py index 0359f8fb53..d63c4e1f03 100644 --- a/client/ayon_core/plugins/publish/validate_version.py +++ b/client/ayon_core/plugins/publish/validate_version.py @@ -17,6 +17,7 @@ class ValidateVersion(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): order = pyblish.api.ValidatorOrder label = "Validate Version" + settings_category = "core" optional = False active = True diff --git a/client/ayon_core/settings/lib.py b/client/ayon_core/settings/lib.py index 72af07799f..a875e0116a 100644 --- a/client/ayon_core/settings/lib.py +++ b/client/ayon_core/settings/lib.py @@ -4,6 +4,8 @@ import logging import collections import copy import time +import warnings +from urllib.parse import urlencode import ayon_api @@ -35,6 +37,37 @@ class CacheItem: return time.time() > self._outdate_time +def _get_addons_settings( + studio_bundle_name, + project_bundle_name, + variant, + project_name=None, +): + """Modified version of `ayon_api.get_addons_settings` function.""" + query_values = { + key: value + for key, value in ( + ("bundle_name", studio_bundle_name), + ("variant", variant), + ("project_name", project_name), + ) + if value + } + if project_bundle_name != studio_bundle_name: + query_values["project_bundle_name"] = project_bundle_name + + site_id = ayon_api.get_site_id() + if site_id: + query_values["site_id"] = site_id + + response = ayon_api.get(f"settings?{urlencode(query_values)}") + response.raise_for_status() + return { + addon["name"]: addon["settings"] + for addon in response.data["addons"] + } + + class _AyonSettingsCache: use_bundles = None variant = None @@ -67,53 +100,70 @@ class _AyonSettingsCache: return _AyonSettingsCache.variant @classmethod - def _get_bundle_name(cls): + def _get_studio_bundle_name(cls): + bundle_name = os.environ.get("AYON_STUDIO_BUNDLE_NAME") + if bundle_name: + return bundle_name + return os.environ["AYON_BUNDLE_NAME"] + + @classmethod + def _get_project_bundle_name(cls): return os.environ["AYON_BUNDLE_NAME"] @classmethod def get_value_by_project(cls, project_name): cache_item = _AyonSettingsCache.cache_by_project_name[project_name] if cache_item.is_outdated: - if cls._use_bundles(): - value = ayon_api.get_addons_settings( - bundle_name=cls._get_bundle_name(), + cache_item.update_value( + _get_addons_settings( + studio_bundle_name=cls._get_studio_bundle_name(), + project_bundle_name=cls._get_project_bundle_name(), project_name=project_name, - variant=cls._get_variant() + variant=cls._get_variant(), ) - else: - value = ayon_api.get_addons_settings(project_name) - cache_item.update_value(value) + ) return cache_item.get_value() @classmethod def _get_addon_versions_from_bundle(cls): - expected_bundle = cls._get_bundle_name() + studio_bundle_name = cls._get_studio_bundle_name() + project_bundle_name = cls._get_project_bundle_name() bundles = ayon_api.get_bundles()["bundles"] - bundle = next( + project_bundle = next( ( bundle for bundle in bundles - if bundle["name"] == expected_bundle + if bundle["name"] == project_bundle_name ), None ) - if bundle is not None: - return bundle["addons"] + studio_bundle = None + if studio_bundle_name and project_bundle_name != studio_bundle_name: + studio_bundle = next( + ( + bundle + for bundle in bundles + if bundle["name"] == studio_bundle_name + ), + None + ) + + if studio_bundle and project_bundle: + addons = copy.deepcopy(studio_bundle["addons"]) + addons.update(project_bundle["addons"]) + project_bundle["addons"] = addons + + if project_bundle is not None: + return project_bundle["addons"] return {} @classmethod def get_addon_versions(cls): cache_item = _AyonSettingsCache.addon_versions if cache_item.is_outdated: - if cls._use_bundles(): - addons = cls._get_addon_versions_from_bundle() - else: - settings_data = ayon_api.get_addons_settings( - only_values=False, - variant=cls._get_variant() - ) - addons = settings_data["versions"] - cache_item.update_value(addons) + cache_item.update_value( + cls._get_addon_versions_from_bundle() + ) return cache_item.get_value() @@ -175,17 +225,22 @@ def get_project_environments(project_name, project_settings=None): def get_current_project_settings(): - """Project settings for current context project. + """DEPRECATE Project settings for current context project. + + Function requires access to pipeline context which is in + 'ayon_core.pipeline'. + + Returns: + dict[str, Any]: Project settings for current context project. - Project name should be stored in environment variable `AYON_PROJECT_NAME`. - This function should be used only in host context where environment - variable must be set and should not happen that any part of process will - change the value of the environment variable. """ - project_name = os.environ.get("AYON_PROJECT_NAME") - if not project_name: - raise ValueError( - "Missing context project in environment" - " variable `AYON_PROJECT_NAME`." - ) - return get_project_settings(project_name) + warnings.warn( + "Used deprecated function 'get_current_project_settings' in" + " 'ayon_core.settings'. The function was moved to" + " 'ayon_core.pipeline.context_tools'.", + DeprecationWarning, + stacklevel=2 + ) + from ayon_core.pipeline.context_tools import get_current_project_settings + + return get_current_project_settings() diff --git a/client/ayon_core/style/data.json b/client/ayon_core/style/data.json index 24629ec085..56d2190e09 100644 --- a/client/ayon_core/style/data.json +++ b/client/ayon_core/style/data.json @@ -97,6 +97,7 @@ }, "publisher": { "error": "#AA5050", + "disabled": "#5b6779", "crash": "#FF6432", "success": "#458056", "warning": "#ffc671", diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 4ef903540e..0d057beb7b 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -829,35 +829,47 @@ HintedLineEditButton { } /* Launcher specific stylesheets */ -ActionsView[mode="icon"] { +ActionsView { /* font size can't be set on items */ - font-size: 9pt; + font-size: 8pt; border: 0px; padding: 0px; margin: 0px; } -ActionsView[mode="icon"]::item { +ActionsView::item { padding-top: 8px; padding-bottom: 4px; border: 0px; - border-radius: 0.3em; + border-radius: 5px; } -ActionsView[mode="icon"]::item:hover { +ActionsView::item:hover { color: {color:font-hover}; background: #424A57; } -ActionsView[mode="icon"]::icon {} +ActionsView::icon {} + +ActionMenuPopup #GroupLabel { + padding: 5px; + color: #ffffff; +} + +ActionMenuPopup #ShadowFrame { + border-radius: 5px; + background: rgba(12, 13, 24, 0.5); +} ActionMenuPopup #Wrapper { - border-radius: 0.3em; + border-radius: 5px; background: #353B46; } -ActionMenuPopup ActionsView[mode="icon"] { + +ActionMenuPopup ActionsView { background: transparent; border: none; + margin: 4px; } #IconView[mode="icon"] { @@ -893,6 +905,70 @@ ActionMenuPopup ActionsView[mode="icon"] { border-radius: 0.1em; } +/* Launcher specific stylesheets */ +FiltersBar { + background: {color:bg-inputs}; + border: 1px solid {color:border}; + border-radius: 5px; +} + +FiltersBar #ScrollArea { + background: {color:bg-inputs}; +} +FiltersBar #SearchButton { + background: transparent; +} + +FiltersBar #BackButton { + background: transparent; +} + +FiltersBar #BackButton:hover { + background: {color:bg-buttons-hover}; +} + +FiltersBar #ConfirmButton { + background: #91CDFB; + color: #03344D; +} + +FiltersPopup #PopupWrapper, FilterValuePopup #PopupWrapper { + border-radius: 5px; + background: {color:bg-inputs}; +} + +FiltersPopup #ShadowFrame, FilterValuePopup #ShadowFrame { + border-radius: 5px; + background: rgba(0, 0, 0, 0.5); +} + +FilterItemButton, FilterValueItemButton { + border-radius: 5px; + background: transparent; +} +FilterItemButton:hover, FilterValueItemButton:hover { + background: {color:bg-buttons-hover}; +} +FilterValueItemButton[selected="1"] { + background: {color:bg-view-selection}; +} +FilterValueItemButton[selected="1"]:hover { + background: {color:bg-view-selection-hover}; +} +FilterValueItemsView #ContentWidget { + background: {color:bg-inputs}; +} +SearchItemDisplayWidget { + border-radius: 5px; +} +SearchItemDisplayWidget:hover { + background: {color:bg-buttons}; +} +SearchItemDisplayWidget #ValueWidget { + border-radius: 3px; + background: {color:bg-buttons}; +} + /* Subset Manager */ #SubsetManagerDetailsText {} #SubsetManagerDetailsText[state="invalid"] { @@ -1077,6 +1153,10 @@ PixmapButton:disabled { color: {color:publisher:error}; } +#ListViewProductName[state="disabled"] { + color: {color:publisher:disabled}; +} + #PublishInfoFrame { background: {color:bg}; border-radius: 0.3em; diff --git a/client/ayon_core/tools/attribute_defs/files_widget.py b/client/ayon_core/tools/attribute_defs/files_widget.py index 8a40b3ff38..4c55ae5620 100644 --- a/client/ayon_core/tools/attribute_defs/files_widget.py +++ b/client/ayon_core/tools/attribute_defs/files_widget.py @@ -892,6 +892,29 @@ class FilesWidget(QtWidgets.QFrame): self._add_filepaths(new_items) self._remove_item_by_ids(item_ids) + def _on_merge_request(self): + if self._multivalue: + return + + item_ids = self._files_view.get_selected_item_ids() + if not item_ids: + return + + all_paths = set() + merged_item_ids = set() + for item_id in item_ids: + file_item = self._files_model.get_file_item_by_id(item_id) + if file_item is None: + continue + merged_item_ids.add(item_id) + all_paths |= { + os.path.join(file_item.directory, filename) + for filename in file_item.filenames + } + self._remove_item_by_ids(merged_item_ids) + new_items = FileDefItem.from_value(list(all_paths), True) + self._add_filepaths(new_items) + def _on_remove_requested(self): if self._multivalue: return @@ -911,6 +934,9 @@ class FilesWidget(QtWidgets.QFrame): split_action.triggered.connect(self._on_split_request) menu.addAction(split_action) + merge_action = QtWidgets.QAction("Merge sequence", menu) + merge_action.triggered.connect(self._on_merge_request) + menu.addAction(merge_action) remove_action = QtWidgets.QAction("Remove", menu) remove_action.triggered.connect(self._on_remove_requested) menu.addAction(remove_action) diff --git a/client/ayon_core/tools/common_models/__init__.py b/client/ayon_core/tools/common_models/__init__.py index ece189fdc6..ec69e20b64 100644 --- a/client/ayon_core/tools/common_models/__init__.py +++ b/client/ayon_core/tools/common_models/__init__.py @@ -2,6 +2,7 @@ from .cache import CacheItem, NestedCacheItem from .projects import ( + TagItem, StatusItem, StatusStates, ProjectItem, @@ -25,6 +26,7 @@ __all__ = ( "CacheItem", "NestedCacheItem", + "TagItem", "StatusItem", "StatusStates", "ProjectItem", diff --git a/client/ayon_core/tools/common_models/hierarchy.py b/client/ayon_core/tools/common_models/hierarchy.py index 891eb80960..37d97af625 100644 --- a/client/ayon_core/tools/common_models/hierarchy.py +++ b/client/ayon_core/tools/common_models/hierarchy.py @@ -100,12 +100,14 @@ class TaskItem: label: Union[str, None], task_type: str, parent_id: str, + tags: list[str], ): self.task_id = task_id self.name = name self.label = label self.task_type = task_type self.parent_id = parent_id + self.tags = tags self._full_label = None @@ -145,6 +147,7 @@ class TaskItem: "label": self.label, "parent_id": self.parent_id, "task_type": self.task_type, + "tags": self.tags, } @classmethod @@ -176,7 +179,8 @@ def _get_task_items_from_tasks(tasks): task["name"], task["label"], task["type"], - folder_id + folder_id, + task["tags"], )) return output @@ -217,6 +221,8 @@ class HierarchyModel(object): lifetime = 60 # A minute def __init__(self, controller): + self._tags_by_entity_type = NestedCacheItem( + levels=1, default_factory=dict, lifetime=self.lifetime) self._folders_items = NestedCacheItem( levels=1, default_factory=dict, lifetime=self.lifetime) self._folders_by_id = NestedCacheItem( @@ -235,6 +241,7 @@ class HierarchyModel(object): self._controller = controller def reset(self): + self._tags_by_entity_type.reset() self._folders_items.reset() self._folders_by_id.reset() @@ -514,6 +521,31 @@ class HierarchyModel(object): return output + def get_available_tags_by_entity_type( + self, project_name: str + ) -> dict[str, list[str]]: + """Get available tags for all entity types in a project.""" + cache = self._tags_by_entity_type.get(project_name) + if not cache.is_valid: + tags = None + if project_name: + response = ayon_api.get(f"projects/{project_name}/tags") + if response.status_code == 200: + tags = response.data + + # Fake empty tags + if tags is None: + tags = { + "folders": [], + "tasks": [], + "products": [], + "versions": [], + "representations": [], + "workfiles": [] + } + cache.update_data(tags) + return cache.get_data() + @contextlib.contextmanager def _folder_refresh_event_manager(self, project_name, sender): self._folders_refreshing.add(project_name) @@ -617,6 +649,6 @@ class HierarchyModel(object): tasks = list(ayon_api.get_tasks( project_name, folder_ids=[folder_id], - fields={"id", "name", "label", "folderId", "type"} + fields={"id", "name", "label", "folderId", "type", "tags"} )) return _get_task_items_from_tasks(tasks) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 7ec941e6bd..034947de3a 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import contextlib from abc import ABC, abstractmethod from typing import Dict, Any +from dataclasses import dataclass import ayon_api @@ -72,6 +75,13 @@ class StatusItem: ) +@dataclass +class TagItem: + """Tag definition set on project anatomy.""" + name: str + color: str + + class FolderTypeItem: """Item representing folder type of project. @@ -140,6 +150,7 @@ class TaskTypeItem: ) +@dataclass class ProjectItem: """Item representing folder entity on a server. @@ -150,21 +161,14 @@ class ProjectItem: active (Union[str, None]): Parent folder id. If 'None' then project is parent. """ - - def __init__(self, name, active, is_library, icon=None): - self.name = name - self.active = active - self.is_library = is_library - if icon is None: - icon = { - "type": "awesome-font", - "name": "fa.book" if is_library else "fa.map", - "color": get_default_entity_icon_color(), - } - self.icon = icon + name: str + active: bool + is_library: bool + icon: dict[str, Any] + is_pinned: bool = False @classmethod - def from_entity(cls, project_entity): + def from_entity(cls, project_entity: dict[str, Any]) -> "ProjectItem": """Creates folder item from entity. Args: @@ -174,10 +178,16 @@ class ProjectItem: ProjectItem: Project item. """ + icon = { + "type": "awesome-font", + "name": "fa.book" if project_entity["library"] else "fa.map", + "color": get_default_entity_icon_color(), + } return cls( project_entity["name"], project_entity["active"], project_entity["library"], + icon ) def to_data(self): @@ -208,16 +218,18 @@ class ProjectItem: return cls(**data) -def _get_project_items_from_entitiy(projects): +def _get_project_items_from_entitiy( + projects: list[dict[str, Any]] +) -> list[ProjectItem]: """ Args: projects (list[dict[str, Any]]): List of projects. Returns: - ProjectItem: Project item. - """ + list[ProjectItem]: Project item. + """ return [ ProjectItem.from_entity(project) for project in projects @@ -288,6 +300,22 @@ class ProjectsModel(object): project_cache.update_data(entity) return project_cache.get_data() + def get_project_anatomy_tags(self, project_name: str) -> list[TagItem]: + """Get project anatomy tags. + + Args: + project_name (str): Project name. + + Returns: + list[TagItem]: Tag definitions. + + """ + project_entity = self.get_project_entity(project_name) + return [ + TagItem(tag["name"], tag["color"]) + for tag in project_entity["tags"] + ] + def get_project_status_items(self, project_name, sender): """Get project status items. @@ -428,9 +456,20 @@ class ProjectsModel(object): self._projects_cache.update_data(project_items) return self._projects_cache.get_data() - def _query_projects(self): + def _query_projects(self) -> list[ProjectItem]: projects = ayon_api.get_projects(fields=["name", "active", "library"]) - return _get_project_items_from_entitiy(projects) + user = ayon_api.get_user() + pinned_projects = ( + user + .get("data", {}) + .get("frontendPreferences", {}) + .get("pinnedProjects") + ) or [] + pinned_projects = set(pinned_projects) + project_items = _get_project_items_from_entitiy(list(projects)) + for project in project_items: + project.is_pinned = project.name in pinned_projects + return project_items def _status_items_getter(self, project_entity): if not project_entity: diff --git a/client/ayon_core/tools/creator/__init__.py b/client/ayon_core/tools/creator/__init__.py deleted file mode 100644 index 585b8bdf80..0000000000 --- a/client/ayon_core/tools/creator/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .window import ( - show, - CreatorWindow -) - -__all__ = ( - "show", - "CreatorWindow" -) diff --git a/client/ayon_core/tools/creator/constants.py b/client/ayon_core/tools/creator/constants.py deleted file mode 100644 index ec555fbe9c..0000000000 --- a/client/ayon_core/tools/creator/constants.py +++ /dev/null @@ -1,8 +0,0 @@ -from qtpy import QtCore - - -PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1 -ITEM_ID_ROLE = QtCore.Qt.UserRole + 2 - -SEPARATOR = "---" -SEPARATORS = {"---", "---separator---"} diff --git a/client/ayon_core/tools/creator/model.py b/client/ayon_core/tools/creator/model.py deleted file mode 100644 index bf6c7380a1..0000000000 --- a/client/ayon_core/tools/creator/model.py +++ /dev/null @@ -1,61 +0,0 @@ -import uuid -from qtpy import QtGui, QtCore - -from ayon_core.pipeline import discover_legacy_creator_plugins - -from . constants import ( - PRODUCT_TYPE_ROLE, - ITEM_ID_ROLE -) - - -class CreatorsModel(QtGui.QStandardItemModel): - def __init__(self, *args, **kwargs): - super(CreatorsModel, self).__init__(*args, **kwargs) - - self._creators_by_id = {} - - def reset(self): - # TODO change to refresh when clearing is not needed - self.clear() - self._creators_by_id = {} - - items = [] - creators = discover_legacy_creator_plugins() - for creator in creators: - if not creator.enabled: - continue - item_id = str(uuid.uuid4()) - self._creators_by_id[item_id] = creator - - label = creator.label or creator.product_type - item = QtGui.QStandardItem(label) - item.setEditable(False) - item.setData(item_id, ITEM_ID_ROLE) - item.setData(creator.product_type, PRODUCT_TYPE_ROLE) - items.append(item) - - if not items: - item = QtGui.QStandardItem("No registered create plugins") - item.setEnabled(False) - item.setData(False, QtCore.Qt.ItemIsEnabled) - items.append(item) - - items.sort(key=lambda item: item.text()) - self.invisibleRootItem().appendRows(items) - - def get_creator_by_id(self, item_id): - return self._creators_by_id.get(item_id) - - def get_indexes_by_product_type(self, product_type): - indexes = [] - for row in range(self.rowCount()): - index = self.index(row, 0) - item_id = index.data(ITEM_ID_ROLE) - creator_plugin = self._creators_by_id.get(item_id) - if creator_plugin and ( - creator_plugin.label.lower() == product_type.lower() - or creator_plugin.product_type.lower() == product_type.lower() - ): - indexes.append(index) - return indexes diff --git a/client/ayon_core/tools/creator/widgets.py b/client/ayon_core/tools/creator/widgets.py deleted file mode 100644 index bbc6848e6c..0000000000 --- a/client/ayon_core/tools/creator/widgets.py +++ /dev/null @@ -1,275 +0,0 @@ -import re -import inspect - -from qtpy import QtWidgets, QtCore, QtGui - -import qtawesome - -from ayon_core.pipeline.create import PRODUCT_NAME_ALLOWED_SYMBOLS -from ayon_core.tools.utils import ErrorMessageBox - -if hasattr(QtGui, "QRegularExpressionValidator"): - RegularExpressionValidatorClass = QtGui.QRegularExpressionValidator - RegularExpressionClass = QtCore.QRegularExpression -else: - RegularExpressionValidatorClass = QtGui.QRegExpValidator - RegularExpressionClass = QtCore.QRegExp - - -class CreateErrorMessageBox(ErrorMessageBox): - def __init__( - self, - product_type, - product_name, - folder_path, - exc_msg, - formatted_traceback, - parent - ): - self._product_type = product_type - self._product_name = product_name - self._folder_path = folder_path - self._exc_msg = exc_msg - self._formatted_traceback = formatted_traceback - super(CreateErrorMessageBox, self).__init__("Creation failed", parent) - - def _create_top_widget(self, parent_widget): - label_widget = QtWidgets.QLabel(parent_widget) - label_widget.setText( - "Failed to create" - ) - return label_widget - - def _get_report_data(self): - report_message = ( - "Failed to create Product: \"{product_name}\"" - " Type: \"{product_type}\"" - " in Folder: \"{folder_path}\"" - "\n\nError: {message}" - ).format( - product_name=self._product_name, - product_type=self._product_type, - folder_path=self._folder_path, - message=self._exc_msg - ) - if self._formatted_traceback: - report_message += "\n\n{}".format(self._formatted_traceback) - return [report_message] - - def _create_content(self, content_layout): - item_name_template = ( - "{}: {{}}
" - "{}: {{}}
" - "{}: {{}}
" - ).format( - "Product type", - "Product name", - "Folder" - ) - exc_msg_template = "{}" - - line = self._create_line() - content_layout.addWidget(line) - - item_name_widget = QtWidgets.QLabel(self) - item_name_widget.setText( - item_name_template.format( - self._product_type, self._product_name, self._folder_path - ) - ) - content_layout.addWidget(item_name_widget) - - message_label_widget = QtWidgets.QLabel(self) - message_label_widget.setText( - exc_msg_template.format(self.convert_text_for_html(self._exc_msg)) - ) - content_layout.addWidget(message_label_widget) - - if self._formatted_traceback: - line_widget = self._create_line() - tb_widget = self._create_traceback_widget( - self._formatted_traceback - ) - content_layout.addWidget(line_widget) - content_layout.addWidget(tb_widget) - - -class ProductNameValidator(RegularExpressionValidatorClass): - invalid = QtCore.Signal(set) - pattern = "^[{}]*$".format(PRODUCT_NAME_ALLOWED_SYMBOLS) - - def __init__(self): - reg = RegularExpressionClass(self.pattern) - super(ProductNameValidator, self).__init__(reg) - - def validate(self, text, pos): - results = super(ProductNameValidator, self).validate(text, pos) - if results[0] == RegularExpressionValidatorClass.Invalid: - self.invalid.emit(self.invalid_chars(text)) - return results - - def invalid_chars(self, text): - invalid = set() - re_valid = re.compile(self.pattern) - for char in text: - if char == " ": - invalid.add("' '") - continue - if not re_valid.match(char): - invalid.add(char) - return invalid - - -class VariantLineEdit(QtWidgets.QLineEdit): - report = QtCore.Signal(str) - colors = { - "empty": (QtGui.QColor("#78879b"), ""), - "exists": (QtGui.QColor("#4E76BB"), "border-color: #4E76BB;"), - "new": (QtGui.QColor("#7AAB8F"), "border-color: #7AAB8F;"), - } - - def __init__(self, *args, **kwargs): - super(VariantLineEdit, self).__init__(*args, **kwargs) - - validator = ProductNameValidator() - self.setValidator(validator) - self.setToolTip("Only alphanumeric characters (A-Z a-z 0-9), " - "'_' and '.' are allowed.") - - self._status_color = self.colors["empty"][0] - - anim = QtCore.QPropertyAnimation() - anim.setTargetObject(self) - anim.setPropertyName(b"status_color") - anim.setEasingCurve(QtCore.QEasingCurve.InCubic) - anim.setDuration(300) - anim.setStartValue(QtGui.QColor("#C84747")) # `Invalid` status color - self.animation = anim - - validator.invalid.connect(self.on_invalid) - - def on_invalid(self, invalid): - message = "Invalid character: %s" % ", ".join(invalid) - self.report.emit(message) - self.animation.stop() - self.animation.start() - - def as_empty(self): - self._set_border("empty") - self.report.emit("Empty product name ..") - - def as_exists(self): - self._set_border("exists") - self.report.emit("Existing product, appending next version.") - - def as_new(self): - self._set_border("new") - self.report.emit("New product, creating first version.") - - def _set_border(self, status): - qcolor, style = self.colors[status] - self.animation.setEndValue(qcolor) - self.setStyleSheet(style) - - def _get_status_color(self): - return self._status_color - - def _set_status_color(self, color): - self._status_color = color - self.setStyleSheet("border-color: %s;" % color.name()) - - status_color = QtCore.Property( - QtGui.QColor, _get_status_color, _set_status_color - ) - - -class ProductTypeDescriptionWidget(QtWidgets.QWidget): - """A product type description widget. - - Shows a product type icon, name and a help description. - Used in creator header. - - _______________________ - | ____ | - | |icon| PRODUCT TYPE | - | |____| help | - |_______________________| - - """ - - SIZE = 35 - - def __init__(self, parent=None): - super(ProductTypeDescriptionWidget, self).__init__(parent=parent) - - icon_label = QtWidgets.QLabel(self) - icon_label.setSizePolicy( - QtWidgets.QSizePolicy.Maximum, - QtWidgets.QSizePolicy.Maximum - ) - - # Add 4 pixel padding to avoid icon being cut off - icon_label.setFixedWidth(self.SIZE + 4) - icon_label.setFixedHeight(self.SIZE + 4) - - label_layout = QtWidgets.QVBoxLayout() - label_layout.setSpacing(0) - - product_type_label = QtWidgets.QLabel(self) - product_type_label.setObjectName("CreatorProductTypeLabel") - product_type_label.setAlignment( - QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft - ) - - help_label = QtWidgets.QLabel(self) - help_label.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) - - label_layout.addWidget(product_type_label) - label_layout.addWidget(help_label) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(5) - layout.addWidget(icon_label) - layout.addLayout(label_layout) - - self._help_label = help_label - self._product_type_label = product_type_label - self._icon_label = icon_label - - def set_item(self, creator_plugin): - """Update elements to display information of a product type item. - - Args: - creator_plugin (dict): A product type item as registered with - name, help and icon. - - Returns: - None - - """ - if not creator_plugin: - self._icon_label.setPixmap(None) - self._product_type_label.setText("") - self._help_label.setText("") - return - - # Support a font-awesome icon - icon_name = getattr(creator_plugin, "icon", None) or "info-circle" - try: - icon = qtawesome.icon("fa.{}".format(icon_name), color="white") - pixmap = icon.pixmap(self.SIZE, self.SIZE) - except Exception: - print("BUG: Couldn't load icon \"fa.{}\"".format(str(icon_name))) - # Create transparent pixmap - pixmap = QtGui.QPixmap() - pixmap.fill(QtCore.Qt.transparent) - pixmap = pixmap.scaled(self.SIZE, self.SIZE) - - # Parse a clean line from the Creator's docstring - docstring = inspect.getdoc(creator_plugin) - creator_help = docstring.splitlines()[0] if docstring else "" - - self._icon_label.setPixmap(pixmap) - self._product_type_label.setText(creator_plugin.product_type) - self._help_label.setText(creator_help) diff --git a/client/ayon_core/tools/creator/window.py b/client/ayon_core/tools/creator/window.py deleted file mode 100644 index 5d1c0a272a..0000000000 --- a/client/ayon_core/tools/creator/window.py +++ /dev/null @@ -1,508 +0,0 @@ -import sys -import traceback -import re - -import ayon_api -from qtpy import QtWidgets, QtCore - -from ayon_core import style -from ayon_core.settings import get_current_project_settings -from ayon_core.tools.utils.lib import qt_app_context -from ayon_core.pipeline import ( - get_current_project_name, - get_current_folder_path, - get_current_task_name, -) -from ayon_core.pipeline.create import ( - PRODUCT_NAME_ALLOWED_SYMBOLS, - legacy_create, - CreatorError, -) - -from .model import CreatorsModel -from .widgets import ( - CreateErrorMessageBox, - VariantLineEdit, - ProductTypeDescriptionWidget -) -from .constants import ( - ITEM_ID_ROLE, - SEPARATOR, - SEPARATORS -) - -module = sys.modules[__name__] -module.window = None - - -class CreatorWindow(QtWidgets.QDialog): - def __init__(self, parent=None): - super(CreatorWindow, self).__init__(parent) - self.setWindowTitle("Instance Creator") - self.setFocusPolicy(QtCore.Qt.StrongFocus) - if not parent: - self.setWindowFlags( - self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint - ) - - creator_info = ProductTypeDescriptionWidget(self) - - creators_model = CreatorsModel() - - creators_proxy = QtCore.QSortFilterProxyModel() - creators_proxy.setSourceModel(creators_model) - - creators_view = QtWidgets.QListView(self) - creators_view.setObjectName("CreatorsView") - creators_view.setModel(creators_proxy) - - folder_path_input = QtWidgets.QLineEdit(self) - variant_input = VariantLineEdit(self) - product_name_input = QtWidgets.QLineEdit(self) - product_name_input.setEnabled(False) - - variants_btn = QtWidgets.QPushButton() - variants_btn.setFixedWidth(18) - variants_menu = QtWidgets.QMenu(variants_btn) - variants_btn.setMenu(variants_menu) - - name_layout = QtWidgets.QHBoxLayout() - name_layout.addWidget(variant_input) - name_layout.addWidget(variants_btn) - name_layout.setSpacing(3) - name_layout.setContentsMargins(0, 0, 0, 0) - - body_layout = QtWidgets.QVBoxLayout() - body_layout.setContentsMargins(0, 0, 0, 0) - - body_layout.addWidget(creator_info, 0) - body_layout.addWidget(QtWidgets.QLabel("Product type", self), 0) - body_layout.addWidget(creators_view, 1) - body_layout.addWidget(QtWidgets.QLabel("Folder path", self), 0) - body_layout.addWidget(folder_path_input, 0) - body_layout.addWidget(QtWidgets.QLabel("Product name", self), 0) - body_layout.addLayout(name_layout, 0) - body_layout.addWidget(product_name_input, 0) - - useselection_chk = QtWidgets.QCheckBox("Use selection", self) - useselection_chk.setCheckState(QtCore.Qt.Checked) - - create_btn = QtWidgets.QPushButton("Create", self) - # Need to store error_msg to prevent garbage collection - msg_label = QtWidgets.QLabel(self) - - footer_layout = QtWidgets.QVBoxLayout() - footer_layout.addWidget(create_btn, 0) - footer_layout.addWidget(msg_label, 0) - footer_layout.setContentsMargins(0, 0, 0, 0) - - layout = QtWidgets.QVBoxLayout(self) - layout.addLayout(body_layout, 1) - layout.addWidget(useselection_chk, 0, QtCore.Qt.AlignLeft) - layout.addLayout(footer_layout, 0) - - msg_timer = QtCore.QTimer() - msg_timer.setSingleShot(True) - msg_timer.setInterval(5000) - - validation_timer = QtCore.QTimer() - validation_timer.setSingleShot(True) - validation_timer.setInterval(300) - - msg_timer.timeout.connect(self._on_msg_timer) - validation_timer.timeout.connect(self._on_validation_timer) - - create_btn.clicked.connect(self._on_create) - variant_input.returnPressed.connect(self._on_create) - variant_input.textChanged.connect(self._on_data_changed) - variant_input.report.connect(self.echo) - folder_path_input.textChanged.connect(self._on_data_changed) - creators_view.selectionModel().currentChanged.connect( - self._on_selection_changed - ) - - # Store valid states and - self._is_valid = False - create_btn.setEnabled(self._is_valid) - - self._first_show = True - - # Message dialog when something goes wrong during creation - self._message_dialog = None - - self._creator_info = creator_info - self._create_btn = create_btn - self._useselection_chk = useselection_chk - self._variant_input = variant_input - self._product_name_input = product_name_input - self._folder_path_input = folder_path_input - - self._creators_model = creators_model - self._creators_proxy = creators_proxy - self._creators_view = creators_view - - self._variants_btn = variants_btn - self._variants_menu = variants_menu - - self._msg_label = msg_label - - self._validation_timer = validation_timer - self._msg_timer = msg_timer - - # Defaults - self.resize(300, 500) - variant_input.setFocus() - - def _set_valid_state(self, valid): - if self._is_valid == valid: - return - self._is_valid = valid - self._create_btn.setEnabled(valid) - - def _build_menu(self, default_names=None): - """Create optional predefined variants. - - Args: - default_names(list): all predefined names - - Returns: - None - """ - if not default_names: - default_names = [] - - menu = self._variants_menu - button = self._variants_btn - - # Get and destroy the action group - group = button.findChild(QtWidgets.QActionGroup) - if group: - group.deleteLater() - - state = any(default_names) - button.setEnabled(state) - if state is False: - return - - # Build new action group - group = QtWidgets.QActionGroup(button) - for name in default_names: - if name in SEPARATORS: - menu.addSeparator() - continue - action = group.addAction(name) - menu.addAction(action) - - group.triggered.connect(self._on_action_clicked) - - def _on_action_clicked(self, action): - self._variant_input.setText(action.text()) - - def _on_data_changed(self, *args): - # Set invalid state until it's reconfirmed to be valid by the - # scheduled callback so any form of creation is held back until - # valid again - self._set_valid_state(False) - - self._validation_timer.start() - - def _on_validation_timer(self): - index = self._creators_view.currentIndex() - item_id = index.data(ITEM_ID_ROLE) - creator_plugin = self._creators_model.get_creator_by_id(item_id) - user_input_text = self._variant_input.text() - folder_path = self._folder_path_input.text() - - # Early exit if no folder path - if not folder_path: - self._build_menu() - self.echo("Folder is required ..") - self._set_valid_state(False) - return - - project_name = get_current_project_name() - folder_entity = None - if creator_plugin: - # Get the folder from the database which match with the name - folder_entity = ayon_api.get_folder_by_path( - project_name, folder_path, fields={"id"} - ) - - # Get plugin - if not folder_entity or not creator_plugin: - self._build_menu() - - if not creator_plugin: - self.echo("No registered product types ..") - else: - self.echo("Folder '{}' not found ..".format(folder_path)) - self._set_valid_state(False) - return - - folder_id = folder_entity["id"] - - task_name = get_current_task_name() - task_entity = ayon_api.get_task_by_name( - project_name, folder_id, task_name - ) - - # Calculate product name with Creator plugin - product_name = creator_plugin.get_product_name( - project_name, folder_entity, task_entity, user_input_text - ) - # Force replacement of prohibited symbols - # QUESTION should Creator care about this and here should be only - # validated with schema regex? - - # Allow curly brackets in product name for dynamic keys - curly_left = "__cbl__" - curly_right = "__cbr__" - tmp_product_name = ( - product_name - .replace("{", curly_left) - .replace("}", curly_right) - ) - # Replace prohibited symbols - tmp_product_name = re.sub( - "[^{}]+".format(PRODUCT_NAME_ALLOWED_SYMBOLS), - "", - tmp_product_name - ) - product_name = ( - tmp_product_name - .replace(curly_left, "{") - .replace(curly_right, "}") - ) - self._product_name_input.setText(product_name) - - # Get all products of the current folder - product_entities = ayon_api.get_products( - project_name, folder_ids={folder_id}, fields={"name"} - ) - existing_product_names = { - product_entity["name"] - for product_entity in product_entities - } - existing_product_names_low = set( - _name.lower() - for _name in existing_product_names - ) - - # Defaults to dropdown - defaults = [] - # Check if Creator plugin has set defaults - if ( - creator_plugin.defaults - and isinstance(creator_plugin.defaults, (list, tuple, set)) - ): - defaults = list(creator_plugin.defaults) - - # Replace - compare_regex = re.compile(re.sub( - user_input_text, "(.+)", product_name, flags=re.IGNORECASE - )) - variant_hints = set() - if user_input_text: - for _name in existing_product_names: - _result = compare_regex.search(_name) - if _result: - variant_hints |= set(_result.groups()) - - if variant_hints: - if defaults: - defaults.append(SEPARATOR) - defaults.extend(variant_hints) - self._build_menu(defaults) - - # Indicate product existence - if not user_input_text: - self._variant_input.as_empty() - elif product_name.lower() in existing_product_names_low: - # validate existence of product name with lowered text - # - "renderMain" vs. "rensermain" mean same path item for - # windows - self._variant_input.as_exists() - else: - self._variant_input.as_new() - - # Update the valid state - valid = product_name.strip() != "" - - self._set_valid_state(valid) - - def _on_selection_changed(self, old_idx, new_idx): - index = self._creators_view.currentIndex() - item_id = index.data(ITEM_ID_ROLE) - - creator_plugin = self._creators_model.get_creator_by_id(item_id) - - self._creator_info.set_item(creator_plugin) - - if creator_plugin is None: - return - - default = None - if hasattr(creator_plugin, "get_default_variant"): - default = creator_plugin.get_default_variant() - - if not default: - if ( - creator_plugin.defaults - and isinstance(creator_plugin.defaults, list) - ): - default = creator_plugin.defaults[0] - else: - default = "Default" - - self._variant_input.setText(default) - - self._on_data_changed() - - def keyPressEvent(self, event): - """Custom keyPressEvent. - - Override keyPressEvent to do nothing so that Maya's panels won't - take focus when pressing "SHIFT" whilst mouse is over viewport or - outliner. This way users don't accidentally perform Maya commands - whilst trying to name an instance. - - """ - pass - - def showEvent(self, event): - super(CreatorWindow, self).showEvent(event) - if self._first_show: - self._first_show = False - self.setStyleSheet(style.load_stylesheet()) - - def refresh(self): - self._folder_path_input.setText(get_current_folder_path()) - - self._creators_model.reset() - - product_types_smart_select = ( - get_current_project_settings() - ["core"] - ["tools"] - ["creator"] - ["product_types_smart_select"] - ) - current_index = None - product_type = None - task_name = get_current_task_name() or None - lowered_task_name = task_name.lower() - if task_name: - for smart_item in product_types_smart_select: - _low_task_names = { - name.lower() for name in smart_item["task_names"] - } - for _task_name in _low_task_names: - if _task_name in lowered_task_name: - product_type = smart_item["name"] - break - if product_type: - break - - if product_type: - indexes = self._creators_model.get_indexes_by_product_type( - product_type - ) - if indexes: - index = indexes[0] - current_index = self._creators_proxy.mapFromSource(index) - - if current_index is None or not current_index.isValid(): - current_index = self._creators_proxy.index(0, 0) - - self._creators_view.setCurrentIndex(current_index) - - def _on_create(self): - # Do not allow creation in an invalid state - if not self._is_valid: - return - - index = self._creators_view.currentIndex() - item_id = index.data(ITEM_ID_ROLE) - creator_plugin = self._creators_model.get_creator_by_id(item_id) - if creator_plugin is None: - return - - product_name = self._product_name_input.text() - folder_path = self._folder_path_input.text() - use_selection = self._useselection_chk.isChecked() - - variant = self._variant_input.text() - - error_info = None - try: - legacy_create( - creator_plugin, - product_name, - folder_path, - options={"useSelection": use_selection}, - data={"variant": variant} - ) - - except CreatorError as exc: - self.echo("Creator error: {}".format(str(exc))) - error_info = (str(exc), None) - - except Exception as exc: - self.echo("Program error: %s" % str(exc)) - - exc_type, exc_value, exc_traceback = sys.exc_info() - formatted_traceback = "".join(traceback.format_exception( - exc_type, exc_value, exc_traceback - )) - error_info = (str(exc), formatted_traceback) - - if error_info: - box = CreateErrorMessageBox( - creator_plugin.product_type, - product_name, - folder_path, - *error_info, - parent=self - ) - box.show() - # Store dialog so is not garbage collected before is shown - self._message_dialog = box - - else: - self.echo("Created %s .." % product_name) - - def _on_msg_timer(self): - self._msg_label.setText("") - - def echo(self, message): - self._msg_label.setText(str(message)) - self._msg_timer.start() - - -def show(parent=None): - """Display product creator GUI - - Arguments: - debug (bool, optional): Run loader in debug-mode, - defaults to False - parent (QtCore.QObject, optional): When provided parent the interface - to this QObject. - - """ - - try: - module.window.close() - del module.window - except (AttributeError, RuntimeError): - pass - - with qt_app_context(): - window = CreatorWindow(parent) - window.refresh() - window.show() - - module.window = window - - # Pull window to the front. - module.window.raise_() - module.window.activateWindow() diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index fb9f950bd1..58d22453be 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -1,6 +1,6 @@ from ayon_core.lib import Logger, get_ayon_username from ayon_core.lib.events import QueuedEventSystem -from ayon_core.settings import get_project_settings +from ayon_core.settings import get_project_settings, get_studio_settings from ayon_core.tools.common_models import ProjectsModel, HierarchyModel from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend @@ -85,7 +85,10 @@ class BaseLauncherController( def get_project_settings(self, project_name): if project_name in self._project_settings: return self._project_settings[project_name] - settings = get_project_settings(project_name) + if project_name: + settings = get_project_settings(project_name) + else: + settings = get_studio_settings() self._project_settings[project_name] = settings return settings diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 0ed4bdad3a..51fbe72143 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -352,9 +352,6 @@ class ActionsModel: ) def _get_webaction_request_data(self, selection: LauncherActionSelection): - if not selection.is_project_selected: - return None - entity_type = None entity_id = None entity_subtypes = [] @@ -368,6 +365,13 @@ class ActionsModel: entity_id = selection.folder_entity["id"] entity_subtypes = [selection.folder_entity["folderType"]] + elif selection.is_project_selected: + # Project actions are supported since AYON 1.9.1 + ma, mi, pa, _, _ = ayon_api.get_server_version_tuple() + if (ma, mi, pa) < (1, 9, 1): + return None + entity_type = "project" + entity_ids = [] if entity_id: entity_ids.append(entity_id) @@ -381,10 +385,10 @@ class ActionsModel: } def _get_webactions(self, selection: LauncherActionSelection): - if not selection.is_project_selected: + request_data = self._get_webaction_request_data(selection) + if request_data is None: return [] - request_data = self._get_webaction_request_data(selection) project_name = selection.project_name entity_id = None if request_data["entityIds"]: @@ -395,7 +399,11 @@ class ActionsModel: return cache.get_data() try: - response = ayon_api.post("actions/list", **request_data) + # 'variant' query is supported since AYON backend 1.10.4 + query = urlencode({"variant": self._variant}) + response = ayon_api.post( + f"actions/list?{query}", **request_data + ) response.raise_for_status() except Exception: self.log.warning("Failed to collect webactions.", exc_info=True) @@ -509,7 +517,12 @@ class ActionsModel: uri = payload["uri"] else: uri = data["uri"] - run_detached_ayon_launcher_process(uri) + + # Remove bundles from environment variables + env = os.environ.copy() + env.pop("AYON_BUNDLE_NAME", None) + env.pop("AYON_STUDIO_BUNDLE_NAME", None) + run_detached_ayon_launcher_process(uri, env=env) elif response_type in ("query", "navigate"): response.error_message = ( diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 0459999958..51cb8e73bc 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -16,12 +16,12 @@ from ayon_core.lib.attribute_definitions import ( from ayon_core.tools.flickcharm import FlickCharm from ayon_core.tools.utils import ( get_qt_icon, - PixmapLabel, ) from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.launcher.abstract import WebactionContext ANIMATION_LEN = 7 +SHADOW_FRAME_MARGINS = (1, 1, 1, 1) ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 ACTION_TYPE_ROLE = QtCore.Qt.UserRole + 2 @@ -51,13 +51,9 @@ def _variant_label_sort_getter(action_item): # --- Replacement for QAction for action variants --- -class LauncherSettingsLabel(PixmapLabel): +class LauncherSettingsLabel(QtWidgets.QWidget): _settings_icon = None - def __init__(self, parent): - icon = self._get_settings_icon() - super().__init__(icon.pixmap(64, 64), parent) - @classmethod def _get_settings_icon(cls): if cls._settings_icon is None: @@ -67,24 +63,52 @@ class LauncherSettingsLabel(PixmapLabel): }) return cls._settings_icon + def paintEvent(self, event): + painter = QtGui.QPainter(self) + + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + + rect = event.rect() + size = min(rect.height(), rect.width()) + pix_rect = QtCore.QRect( + rect.x(), rect.y(), + size, size + ) + pix = self._get_settings_icon().pixmap(size, size) + painter.drawPixmap(pix_rect, pix) + + painter.end() + class ActionOverlayWidget(QtWidgets.QFrame): - config_requested = QtCore.Signal(str) - def __init__(self, item_id, parent): super().__init__(parent) self._item_id = item_id settings_icon = LauncherSettingsLabel(self) settings_icon.setToolTip("Right click for options") + settings_icon.setVisible(False) main_layout = QtWidgets.QGridLayout(self) main_layout.setContentsMargins(5, 5, 0, 0) main_layout.addWidget(settings_icon, 0, 0) - main_layout.setColumnStretch(1, 1) - main_layout.setRowStretch(1, 1) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 5) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + self._settings_icon = settings_icon + + def enterEvent(self, event): + super().enterEvent(event) + self._settings_icon.setVisible(True) + + def leaveEvent(self, event): + super().leaveEvent(event) + self._settings_icon.setVisible(False) class ActionsQtModel(QtGui.QStandardItemModel): @@ -138,6 +162,12 @@ class ActionsQtModel(QtGui.QStandardItemModel): def get_item_by_id(self, action_id): return self._items_by_id.get(action_id) + def get_index_by_id(self, action_id): + item = self.get_item_by_id(action_id) + if item is not None: + return self.indexFromItem(item) + return QtCore.QModelIndex() + def get_group_item_by_action_id(self, action_id): item = self._items_by_id.get(action_id) if item is not None: @@ -222,7 +252,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): item.setFlags(QtCore.Qt.ItemIsEnabled) item.setData(label, QtCore.Qt.DisplayRole) - # item.setData(label, QtCore.Qt.ToolTipRole) + item.setData(label, QtCore.Qt.ToolTipRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(is_group, ACTION_IS_GROUP_ROLE) item.setData(has_configs, ACTION_HAS_CONFIGS_ROLE) @@ -295,8 +325,8 @@ class ActionMenuPopupModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem() item.setFlags(QtCore.Qt.ItemIsEnabled) - # item.setData(action_item.full_label, QtCore.Qt.ToolTipRole) - item.setData(action_item.full_label, QtCore.Qt.DisplayRole) + item.setData(action_item.variant_label, QtCore.Qt.DisplayRole) + item.setData(action_item.full_label, QtCore.Qt.ToolTipRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(action_item.identifier, ACTION_ID_ROLE) item.setData( @@ -344,8 +374,24 @@ class ActionMenuPopupModel(QtGui.QStandardItemModel): class ActionMenuPopup(QtWidgets.QWidget): + """Popup widget for group varaints. + + The popup is handling most of the layout and showing of the items + manually. + + There 4 parts: + 1. Shadow - semi transparent black widget used as shadow. + 2. Background - painted over the shadow with blur effect. All + other items are painted over. + 3. Label - show group label and positioned manually at the top + of the popup. + 4. View - View with variant action items. View is positioned + and resized manually according to the items in the group and then + animated using mask region. + + """ action_triggered = QtCore.Signal(str) - config_requested = QtCore.Signal(str) + config_requested = QtCore.Signal(str, QtCore.QPoint) def __init__(self, parent): super().__init__(parent) @@ -363,16 +409,34 @@ class ActionMenuPopup(QtWidgets.QWidget): expand_anim.setDuration(60) expand_anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad) + sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS + + group_label = QtWidgets.QLabel("|", self) + group_label.setObjectName("GroupLabel") + # View with actions view = ActionsView(self) view.setGridSize(QtCore.QSize(75, 80)) view.setIconSize(QtCore.QSize(32, 32)) - view.move(QtCore.QPoint(3, 3)) + view.move(sh_l, sh_t) + + view.stackUnder(group_label) # Background draw + bg_frame = QtWidgets.QFrame(self) + bg_frame.setObjectName("ShadowFrame") + bg_frame.stackUnder(view) + wrapper = QtWidgets.QFrame(self) wrapper.setObjectName("Wrapper") - wrapper.stackUnder(view) + + effect = QtWidgets.QGraphicsBlurEffect(wrapper) + effect.setBlurRadius(3.0) + wrapper.setGraphicsEffect(effect) + + bg_layout = QtWidgets.QVBoxLayout(bg_frame) + bg_layout.setContentsMargins(sh_l, sh_t, sh_r, sh_b) + bg_layout.addWidget(wrapper) model = ActionMenuPopupModel() proxy_model = ActionsProxyModel() @@ -387,10 +451,12 @@ class ActionMenuPopup(QtWidgets.QWidget): expand_anim.finished.connect(self._on_expand_finish) view.clicked.connect(self._on_clicked) - view.config_requested.connect(self.config_requested) + view.config_requested.connect(self._on_configs_trigger) + self._group_label = group_label self._view = view - self._wrapper = wrapper + self._bg_frame = bg_frame + self._effect = effect self._model = model self._proxy_model = proxy_model @@ -417,7 +483,8 @@ class ActionMenuPopup(QtWidgets.QWidget): super().leaveEvent(event) self._close_timer.start() - def show_items(self, action_id, action_items, pos): + def show_items(self, group_label, action_id, action_items, pos): + self._group_label.setText(group_label) if not action_items: if self._showed: self._close_timer.start() @@ -426,70 +493,82 @@ class ActionMenuPopup(QtWidgets.QWidget): self._close_timer.stop() - update_position = False if action_id != self._current_id: - update_position = True + self.setGeometry(pos.x(), pos.y(), 1, 1) self._current_id = action_id self._update_items(action_items) # Make sure is visible if not self._showed: - update_position = True self.show() - if not update_position: - self.raise_() - return - # Set geometry to position # - first make sure widget changes from '_update_items' # are recalculated app = QtWidgets.QApplication.instance() app.processEvents() - items_count, size, target_size = self._get_size_hint() + items_count, start_size, target_size = self._get_size_hint() self._model.fill_to_count(items_count) + label_sh = self._group_label.sizeHint() + label_width, label_height = label_sh.width(), label_sh.height() window = self.screen() window_geo = window.geometry() + _target_x = pos.x() + target_size.width() + _target_y = pos.y() + target_size.height() + label_height right_to_left = ( - pos.x() + target_size.width() > window_geo.right() - or pos.y() + target_size.height() > window_geo.bottom() + _target_x > window_geo.right() + or _target_y > window_geo.bottom() ) - pos_x = pos.x() - 5 - pos_y = pos.y() - 4 - - wrap_x = wrap_y = 0 + sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS + viewport_offset = self._view.viewport().geometry().topLeft() + pos_x = pos.x() - (sh_l + viewport_offset.x() + 2) + pos_y = pos.y() - (sh_t + viewport_offset.y() + 1) + bg_x = bg_y = 0 sort_order = QtCore.Qt.DescendingOrder if right_to_left: sort_order = QtCore.Qt.AscendingOrder - size_diff = target_size - size + size_diff = target_size - start_size pos_x -= size_diff.width() pos_y -= size_diff.height() - wrap_x = size_diff.width() - wrap_y = size_diff.height() + bg_x = size_diff.width() + bg_y = size_diff.height() - label_height - wrap_geo = QtCore.QRect( - wrap_x, wrap_y, size.width(), size.height() + bg_geo = QtCore.QRect( + bg_x, bg_y, + start_size.width(), start_size.height() + label_height ) + + label_pos_x = sh_l + label_pos_y = bg_y + sh_t + if label_width < start_size.width(): + label_pos_x = bg_x + (start_size.width() - label_width) // 2 + if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: self._expand_anim.stop() - self._first_anim_frame = True + self._right_to_left = right_to_left self._proxy_model.sort(0, sort_order) self.setUpdatesEnabled(False) - self._view.setMask(wrap_geo) + self._view.setMask( + bg_geo.adjusted(sh_l, sh_t, -sh_r, -sh_b) + ) self._view.setMinimumWidth(target_size.width()) self._view.setMaximumWidth(target_size.width()) - self._wrapper.setGeometry(wrap_geo) + self._view.setMinimumHeight(target_size.height()) + self._view.move(sh_l, sh_t + label_height) self.setGeometry( - pos_x, pos_y, - target_size.width(), target_size.height() + pos_x, pos_y - label_height, + target_size.width(), target_size.height() + label_height ) + self._bg_frame.setGeometry(bg_geo) + self._group_label.move(label_pos_x, label_pos_y) self.setUpdatesEnabled(True) + self._expand_anim.updateCurrentTime(0) - self._expand_anim.setStartValue(size) + self._expand_anim.setStartValue(start_size) self._expand_anim.setEndValue(target_size) self._expand_anim.start() @@ -511,20 +590,37 @@ class ActionMenuPopup(QtWidgets.QWidget): self._expand_anim.stop() return - wrapper_geo = self._wrapper.geometry() - wrapper_geo.setWidth(value.width()) - wrapper_geo.setHeight(value.height()) + bg_geo = self._bg_frame.geometry() + label_sh = self._group_label.sizeHint() + label_width, label_height = label_sh.width(), label_sh.height() if self._right_to_left: - geo = self.geometry() + popup_geo = self.geometry() + diff_size = popup_geo.size() - value pos = QtCore.QPoint( - geo.width() - value.width(), - geo.height() - value.height(), + diff_size.width(), diff_size.height() - label_height ) - wrapper_geo.setTopLeft(pos) - self._view.setMask(wrapper_geo) - self._wrapper.setGeometry(wrapper_geo) + bg_geo.moveTopLeft(pos) + + bg_geo.setWidth(value.width()) + bg_geo.setHeight(value.height() + label_height) + + label_width = self._group_label.sizeHint().width() + bgeo_tl = bg_geo.topLeft() + sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS + + label_pos_x = sh_l + if label_width < value.width(): + label_pos_x = bgeo_tl.x() + (value.width() - label_width) // 2 + + self.setUpdatesEnabled(False) + self._view.setMask( + bg_geo.adjusted(sh_l, sh_t, -sh_r, -sh_b) + ) + self._group_label.move(label_pos_x, sh_t) + self._bg_frame.setGeometry(bg_geo) + self.setUpdatesEnabled(True) def _on_expand_finish(self): # Make sure that size is recalculated if src and targe size is same @@ -547,16 +643,25 @@ class ActionMenuPopup(QtWidgets.QWidget): if rows == 1: cols = row_count - m_l, m_t, m_r, m_b = (3, 3, 1, 1) - # QUESTION how to get the margins from Qt? - border = 2 * 1 + viewport_geo = self._view.viewport().geometry() + viewport_offset = viewport_geo.topLeft() + # QUESTION how to get the bottom and right margins from Qt? + vp_lr = viewport_offset.x() + vp_tb = viewport_offset.y() + m_l, m_t, m_r, m_b = ( + s_m + vp_m + for s_m, vp_m in zip( + SHADOW_FRAME_MARGINS, + (vp_lr, vp_tb, vp_lr, vp_tb) + ) + ) single_width = ( grid_size.width() - + self._view.horizontalOffset() + border + m_l + m_r + 1 + + self._view.horizontalOffset() + m_l + m_r + 1 ) single_height = ( grid_size.height() - + self._view.verticalOffset() + border + m_b + m_t + 1 + + self._view.verticalOffset() + m_b + m_t + 1 ) total_width = single_width total_height = single_height @@ -586,14 +691,13 @@ class ActionMenuPopup(QtWidgets.QWidget): self.action_triggered.emit(action_id) self.close() - def _on_configs_trigger(self, action_id): - self.config_requested.emit(action_id) + def _on_configs_trigger(self, action_id, center_pos): + self.config_requested.emit(action_id, center_pos) self.close() class ActionDelegate(QtWidgets.QStyledItemDelegate): - _cached_extender = {} - _cached_extender_base_pix = None + _extender_icon = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -653,31 +757,18 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): painter.restore() @classmethod - def _get_extender_pixmap(cls, size): - pix = cls._cached_extender.get(size) - if pix is not None: - return pix - - base_pix = cls._cached_extender_base_pix - if base_pix is None: - icon = get_qt_icon({ + def _get_extender_pixmap(cls): + if cls._extender_icon is None: + cls._extender_icon = get_qt_icon({ "type": "material-symbols", "name": "more_horiz", }) - base_pix = icon.pixmap(64, 64) - cls._cached_extender_base_pix = base_pix - - pix = base_pix.scaled( - size, size, - QtCore.Qt.KeepAspectRatio, - QtCore.Qt.SmoothTransformation - ) - cls._cached_extender[size] = pix - return pix + return cls._extender_icon def paint(self, painter, option, index): painter.setRenderHints( QtGui.QPainter.Antialiasing + | QtGui.QPainter.TextAntialiasing | QtGui.QPainter.SmoothPixmapTransform ) @@ -690,20 +781,15 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): return grid_size = option.widget.gridSize() - x_offset = int( - (grid_size.width() / 2) - - (option.rect.width() / 2) - ) - item_x = option.rect.x() - x_offset - tenth_size = int(grid_size.width() / 10) - extender_size = int(tenth_size * 2.4) + extender_rect = option.rect.adjusted(5, 5, 0, 0) + extender_size = grid_size.width() // 6 + extender_rect.setWidth(extender_size) + extender_rect.setHeight(extender_size) - extender_x = item_x + tenth_size - extender_y = option.rect.y() + tenth_size - - pix = self._get_extender_pixmap(extender_size) - painter.drawPixmap(extender_x, extender_y, pix) + icon = self._get_extender_pixmap() + pix = icon.pixmap(extender_size, extender_size) + painter.drawPixmap(extender_rect, pix) class ActionsProxyModel(QtCore.QSortFilterProxyModel): @@ -739,12 +825,10 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): class ActionsView(QtWidgets.QListView): - action_triggered = QtCore.Signal(str) - config_requested = QtCore.Signal(str) + config_requested = QtCore.Signal(str, QtCore.QPoint) def __init__(self, parent): super().__init__(parent) - self.setProperty("mode", "icon") self.setViewMode(QtWidgets.QListView.IconMode) self.setResizeMode(QtWidgets.QListView.Adjust) self.setSelectionMode(QtWidgets.QListView.NoSelection) @@ -773,18 +857,6 @@ class ActionsView(QtWidgets.QListView): self._overlay_widgets = [] self._flick = flick self._delegate = delegate - self._popup_widget = None - - def mouseMoveEvent(self, event): - """Handle mouse move event.""" - super().mouseMoveEvent(event) - # Update hover state for the item under mouse - index = self.indexAt(event.pos()) - if index.isValid() and index.data(ACTION_IS_GROUP_ROLE): - self._show_group_popup(index) - - elif self._popup_widget is not None: - self._popup_widget.close() def _on_context_menu(self, point): """Creates menu to force skip opening last workfile.""" @@ -792,34 +864,9 @@ class ActionsView(QtWidgets.QListView): if not index.isValid(): return action_id = index.data(ACTION_ID_ROLE) - self.config_requested.emit(action_id) - - def _get_popup_widget(self): - if self._popup_widget is None: - popup_widget = ActionMenuPopup(self) - - popup_widget.action_triggered.connect(self.action_triggered) - popup_widget.config_requested.connect(self.config_requested) - self._popup_widget = popup_widget - return self._popup_widget - - def _show_group_popup(self, index): - action_id = index.data(ACTION_ID_ROLE) - model = self.model() - while hasattr(model, "sourceModel"): - model = model.sourceModel() - - if not hasattr(model, "get_group_items"): - return - - action_items = model.get_group_items(action_id) rect = self.visualRect(index) - pos = self.mapToGlobal(rect.topLeft()) - - popup_widget = self._get_popup_widget() - popup_widget.show_items( - action_id, action_items, pos - ) + global_center = self.mapToGlobal(rect.center()) + self.config_requested.emit(action_id, global_center) def update_on_refresh(self): viewport = self.viewport() @@ -837,9 +884,6 @@ class ActionsView(QtWidgets.QListView): if has_configs: item_id = index.data(ACTION_ID_ROLE) widget = ActionOverlayWidget(item_id, viewport) - widget.config_requested.connect( - self.config_requested - ) overlay_widgets.append(widget) self.setIndexWidget(index, widget) @@ -877,8 +921,7 @@ class ActionsWidget(QtWidgets.QWidget): animation_timer.timeout.connect(self._on_animation) view.clicked.connect(self._on_clicked) - view.action_triggered.connect(self._trigger_action) - view.config_requested.connect(self._on_config_request) + view.config_requested.connect(self._show_config_dialog) model.refreshed.connect(self._on_model_refresh) self._animated_items = set() @@ -888,6 +931,8 @@ class ActionsWidget(QtWidgets.QWidget): self._model = model self._proxy_model = proxy_model + self._popup_widget = None + self._set_row_height(1) def refresh(self): @@ -974,10 +1019,32 @@ class ActionsWidget(QtWidgets.QWidget): return is_group = index.data(ACTION_IS_GROUP_ROLE) - if is_group: - return action_id = index.data(ACTION_ID_ROLE) - self._trigger_action(action_id, index) + if is_group: + self._show_group_popup(index) + else: + self._trigger_action(action_id, index) + + def _get_popup_widget(self): + if self._popup_widget is None: + popup_widget = ActionMenuPopup(self) + + popup_widget.action_triggered.connect(self._trigger_action) + popup_widget.config_requested.connect(self._show_config_dialog) + self._popup_widget = popup_widget + return self._popup_widget + + def _show_group_popup(self, index): + action_id = index.data(ACTION_ID_ROLE) + group_label = index.data(QtCore.Qt.DisplayRole) + action_items = self._model.get_group_items(action_id) + rect = self._view.visualRect(index) + pos = self.mapToGlobal(rect.topLeft()) + + popup_widget = self._get_popup_widget() + popup_widget.show_items( + group_label, action_id, action_items, pos + ) def _trigger_action(self, action_id, index=None): project_name = self._model.get_selected_project_name() @@ -1011,10 +1078,7 @@ class ActionsWidget(QtWidgets.QWidget): if index is not None: self._start_animation(index) - def _on_config_request(self, action_id): - self._show_config_dialog(action_id) - - def _show_config_dialog(self, action_id): + def _show_config_dialog(self, action_id, center_point): action_item = self._model.get_action_item_by_id(action_id) config_fields = self._model.get_action_config_fields(action_id) if not config_fields: @@ -1040,11 +1104,31 @@ class ActionsWidget(QtWidgets.QWidget): "Cancel", ) dialog.set_values(values) + dialog.show() + self._center_dialog(dialog, center_point) result = dialog.exec_() if result == QtWidgets.QDialog.Accepted: new_values = dialog.get_values() self._controller.set_action_config_values(context, new_values) + @staticmethod + def _center_dialog(dialog, target_center_pos): + dialog_geo = dialog.geometry() + dialog_geo.moveCenter(target_center_pos) + + screen = dialog.screen() + screen_geo = screen.availableGeometry() + if screen_geo.left() > dialog_geo.left(): + dialog_geo.moveLeft(screen_geo.left()) + elif screen_geo.right() < dialog_geo.right(): + dialog_geo.moveRight(screen_geo.right()) + + if screen_geo.top() > dialog_geo.top(): + dialog_geo.moveTop(screen_geo.top()) + elif screen_geo.bottom() < dialog_geo.bottom(): + dialog_geo.moveBottom(screen_geo.bottom()) + dialog.move(dialog_geo.topLeft()) + def _create_attrs_dialog( self, config_fields, diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index 7c34989947..65efdc27ac 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -68,6 +68,7 @@ class HierarchyPage(QtWidgets.QWidget): # - Folders widget folders_widget = FoldersWidget(controller, content_body) folders_widget.set_header_visible(True) + folders_widget.set_deselectable(True) # - Tasks widget tasks_widget = TasksWidget(controller, content_body) diff --git a/client/ayon_core/tools/launcher/ui/projects_widget.py b/client/ayon_core/tools/launcher/ui/projects_widget.py deleted file mode 100644 index e2af54b55d..0000000000 --- a/client/ayon_core/tools/launcher/ui/projects_widget.py +++ /dev/null @@ -1,154 +0,0 @@ -from qtpy import QtWidgets, QtCore - -from ayon_core.tools.flickcharm import FlickCharm -from ayon_core.tools.utils import ( - PlaceholderLineEdit, - RefreshButton, - ProjectsQtModel, - ProjectSortFilterProxy, -) -from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER - - -class ProjectIconView(QtWidgets.QListView): - """Styled ListView that allows to toggle between icon and list mode. - - Toggling between the two modes is done by Right Mouse Click. - """ - - IconMode = 0 - ListMode = 1 - - def __init__(self, parent=None, mode=ListMode): - super(ProjectIconView, self).__init__(parent=parent) - - # Workaround for scrolling being super slow or fast when - # toggling between the two visual modes - self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) - self.setObjectName("IconView") - - self._mode = None - self.set_mode(mode) - - def set_mode(self, mode): - if mode == self._mode: - return - - self._mode = mode - - if mode == self.IconMode: - self.setViewMode(QtWidgets.QListView.IconMode) - self.setResizeMode(QtWidgets.QListView.Adjust) - self.setWrapping(True) - self.setWordWrap(True) - self.setGridSize(QtCore.QSize(151, 90)) - self.setIconSize(QtCore.QSize(50, 50)) - self.setSpacing(0) - self.setAlternatingRowColors(False) - - self.setProperty("mode", "icon") - self.style().polish(self) - - self.verticalScrollBar().setSingleStep(30) - - elif self.ListMode: - self.setProperty("mode", "list") - self.style().polish(self) - - self.setViewMode(QtWidgets.QListView.ListMode) - self.setResizeMode(QtWidgets.QListView.Adjust) - self.setWrapping(False) - self.setWordWrap(False) - self.setIconSize(QtCore.QSize(20, 20)) - self.setGridSize(QtCore.QSize(100, 25)) - self.setSpacing(0) - self.setAlternatingRowColors(False) - - self.verticalScrollBar().setSingleStep(34) - - def mousePressEvent(self, event): - if event.button() == QtCore.Qt.RightButton: - self.set_mode(int(not self._mode)) - return super(ProjectIconView, self).mousePressEvent(event) - - -class ProjectsWidget(QtWidgets.QWidget): - """Projects Page""" - - refreshed = QtCore.Signal() - - def __init__(self, controller, parent=None): - super(ProjectsWidget, self).__init__(parent=parent) - - header_widget = QtWidgets.QWidget(self) - - projects_filter_text = PlaceholderLineEdit(header_widget) - projects_filter_text.setPlaceholderText("Filter projects...") - - refresh_btn = RefreshButton(header_widget) - - header_layout = QtWidgets.QHBoxLayout(header_widget) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.addWidget(projects_filter_text, 1) - header_layout.addWidget(refresh_btn, 0) - - projects_view = ProjectIconView(parent=self) - projects_view.setSelectionMode(QtWidgets.QListView.NoSelection) - flick = FlickCharm(parent=self) - flick.activateOn(projects_view) - projects_model = ProjectsQtModel(controller) - projects_proxy_model = ProjectSortFilterProxy() - projects_proxy_model.setSourceModel(projects_model) - - projects_view.setModel(projects_proxy_model) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.addWidget(header_widget, 0) - main_layout.addWidget(projects_view, 1) - - projects_view.clicked.connect(self._on_view_clicked) - projects_model.refreshed.connect(self.refreshed) - projects_filter_text.textChanged.connect( - self._on_project_filter_change) - refresh_btn.clicked.connect(self._on_refresh_clicked) - - controller.register_event_callback( - "projects.refresh.finished", - self._on_projects_refresh_finished - ) - - self._controller = controller - - self._projects_view = projects_view - self._projects_model = projects_model - self._projects_proxy_model = projects_proxy_model - - def has_content(self): - """Model has at least one project. - - Returns: - bool: True if there is any content in the model. - """ - - return self._projects_model.has_content() - - def _on_view_clicked(self, index): - if not index.isValid(): - return - model = index.model() - flags = model.flags(index) - if not flags & QtCore.Qt.ItemIsEnabled: - return - project_name = index.data(QtCore.Qt.DisplayRole) - self._controller.set_selected_project(project_name) - - def _on_project_filter_change(self, text): - self._projects_proxy_model.setFilterFixedString(text) - - def _on_refresh_clicked(self): - self._controller.refresh() - - def _on_projects_refresh_finished(self, event): - if event["sender"] != PROJECTS_MODEL_SENDER: - self._projects_model.refresh() diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index 7fde8518b0..819e141d59 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -3,9 +3,13 @@ from qtpy import QtWidgets, QtCore, QtGui from ayon_core import style, resources from ayon_core.tools.launcher.control import BaseLauncherController -from ayon_core.tools.utils import MessageOverlayObject +from ayon_core.tools.utils import ( + MessageOverlayObject, + PlaceholderLineEdit, + RefreshButton, + ProjectsWidget, +) -from .projects_widget import ProjectsWidget from .hierarchy_page import HierarchyPage from .actions_widget import ActionsWidget @@ -50,7 +54,25 @@ class LauncherWindow(QtWidgets.QWidget): pages_widget = QtWidgets.QWidget(content_body) # - First page - Projects - projects_page = ProjectsWidget(controller, pages_widget) + projects_page = QtWidgets.QWidget(pages_widget) + projects_header_widget = QtWidgets.QWidget(projects_page) + + projects_filter_text = PlaceholderLineEdit(projects_header_widget) + projects_filter_text.setPlaceholderText("Filter projects...") + + refresh_btn = RefreshButton(projects_header_widget) + + projects_header_layout = QtWidgets.QHBoxLayout(projects_header_widget) + projects_header_layout.setContentsMargins(0, 0, 0, 0) + projects_header_layout.addWidget(projects_filter_text, 1) + projects_header_layout.addWidget(refresh_btn, 0) + + projects_widget = ProjectsWidget(controller, pages_widget) + + projects_layout = QtWidgets.QVBoxLayout(projects_page) + projects_layout.setContentsMargins(0, 0, 0, 0) + projects_layout.addWidget(projects_header_widget, 0) + projects_layout.addWidget(projects_widget, 1) # - Second page - Hierarchy (folders & tasks) hierarchy_page = HierarchyPage(controller, pages_widget) @@ -102,12 +124,16 @@ class LauncherWindow(QtWidgets.QWidget): page_slide_anim.setEndValue(1.0) page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) - projects_page.refreshed.connect(self._on_projects_refresh) + refresh_btn.clicked.connect(self._on_refresh_request) + projects_widget.refreshed.connect(self._on_projects_refresh) + actions_refresh_timer.timeout.connect( self._on_actions_refresh_timeout) page_slide_anim.valueChanged.connect( self._on_page_slide_value_changed) page_slide_anim.finished.connect(self._on_page_slide_finished) + projects_filter_text.textChanged.connect( + self._on_project_filter_change) controller.register_event_callback( "selection.project.changed", @@ -142,6 +168,7 @@ class LauncherWindow(QtWidgets.QWidget): self._pages_widget = pages_widget self._pages_layout = pages_layout self._projects_page = projects_page + self._projects_widget = projects_widget self._hierarchy_page = hierarchy_page self._actions_widget = actions_widget # self._action_history = action_history @@ -194,6 +221,12 @@ class LauncherWindow(QtWidgets.QWidget): elif self._is_on_projects_page: self._go_to_hierarchy_page(project_name) + def _on_project_filter_change(self, text): + self._projects_widget.set_name_filter(text) + + def _on_refresh_request(self): + self._controller.refresh() + def _on_projects_refresh(self): # Refresh only actions on projects page if self._is_on_projects_page: @@ -201,7 +234,7 @@ class LauncherWindow(QtWidgets.QWidget): return # No projects were found -> go back to projects page - if not self._projects_page.has_content(): + if not self._projects_widget.has_content(): self._go_to_projects_page() return @@ -280,6 +313,9 @@ class LauncherWindow(QtWidgets.QWidget): def _go_to_projects_page(self): if self._is_on_projects_page: return + + # Deselect project in projects widget + self._projects_widget.set_selected_project(None) self._is_on_projects_page = True self._hierarchy_page.set_page_visible(False) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index d0d7cd430b..5ab7e78212 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -1,11 +1,15 @@ +"""Abstract base classes for loader tool.""" +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import List +from typing import Iterable, Any, Optional from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, - serialize_attr_defs, deserialize_attr_defs, + serialize_attr_defs, ) +from ayon_core.tools.common_models import TaskItem, TagItem class ProductTypeItem: @@ -16,7 +20,7 @@ class ProductTypeItem: icon (dict[str, Any]): Product type icon definition. """ - def __init__(self, name, icon): + def __init__(self, name: str, icon: dict[str, Any]): self.name = name self.icon = icon @@ -31,6 +35,41 @@ class ProductTypeItem: return cls(**data) +class ProductBaseTypeItem: + """Item representing the product base type.""" + + def __init__(self, name: str, icon: dict[str, Any]): + """Initialize product base type item.""" + self.name = name + self.icon = icon + + def to_data(self) -> dict[str, Any]: + """Convert item to data dictionary. + + Returns: + dict[str, Any]: Data representation of the item. + + """ + return { + "name": self.name, + "icon": self.icon, + } + + @classmethod + def from_data( + cls, data: dict[str, Any]) -> ProductBaseTypeItem: + """Create item from data dictionary. + + Args: + data (dict[str, Any]): Data to create item from. + + Returns: + ProductBaseTypeItem: Item created from the provided data. + + """ + return cls(**data) + + class ProductItem: """Product item with it versions. @@ -49,35 +88,41 @@ class ProductItem: def __init__( self, - product_id, - product_type, - product_name, - product_icon, - product_type_icon, - product_in_scene, - group_name, - folder_id, - folder_label, - version_items, + product_id: str, + product_type: str, + product_base_type: str, + product_name: str, + product_icon: dict[str, Any], + product_type_icon: dict[str, Any], + product_base_type_icon: dict[str, Any], + group_name: str, + folder_id: str, + folder_label: str, + version_items: dict[str, VersionItem], + product_in_scene: bool, ): self.product_id = product_id self.product_type = product_type + self.product_base_type = product_base_type self.product_name = product_name self.product_icon = product_icon self.product_type_icon = product_type_icon + self.product_base_type_icon = product_base_type_icon self.product_in_scene = product_in_scene self.group_name = group_name self.folder_id = folder_id self.folder_label = folder_label self.version_items = version_items - def to_data(self): + def to_data(self) -> dict[str, Any]: return { "product_id": self.product_id, "product_type": self.product_type, + "product_base_type": self.product_base_type, "product_name": self.product_name, "product_icon": self.product_icon, "product_type_icon": self.product_type_icon, + "product_base_type_icon": self.product_base_type_icon, "product_in_scene": self.product_in_scene, "group_name": self.group_name, "folder_id": self.folder_id, @@ -113,6 +158,7 @@ class VersionItem: published_time (Union[str, None]): Published time in format '%Y%m%dT%H%M%SZ'. status (Union[str, None]): Status name. + tags (Union[list[str], None]): Tags. author (Union[str, None]): Author. frame_range (Union[str, None]): Frame range. duration (Union[int, None]): Duration. @@ -124,21 +170,22 @@ class VersionItem: def __init__( self, - version_id, - version, - is_hero, - product_id, - task_id, - thumbnail_id, - published_time, - author, - status, - frame_range, - duration, - handles, - step, - comment, - source, + version_id: str, + version: int, + is_hero: bool, + product_id: str, + task_id: Optional[str], + thumbnail_id: Optional[str], + published_time: Optional[str], + tags: Optional[list[str]], + author: Optional[str], + status: Optional[str], + frame_range: Optional[str], + duration: Optional[int], + handles: Optional[str], + step: Optional[int], + comment: Optional[str], + source: Optional[str], ): self.version_id = version_id self.product_id = product_id @@ -148,6 +195,7 @@ class VersionItem: self.is_hero = is_hero self.published_time = published_time self.author = author + self.tags = tags self.status = status self.frame_range = frame_range self.duration = duration @@ -198,7 +246,7 @@ class VersionItem: def __le__(self, other): return self.__eq__(other) or self.__lt__(other) - def to_data(self): + def to_data(self) -> dict[str, Any]: return { "version_id": self.version_id, "product_id": self.product_id, @@ -208,6 +256,7 @@ class VersionItem: "is_hero": self.is_hero, "published_time": self.published_time, "author": self.author, + "tags": self.tags, "status": self.status, "frame_range": self.frame_range, "duration": self.duration, @@ -218,7 +267,7 @@ class VersionItem: } @classmethod - def from_data(cls, data): + def from_data(cls, data: dict[str, Any]) -> VersionItem: return cls(**data) @@ -354,8 +403,8 @@ class ProductTypesFilter: Defines the filtering for product types. """ - def __init__(self, product_types: List[str], is_allow_list: bool): - self.product_types: List[str] = 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 @@ -517,8 +566,21 @@ class FrontendLoaderController(_BaseLoaderController): Returns: list[ProjectItem]: List of project items. - """ + """ + pass + + @abstractmethod + def get_project_anatomy_tags(self, project_name: str) -> list[TagItem]: + """Tag items defined on project anatomy. + + Args: + project_name (str): Project name. + + Returns: + list[TagItem]: Tag definition items. + + """ pass @abstractmethod @@ -542,7 +604,12 @@ class FrontendLoaderController(_BaseLoaderController): pass @abstractmethod - def get_task_items(self, project_name, folder_ids, sender=None): + def get_task_items( + self, + project_name: str, + folder_ids: Iterable[str], + sender: Optional[str] = None, + ) -> list[TaskItem]: """Task items for folder ids. Args: @@ -590,6 +657,21 @@ class FrontendLoaderController(_BaseLoaderController): """ pass + @abstractmethod + def get_available_tags_by_entity_type( + self, project_name: str + ) -> dict[str, list[str]]: + """Get available tags by entity type. + + Args: + project_name (str): Project name. + + Returns: + dict[str, list[str]]: Available tags by entity type. + + """ + pass + @abstractmethod def get_project_status_items(self, project_name, sender=None): """Items for all projects available on server. diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index b3a80b34d4..7ba42a0981 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import uuid @@ -13,6 +15,7 @@ from ayon_core.tools.common_models import ( ProjectsModel, HierarchyModel, ThumbnailsModel, + TagItem, ) from .abstract import ( @@ -223,6 +226,16 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): output[folder_id] = label return output + def get_available_tags_by_entity_type( + self, project_name: str + ) -> dict[str, list[str]]: + return self._hierarchy_model.get_available_tags_by_entity_type( + project_name + ) + + def get_project_anatomy_tags(self, project_name: str) -> list[TagItem]: + return self._projects_model.get_project_anatomy_tags(project_name) + def get_product_items(self, project_name, folder_ids, sender=None): return self._products_model.get_product_items( project_name, folder_ids, sender) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index cfe91cadab..b792f92dfd 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys import traceback import inspect @@ -322,7 +324,6 @@ class LoaderActionsModel: available_loaders = self._filter_loaders_by_tool_name( project_name, discover_loader_plugins(project_name) ) - repre_loaders = [] product_loaders = [] loaders_by_identifier = {} @@ -340,6 +341,7 @@ class LoaderActionsModel: loaders_by_identifier_c.update_data(loaders_by_identifier) product_loaders_c.update_data(product_loaders) repre_loaders_c.update_data(repre_loaders) + return product_loaders, repre_loaders def _get_loader_by_identifier(self, project_name, identifier): @@ -719,7 +721,12 @@ class LoaderActionsModel: loader, repre_contexts, options ) - def _load_representations_by_loader(self, loader, repre_contexts, options): + def _load_representations_by_loader( + self, + loader, + repre_contexts, + options + ): """Loops through list of repre_contexts and loads them with one loader Args: @@ -770,7 +777,12 @@ class LoaderActionsModel: )) return error_info - def _load_products_by_loader(self, loader, version_contexts, options): + def _load_products_by_loader( + self, + loader, + version_contexts, + options + ): """Triggers load with ProductLoader type of loaders. Warning: @@ -796,7 +808,6 @@ class LoaderActionsModel: version_contexts, options=options ) - except Exception as exc: formatted_traceback = None if not isinstance(exc, LoadError): diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 34acc0550c..87e2406c81 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -1,24 +1,35 @@ +"""Products model for loader tools.""" +from __future__ import annotations + import collections import contextlib +from typing import TYPE_CHECKING, Iterable, Optional import arrow import ayon_api from ayon_api.operations import OperationsSession + from ayon_core.lib import NestedCacheItem from ayon_core.style import get_default_entity_icon_color from ayon_core.tools.loader.abstract import ( ProductTypeItem, + ProductBaseTypeItem, ProductItem, VersionItem, RepreItem, ) +if TYPE_CHECKING: + from ayon_api.typing import ProductBaseTypeDict, ProductDict, VersionDict + + PRODUCTS_MODEL_SENDER = "products.model" def version_item_from_entity(version): version_attribs = version["attrib"] + tags = version["tags"] frame_start = version_attribs.get("frameStart") frame_end = version_attribs.get("frameEnd") handle_start = version_attribs.get("handleStart") @@ -59,6 +70,7 @@ def version_item_from_entity(version): thumbnail_id=version["thumbnailId"], published_time=published_time, author=author, + tags=tags, status=version["status"], frame_range=frame_range, duration=duration, @@ -70,9 +82,10 @@ def version_item_from_entity(version): def product_item_from_entity( - product_entity, + product_entity: ProductDict, version_entities, - product_type_items_by_name, + product_type_items_by_name: dict[str, ProductTypeItem], + product_base_type_items_by_name: dict[str, ProductBaseTypeItem], folder_label, product_in_scene, ): @@ -88,8 +101,20 @@ def product_item_from_entity( # Cache the item for future use product_type_items_by_name[product_type] = product_type_item - product_type_icon = product_type_item.icon + product_base_type = product_entity.get("productBaseType") + product_base_type_item = product_base_type_items_by_name.get( + product_base_type) + # Same as for product type item above. Not sure if this is still needed + # though. + if product_base_type_item is None: + product_base_type_item = create_default_product_base_type_item( + product_base_type) + # Cache the item for future use + product_base_type_items_by_name[product_base_type] = ( + product_base_type_item) + product_type_icon = product_type_item.icon + product_base_type_icon = product_base_type_item.icon product_icon = { "type": "awesome-font", "name": "fa.file-o", @@ -103,9 +128,11 @@ def product_item_from_entity( return ProductItem( product_id=product_entity["id"], product_type=product_type, + product_base_type=product_base_type, product_name=product_entity["name"], product_icon=product_icon, product_type_icon=product_type_icon, + product_base_type_icon=product_base_type_icon, product_in_scene=product_in_scene, group_name=group, folder_id=product_entity["folderId"], @@ -114,7 +141,8 @@ def product_item_from_entity( ) -def product_type_item_from_data(product_type_data): +def product_type_item_from_data( + product_type_data: ProductDict) -> ProductTypeItem: # TODO implement icon implementation # icon = product_type_data["icon"] # color = product_type_data["color"] @@ -127,7 +155,29 @@ def product_type_item_from_data(product_type_data): return ProductTypeItem(product_type_data["name"], icon) -def create_default_product_type_item(product_type): +def product_base_type_item_from_data( + product_base_type_data: ProductBaseTypeDict +) -> ProductBaseTypeItem: + """Create product base type item from data. + + Args: + product_base_type_data (ProductBaseTypeDict): Product base type data. + + Returns: + ProductBaseTypeDict: Product base type item. + + """ + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + return ProductBaseTypeItem( + name=product_base_type_data["name"], + icon=icon) + + +def create_default_product_type_item(product_type: str) -> ProductTypeItem: icon = { "type": "awesome-font", "name": "fa.folder", @@ -136,10 +186,28 @@ def create_default_product_type_item(product_type): return ProductTypeItem(product_type, icon) +def create_default_product_base_type_item( + product_base_type: str) -> ProductBaseTypeItem: + """Create default product base type item. + + Args: + product_base_type (str): Product base type name. + + Returns: + ProductBaseTypeItem: Default product base type item. + """ + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + return ProductBaseTypeItem(product_base_type, icon) + + class ProductsModel: """Model for products, version and representation. - All of the entities are product based. This model prepares data for UI + All the entities are product based. This model prepares data for UI and caches it for faster access. Note: @@ -161,6 +229,8 @@ class ProductsModel: # Cache helpers self._product_type_items_cache = NestedCacheItem( levels=1, default_factory=list, lifetime=self.lifetime) + self._product_base_type_items_cache = NestedCacheItem( + levels=1, default_factory=list, lifetime=self.lifetime) self._product_items_cache = NestedCacheItem( levels=2, default_factory=dict, lifetime=self.lifetime) self._repre_items_cache = NestedCacheItem( @@ -199,6 +269,36 @@ class ProductsModel: ]) return cache.get_data() + def get_product_base_type_items( + self, + project_name: Optional[str]) -> list[ProductBaseTypeItem]: + """Product base type items for the project. + + Args: + project_name (optional, str): Project name. + + Returns: + list[ProductBaseTypeDict]: Product base type items. + + """ + if not project_name: + return [] + + cache = self._product_base_type_items_cache[project_name] + if not cache.is_valid: + product_base_types = [] + # TODO add temp implementation here when it is actually + # implemented and available on server. + if hasattr(ayon_api, "get_project_product_base_types"): + product_base_types = ayon_api.get_project_product_base_types( + project_name + ) + cache.update_data([ + product_base_type_item_from_data(product_base_type) + for product_base_type in product_base_types + ]) + return cache.get_data() + def get_product_items(self, project_name, folder_ids, sender): """Product items with versions for project and folder ids. @@ -449,11 +549,12 @@ class ProductsModel: def _create_product_items( self, - project_name, - products, - versions, + project_name: str, + products: Iterable[ProductDict], + versions: Iterable[VersionDict], folder_items=None, product_type_items=None, + product_base_type_items: Optional[Iterable[ProductBaseTypeItem]] = None ): if folder_items is None: folder_items = self._controller.get_folder_items(project_name) @@ -461,6 +562,11 @@ class ProductsModel: if product_type_items is None: product_type_items = self.get_product_type_items(project_name) + if product_base_type_items is None: + product_base_type_items = self.get_product_base_type_items( + project_name + ) + loaded_product_ids = self._controller.get_loaded_product_ids() versions_by_product_id = collections.defaultdict(list) @@ -470,7 +576,13 @@ class ProductsModel: product_type_item.name: product_type_item for product_type_item in product_type_items } - output = {} + + product_base_type_items_by_name: dict[str, ProductBaseTypeItem] = { + product_base_type_item.name: product_base_type_item + for product_base_type_item in product_base_type_items + } + + output: dict[str, ProductItem] = {} for product in products: product_id = product["id"] folder_id = product["folderId"] @@ -484,6 +596,7 @@ class ProductsModel: product, versions, product_type_items_by_name, + product_base_type_items_by_name, folder_item.label, product_id in loaded_product_ids, ) diff --git a/client/ayon_core/tools/loader/models/selection.py b/client/ayon_core/tools/loader/models/selection.py index 04add26f86..f2148352cd 100644 --- a/client/ayon_core/tools/loader/models/selection.py +++ b/client/ayon_core/tools/loader/models/selection.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + class SelectionModel(object): """Model handling selection changes. diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index c7f0038df4..3a54a1b5f8 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import collections from ayon_api import ( diff --git a/client/ayon_core/tools/loader/ui/_multicombobox.py b/client/ayon_core/tools/loader/ui/_multicombobox.py index 393272fdf9..b4e1707242 100644 --- a/client/ayon_core/tools/loader/ui/_multicombobox.py +++ b/client/ayon_core/tools/loader/ui/_multicombobox.py @@ -1,4 +1,5 @@ from __future__ import annotations + import typing from typing import List, Tuple, Optional, Iterable, Any diff --git a/client/ayon_core/tools/loader/ui/product_types_combo.py b/client/ayon_core/tools/loader/ui/product_types_combo.py deleted file mode 100644 index 525f1cae1b..0000000000 --- a/client/ayon_core/tools/loader/ui/product_types_combo.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations -from qtpy import QtGui, QtCore - -from ._multicombobox import ( - CustomPaintMultiselectComboBox, - BaseQtModel, -) - -STATUS_ITEM_TYPE = 0 -SELECT_ALL_TYPE = 1 -DESELECT_ALL_TYPE = 2 -SWAP_STATE_TYPE = 3 - -PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1 -ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 2 -ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 3 - - -class ProductTypesQtModel(BaseQtModel): - refreshed = QtCore.Signal() - - def __init__(self, controller): - self._reset_filters_on_refresh = True - self._refreshing = False - self._bulk_change = False - self._items_by_name = {} - - super().__init__( - item_type_role=ITEM_TYPE_ROLE, - item_subtype_role=ITEM_SUBTYPE_ROLE, - empty_values_label="No product types...", - controller=controller, - ) - - def is_refreshing(self): - return self._refreshing - - def refresh(self, project_name): - self._refreshing = True - super().refresh(project_name) - - 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 _get_standard_items(self) -> list[QtGui.QStandardItem]: - return list(self._items_by_name.values()) - - def _clear_standard_items(self): - self._items_by_name.clear() - - def _prepare_new_value_items(self, project_name: str, _: bool) -> tuple[ - list[QtGui.QStandardItem], list[QtGui.QStandardItem] - ]: - product_type_items = self._controller.get_product_type_items( - project_name) - self._last_project = project_name - - names_to_remove = set(self._items_by_name.keys()) - items = [] - items_filter_required = {} - for product_type_item in product_type_items: - name = product_type_item.name - names_to_remove.discard(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) - item.setCheckable(True) - self._items_by_name[name] = item - - items.append(item) - - if filter_required: - items_filter_required[name] = item - - 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) - ) - item.setCheckState( - QtCore.Qt.Checked - if matching % 2 == 0 - else QtCore.Qt.Unchecked - ) - - items_to_remove = [] - for name in names_to_remove: - items_to_remove.append( - self._items_by_name.pop(name) - ) - - # Uncheck all if all are checked (same result) - if all( - item.checkState() == QtCore.Qt.Checked - for item in items - ): - for item in items: - item.setCheckState(QtCore.Qt.Unchecked) - - return items, items_to_remove - - -class ProductTypesCombobox(CustomPaintMultiselectComboBox): - def __init__(self, controller, parent): - self._controller = controller - model = ProductTypesQtModel(controller) - super().__init__( - PRODUCT_TYPE_ROLE, - PRODUCT_TYPE_ROLE, - QtCore.Qt.ForegroundRole, - QtCore.Qt.DecorationRole, - item_type_role=ITEM_TYPE_ROLE, - model=model, - parent=parent - ) - - model.refreshed.connect(self._on_model_refresh) - - self.set_placeholder_text("Product types filter...") - self._model = model - self._last_project_name = None - self._fully_disabled_filter = False - - controller.register_event_callback( - "selection.project.changed", - self._on_project_change - ) - controller.register_event_callback( - "projects.refresh.finished", - self._on_projects_refresh - ) - self.setToolTip("Product types filter") - self.value_changed.connect( - self._on_product_type_filter_change - ) - - def reset_product_types_filter_on_refresh(self): - self._model.reset_product_types_filter_on_refresh() - - def _on_model_refresh(self): - self.value_changed.emit() - - def _on_product_type_filter_change(self): - lines = ["Product types filter"] - for item in self.get_value_info(): - status_name, enabled = item - lines.append(f"{'✔' if enabled else '☐'} {status_name}") - - self.setToolTip("\n".join(lines)) - - def _on_project_change(self, event): - project_name = event["project_name"] - self._last_project_name = project_name - self._model.refresh(project_name) - - def _on_projects_refresh(self): - if self._last_project_name: - self._model.refresh(self._last_project_name) - self._on_product_type_filter_change() diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 8cece4687f..b500b86b97 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -1,6 +1,7 @@ +from __future__ import annotations + import numbers import uuid -from typing import Dict from qtpy import QtWidgets, QtCore, QtGui @@ -18,16 +19,19 @@ from .products_model import ( SYNC_REMOTE_SITE_AVAILABILITY, ) -STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 -TASK_ID_ROLE = QtCore.Qt.UserRole + 2 +COMBO_VERSION_ID_ROLE = QtCore.Qt.UserRole + 1 +COMBO_TASK_ID_ROLE = QtCore.Qt.UserRole + 2 +COMBO_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 3 +COMBO_VERSION_TAGS_ROLE = QtCore.Qt.UserRole + 4 +COMBO_TASK_TAGS_ROLE = QtCore.Qt.UserRole + 5 -class VersionsModel(QtGui.QStandardItemModel): +class ComboVersionsModel(QtGui.QStandardItemModel): def __init__(self): super().__init__() self._items_by_id = {} - def update_versions(self, version_items): + def update_versions(self, version_items, task_tags_by_version_id): version_ids = { version_item.version_id for version_item in version_items @@ -39,6 +43,7 @@ class VersionsModel(QtGui.QStandardItemModel): item = self._items_by_id.pop(item_id) root_item.removeRow(item.row()) + version_tags_by_version_id = {} for idx, version_item in enumerate(version_items): version_id = version_item.version_id @@ -48,34 +53,74 @@ class VersionsModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem(label) item.setData(version_id, QtCore.Qt.UserRole) self._items_by_id[version_id] = item - item.setData(version_item.status, STATUS_NAME_ROLE) - item.setData(version_item.task_id, TASK_ID_ROLE) + version_tags = set(version_item.tags) + task_tags = task_tags_by_version_id[version_id] + item.setData(version_id, COMBO_VERSION_ID_ROLE) + item.setData(version_item.status, COMBO_STATUS_NAME_ROLE) + item.setData(version_item.task_id, COMBO_TASK_ID_ROLE) + item.setData("|".join(version_tags), COMBO_VERSION_TAGS_ROLE) + item.setData("|".join(task_tags), COMBO_TASK_TAGS_ROLE) + version_tags_by_version_id[version_id] = set(version_item.tags) if item.row() != idx: root_item.insertRow(idx, item) -class VersionsFilterModel(QtCore.QSortFilterProxyModel): +class ComboVersionsFilterModel(QtCore.QSortFilterProxyModel): def __init__(self): super().__init__() self._status_filter = None self._task_ids_filter = None + self._version_tags_filter = None + self._task_tags_filter = None def filterAcceptsRow(self, row, parent): + index = None if self._status_filter is not None: if not self._status_filter: return False - - index = self.sourceModel().index(row, 0, parent) - status = index.data(STATUS_NAME_ROLE) + if index is None: + index = self.sourceModel().index(row, 0, parent) + status = index.data(COMBO_STATUS_NAME_ROLE) if status not in self._status_filter: return False if self._task_ids_filter: - index = self.sourceModel().index(row, 0, parent) - task_id = index.data(TASK_ID_ROLE) + if index is None: + index = self.sourceModel().index(row, 0, parent) + task_id = index.data(COMBO_TASK_ID_ROLE) if task_id not in self._task_ids_filter: return False + + if self._version_tags_filter is not None: + if not self._version_tags_filter: + return False + + if index is None: + model = self.sourceModel() + index = model.index(row, 0, parent) + version_tags_s = index.data(COMBO_TASK_TAGS_ROLE) + version_tags = set() + if version_tags_s: + version_tags = set(version_tags_s.split("|")) + + if not version_tags & self._version_tags_filter: + return False + + if self._task_tags_filter is not None: + if not self._task_tags_filter: + return False + + if index is None: + model = self.sourceModel() + index = model.index(row, 0, parent) + task_tags_s = index.data(COMBO_TASK_TAGS_ROLE) + task_tags = set() + if task_tags_s: + task_tags = set(task_tags_s.split("|")) + if not (task_tags & self._task_tags_filter): + return False + return True def set_tasks_filter(self, task_ids): @@ -84,12 +129,24 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): self._task_ids_filter = task_ids self.invalidateFilter() + def set_task_tags_filter(self, tags): + if self._task_tags_filter == tags: + return + self._task_tags_filter = tags + self.invalidateFilter() + def set_statuses_filter(self, status_names): if self._status_filter == status_names: return self._status_filter = status_names self.invalidateFilter() + def set_version_tags_filter(self, tags): + if self._version_tags_filter == tags: + return + self._version_tags_filter = tags + self.invalidateFilter() + class VersionComboBox(QtWidgets.QComboBox): value_changed = QtCore.Signal(str, str) @@ -97,8 +154,8 @@ class VersionComboBox(QtWidgets.QComboBox): def __init__(self, product_id, parent): super().__init__(parent) - versions_model = VersionsModel() - proxy_model = VersionsFilterModel() + versions_model = ComboVersionsModel() + proxy_model = ComboVersionsFilterModel() proxy_model.setSourceModel(versions_model) self.setModel(proxy_model) @@ -123,6 +180,13 @@ class VersionComboBox(QtWidgets.QComboBox): if self.currentIndex() != 0: self.setCurrentIndex(0) + def set_task_tags_filter(self, tags): + self._proxy_model.set_task_tags_filter(tags) + if self.count() == 0: + return + if self.currentIndex() != 0: + self.setCurrentIndex(0) + def set_statuses_filter(self, status_names): self._proxy_model.set_statuses_filter(status_names) if self.count() == 0: @@ -130,12 +194,24 @@ class VersionComboBox(QtWidgets.QComboBox): if self.currentIndex() != 0: self.setCurrentIndex(0) + def set_version_tags_filter(self, tags): + self._proxy_model.set_version_tags_filter(tags) + if self.count() == 0: + return + if self.currentIndex() != 0: + self.setCurrentIndex(0) + def all_versions_filtered_out(self): if self._items_by_id: return self.count() == 0 return False - def update_versions(self, version_items, current_version_id): + def update_versions( + self, + version_items, + current_version_id, + task_tags_by_version_id, + ): self.blockSignals(True) version_items = list(version_items) version_ids = [ @@ -146,7 +222,9 @@ class VersionComboBox(QtWidgets.QComboBox): current_version_id = version_ids[0] self._current_id = current_version_id - self._versions_model.update_versions(version_items) + self._versions_model.update_versions( + version_items, task_tags_by_version_id + ) index = version_ids.index(current_version_id) if self.currentIndex() != index: @@ -170,9 +248,11 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._editor_by_id: Dict[str, VersionComboBox] = {} + self._editor_by_id: dict[str, VersionComboBox] = {} self._task_ids_filter = None self._statuses_filter = None + self._version_tags_filter = None + self._task_tags_filter = None def displayText(self, value, locale): if not isinstance(value, numbers.Integral): @@ -185,10 +265,26 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): widget.set_tasks_filter(task_ids) def set_statuses_filter(self, status_names): - self._statuses_filter = set(status_names) + if status_names is not None: + status_names = set(status_names) + self._statuses_filter = status_names for widget in self._editor_by_id.values(): widget.set_statuses_filter(status_names) + def set_version_tags_filter(self, tags): + if tags is not None: + tags = set(tags) + self._version_tags_filter = tags + for widget in self._editor_by_id.values(): + widget.set_version_tags_filter(tags) + + def set_task_tags_filter(self, tags): + if tags is not None: + tags = set(tags) + self._task_tags_filter = tags + for widget in self._editor_by_id.values(): + widget.set_task_tags_filter(tags) + def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) if fg_color: @@ -200,7 +296,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): fg_color = None if not fg_color: - return super(VersionDelegate, self).paint(painter, option, index) + return super().paint(painter, option, index) if option.widget: style = option.widget.style() @@ -263,11 +359,22 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): editor.clear() # Current value of the index - versions = index.data(VERSION_NAME_EDIT_ROLE) or [] + product_id = index.data(PRODUCT_ID_ROLE) version_id = index.data(VERSION_ID_ROLE) + model = index.model() + while hasattr(model, "sourceModel"): + model = model.sourceModel() + versions = model.get_version_items_by_product_id(product_id) + task_tags_by_version_id = { + version_item.version_id: model.get_task_tags_by_id( + version_item.task_id + ) + for version_item in versions + } - editor.update_versions(versions, version_id) + editor.update_versions(versions, version_id, task_tags_by_version_id) editor.set_tasks_filter(self._task_ids_filter) + editor.set_task_tags_filter(self._task_tags_filter) editor.set_statuses_filter(self._statuses_filter) def setModelData(self, editor, model, index): diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index cebae9bca7..f3e5271f51 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -16,31 +16,34 @@ TASK_ID_ROLE = QtCore.Qt.UserRole + 5 PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6 PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7 PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8 -PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 9 -PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 10 -VERSION_ID_ROLE = QtCore.Qt.UserRole + 11 -VERSION_HERO_ROLE = QtCore.Qt.UserRole + 12 -VERSION_NAME_ROLE = QtCore.Qt.UserRole + 13 -VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 14 -VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 15 -VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 16 -VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 17 -VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 18 -VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 19 -VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 20 -VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 21 -VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 22 -VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 23 -VERSION_STEP_ROLE = QtCore.Qt.UserRole + 24 -VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 25 -VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 26 -ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27 -REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 28 -REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 29 -SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30 -SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 +PRODUCT_BASE_TYPE_ROLE = QtCore.Qt.UserRole + 9 +PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 10 +PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 11 +VERSION_ID_ROLE = QtCore.Qt.UserRole + 12 +VERSION_HERO_ROLE = QtCore.Qt.UserRole + 13 +VERSION_NAME_ROLE = QtCore.Qt.UserRole + 14 +VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 15 +VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 16 +VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 17 +VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 18 +VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 19 +VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 20 +VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 21 +VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 22 +VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 23 +VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 24 +VERSION_STEP_ROLE = QtCore.Qt.UserRole + 25 +VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 26 +VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 27 +ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 28 +REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 29 +REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 30 +SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 +SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 32 -STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 32 +STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 33 +TASK_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 34 +VERSION_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 35 class ProductsModel(QtGui.QStandardItemModel): @@ -49,6 +52,7 @@ class ProductsModel(QtGui.QStandardItemModel): column_labels = [ "Product name", "Product type", + "Product base type", "Folder", "Version", "Status", @@ -79,6 +83,7 @@ class ProductsModel(QtGui.QStandardItemModel): product_name_col = column_labels.index("Product name") product_type_col = column_labels.index("Product type") + product_base_type_col = column_labels.index("Product base type") folders_label_col = column_labels.index("Folder") version_col = column_labels.index("Version") status_col = column_labels.index("Status") @@ -93,6 +98,7 @@ class ProductsModel(QtGui.QStandardItemModel): _display_role_mapping = { product_name_col: QtCore.Qt.DisplayRole, product_type_col: PRODUCT_TYPE_ROLE, + product_base_type_col: PRODUCT_BASE_TYPE_ROLE, folders_label_col: FOLDER_LABEL_ROLE, version_col: VERSION_NAME_ROLE, status_col: VERSION_STATUS_NAME_ROLE, @@ -130,6 +136,7 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_folder_ids = [] self._last_project_statuses = {} self._last_status_icons_by_name = {} + self._last_task_tags_by_task_id = {} def get_product_item_indexes(self): return [ @@ -170,6 +177,17 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_folder_ids ) + def get_task_tags_by_id(self, task_id): + return self._last_task_tags_by_task_id.get(task_id, set()) + + def get_version_items_by_product_id(self, product_id: str): + product_item = self._product_items_by_id.get(product_id) + if product_item is None: + return None + version_items = list(product_item.version_items.values()) + version_items.sort(reverse=True) + return version_items + def flags(self, index): # Make the version column editable if index.column() == self.version_col and index.data(PRODUCT_ID_ROLE): @@ -224,9 +242,9 @@ class ProductsModel(QtGui.QStandardItemModel): product_item = self._product_items_by_id.get(product_id) if product_item is None: return None - product_items = list(product_item.version_items.values()) - product_items.sort(reverse=True) - return product_items + version_items = list(product_item.version_items.values()) + version_items.sort(reverse=True) + return version_items if role == QtCore.Qt.EditRole: return None @@ -422,6 +440,16 @@ class ProductsModel(QtGui.QStandardItemModel): version_item.status for version_item in product_item.version_items.values() } + version_tags = set() + task_tags = set() + for version_item in product_item.version_items.values(): + version_tags |= set(version_item.tags) + _task_tags = self._last_task_tags_by_task_id.get( + version_item.task_id + ) + if _task_tags: + task_tags |= set(_task_tags) + if model_item is None: product_id = product_item.product_id model_item = QtGui.QStandardItem(product_item.product_name) @@ -432,6 +460,9 @@ class ProductsModel(QtGui.QStandardItemModel): model_item.setData(icon, QtCore.Qt.DecorationRole) model_item.setData(product_id, PRODUCT_ID_ROLE) model_item.setData(product_item.product_name, PRODUCT_NAME_ROLE) + model_item.setData( + product_item.product_base_type, PRODUCT_BASE_TYPE_ROLE + ) model_item.setData(product_item.product_type, PRODUCT_TYPE_ROLE) model_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE) model_item.setData(product_item.folder_id, FOLDER_ID_ROLE) @@ -440,6 +471,8 @@ class ProductsModel(QtGui.QStandardItemModel): self._items_by_id[product_id] = model_item model_item.setData("|".join(statuses), STATUS_NAME_FILTER_ROLE) + model_item.setData("|".join(version_tags), VERSION_TAGS_FILTER_ROLE) + model_item.setData("|".join(task_tags), TASK_TAGS_FILTER_ROLE) model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE) in_scene = 1 if product_item.product_in_scene else 0 model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE) @@ -470,6 +503,14 @@ class ProductsModel(QtGui.QStandardItemModel): } self._last_status_icons_by_name = {} + task_items = self._controller.get_task_items( + project_name, folder_ids, sender=PRODUCTS_MODEL_SENDER_NAME + ) + self._last_task_tags_by_task_id = { + task_item.task_id: task_item.tags + for task_item in task_items + } + active_site_icon_def = self._controller.get_active_site_icon_def( project_name ) @@ -484,6 +525,7 @@ class ProductsModel(QtGui.QStandardItemModel): folder_ids, sender=PRODUCTS_MODEL_SENDER_NAME ) + product_items_by_id = { product_item.product_id: product_item for product_item in product_items diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 94d95b9026..e5bb75a208 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -1,9 +1,11 @@ from __future__ import annotations + import collections from typing import Optional from qtpy import QtWidgets, QtCore +from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.tools.utils import ( RecursiveSortFilterProxyModel, DeselectableTreeView, @@ -26,6 +28,8 @@ from .products_model import ( VERSION_STATUS_ICON_ROLE, VERSION_THUMBNAIL_ID_ROLE, STATUS_NAME_FILTER_ROLE, + VERSION_TAGS_FILTER_ROLE, + TASK_TAGS_FILTER_ROLE, ) from .products_delegates import ( VersionDelegate, @@ -41,6 +45,8 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._product_type_filters = None self._statuses_filter = None + self._version_tags_filter = None + self._task_tags_filter = None self._task_ids_filter = None self._ascending_sort = True @@ -67,6 +73,18 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._statuses_filter = statuses_filter self.invalidateFilter() + def set_version_tags_filter(self, tags): + if self._version_tags_filter == tags: + return + self._version_tags_filter = tags + self.invalidateFilter() + + def set_task_tags_filter(self, tags): + if self._task_tags_filter == tags: + return + self._task_tags_filter = tags + self.invalidateFilter() + def filterAcceptsRow(self, source_row, source_parent): source_model = self.sourceModel() index = source_model.index(source_row, 0, source_parent) @@ -83,6 +101,16 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): ): return False + if not self._accept_row_by_role_value( + index, self._version_tags_filter, VERSION_TAGS_FILTER_ROLE + ): + return False + + if not self._accept_row_by_role_value( + index, self._task_tags_filter, TASK_TAGS_FILTER_ROLE + ): + return False + return super().filterAcceptsRow(source_row, source_parent) def _accept_task_ids_filter(self, index): @@ -102,10 +130,11 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): if not filter_value: return False - status_s = index.data(role) - for status in status_s.split("|"): - if status in filter_value: - return True + value_s = index.data(role) + if value_s: + for value in value_s.split("|"): + if value in filter_value: + return True return False def lessThan(self, left, right): @@ -142,6 +171,7 @@ class ProductsWidget(QtWidgets.QWidget): default_widths = ( 200, # Product name 90, # Product type + 90, # Product base type 130, # Folder label 60, # Version 100, # Status @@ -261,6 +291,12 @@ class ProductsWidget(QtWidgets.QWidget): self._controller.is_sitesync_enabled() ) + if not is_product_base_type_supported(): + # Hide product base type column + products_view.setColumnHidden( + products_model.product_base_type_col, True + ) + def set_name_filter(self, name): """Set filter of product name. @@ -290,6 +326,14 @@ class ProductsWidget(QtWidgets.QWidget): self._version_delegate.set_statuses_filter(status_names) self._products_proxy_model.set_statuses_filter(status_names) + def set_version_tags_filter(self, version_tags): + self._version_delegate.set_version_tags_filter(version_tags) + self._products_proxy_model.set_version_tags_filter(version_tags) + + def set_task_tags_filter(self, task_tags): + self._version_delegate.set_task_tags_filter(task_tags) + self._products_proxy_model.set_task_tags_filter(task_tags) + def set_product_type_filter(self, product_type_filters): """ diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py new file mode 100644 index 0000000000..a855a3c452 --- /dev/null +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -0,0 +1,1124 @@ +from __future__ import annotations + +import copy +import uuid +from dataclasses import dataclass +from typing import Any, Optional + +from qtpy import QtCore, QtWidgets, QtGui + +from ayon_core.style import get_objected_colors +from ayon_core.tools.utils import ( + get_qt_icon, + SquareButton, + BaseClickableFrame, + PixmapLabel, + SeparatorWidget, +) + + +def set_line_edit_focus( + widget: QtWidgets.QLineEdit, + *, + append_text: Optional[str] = None, + backspace: bool = False, +): + full_text = widget.text() + if backspace and full_text: + full_text = full_text[:-1] + + if append_text: + full_text += append_text + widget.setText(full_text) + widget.setFocus() + widget.setCursorPosition(len(full_text)) + + +@dataclass +class FilterDefinition: + """Search bar definition. + + Attributes: + name (str): Name of the definition. + title (str): Title of the search bar. + icon (str): Icon name for the search bar. + placeholder (str): Placeholder text for the search bar. + + """ + name: str + title: str + filter_type: str + icon: Optional[dict[str, Any]] = None + placeholder: Optional[str] = None + items: Optional[list[dict[str, str]]] = None + + +class CloseButton(SquareButton): + """Close button for search item display widget.""" + _icon = None + _hover_color = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.__class__._icon is None: + self.__class__._icon = get_qt_icon({ + "type": "material-symbols", + "name": "close", + "color": "#FFFFFF", + }) + if self.__class__._hover_color is None: + color = get_objected_colors("bg-view-selection-hover") + self.__class__._hover_color = color.get_qcolor() + + self.setIcon(self.__class__._icon) + + def paintEvent(self, event): + """Override paint event to draw a close button.""" + painter = QtWidgets.QStylePainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + option = QtWidgets.QStyleOptionButton() + self.initStyleOption(option) + icon = self.icon() + size = min(self.width(), self.height()) + rect = QtCore.QRect(0, 0, size, size) + rect.adjust(2, 2, -2, -2) + painter.setPen(QtCore.Qt.NoPen) + bg_color = QtCore.Qt.transparent + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color = self._hover_color + + painter.setBrush(bg_color) + painter.setClipRect(event.rect()) + painter.drawEllipse(rect) + rect.adjust(2, 2, -2, -2) + icon.paint(painter, rect) + + +class SearchItemDisplayWidget(BaseClickableFrame): + """Widget displaying a set filter in the bar.""" + close_requested = QtCore.Signal(str) + edit_requested = QtCore.Signal(str) + + def __init__( + self, + filter_def: FilterDefinition, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + self._filter_def = filter_def + + title_widget = QtWidgets.QLabel(f"{filter_def.title}:", self) + + value_wrapper = QtWidgets.QWidget(self) + value_wrapper.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + value_widget = QtWidgets.QLabel(value_wrapper) + value_widget.setObjectName("ValueWidget") + value_widget.setText("") + value_layout = QtWidgets.QVBoxLayout(value_wrapper) + value_layout.setContentsMargins(2, 2, 2, 2) + value_layout.addWidget(value_widget) + + close_btn = CloseButton(self) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(4, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(title_widget, 0) + main_layout.addWidget(value_wrapper, 0) + main_layout.addWidget(close_btn, 0) + + close_btn.clicked.connect(self._on_remove_clicked) + + self._value_wrapper = value_wrapper + self._value_widget = value_widget + self._value = None + + def set_value(self, value: "str | list[str]"): + text = "" + ellide = True + if value is None: + pass + elif isinstance(value, str): + text = value + elif len(value) == 1: + text = value[0] + elif len(value) > 1: + ellide = False + text = f"Items: {len(value)}" + + if ellide and len(text) > 9: + text = text[:9] + "..." + + text = " " + text + " " + + self._value = copy.deepcopy(value) + self._value_widget.setText(text) + + def get_value(self): + return copy.deepcopy(self._value) + + def _on_remove_clicked(self): + self.close_requested.emit(self._filter_def.name) + + def _mouse_release_callback(self): + self.edit_requested.emit(self._filter_def.name) + + +class FilterItemButton(BaseClickableFrame): + filter_requested = QtCore.Signal(str) + + def __init__( + self, + filter_def: FilterDefinition, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + self._filter_def = filter_def + + title_widget = QtWidgets.QLabel(filter_def.title, self) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addWidget(title_widget, 1) + + self._title_widget = title_widget + + def _mouse_release_callback(self): + """Handle mouse release event to emit filter request.""" + self.filter_requested.emit(self._filter_def.name) + + +class FiltersPopup(QtWidgets.QWidget): + filter_requested = QtCore.Signal(str) + text_filter_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + shadow_frame = QtWidgets.QFrame(self) + shadow_frame.setObjectName("ShadowFrame") + + wrapper = QtWidgets.QWidget(self) + wrapper.setObjectName("PopupWrapper") + + wraper_layout = QtWidgets.QVBoxLayout(wrapper) + wraper_layout.setContentsMargins(5, 5, 5, 5) + wraper_layout.setSpacing(5) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(2, 2, 2, 2) + main_layout.addWidget(wrapper) + + shadow_frame.stackUnder(wrapper) + + self._shadow_frame = shadow_frame + self._wrapper = wrapper + self._wrapper_layout = wraper_layout + self._preferred_width = None + + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): + event.accept() + self.close() + return + + if event.key() in ( + QtCore.Qt.Key_Backtab, + QtCore.Qt.Key_Backspace, + ): + self.text_filter_requested.emit("") + event.accept() + return + + valid_modifiers = event.modifiers() in ( + QtCore.Qt.NoModifier, + QtCore.Qt.ShiftModifier, + ) + if valid_modifiers and event.key() not in ( + QtCore.Qt.Key_Escape, + QtCore.Qt.Key_Tab, + QtCore.Qt.Key_Return, + ): + text = event.text() + if text: + event.accept() + self.text_filter_requested.emit(text) + return + super().keyPressEvent(event) + + def set_preferred_width(self, width: int): + self._preferred_width = width + + def sizeHint(self): + sh = super().sizeHint() + if self._preferred_width is not None: + sh.setWidth(self._preferred_width) + return sh + + def set_filter_items(self, filter_items): + while self._wrapper_layout.count() > 0: + item = self._wrapper_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setVisible(False) + widget.deleteLater() + + for item in filter_items: + widget = FilterItemButton(item, self._wrapper) + widget.filter_requested.connect(self.filter_requested) + self._wrapper_layout.addWidget(widget, 0) + + if self._wrapper_layout.count() == 0: + empty_label = QtWidgets.QLabel( + "No filters available...", self._wrapper + ) + self._wrapper_layout.addWidget(empty_label, 0) + + def showEvent(self, event): + super().showEvent(event) + self._update_shadow() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_shadow() + + def _update_shadow(self): + geo = self.geometry() + geo.moveTopLeft(QtCore.QPoint(0, 0)) + self._shadow_frame.setGeometry(geo) + + +class FilterValueItemButton(BaseClickableFrame): + selected = QtCore.Signal(str) + + def __init__(self, widget_id, value, icon, color, parent): + super().__init__(parent) + + title_widget = QtWidgets.QLabel(str(value), self) + if color: + title_widget.setStyleSheet(f"color: {color};") + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addWidget(title_widget, 1) + + self._icon_widget = None + self._title_widget = title_widget + self._main_layout = main_layout + self._selected = False + self._value = value + self._widget_id = widget_id + + if icon: + self.set_icon(icon) + + def set_icon(self, icon: dict[str, Any]): + """Set the icon for the widget.""" + icon = get_qt_icon(icon) + pixmap = icon.pixmap(64, 64) + if self._icon_widget is None: + self._icon_widget = PixmapLabel(pixmap, self) + self._main_layout.insertWidget(0, self._icon_widget, 0) + else: + self._icon_widget.setPixmap(pixmap) + + def get_value(self): + return self._value + + def set_selected(self, selected: bool): + """Set the selection state of the widget.""" + if self._selected == selected: + return + self._selected = selected + self.setProperty("selected", "1" if selected else "") + self.style().polish(self) + + def is_selected(self) -> bool: + return self._selected + + def _mouse_release_callback(self): + """Handle mouse release event to emit filter request.""" + self.selected.emit(self._widget_id) + + +class FilterValueTextInput(QtWidgets.QWidget): + back_requested = QtCore.Signal() + value_changed = QtCore.Signal(str) + close_requested = QtCore.Signal() + + def __init__(self, parent): + super().__init__(parent) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + # Timeout is used to delay the filter focus change on 'showEvent' + # - the focus is changed to something else if is not delayed + filter_timeout = QtCore.QTimer(self) + filter_timeout.setSingleShot(True) + filter_timeout.setInterval(20) + + btns_sep = SeparatorWidget(size=1, parent=self) + btns_widget = QtWidgets.QWidget(self) + btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + text_input = QtWidgets.QLineEdit(self) + + back_btn = QtWidgets.QPushButton("Back", btns_widget) + back_btn.setObjectName("BackButton") + back_btn.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "arrow_back", + })) + confirm_btn = QtWidgets.QPushButton("Confirm", btns_widget) + confirm_btn.setObjectName("ConfirmButton") + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(back_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(text_input, 0) + main_layout.addWidget(btns_sep, 0) + main_layout.addWidget(btns_widget, 0) + + filter_timeout.timeout.connect(self._on_filter_timeout) + text_input.textChanged.connect(self.value_changed) + text_input.returnPressed.connect(self.close_requested) + back_btn.clicked.connect(self.back_requested) + confirm_btn.clicked.connect(self.close_requested) + + self._filter_timeout = filter_timeout + self._text_input = text_input + + def showEvent(self, event): + super().showEvent(event) + + self._filter_timeout.start() + + def get_value(self) -> str: + return self._text_input.text() + + def set_value(self, value: str): + self._text_input.setText(value) + + def set_placeholder_text(self, placeholder_text: str): + self._text_input.setPlaceholderText(placeholder_text) + + def set_text_filter(self, text: str): + kwargs = {} + if text: + kwargs["append_text"] = text + else: + kwargs["backspace"] = True + + set_line_edit_focus(self._text_input, **kwargs) + + def _on_filter_timeout(self): + set_line_edit_focus(self._text_input) + + +class FilterValueItemsView(QtWidgets.QWidget): + value_changed = QtCore.Signal() + close_requested = QtCore.Signal() + back_requested = QtCore.Signal() + + def __init__(self, parent): + super().__init__(parent) + + filter_input = QtWidgets.QLineEdit(self) + filter_input.setPlaceholderText("Filter items...") + + # Timeout is used to delay the filter focus change on 'showEvent' + # - the focus is changed to something else if is not delayed + filter_timeout = QtCore.QTimer(self) + filter_timeout.setSingleShot(True) + filter_timeout.setInterval(20) + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setObjectName("ScrollArea") + srcoll_viewport = scroll_area.viewport() + srcoll_viewport.setContentsMargins(0, 0, 0, 0) + scroll_area.setWidgetResizable(True) + scroll_area.setMinimumHeight(20) + scroll_area.setMaximumHeight(400) + + content_widget = QtWidgets.QWidget(scroll_area) + content_widget.setObjectName("ContentWidget") + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + + scroll_area.setWidget(content_widget) + + btns_sep = SeparatorWidget(size=1, parent=self) + btns_widget = QtWidgets.QWidget(self) + btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + back_btn = QtWidgets.QPushButton("Back", btns_widget) + back_btn.setObjectName("BackButton") + back_btn.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "arrow_back", + })) + + select_all_btn = QtWidgets.QPushButton("Select all", btns_widget) + clear_btn = QtWidgets.QPushButton("Clear", btns_widget) + swap_btn = QtWidgets.QPushButton("Invert", btns_widget) + + confirm_btn = QtWidgets.QPushButton("Confirm", btns_widget) + confirm_btn.setObjectName("ConfirmButton") + confirm_btn.clicked.connect(self.close_requested) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(back_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(select_all_btn, 0) + btns_layout.addWidget(clear_btn, 0) + btns_layout.addWidget(swap_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(filter_input, 0) + main_layout.addWidget(scroll_area) + main_layout.addWidget(btns_sep, 0) + main_layout.addWidget(btns_widget, 0) + + filter_timeout.timeout.connect(self._on_filter_timeout) + filter_input.textChanged.connect(self._on_filter_change) + filter_input.returnPressed.connect(self.close_requested) + back_btn.clicked.connect(self.back_requested) + select_all_btn.clicked.connect(self._on_select_all) + clear_btn.clicked.connect(self._on_clear_selection) + swap_btn.clicked.connect(self._on_swap_selection) + + self._filter_timeout = filter_timeout + self._filter_input = filter_input + self._btns_widget = btns_widget + self._multiselection = False + self._content_layout = content_layout + self._last_selected_widget = None + self._widgets_by_id = {} + + def showEvent(self, event): + super().showEvent(event) + self._filter_timeout.start() + + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): + event.accept() + self.close_requested.emit() + return + + if event.key() in ( + QtCore.Qt.Key_Backtab, + QtCore.Qt.Key_Backspace, + ): + event.accept() + set_line_edit_focus(self._filter_input, backspace=True) + return + + valid_modifiers = event.modifiers() in ( + QtCore.Qt.NoModifier, + QtCore.Qt.ShiftModifier, + ) + if valid_modifiers and event.key() not in ( + QtCore.Qt.Key_Escape, + QtCore.Qt.Key_Tab, + QtCore.Qt.Key_Return, + ): + text = event.text() + if text: + event.accept() + set_line_edit_focus(self._filter_input, append_text=text) + return + + super().keyPressEvent(event) + + def set_value(self, value): + current_value = self.get_value() + if self._multiselection: + if value is None: + value = [] + if not isinstance(value, list): + value = [value] + for widget in self._widgets_by_id.values(): + selected = widget.get_value() in value + if selected and self._last_selected_widget is None: + self._last_selected_widget = widget + widget.set_selected(selected) + + if value != current_value: + self.value_changed.emit() + return + + if isinstance(value, list): + if len(value) > 0: + value = value[0] + else: + value = None + + if value is None: + widget = next(iter(self._widgets_by_id.values())) + value = widget.get_value() + + self._last_selected_widget = None + for widget in self._widgets_by_id.values(): + selected = widget.get_value() in value + widget.set_selected(selected) + if selected: + self._last_selected_widget = widget + + if self._last_selected_widget is None: + widget = next(iter(self._widgets_by_id.values())) + self._last_selected_widget = widget + widget.set_selected(True) + + if value != current_value: + self.value_changed.emit() + + def set_multiselection(self, multiselection: bool): + self._multiselection = multiselection + if not self._widgets_by_id or not self._multiselection: + self._btns_widget.setVisible(False) + else: + self._btns_widget.setVisible(True) + + if not self._widgets_by_id or self._multiselection: + return + + value_changed = False + if self._last_selected_widget is None: + value_changed = True + self._last_selected_widget = next( + iter(self._widgets_by_id.values()) + ) + for widget in self._widgets_by_id.values(): + widget.set_selected(widget is self._last_selected_widget) + + if value_changed: + self.value_changed.emit() + + def get_value(self): + """Get the value from the items view.""" + if self._multiselection: + return [ + widget.get_value() + for widget in self._widgets_by_id.values() + if widget.is_selected() + ] + if self._last_selected_widget is not None: + return self._last_selected_widget.get_value() + return None + + def set_items(self, items: list[dict[str, Any]]): + while self._content_layout.count() > 0: + item = self._content_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setVisible(False) + widget.deleteLater() + + self._widgets_by_id = {} + self._last_selected_widget = None + # Change filter + self._filter_input.setText("") + + for item in items: + widget_id = uuid.uuid4().hex + widget = FilterValueItemButton( + widget_id, + item["value"], + item.get("icon"), + item.get("color"), + self, + ) + widget.selected.connect(self._on_item_clicked) + self._widgets_by_id[widget_id] = widget + self._content_layout.addWidget(widget, 0) + + if self._content_layout.count() == 0: + empty_label = QtWidgets.QLabel( + "No items to select from...", self + ) + self._btns_widget.setVisible(False) + self._filter_input.setVisible(False) + self._content_layout.addWidget(empty_label, 0) + else: + self._filter_input.setVisible(True) + self._btns_widget.setVisible(self._multiselection) + self._content_layout.addStretch(1) + + def _on_filter_timeout(self): + self._filter_input.setFocus() + + def _on_filter_change(self, text): + text = text.lower() + for widget in self._widgets_by_id.values(): + visible = not text or text in widget.get_value().lower() + widget.setVisible(visible) + + def _on_select_all(self): + changed = False + for widget in self._widgets_by_id.values(): + if not widget.is_selected(): + changed = True + widget.set_selected(True) + if self._last_selected_widget is None: + self._last_selected_widget = widget + + if changed: + self.value_changed.emit() + + def _on_swap_selection(self): + self._last_selected_widget = None + for widget in self._widgets_by_id.values(): + selected = not widget.is_selected() + widget.set_selected(selected) + if selected and self._last_selected_widget is None: + self._last_selected_widget = widget + + self.value_changed.emit() + + def _on_clear_selection(self): + self._last_selected_widget = None + changed = False + for widget in self._widgets_by_id.values(): + if widget.is_selected(): + changed = True + widget.set_selected(False) + + if changed: + self.value_changed.emit() + + def _on_item_clicked(self, widget_id): + widget = self._widgets_by_id.get(widget_id) + if widget is None: + return + + previous_widget = self._last_selected_widget + self._last_selected_widget = widget + if self._multiselection: + widget.set_selected(not widget.is_selected()) + else: + widget.set_selected(True) + if previous_widget is not None: + previous_widget.set_selected(False) + self.value_changed.emit() + + +class FilterValuePopup(QtWidgets.QWidget): + value_changed = QtCore.Signal(str) + closed = QtCore.Signal(str) + back_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + shadow_frame = QtWidgets.QFrame(self) + shadow_frame.setObjectName("ShadowFrame") + + wrapper = QtWidgets.QWidget(self) + wrapper.setObjectName("PopupWrapper") + + text_input = FilterValueTextInput(wrapper) + text_input.setVisible(False) + + items_view = FilterValueItemsView(wrapper) + items_view.setVisible(False) + + wraper_layout = QtWidgets.QVBoxLayout(wrapper) + wraper_layout.setContentsMargins(5, 5, 5, 5) + wraper_layout.setSpacing(5) + wraper_layout.addWidget(text_input, 0) + wraper_layout.addWidget(items_view, 0) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(2, 2, 2, 2) + main_layout.addWidget(wrapper) + + text_input.value_changed.connect(self._text_changed) + text_input.close_requested.connect(self._close_requested) + text_input.back_requested.connect(self._back_requested) + + items_view.value_changed.connect(self._selection_changed) + items_view.close_requested.connect(self._close_requested) + items_view.back_requested.connect(self._back_requested) + + shadow_frame.stackUnder(wrapper) + + self._shadow_frame = shadow_frame + self._wrapper = wrapper + self._wrapper_layout = wraper_layout + self._text_input = text_input + self._items_view = items_view + + self._active_widget = None + self._filter_name = None + self._preferred_width = None + + def set_preferred_width(self, width: int): + self._preferred_width = width + + def sizeHint(self): + sh = super().sizeHint() + if self._preferred_width is not None: + sh.setWidth(self._preferred_width) + return sh + + def set_text_filter(self, text: str): + if self._active_widget is None: + return + + if self._active_widget is self._text_input: + self._active_widget.set_text_filter(text) + + def set_filter_item( + self, + filter_def: FilterDefinition, + value, + ): + self._text_input.setVisible(False) + self._items_view.setVisible(False) + self._filter_name = filter_def.name + self._active_widget = None + if filter_def.filter_type == "text": + if filter_def.items: + if value is None: + value = filter_def.items[0]["value"] + self._active_widget = self._items_view + self._items_view.set_items(filter_def.items) + self._items_view.set_multiselection(False) + self._items_view.set_value(value) + else: + if value is None: + value = "" + self._text_input.set_placeholder_text( + filter_def.placeholder or "" + ) + self._text_input.set_value(value) + self._active_widget = self._text_input + + elif filter_def.filter_type == "list": + if value is None: + value = [] + self._items_view.set_items(filter_def.items) + self._items_view.set_multiselection(True) + self._items_view.set_value(value) + self._active_widget = self._items_view + + if self._active_widget is not None: + self._active_widget.setVisible(True) + + def showEvent(self, event): + super().showEvent(event) + if self._active_widget is not None: + self._active_widget.setFocus() + self._update_shadow() + + def closeEvent(self, event): + super().closeEvent(event) + self.closed.emit(self._filter_name) + + def hideEvent(self, event): + super().hideEvent(event) + self.closed.emit(self._filter_name) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_shadow() + + def _update_shadow(self): + geo = self.geometry() + geo.moveTopLeft(QtCore.QPoint(0, 0)) + self._shadow_frame.setGeometry(geo) + + def get_value(self): + """Get the value from the active widget.""" + if self._active_widget is self._text_input: + return self._text_input.get_value() + elif self._active_widget is self._items_view: + return self._active_widget.get_value() + return None + + def _text_changed(self): + """Handle text change in the text input.""" + if self._active_widget is self._text_input: + # Emit value changed signal if text input is active + self.value_changed.emit(self._filter_name) + + def _selection_changed(self): + self.value_changed.emit(self._filter_name) + + def _close_requested(self): + self.close() + + def _back_requested(self): + self.back_requested.emit(self._filter_name) + self.close() + + +class FiltersBar(BaseClickableFrame): + filter_changed = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + + search_icon = get_qt_icon({ + "type": "material-symbols", + "name": "search", + "color": "#FFFFFF", + }) + search_btn = SquareButton(self) + search_btn.setIcon(search_icon) + search_btn.setFlat(True) + search_btn.setObjectName("SearchButton") + + # Wrapper is used to avoid squashing filters + # - the filters are positioned manually without layout + filters_wrap = QtWidgets.QWidget(self) + filters_wrap.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + # Widget where set filters are displayed + filters_widget = QtWidgets.QWidget(filters_wrap) + filters_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + filters_layout = QtWidgets.QHBoxLayout(filters_widget) + filters_layout.setContentsMargins(0, 0, 0, 0) + filters_layout.addStretch(1) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(4, 4, 4, 4) + main_layout.setSpacing(5) + main_layout.addWidget(search_btn, 0) + main_layout.addWidget(filters_wrap, 1) + + filters_popup = FiltersPopup(self) + filter_value_popup = FilterValuePopup(self) + + search_btn.clicked.connect(self._on_filters_request) + filters_popup.text_filter_requested.connect( + self._on_text_filter_request + ) + + self._search_btn = search_btn + self._filters_wrap = filters_wrap + self._filters_widget = filters_widget + self._filters_layout = filters_layout + self._widgets_by_name = {} + self._filter_defs_by_name = {} + self._filters_popup = filters_popup + self._filter_value_popup = filter_value_popup + + def showEvent(self, event): + super().showEvent(event) + self._update_filters_geo() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_filters_geo() + + def wheelEvent(self, event): + scroll_speed = 15 + diff = event.angleDelta().y() / 120.0 + pos_x = self._filters_widget.pos().x() + if diff > 0: + pos_x = min(0, pos_x + scroll_speed) + self._filters_widget.move(pos_x, 0) + return + + rect = self._filters_wrap.rect() + size_hint = self._filters_widget.sizeHint() + if size_hint.width() < rect.width(): + return + pos_x = max( + pos_x - scroll_speed, rect.width() - size_hint.width() + ) + self._filters_widget.move(pos_x, 0) + + def show_filters_popup(self): + filter_defs = [ + filter_def + for filter_def in self._filter_defs_by_name.values() + if filter_def.name not in self._widgets_by_name + ] + filters_popup = FiltersPopup(self) + filters_popup.filter_requested.connect(self._on_filter_request) + filters_popup.text_filter_requested.connect( + self._on_text_filter_request + ) + filters_popup.set_filter_items(filter_defs) + filters_popup.set_preferred_width(self.width()) + + old_popup, self._filters_popup = self._filters_popup, filters_popup + + self._filter_value_popup.setVisible(False) + old_popup.setVisible(False) + old_popup.deleteLater() + + self._show_popup(filters_popup) + + def set_search_items(self, filter_defs: list[FilterDefinition]): + self._filter_defs_by_name = { + filter_def.name: filter_def + for filter_def in filter_defs + } + + def get_filter_value(self, name: str) -> Optional[Any]: + """Get the value of a filter by its name.""" + item_widget = self._widgets_by_name.get(name) + if item_widget is not None: + value = item_widget.get_value() + if isinstance(value, list) and len(value) == 0: + return None + return value + return None + + def set_filter_value(self, name: str, value: Any): + """Set the value of a filter by its name.""" + if name not in self._filter_defs_by_name: + return + + item_widget = self._widgets_by_name.get(name) + if item_widget is None: + self.add_item(name) + item_widget = self._widgets_by_name.get(name) + + item_widget.set_value(value) + self.filter_changed.emit(name) + + def add_item(self, name: str): + """Add a new item to the search bar. + + Args: + name (str): Search definition name. + + """ + filter_def = self._filter_defs_by_name.get(name) + if filter_def is None: + return + + item_widget = self._widgets_by_name.get(name) + if item_widget is not None: + return + + item_widget = SearchItemDisplayWidget( + filter_def, + parent=self._filters_widget, + ) + item_widget.edit_requested.connect(self._on_filter_request) + item_widget.close_requested.connect(self._on_item_close_requested) + self._widgets_by_name[name] = item_widget + idx = self._filters_layout.count() - 1 + self._filters_layout.insertWidget(idx, item_widget, 0) + + def _update_filters_geo(self): + geo = self._filters_wrap.geometry() + geo.moveTopLeft(QtCore.QPoint(0, 0)) + # Arbitrary width + geo.setWidth(3000) + + self._filters_widget.setGeometry(geo) + + def _reposition_filters_widget(self): + rect = self._filters_wrap.rect() + size_hint = self._filters_widget.sizeHint() + if size_hint.width() < rect.width(): + self._filters_widget.move(0, 0) + return + pos_x = self._filters_widget.pos().x() + pos_x = max( + pos_x, rect.width() - size_hint.width() + ) + self._filters_widget.move(pos_x, 0) + + def _mouse_release_callback(self): + self.show_filters_popup() + + def _on_filters_request(self): + self.show_filters_popup() + + def _on_text_filter_request(self, text: str): + if "product_name" not in self._filter_defs_by_name: + return + + self._on_filter_request("product_name") + self._filter_value_popup.set_text_filter(text) + + def _on_filter_request(self, filter_name: str): + """Handle filter request from the popup.""" + self.add_item(filter_name) + self._filters_popup.hide() + filter_def = self._filter_defs_by_name.get(filter_name) + widget = self._widgets_by_name.get(filter_name) + value = None + if widget is not None: + value = widget.get_value() + + filter_value_popup = FilterValuePopup(self) + filter_value_popup.set_preferred_width(self.width()) + filter_value_popup.set_filter_item(filter_def, value) + filter_value_popup.value_changed.connect(self._on_filter_value_change) + filter_value_popup.closed.connect(self._on_filter_value_closed) + filter_value_popup.back_requested.connect(self._on_filter_value_back) + + old_popup, self._filter_value_popup = ( + self._filter_value_popup, filter_value_popup + ) + + old_popup.setVisible(False) + old_popup.deleteLater() + + self._filters_popup.setVisible(False) + + self._show_popup(filter_value_popup) + self._on_filter_value_change(filter_def.name) + + def _show_popup(self, popup: QtWidgets.QWidget): + """Show a popup widget.""" + geo = self.geometry() + bl_pos_g = self.mapToGlobal(QtCore.QPoint(0, geo.height() + 5)) + popup.show() + popup.move(bl_pos_g.x(), bl_pos_g.y()) + popup.raise_() + + def _on_filter_value_change(self, name): + value = self._filter_value_popup.get_value() + item_widget = self._widgets_by_name.get(name) + item_widget.set_value(value) + self.filter_changed.emit(name) + + def _on_filter_value_closed(self, name): + widget = self._widgets_by_name.get(name) + if widget is None: + return + + value = widget.get_value() + if not value: + self._on_item_close_requested(name) + + def _on_filter_value_back(self, name): + self._on_filter_value_closed(name) + self.show_filters_popup() + + def _on_item_close_requested(self, name): + widget = self._widgets_by_name.pop(name, None) + if widget is None: + return + idx = self._filters_layout.indexOf(widget) + if idx > -1: + self._filters_layout.takeAt(idx) + widget.setVisible(False) + widget.deleteLater() + self.filter_changed.emit(name) + + self._reposition_filters_widget() diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py deleted file mode 100644 index 2f034d00de..0000000000 --- a/client/ayon_core/tools/loader/ui/statuses_combo.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -from qtpy import QtCore, QtGui - -from ayon_core.tools.utils import get_qt_icon -from ayon_core.tools.common_models import StatusItem - -from ._multicombobox import ( - CustomPaintMultiselectComboBox, - BaseQtModel, -) - -STATUS_ITEM_TYPE = 0 -SELECT_ALL_TYPE = 1 -DESELECT_ALL_TYPE = 2 -SWAP_STATE_TYPE = 3 - -STATUSES_FILTER_SENDER = "loader.statuses_filter" -STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 -STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 2 -STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 3 -STATUS_ICON_ROLE = QtCore.Qt.UserRole + 4 -ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 -ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 6 - - -class StatusesQtModel(BaseQtModel): - def __init__(self, controller): - self._items_by_name: dict[str, QtGui.QStandardItem] = {} - self._icons_by_name_n_color: dict[str, QtGui.QIcon] = {} - super().__init__( - ITEM_TYPE_ROLE, - ITEM_SUBTYPE_ROLE, - "No statuses...", - controller, - ) - - def _get_standard_items(self) -> list[QtGui.QStandardItem]: - return list(self._items_by_name.values()) - - def _clear_standard_items(self): - self._items_by_name.clear() - - def _prepare_new_value_items( - self, project_name: str, project_changed: bool - ): - status_items: list[StatusItem] = ( - self._controller.get_project_status_items( - project_name, sender=STATUSES_FILTER_SENDER - ) - ) - items = [] - items_to_remove = [] - if not status_items: - return items, items_to_remove - - names_to_remove = set(self._items_by_name) - for row_idx, status_item in enumerate(status_items): - name = status_item.name - if name in self._items_by_name: - item = self._items_by_name[name] - names_to_remove.discard(name) - else: - item = QtGui.QStandardItem() - item.setData(ITEM_SUBTYPE_ROLE, STATUS_ITEM_TYPE) - item.setCheckState(QtCore.Qt.Unchecked) - item.setFlags( - QtCore.Qt.ItemIsEnabled - | QtCore.Qt.ItemIsSelectable - | QtCore.Qt.ItemIsUserCheckable - ) - self._items_by_name[name] = item - - icon = self._get_icon(status_item) - for role, value in ( - (STATUS_NAME_ROLE, status_item.name), - (STATUS_SHORT_ROLE, status_item.short), - (STATUS_COLOR_ROLE, status_item.color), - (STATUS_ICON_ROLE, icon), - ): - if item.data(role) != value: - item.setData(value, role) - - if project_changed: - item.setCheckState(QtCore.Qt.Unchecked) - items.append(item) - - for name in names_to_remove: - items_to_remove.append(self._items_by_name.pop(name)) - - return items, items_to_remove - - def _get_icon(self, status_item: StatusItem) -> QtGui.QIcon: - name = status_item.name - color = status_item.color - unique_id = "|".join([name or "", color or ""]) - icon = self._icons_by_name_n_color.get(unique_id) - if icon is not None: - return icon - - icon: QtGui.QIcon = get_qt_icon({ - "type": "material-symbols", - "name": status_item.icon, - "color": status_item.color - }) - self._icons_by_name_n_color[unique_id] = icon - return icon - - -class StatusesCombobox(CustomPaintMultiselectComboBox): - def __init__(self, controller, parent): - self._controller = controller - model = StatusesQtModel(controller) - super().__init__( - STATUS_NAME_ROLE, - STATUS_SHORT_ROLE, - STATUS_COLOR_ROLE, - STATUS_ICON_ROLE, - item_type_role=ITEM_TYPE_ROLE, - model=model, - parent=parent - ) - self.set_placeholder_text("Version status filter...") - self._model = model - self._last_project_name = None - self._fully_disabled_filter = False - - controller.register_event_callback( - "selection.project.changed", - self._on_project_change - ) - controller.register_event_callback( - "projects.refresh.finished", - self._on_projects_refresh - ) - self.setToolTip("Statuses filter") - self.value_changed.connect( - self._on_status_filter_change - ) - - def _on_status_filter_change(self): - lines = ["Statuses filter"] - for item in self.get_value_info(): - status_name, enabled = item - lines.append(f"{'✔' if enabled else '☐'} {status_name}") - - self.setToolTip("\n".join(lines)) - - def _on_project_change(self, event): - project_name = event["project_name"] - self._last_project_name = project_name - self._model.refresh(project_name) - - def _on_projects_refresh(self): - if self._last_project_name: - self._model.refresh(self._last_project_name) - self._on_status_filter_change() diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index 5779fc2a01..cc7e2e9c95 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -332,10 +332,6 @@ class LoaderTasksWidget(QtWidgets.QWidget): "selection.folders.changed", self._on_folders_selection_changed, ) - controller.register_event_callback( - "tasks.refresh.finished", - self._on_tasks_refresh_finished - ) selection_model = tasks_view.selectionModel() selection_model.selectionChanged.connect(self._on_selection_change) @@ -373,10 +369,6 @@ class LoaderTasksWidget(QtWidgets.QWidget): def _clear(self): self._tasks_model.clear() - def _on_tasks_refresh_finished(self, event): - if event["sender"] != TASKS_MODEL_SENDER_NAME: - self._set_project_name(event["project_name"]) - def _on_folders_selection_changed(self, event): project_name = event["project_name"] folder_ids = event["folder_ids"] diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index b70f5554c7..df5beb708f 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from qtpy import QtWidgets, QtCore, QtGui from ayon_core.resources import get_ayon_icon_filepath @@ -11,16 +13,24 @@ from ayon_core.tools.utils import ( ) from ayon_core.tools.utils.lib import center_window from ayon_core.tools.utils import ProjectsCombobox +from ayon_core.tools.common_models import StatusItem +from ayon_core.tools.loader.abstract import ProductTypeItem from ayon_core.tools.loader.control import LoaderController from .folders_widget import LoaderFoldersWidget from .tasks_widget import LoaderTasksWidget from .products_widget import ProductsWidget -from .product_types_combo import ProductTypesCombobox from .product_group_dialog import ProductGroupDialog from .info_widget import InfoWidget from .repres_widget import RepresentationsWidget -from .statuses_combo import StatusesCombobox +from .search_bar import FiltersBar, FilterDefinition + +FIND_KEY_SEQUENCE = QtGui.QKeySequence( + QtCore.Qt.Modifier.CTRL | QtCore.Qt.Key_F +) +GROUP_KEY_SEQUENCE = QtGui.QKeySequence( + QtCore.Qt.Modifier.CTRL | QtCore.Qt.Key_G +) class LoadErrorMessageBox(ErrorMessageBox): @@ -182,29 +192,19 @@ class LoaderWindow(QtWidgets.QWidget): products_wrap_widget = QtWidgets.QWidget(main_splitter) products_inputs_widget = QtWidgets.QWidget(products_wrap_widget) - - products_filter_input = PlaceholderLineEdit(products_inputs_widget) - products_filter_input.setPlaceholderText("Product name filter...") - - product_types_filter_combo = ProductTypesCombobox( - controller, products_inputs_widget - ) - - product_status_filter_combo = StatusesCombobox(controller, self) + search_bar = FiltersBar(products_inputs_widget) product_group_checkbox = QtWidgets.QCheckBox( "Enable grouping", products_inputs_widget) product_group_checkbox.setChecked(True) - products_widget = ProductsWidget(controller, products_wrap_widget) - products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget) products_inputs_layout.setContentsMargins(0, 0, 0, 0) - products_inputs_layout.addWidget(products_filter_input, 1) - products_inputs_layout.addWidget(product_types_filter_combo, 1) - products_inputs_layout.addWidget(product_status_filter_combo, 1) + products_inputs_layout.addWidget(search_bar, 1) products_inputs_layout.addWidget(product_group_checkbox, 0) + products_widget = ProductsWidget(controller, products_wrap_widget) + products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget) products_wrap_layout.setContentsMargins(0, 0, 0, 0) products_wrap_layout.addWidget(products_inputs_widget, 0) @@ -250,15 +250,7 @@ class LoaderWindow(QtWidgets.QWidget): folders_filter_input.textChanged.connect( self._on_folder_filter_change ) - products_filter_input.textChanged.connect( - self._on_product_filter_change - ) - product_types_filter_combo.value_changed.connect( - self._on_product_type_filter_change - ) - product_status_filter_combo.value_changed.connect( - self._on_status_filter_change - ) + search_bar.filter_changed.connect(self._on_filter_change) product_group_checkbox.stateChanged.connect( self._on_product_group_change ) @@ -316,9 +308,7 @@ class LoaderWindow(QtWidgets.QWidget): self._tasks_widget = tasks_widget - self._products_filter_input = products_filter_input - self._product_types_filter_combo = product_types_filter_combo - self._product_status_filter_combo = product_status_filter_combo + self._search_bar = search_bar self._product_group_checkbox = product_group_checkbox self._products_widget = products_widget @@ -337,6 +327,8 @@ class LoaderWindow(QtWidgets.QWidget): self._selected_folder_ids = set() self._selected_version_ids = set() + self._set_product_type_filters = True + self._products_widget.set_enable_grouping( self._product_group_checkbox.isChecked() ) @@ -356,22 +348,24 @@ class LoaderWindow(QtWidgets.QWidget): def closeEvent(self, event): super().closeEvent(event) - ( - self - ._product_types_filter_combo - .reset_product_types_filter_on_refresh() - ) - self._reset_on_show = True def keyPressEvent(self, event): - modifiers = event.modifiers() - ctrl_pressed = QtCore.Qt.ControlModifier & modifiers + if hasattr(event, "keyCombination"): + combination = event.keyCombination() + else: + combination = QtGui.QKeySequence(event.modifiers() | event.key()) + if ( + FIND_KEY_SEQUENCE == combination + and not event.isAutoRepeat() + ): + self._search_bar.show_filters_popup() + event.setAccepted(True) + return # Grouping products on pressing Ctrl + G if ( - ctrl_pressed - and event.key() == QtCore.Qt.Key_G + GROUP_KEY_SEQUENCE == combination and not event.isAutoRepeat() ): self._show_group_dialog() @@ -435,20 +429,30 @@ class LoaderWindow(QtWidgets.QWidget): self._product_group_checkbox.isChecked() ) - def _on_product_filter_change(self, text): - self._products_widget.set_name_filter(text) + def _on_filter_change(self, filter_name): + if filter_name == "product_name": + self._products_widget.set_name_filter( + self._search_bar.get_filter_value("product_name") + ) + elif filter_name == "product_types": + product_types = self._search_bar.get_filter_value("product_types") + self._products_widget.set_product_type_filter(product_types) + + elif filter_name == "statuses": + status_names = self._search_bar.get_filter_value("statuses") + self._products_widget.set_statuses_filter(status_names) + + elif filter_name == "version_tags": + version_tags = self._search_bar.get_filter_value("version_tags") + self._products_widget.set_version_tags_filter(version_tags) + + elif filter_name == "task_tags": + task_tags = self._search_bar.get_filter_value("task_tags") + self._products_widget.set_task_tags_filter(task_tags) def _on_tasks_selection_change(self, event): self._products_widget.set_tasks_filter(event["task_ids"]) - def _on_status_filter_change(self): - status_names = self._product_status_filter_combo.get_value() - self._products_widget.set_statuses_filter(status_names) - - def _on_product_type_filter_change(self): - product_types = self._product_types_filter_combo.get_value() - self._products_widget.set_product_type_filter(product_types) - def _on_merged_products_selection_change(self): items = self._products_widget.get_selected_merged_products() self._folders_widget.set_merged_products_selection(items) @@ -480,6 +484,7 @@ class LoaderWindow(QtWidgets.QWidget): self._projects_combobox.set_current_context_project(project_name) if not self._refresh_handler.project_refreshed: self._projects_combobox.refresh() + self._update_filters() def _on_load_finished(self, event): error_info = event["error_info"] @@ -491,6 +496,124 @@ class LoaderWindow(QtWidgets.QWidget): def _on_project_selection_changed(self, event): self._selected_project_name = event["project_name"] + self._update_filters() + + def _update_filters(self): + project_name = self._selected_project_name + if not project_name: + self._search_bar.set_search_items([]) + return + + product_type_items: list[ProductTypeItem] = ( + self._controller.get_product_type_items(project_name) + ) + status_items: list[StatusItem] = ( + self._controller.get_project_status_items(project_name) + ) + tags_by_entity_type = ( + self._controller.get_available_tags_by_entity_type(project_name) + ) + tag_items = self._controller.get_project_anatomy_tags(project_name) + tag_color_by_name = { + tag_item.name: tag_item.color + for tag_item in tag_items + } + + filter_product_type_items = [ + { + "value": item.name, + "icon": item.icon, + } + for item in product_type_items + ] + filter_status_items = [ + { + "icon": { + "type": "material-symbols", + "name": status_item.icon, + "color": status_item.color + }, + "color": status_item.color, + "value": status_item.name, + } + for status_item in status_items + ] + version_tags = [ + { + "value": tag_name, + "color": tag_color_by_name.get(tag_name), + } + for tag_name in tags_by_entity_type.get("versions") or [] + ] + task_tags = [ + { + "value": tag_name, + "color": tag_color_by_name.get(tag_name), + } + for tag_name in tags_by_entity_type.get("tasks") or [] + ] + + self._search_bar.set_search_items([ + FilterDefinition( + name="product_name", + title="Product name", + filter_type="text", + icon=None, + placeholder="Product name filter...", + items=None, + ), + FilterDefinition( + name="product_types", + title="Product type", + filter_type="list", + icon=None, + items=filter_product_type_items, + ), + FilterDefinition( + name="statuses", + title="Statuses", + filter_type="list", + icon=None, + items=filter_status_items, + ), + FilterDefinition( + name="version_tags", + title="Version tags", + filter_type="list", + icon=None, + items=version_tags, + ), + FilterDefinition( + name="task_tags", + title="Task tags", + filter_type="list", + icon=None, + items=task_tags, + ), + ]) + + # Set product types filter from settings + if self._set_product_type_filters: + self._set_product_type_filters = False + product_types_filter = self._controller.get_product_types_filter() + product_types = [] + for item in filter_product_type_items: + product_type = item["value"] + matching = ( + int(product_type in product_types_filter.product_types) + + int(product_types_filter.is_allow_list) + ) + if matching % 2 == 0: + product_types.append(product_type) + + if ( + product_types + and len(product_types) < len(filter_product_type_items) + ): + self._search_bar.set_filter_value( + "product_types", + product_types + ) def _on_folders_selection_changed(self, event): self._selected_folder_ids = set(event["folder_ids"]) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 6d0027d35d..14da15793d 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -13,7 +13,7 @@ from typing import ( ) from ayon_core.lib import AbstractAttrDef -from ayon_core.host import HostBase +from ayon_core.host import AbstractHost from ayon_core.pipeline.create import ( CreateContext, ConvertorItem, @@ -176,7 +176,7 @@ class AbstractPublisherBackend(AbstractPublisherCommon): pass @abstractmethod - def get_host(self) -> HostBase: + def get_host(self) -> AbstractHost: pass @abstractmethod diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index ef2e122692..038816c6fc 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -53,6 +53,8 @@ class PublisherController( changed. "create.context.create.attrs.changed" - Create attributes changed. "create.context.publish.attrs.changed" - Publish attributes changed. + "create.context.instance.requirement.changed" - Instance requirement + changed. "create.context.removed.instance" - Instance removed from context. "create.model.instances.context.changed" - Instances changed context. like folder, task or variant. diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 77e50dc788..3ba4ca9d9b 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -219,7 +219,10 @@ class InstanceItem: folder_path: Optional[str], task_name: Optional[str], is_active: bool, + is_mandatory: bool, has_promised_context: bool, + parent_instance_id: Optional[str], + parent_flags: int, ): self._instance_id: str = instance_id self._creator_identifier: str = creator_identifier @@ -231,7 +234,10 @@ class InstanceItem: self._folder_path: Optional[str] = folder_path self._task_name: Optional[str] = task_name self._is_active: bool = is_active + self._is_mandatory: bool = is_mandatory self._has_promised_context: bool = has_promised_context + self._parent_instance_id: Optional[str] = parent_instance_id + self._parent_flags: int = parent_flags @property def id(self): @@ -253,10 +259,22 @@ class InstanceItem: def product_type(self): return self._product_type + @property + def is_mandatory(self): + return self._is_mandatory + @property def has_promised_context(self): return self._has_promised_context + @property + def parent_instance_id(self): + return self._parent_instance_id + + @property + def parent_flags(self) -> int: + return self._parent_flags + def get_variant(self): return self._variant @@ -306,7 +324,10 @@ class InstanceItem: instance["folderPath"], instance["task"], instance["active"], + instance.is_mandatory, instance.has_promised_context, + instance.parent_instance_id, + instance.parent_flags, ) @@ -478,6 +499,12 @@ class CreateModel: self._create_context.add_publish_attr_defs_change_callback( self._cc_publish_attr_changed ) + self._create_context.add_instance_requirement_change_callback( + self._cc_instance_requirement_changed + ) + self._create_context.add_instance_parent_change_callback( + self._cc_instance_parent_changed + ) self._create_context.reset_finalization() @@ -558,15 +585,21 @@ class CreateModel: def set_instances_active_state( self, active_state_by_id: Dict[str, bool] ): + changed_ids = set() with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): for instance_id, active in active_state_by_id.items(): instance = self._create_context.get_instance_by_id(instance_id) - instance["active"] = active + if instance["active"] is not active: + instance["active"] = active + changed_ids.add(instance_id) + + if not changed_ids: + return self._emit_event( "create.model.instances.context.changed", { - "instance_ids": set(active_state_by_id.keys()) + "instance_ids": changed_ids } ) @@ -1178,6 +1211,26 @@ class CreateModel: event_data, ) + def _cc_instance_requirement_changed(self, event): + instance_ids = { + instance.id + for instance in event.data["instances"] + } + self._emit_event( + "create.model.instance.requirement.changed", + {"instance_ids": instance_ids}, + ) + + def _cc_instance_parent_changed(self, event): + instance_ids = { + instance.id + for instance in event.data["instances"] + } + self._emit_event( + "create.model.instance.parent.changed", + {"instance_ids": instance_ids}, + ) + def _get_allowed_creators_pattern(self) -> Union[Pattern, None]: """Provide regex pattern for configured creator labels in this context 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 2f633b3149..84786a671e 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -19,18 +19,21 @@ Only one item can be selected at a time. └──────────────────────┘ ``` """ +from __future__ import annotations import re import collections -from typing import Dict +from typing import Optional from qtpy import QtWidgets, QtCore -from ayon_core.tools.utils import NiceCheckbox +from ayon_core.pipeline.create import ( + InstanceContextInfo, + ParentFlags, +) -from ayon_core.tools.utils import BaseClickableFrame +from ayon_core.tools.utils import BaseClickableFrame, NiceCheckbox from ayon_core.tools.utils.lib import html_escape - from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend from ayon_core.tools.publisher.constants import ( CONTEXT_ID, @@ -38,7 +41,9 @@ from ayon_core.tools.publisher.constants import ( CONTEXT_GROUP, CONVERTOR_ITEM_GROUP, ) - +from ayon_core.tools.publisher.models.create import ( + InstanceItem, +) from .widgets import ( AbstractInstanceView, ContextWarningLabel, @@ -82,7 +87,6 @@ class BaseGroupWidget(QtWidgets.QWidget): self._group = group_name self._widgets_by_id = {} - self._ordered_item_ids = [] self._label_widget = label_widget self._content_layout = layout @@ -97,48 +101,25 @@ class BaseGroupWidget(QtWidgets.QWidget): return self._group - def get_widget_by_item_id(self, item_id): - """Get instance widget by its id.""" + def set_widgets( + self, + widgets_by_id: dict[str, QtWidgets.QWidget], + ordered_ids: list[str], + ) -> None: + self._remove_all_except(set(self._widgets_by_id)) + idx = 1 + for item_id in ordered_ids: + widget = widgets_by_id[item_id] + self._content_layout.insertWidget(idx, widget) + self._widgets_by_id[item_id] = widget + idx += 1 - return self._widgets_by_id.get(item_id) - - def get_selected_item_ids(self): - """Selected instance ids. - - Returns: - Set[str]: Instance ids that are selected. - """ - - return { - instance_id - for instance_id, widget in self._widgets_by_id.items() - if widget.is_selected - } - - def get_selected_widgets(self): - """Access to widgets marked as selected. - - Returns: - List[InstanceCardWidget]: Instance widgets that are selected. - """ - - return [ - widget - for instance_id, widget in self._widgets_by_id.items() - if widget.is_selected - ] - - def get_ordered_widgets(self): - """Get instance ids in order as are shown in ui. - - Returns: - List[str]: Instance ids. - """ - - return [ - self._widgets_by_id[instance_id] - for instance_id in self._ordered_item_ids - ] + def take_widgets(self, widget_ids: set[str]): + for widget_id in widget_ids: + widget = self._widgets_by_id.pop(widget_id) + index = self._content_layout.indexOf(widget) + if index >= 0: + self._content_layout.takeAt(index) def _remove_all_except(self, item_ids): item_ids = set(item_ids) @@ -155,131 +136,6 @@ class BaseGroupWidget(QtWidgets.QWidget): self._content_layout.removeWidget(widget) widget.deleteLater() - def _update_ordered_item_ids(self): - ordered_item_ids = [] - for idx in range(self._content_layout.count()): - if idx > 0: - item = self._content_layout.itemAt(idx) - widget = item.widget() - if widget is not None: - ordered_item_ids.append(widget.id) - - self._ordered_item_ids = ordered_item_ids - - def _on_widget_selection(self, instance_id, group_id, selection_type): - self.selected.emit(instance_id, group_id, selection_type) - - def set_active_toggle_enabled(self, enabled): - for widget in self._widgets_by_id.values(): - if isinstance(widget, InstanceCardWidget): - widget.set_active_toggle_enabled(enabled) - - -class ConvertorItemsGroupWidget(BaseGroupWidget): - def update_items(self, items_by_id): - items_by_label = collections.defaultdict(list) - for item in items_by_id.values(): - items_by_label[item.label].append(item) - - # Remove instance widgets that are not in passed instances - self._remove_all_except(items_by_id.keys()) - - # Sort instances by product name - sorted_labels = list(sorted(items_by_label.keys())) - - # Add new instances to widget - widget_idx = 1 - for label in sorted_labels: - for item in items_by_label[label]: - if item.id in self._widgets_by_id: - widget = self._widgets_by_id[item.id] - widget.update_item(item) - else: - widget = ConvertorItemCardWidget(item, self) - widget.selected.connect(self._on_widget_selection) - widget.double_clicked.connect(self.double_clicked) - self._widgets_by_id[item.id] = widget - self._content_layout.insertWidget(widget_idx, widget) - widget_idx += 1 - - self._update_ordered_item_ids() - - -class InstanceGroupWidget(BaseGroupWidget): - """Widget wrapping instances under group.""" - - active_changed = QtCore.Signal(str, str, bool) - - def __init__(self, group_icons, *args, **kwargs): - super().__init__(*args, **kwargs) - - self._group_icons = group_icons - - def update_icons(self, group_icons): - self._group_icons = group_icons - - def update_instance_values( - self, context_info_by_id, instance_items_by_id, instance_ids - ): - """Trigger update on instance widgets.""" - - for instance_id, widget in self._widgets_by_id.items(): - if instance_ids is not None and instance_id not in instance_ids: - continue - widget.update_instance( - instance_items_by_id[instance_id], - context_info_by_id[instance_id] - ) - - def update_instances(self, instances, context_info_by_id): - """Update instances for the group. - - Args: - instances (list[InstanceItem]): 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) - for instance in instances: - instances_by_id[instance.id] = instance - product_name = instance.product_name - instances_by_product_name[product_name].append(instance) - - # Remove instance widgets that are not in passed instances - self._remove_all_except(instances_by_id.keys()) - - # Sort instances by product name - sorted_product_names = list(sorted(instances_by_product_name.keys())) - - # Add new instances to widget - 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, context_info) - else: - group_icon = self._group_icons[instance.creator_identifier] - widget = InstanceCardWidget( - instance, context_info, group_icon, self - ) - widget.selected.connect(self._on_widget_selection) - widget.active_changed.connect(self._on_active_changed) - widget.double_clicked.connect(self.double_clicked) - self._widgets_by_id[instance.id] = widget - self._content_layout.insertWidget(widget_idx, widget) - widget_idx += 1 - - self._update_ordered_item_ids() - - def _on_active_changed(self, instance_id, value): - self.active_changed.emit(self.group_name, instance_id, value) - class CardWidget(BaseClickableFrame): """Clickable card used as bigger button.""" @@ -400,20 +256,34 @@ class ConvertorItemCardWidget(CardWidget): self._icon_widget = icon_widget self._label_widget = label_widget + def update_item(self, item): + self._id = item.id + self.identifier = item.identifier + class InstanceCardWidget(CardWidget): """Card widget representing instance.""" active_changed = QtCore.Signal(str, bool) - def __init__(self, instance, context_info, group_icon, parent): + def __init__( + self, + instance, + context_info, + is_parent_active: bool, + group_icon, + parent: BaseGroupWidget, + ): super().__init__(parent) + self.instance = instance + self._is_active = instance.is_active + self._id = instance.id self._group_identifier = instance.group_label self._group_icon = group_icon - - self.instance = instance + self._is_parent_active = is_parent_active + self._toggle_is_enabled = True self._last_product_name = None self._last_variant = None @@ -439,10 +309,6 @@ class InstanceCardWidget(CardWidget): expand_btn.setMaximumWidth(14) expand_btn.setEnabled(False) - detail_widget = QtWidgets.QWidget(self) - detail_widget.setVisible(False) - self.detail_widget = detail_widget - top_layout = QtWidgets.QHBoxLayout() top_layout.addLayout(icon_layout, 0) top_layout.addWidget(label_widget, 1) @@ -450,6 +316,9 @@ class InstanceCardWidget(CardWidget): top_layout.addWidget(active_checkbox, 0) top_layout.addWidget(expand_btn, 0) + detail_widget = QtWidgets.QWidget(self) + detail_widget.setVisible(False) + layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 2, 10, 2) layout.addLayout(top_layout) @@ -467,25 +336,47 @@ class InstanceCardWidget(CardWidget): self._active_checkbox = active_checkbox self._expand_btn = expand_btn - self._update_instance_values(context_info) + self._detail_widget = detail_widget - def set_active_toggle_enabled(self, enabled): - self._active_checkbox.setEnabled(enabled) + self._update_instance_values(context_info, is_parent_active) - @property - def is_active(self): + def set_active_toggle_enabled(self, enabled: bool) -> None: + if self._toggle_is_enabled is enabled: + return + self._toggle_is_enabled = enabled + self._update_checkbox_state() + + def is_active(self) -> bool: return self._active_checkbox.isChecked() - def _set_active(self, new_value): - """Set instance as active.""" - checkbox_value = self._active_checkbox.isChecked() - if checkbox_value != new_value: - self._active_checkbox.setChecked(new_value) + def set_active(self, active: Optional[bool]) -> None: + if not self.is_checkbox_enabled(): + return + if active is None: + active = not self._is_active + self._set_checked(active) - def update_instance(self, instance, context_info): + def is_parent_active(self) -> bool: + return self._is_parent_active + + def set_parent_active(self, is_active: bool) -> None: + if self._is_parent_active is is_active: + return + self._is_parent_active = is_active + self._update_checkbox_state() + + def is_checkbox_enabled(self) -> bool: + """Checkbox can be changed by user.""" + return ( + self._used_parent_active() + and not self.instance.is_mandatory + ) + + def update_instance(self, instance, context_info, is_parent_active): """Update instance object and update UI.""" self.instance = instance - self._update_instance_values(context_info) + self._is_active = instance.is_active + self._update_instance_values(context_info, is_parent_active) def _validate_context(self, context_info): valid = context_info.is_valid @@ -496,6 +387,7 @@ class InstanceCardWidget(CardWidget): variant = self.instance.variant product_name = self.instance.product_name label = self.instance.label + if ( variant == self._last_variant and product_name == self._last_product_name @@ -521,23 +413,53 @@ class InstanceCardWidget(CardWidget): QtCore.Qt.NoTextInteraction ) - def _update_instance_values(self, context_info): + def _update_instance_values(self, context_info, is_parent_active): """Update instance data""" + self._is_parent_active = is_parent_active self._update_product_name() - self._set_active(self.instance.is_active) + self._update_checkbox_state() self._validate_context(context_info) + def _update_checkbox_state(self): + parent_is_enabled = self._used_parent_active() + self._label_widget.setEnabled(parent_is_enabled) + self._active_checkbox.setEnabled( + self._toggle_is_enabled + and not self.instance.is_mandatory + and parent_is_enabled + ) + # Hide checkbox for mandatory instances + self._active_checkbox.setVisible(not self.instance.is_mandatory) + + # Visually disable instance if parent is disabled + checked = parent_is_enabled and self._is_active + self._set_checked(checked) + + def _set_checked(self, checked: bool) -> None: + if checked is not self._active_checkbox.isChecked(): + self._active_checkbox.blockSignals(True) + self._active_checkbox.setChecked(checked) + self._active_checkbox.blockSignals(False) + + def _used_parent_active(self) -> bool: + parent_enabled = True + if self.instance.parent_flags & ParentFlags.share_active: + parent_enabled = self._is_parent_active + return parent_enabled + def _set_expanded(self, expanded=None): if expanded is None: - expanded = not self.detail_widget.isVisible() - self.detail_widget.setVisible(expanded) + expanded = not self._detail_widget.isVisible() + self._detail_widget.setVisible(expanded) def _on_active_change(self): - new_value = self._active_checkbox.isChecked() - old_value = self.instance.is_active - if new_value == old_value: + if not self.is_checkbox_enabled(): return - + new_value = self._active_checkbox.isChecked() + old_value = self._is_active + if new_value is old_value: + return + self._is_active = new_value self.active_changed.emit(self._id, new_value) def _on_expend_clicked(self): @@ -591,11 +513,22 @@ class InstanceCardView(AbstractInstanceView): self._content_layout = content_layout self._content_widget = content_widget - self._context_widget = None - self._convertor_items_group = None - self._active_toggle_enabled = True - self._widgets_by_group: Dict[str, InstanceGroupWidget] = {} + self._active_toggle_enabled: bool = True + self._convertors_group: Optional[BaseGroupWidget] = None + self._convertor_widgets_by_id: dict[str, ConvertorItemCardWidget] = {} + self._convertor_ids: list[str] = [] + + self._group_name_by_instance_id: dict[str, str] = {} + self._instance_ids_by_group_name: dict[str, list[str]] = ( + collections.defaultdict(list) + ) self._ordered_groups = [] + self._context_widget: Optional[ContextCardWidget] = None + self._widgets_by_id: dict[str, InstanceCardWidget] = {} + self._widgets_by_group: dict[str, BaseGroupWidget] = {} + + self._parent_id_by_id = {} + self._instance_ids_by_parent_id = collections.defaultdict(set) self._explicitly_selected_instance_ids = [] self._explicitly_selected_groups = [] @@ -618,42 +551,104 @@ class InstanceCardView(AbstractInstanceView): result.setWidth(width) return result - def _toggle_instances(self, value): - if not self._active_toggle_enabled: - return + def get_current_instance_count(self) -> int: + """How many instances are currently in the view.""" + return len(self._widgets_by_id) - widgets = self._get_selected_widgets() - active_state_by_id = {} - for widget in widgets: - if not isinstance(widget, InstanceCardWidget): + def _get_affected_ids(self, instance_ids: set[str]) -> set[str]: + affected_ids = set() + affected_queue = collections.deque() + affected_queue.extend(instance_ids) + while affected_queue: + instance_id = affected_queue.popleft() + if instance_id in affected_ids: continue + affected_ids.add(instance_id) + parent_id = instance_id + while True: + parent_id = self._parent_id_by_id[parent_id] + if parent_id is None: + break + affected_ids.add(parent_id) - instance_id = widget.id - is_active = widget.is_active - if value == -1: - active_state_by_id[instance_id] = not is_active - continue + child_ids = set(self._instance_ids_by_parent_id[instance_id]) + affected_queue.extend(child_ids - affected_ids) + return affected_ids - _value = bool(value) - if is_active is not _value: - active_state_by_id[instance_id] = _value + def _toggle_instances( + self, + new_value: Optional[bool], + active_id: Optional[str] = None, + ) -> None: + instance_ids = { + widget.id + for widget in self._get_selected_instance_widgets() + if widget.is_selected + } + active_by_id = {} + if active_id and active_id not in instance_ids: + instance_ids = {active_id} - if not active_state_by_id: - return + ids_to_toggle = set(instance_ids) - self._controller.set_instances_active_state(active_state_by_id) + affected_ids = self._get_affected_ids(instance_ids) + + _queue = collections.deque() + _queue.append((set(self._instance_ids_by_parent_id[None]), True)) + discarted_ids = set() + while _queue: + if not instance_ids: + break + + chilren_ids, is_parent_active = _queue.pop() + for instance_id in chilren_ids: + if instance_id not in affected_ids: + continue + + widget = self._widgets_by_id[instance_id] + if is_parent_active is not widget.is_parent_active(): + widget.set_parent_active(is_parent_active) + + instance_ids.discard(instance_id) + if instance_id in ids_to_toggle: + discarted_ids.add(instance_id) + old_value = widget.is_active() + value = new_value + if value is None: + value = not old_value + + widget.set_active(value) + if widget.is_parent_active(): + active_by_id[instance_id] = widget.is_active() + + children_ids = self._instance_ids_by_parent_id[instance_id] + children = { + child_id + for child_id in children_ids + if child_id not in discarted_ids + } + + if children: + instance_ids |= children + _queue.append((children, widget.is_active())) + + if not instance_ids: + break + + if active_by_id: + self._controller.set_instances_active_state(active_by_id) def keyPressEvent(self, event): if event.key() == QtCore.Qt.Key_Space: - self._toggle_instances(-1) + self._toggle_instances(None) return True elif event.key() == QtCore.Qt.Key_Backspace: - self._toggle_instances(0) + self._toggle_instances(False) return True elif event.key() == QtCore.Qt.Key_Return: - self._toggle_instances(1) + self._toggle_instances(True) return True return super().keyPressEvent(event) @@ -666,15 +661,25 @@ class InstanceCardView(AbstractInstanceView): ): output.append(self._context_widget) - if self._convertor_items_group is not None: - output.extend(self._convertor_items_group.get_selected_widgets()) - - for group_widget in self._widgets_by_group.values(): - for widget in group_widget.get_selected_widgets(): - output.append(widget) + output.extend(self._get_selected_convertor_widgets()) + output.extend(self._get_selected_instance_widgets()) return output - def _get_selected_instance_ids(self): + def _get_selected_instance_widgets(self) -> list[InstanceCardWidget]: + return [ + widget + for widget in self._widgets_by_id.values() + if widget.is_selected + ] + + def _get_selected_convertor_widgets(self) -> list[ConvertorItemCardWidget]: + return [ + widget + for widget in self._convertor_widgets_by_id.values() + if widget.is_selected + ] + + def _get_selected_item_ids(self): output = [] if ( self._context_widget is not None @@ -682,11 +687,17 @@ class InstanceCardView(AbstractInstanceView): ): output.append(CONTEXT_ID) - if self._convertor_items_group is not None: - output.extend(self._convertor_items_group.get_selected_item_ids()) + output.extend( + conv_id + for conv_id, widget in self._widgets_by_id.items() + if widget.is_selected + ) - for group_widget in self._widgets_by_group.values(): - output.extend(group_widget.get_selected_item_ids()) + output.extend( + widget.id + for instance_id, widget in self._widgets_by_id.items() + if widget.is_selected + ) return output def refresh(self): @@ -694,25 +705,102 @@ class InstanceCardView(AbstractInstanceView): self._make_sure_context_widget_exists() - self._update_convertor_items_group() + self._update_convertors_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) - for instance in self._controller.get_instance_items(): + identifiers: set[str] = set() + instances_by_id = {} + parent_id_by_id = {} + instance_ids_by_parent_id = collections.defaultdict(set) + instance_items = self._controller.get_instance_items() + for instance in instance_items: group_name = instance.group_label instances_by_group[group_name].append(instance) identifiers_by_group[group_name].add( instance.creator_identifier ) + identifiers.add(instance.creator_identifier) + instances_by_id[instance.id] = instance + instance_ids_by_parent_id[instance.parent_instance_id].add( + instance.id + ) + parent_id_by_id[instance.id] = instance.parent_instance_id - # Remove groups that were not found in apassed instances - for group_name in tuple(self._widgets_by_group.keys()): - if group_name in instances_by_group: - continue + parent_active_by_id = { + instance_id: False + for instance_id in instances_by_id + } + _queue = collections.deque() + _queue.append((None, True)) + while _queue: + parent_id, is_parent_active = _queue.popleft() + for instance_id in instance_ids_by_parent_id[parent_id]: + instance_item = instances_by_id[instance_id] + is_active = instance_item.is_active + if ( + not is_parent_active + and instance_item.parent_flags & ParentFlags.share_active + ): + is_active = False + parent_active_by_id[instance_id] = is_parent_active + _queue.append( + (instance_id, is_active) + ) + + # Remove groups that were not found in passed instances + groups_to_remove = ( + set(self._widgets_by_group) - set(instances_by_group) + ) + ids_to_remove = ( + set(self._widgets_by_id) - set(instances_by_id) + ) + + # Sort groups + sorted_group_names = list(sorted(instances_by_group.keys())) + + # Keep track of widget indexes + # - we start with 1 because Context item as at the top + widget_idx = 1 + if self._convertors_group is not None: + widget_idx += 1 + + group_by_instance_id = {} + instance_ids_by_group_name = collections.defaultdict(list) + group_icons = { + identifier: self._controller.get_creator_icon(identifier) + for identifier in identifiers + } + for group_name in sorted_group_names: + if group_name not in self._widgets_by_group: + group_widget = BaseGroupWidget( + group_name, self._content_widget + ) + group_widget.double_clicked.connect(self.double_clicked) + self._content_layout.insertWidget(widget_idx, group_widget) + self._widgets_by_group[group_name] = group_widget + + widget_idx += 1 + + instances = instances_by_group[group_name] + for instance in instances: + group_by_instance_id[instance.id] = group_name + instance_ids_by_group_name[group_name].append(instance.id) + + self._update_instance_widgets( + group_name, + instances, + context_info_by_id, + parent_active_by_id, + group_icons, + ) + + # Remove empty groups + for group_name in groups_to_remove: widget = self._widgets_by_group.pop(group_name) widget.setVisible(False) self._content_layout.removeWidget(widget) @@ -721,61 +809,89 @@ class InstanceCardView(AbstractInstanceView): if group_name in self._explicitly_selected_groups: self._explicitly_selected_groups.remove(group_name) - # Sort groups - sorted_group_names = list(sorted(instances_by_group.keys())) + for instance_id in ids_to_remove: + widget = self._widgets_by_id.pop(instance_id) + widget.setVisible(False) + widget.deleteLater() - # Keep track of widget indexes - # - we start with 1 because Context item as at the top - widget_idx = 1 - if self._convertor_items_group is not None: - widget_idx += 1 + self._parent_id_by_id = parent_id_by_id + self._instance_ids_by_parent_id = instance_ids_by_parent_id + self._group_name_by_instance_id = group_by_instance_id + self._instance_ids_by_group_name = instance_ids_by_group_name + self._ordered_groups = sorted_group_names - for group_name in sorted_group_names: - group_icons = { - identifier: self._controller.get_creator_icon(identifier) - for identifier in identifiers_by_group[group_name] - } - if group_name in self._widgets_by_group: - group_widget = self._widgets_by_group[group_name] - group_widget.update_icons(group_icons) - - else: - group_widget = InstanceGroupWidget( - group_icons, group_name, self._content_widget - ) - group_widget.active_changed.connect(self._on_active_changed) - group_widget.selected.connect(self._on_widget_selection) - group_widget.double_clicked.connect(self.double_clicked) - self._content_layout.insertWidget(widget_idx, group_widget) - self._widgets_by_group[group_name] = group_widget - - widget_idx += 1 - group_widget.update_instances( - instances_by_group[group_name], context_info_by_id - ) - group_widget.set_active_toggle_enabled( - self._active_toggle_enabled - ) - - self._update_ordered_group_names() - - def has_items(self): - if self._convertor_items_group is not None: + def has_items(self) -> bool: + if self._convertors_group is not None: return True - if self._widgets_by_group: + if self._widgets_by_id: return True return False - def _update_ordered_group_names(self): - ordered_group_names = [CONTEXT_GROUP] - for idx in range(self._content_layout.count()): - if idx > 0: - item = self._content_layout.itemAt(idx) - group_widget = item.widget() - if group_widget is not None: - ordered_group_names.append(group_widget.group_name) + def _update_instance_widgets( + self, + group_name: str, + instances: list[InstanceItem], + context_info_by_id: dict[str, InstanceContextInfo], + parent_active_by_id: dict[str, bool], + group_icons: dict[str, str], + ) -> None: + """Update instances for the group. - self._ordered_groups = ordered_group_names + Args: + instances (list[InstanceItem]): List of instances in + CreateContext. + context_info_by_id (dict[str, InstanceContextInfo]): Instance + context info by instance id. + parent_active_by_id (dict[str, bool]): Instance has active parent. + + """ + # Store instances by id and by product name + group_widget: BaseGroupWidget = self._widgets_by_group[group_name] + instances_by_id = {} + instances_by_product_name = collections.defaultdict(list) + for instance in instances: + instances_by_id[instance.id] = instance + product_name = instance.product_name + instances_by_product_name[product_name].append(instance) + + to_remove_ids = set( + self._instance_ids_by_group_name[group_name] + ) - set(instances_by_id) + group_widget.take_widgets(to_remove_ids) + + # Sort instances by product name + sorted_product_names = list(sorted(instances_by_product_name.keys())) + + # Add new instances to widget + ordered_ids = [] + widgets_by_id = {} + for product_names in sorted_product_names: + for instance in instances_by_product_name[product_names]: + context_info = context_info_by_id[instance.id] + is_parent_active = parent_active_by_id[instance.id] + if instance.id in self._widgets_by_id: + widget = self._widgets_by_id[instance.id] + widget.update_instance( + instance, context_info, is_parent_active + ) + else: + group_icon = group_icons[instance.creator_identifier] + widget = InstanceCardWidget( + instance, + context_info, + is_parent_active, + group_icon, + group_widget + ) + widget.selected.connect(self._on_widget_selection) + widget.active_changed.connect(self._on_active_changed) + widget.double_clicked.connect(self.double_clicked) + self._widgets_by_id[instance.id] = widget + + ordered_ids.append(instance.id) + widgets_by_id[instance.id] = widget + + group_widget.set_widgets(widgets_by_id, ordered_ids) def _make_sure_context_widget_exists(self): # Create context item if is not already existing @@ -793,28 +909,65 @@ class InstanceCardView(AbstractInstanceView): self.selection_changed.emit() self._content_layout.insertWidget(0, widget) - def _update_convertor_items_group(self): + def _update_convertors_group(self): convertor_items = self._controller.get_convertor_items() - if not convertor_items and self._convertor_items_group is None: + if not convertor_items and self._convertors_group is None: return + ids_to_remove = set(self._convertor_widgets_by_id) - set( + convertor_items + ) + if ids_to_remove: + self._convertors_group.take_widgets(ids_to_remove) + + for conv_id in ids_to_remove: + widget = self._convertor_widgets_by_id.pop(conv_id) + widget.setVisible(False) + widget.deleteLater() + if not convertor_items: - self._convertor_items_group.setVisible(False) - self._content_layout.removeWidget(self._convertor_items_group) - self._convertor_items_group.deleteLater() - self._convertor_items_group = None + self._convertors_group.setVisible(False) + self._content_layout.removeWidget(self._convertors_group) + self._convertors_group.deleteLater() + self._convertors_group = None + self._convertor_ids = [] + self._convertor_widgets_by_id = {} return - if self._convertor_items_group is None: - group_widget = ConvertorItemsGroupWidget( + if self._convertors_group is None: + group_widget = BaseGroupWidget( CONVERTOR_ITEM_GROUP, self._content_widget ) - group_widget.selected.connect(self._on_widget_selection) - group_widget.double_clicked.connect(self.double_clicked) self._content_layout.insertWidget(1, group_widget) - self._convertor_items_group = group_widget + self._convertors_group = group_widget - self._convertor_items_group.update_items(convertor_items) + # TODO create convertor widgets + items_by_label = collections.defaultdict(list) + for item in convertor_items.values(): + items_by_label[item.label].append(item) + + # Sort instances by product name + sorted_labels = list(sorted(items_by_label.keys())) + + # Add new instances to widget + convertor_ids: list[str] = [] + widgets_by_id: dict[str, ConvertorItemCardWidget] = {} + for label in sorted_labels: + for item in items_by_label[label]: + convertor_ids.append(item.id) + if item.id in self._convertor_widgets_by_id: + widget = self._convertor_widgets_by_id[item.id] + widget.update_item(item) + else: + widget = ConvertorItemCardWidget(item, self) + widget.selected.connect(self._on_widget_selection) + widget.double_clicked.connect(self.double_clicked) + self._convertor_widgets_by_id[item.id] = widget + widgets_by_id[item.id] = widget + + self._convertors_group.set_widgets(widgets_by_id, convertor_ids) + self._convertor_ids = convertor_ids + self._convertor_widgets_by_id = widgets_by_id def refresh_instance_states(self, instance_ids=None): """Trigger update of instances on group widgets.""" @@ -824,23 +977,45 @@ class InstanceCardView(AbstractInstanceView): instance_items_by_id = self._controller.get_instance_items_by_id( instance_ids ) - for widget in self._widgets_by_group.values(): - widget.update_instance_values( - context_info_by_id, instance_items_by_id, instance_ids - ) + instance_ids: set[str] = set(instance_items_by_id) + available_ids: set[str] = set(instance_items_by_id) - def _on_active_changed(self, group_name, instance_id, value): - group_widget = self._widgets_by_group[group_name] - instance_widget = group_widget.get_widget_by_item_id(instance_id) - active_state_by_id = {} - if not instance_widget.is_selected: - active_state_by_id[instance_id] = value - else: - for widget in self._get_selected_widgets(): - if isinstance(widget, InstanceCardWidget): - active_state_by_id[widget.id] = value + affected_ids = self._get_affected_ids(instance_ids) - self._controller.set_instances_active_state(active_state_by_id) + _queue = collections.deque() + _queue.append((set(self._instance_ids_by_parent_id[None]), True)) + while _queue: + if not affected_ids: + break + + chilren_ids, is_parent_active = _queue.pop() + for instance_id in chilren_ids: + if instance_id not in affected_ids: + continue + affected_ids.discard(instance_id) + widget = self._widgets_by_id[instance_id] + if instance_id in instance_ids: + instance_ids.discard(instance_id) + if instance_id in available_ids: + available_ids.discard(instance_id) + widget.update_instance( + instance_items_by_id[instance_id], + context_info_by_id[instance_id], + is_parent_active, + ) + else: + widget.set_parent_active(is_parent_active) + + if not affected_ids: + break + + children = set(self._instance_ids_by_parent_id[instance_id]) + if children: + instance_ids |= children + _queue.append((children, widget.is_active())) + + def _on_active_changed(self, instance_id: str, value: bool) -> None: + self._toggle_instances(value, instance_id) def _on_widget_selection(self, instance_id, group_name, selection_type): """Select specific item by instance id. @@ -853,10 +1028,9 @@ class InstanceCardView(AbstractInstanceView): else: if group_name == CONVERTOR_ITEM_GROUP: - group_widget = self._convertor_items_group + new_widget = self._convertor_widgets_by_id[instance_id] else: - group_widget = self._widgets_by_group[group_name] - new_widget = group_widget.get_widget_by_item_id(instance_id) + new_widget = self._widgets_by_id[instance_id] if selection_type == SelectionTypes.clear: self._select_item_clear(instance_id, group_name, new_widget) @@ -892,7 +1066,7 @@ class InstanceCardView(AbstractInstanceView): """ self._explicitly_selected_instance_ids = ( - self._get_selected_instance_ids() + self._get_selected_item_ids() ) if new_widget.is_selected: self._explicitly_selected_instance_ids.remove(instance_id) @@ -901,11 +1075,21 @@ class InstanceCardView(AbstractInstanceView): if instance_id == CONTEXT_ID: remove_group = True else: + has_selected_items = False if group_name == CONVERTOR_ITEM_GROUP: - group_widget = self._convertor_items_group + for widget in self._convertor_widgets_by_id.values(): + if widget.is_selected: + has_selected_items = True + break else: - group_widget = self._widgets_by_group[group_name] - if not group_widget.get_selected_widgets(): + group_ids = self._instance_ids_by_group_name[group_name] + for instance_id in group_ids: + widget = self._widgets_by_id[instance_id] + if widget.is_selected: + has_selected_items = True + break + + if not has_selected_items: remove_group = True if remove_group: @@ -1017,10 +1201,16 @@ class InstanceCardView(AbstractInstanceView): sorted_widgets = [self._context_widget] else: if name == CONVERTOR_ITEM_GROUP: - group_widget = self._convertor_items_group + sorted_widgets = [ + self._convertor_widgets_by_id[conv_id] + for conv_id in self._convertor_ids + ] else: - group_widget = self._widgets_by_group[name] - sorted_widgets = group_widget.get_ordered_widgets() + instance_ids = self._instance_ids_by_group_name[name] + sorted_widgets = [ + self._widgets_by_id[instance_id] + for instance_id in instance_ids + ] # Change selection based on explicit selection if start group # was not passed yet @@ -1132,21 +1322,18 @@ class InstanceCardView(AbstractInstanceView): def get_selected_items(self): """Get selected instance ids and context.""" - convertor_identifiers = [] - instances = [] - selected_widgets = self._get_selected_widgets() - - context_selected = False - for widget in selected_widgets: - if widget is self._context_widget: - context_selected = True - - elif isinstance(widget, InstanceCardWidget): - instances.append(widget.id) - - elif isinstance(widget, ConvertorItemCardWidget): - convertor_identifiers.append(widget.identifier) - + context_selected = ( + self._context_widget is not None + and self._context_widget.is_selected + ) + instances = [ + widget.id + for widget in self._get_selected_instance_widgets() + ] + convertor_identifiers = [ + widget.identifier + for widget in self._get_selected_convertor_widgets() + ] return instances, context_selected, convertor_identifiers def set_selected_items( @@ -1178,12 +1365,19 @@ class InstanceCardView(AbstractInstanceView): is_convertor_group = group_name == CONVERTOR_ITEM_GROUP if is_convertor_group: - group_widget = self._convertor_items_group + sorted_widgets = [ + self._convertor_widgets_by_id[conv_id] + for conv_id in self._convertor_ids + ] else: - group_widget = self._widgets_by_group[group_name] + instance_ids = self._instance_ids_by_group_name[group_name] + sorted_widgets = [ + self._widgets_by_id[instance_id] + for instance_id in instance_ids + ] group_selected = False - for widget in group_widget.get_ordered_widgets(): + for widget in sorted_widgets: select = False if is_convertor_group: is_in = widget.identifier in s_convertor_identifiers @@ -1205,5 +1399,5 @@ class InstanceCardView(AbstractInstanceView): if self._active_toggle_enabled is enabled: return self._active_toggle_enabled = enabled - for group_widget in self._widgets_by_group.values(): - group_widget.set_active_toggle_enabled(enabled) + for widget in self._widgets_by_id.values(): + widget.set_active_toggle_enabled(enabled) diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index aecea2ec44..b9b3afd895 100644 --- a/client/ayon_core/tools/publisher/widgets/create_widget.py +++ b/client/ayon_core/tools/publisher/widgets/create_widget.py @@ -683,7 +683,7 @@ class CreateWidget(QtWidgets.QWidget): options = list(self._current_creator_variant_hints) if options: options.append("---") - options.extend(variant_hints) + options.extend(sorted(variant_hints)) # Add hints to actions self._variant_widget.set_options(options) 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 bc3353ba5e..c524b96d5f 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -22,15 +22,26 @@ selection can be enabled disabled using checkbox or keyboard key presses: ... ``` """ +from __future__ import annotations + import collections +from typing import Optional from qtpy import QtWidgets, QtCore, QtGui from ayon_core.style import get_objected_colors -from ayon_core.tools.utils import NiceCheckbox -from ayon_core.tools.utils.lib import html_escape, checkstate_int_to_enum +from ayon_core.pipeline.create import ( + InstanceContextInfo, + ParentFlags, +) + +from ayon_core.tools.utils import NiceCheckbox, BaseClickableFrame +from ayon_core.tools.utils.lib import html_escape, checkstate_int_to_enum from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend +from ayon_core.tools.publisher.models.create import ( + InstanceItem, +) from ayon_core.tools.publisher.constants import ( INSTANCE_ID_ROLE, SORT_VALUE_ROLE, @@ -115,7 +126,13 @@ class InstanceListItemWidget(QtWidgets.QWidget): active_changed = QtCore.Signal(str, bool) double_clicked = QtCore.Signal() - def __init__(self, instance, context_info, parent): + def __init__( + self, + instance: InstanceItem, + context_info: InstanceContextInfo, + parent_is_active: bool, + parent: QtWidgets.QWidget, + ): super().__init__(parent) self._instance_id = instance.id @@ -131,27 +148,40 @@ class InstanceListItemWidget(QtWidgets.QWidget): product_name_label.setObjectName("ListViewProductName") active_checkbox = NiceCheckbox(parent=self) - active_checkbox.setChecked(instance.is_active) layout = QtWidgets.QHBoxLayout(self) - content_margins = layout.contentsMargins() - layout.setContentsMargins(content_margins.left() + 2, 0, 2, 0) + layout.setContentsMargins(2, 0, 2, 0) layout.addWidget(product_name_label) layout.addStretch(1) layout.addWidget(active_checkbox) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - product_name_label.setAttribute(QtCore.Qt.WA_TranslucentBackground) - active_checkbox.setAttribute(QtCore.Qt.WA_TranslucentBackground) + for widget in ( + self, + product_name_label, + active_checkbox, + ): + widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) active_checkbox.stateChanged.connect(self._on_active_change) self._instance_label_widget = product_name_label self._active_checkbox = active_checkbox - self._has_valid_context = None + # Instance info + self._has_valid_context = context_info.is_valid + self._is_mandatory = instance.is_mandatory + self._instance_is_active = instance.is_active + self._parent_flags = instance.parent_flags - self._set_valid_property(context_info.is_valid) + # Parent active state is fluent and can change + self._parent_is_active = parent_is_active + + # Widget logic info + self._state = None + self._toggle_is_enabled = True + + self._update_style_state() + self._update_checkbox_state() def mouseDoubleClickEvent(self, event): widget = self.childAt(event.pos()) @@ -159,50 +189,119 @@ class InstanceListItemWidget(QtWidgets.QWidget): if widget is not self._active_checkbox: self.double_clicked.emit() - def _set_valid_property(self, valid): - if self._has_valid_context == valid: - return - self._has_valid_context = valid - state = "" - if not valid: - state = "invalid" - self._instance_label_widget.setProperty("state", state) - self._instance_label_widget.style().polish(self._instance_label_widget) - - def is_active(self): + def is_active(self) -> bool: """Instance is activated.""" return self._active_checkbox.isChecked() - def set_active(self, new_value): - """Change active state of instance and checkbox.""" - old_value = self.is_active() + def is_checkbox_enabled(self) -> bool: + """Checkbox can be changed by user.""" + return ( + self._used_parent_active() + and not self._is_mandatory + ) + + def set_active_toggle_enabled(self, enabled: bool) -> None: + """Toggle can be available for user.""" + self._toggle_is_enabled = enabled + self._update_checkbox_state() + + def set_active(self, new_value: Optional[bool]) -> None: + """Change active state of instance and checkbox by user interaction. + + Args: + new_value (Optional[bool]): New active state of instance. Toggle + if is 'None'. + + """ + # Do not allow to change state if is mandatory or parent is not active + if not self.is_checkbox_enabled(): + return + if new_value is None: - new_value = not old_value + new_value = not self._active_checkbox.isChecked() + # Update instance active state + self._instance_is_active = new_value + self._set_checked(new_value) - if new_value != old_value: - self._active_checkbox.blockSignals(True) - self._active_checkbox.setChecked(new_value) - self._active_checkbox.blockSignals(False) - - def update_instance(self, instance, context_info): + def update_instance( + self, + instance: InstanceItem, + context_info: InstanceContextInfo, + parent_is_active: bool, + ) -> None: """Update instance object.""" # Check product name + self._instance_id = instance.id label = instance.label if label != self._instance_label_widget.text(): self._instance_label_widget.setText(html_escape(label)) - # Check active state - self.set_active(instance.is_active) - # Check valid states - self._set_valid_property(context_info.is_valid) + + self._is_mandatory = instance.is_mandatory + self._instance_is_active = instance.is_active + self._has_valid_context = context_info.is_valid + self._parent_is_active = parent_is_active + self._parent_flags = instance.parent_flags + + self._update_checkbox_state() + self._update_style_state() + + def is_parent_active(self) -> bool: + return self._parent_is_active + + def _used_parent_active(self) -> bool: + parent_enabled = True + if self._parent_flags & ParentFlags.share_active: + parent_enabled = self._parent_is_active + return parent_enabled + + def set_parent_is_active(self, active: bool) -> None: + if self._parent_is_active is active: + return + self._parent_is_active = active + self._update_style_state() + self._update_checkbox_state() + + def _set_checked(self, checked: bool) -> None: + """Change checked state in UI without triggering checkstate change.""" + old_value = self._active_checkbox.isChecked() + if checked is not old_value: + self._active_checkbox.blockSignals(True) + self._active_checkbox.setChecked(checked) + self._active_checkbox.blockSignals(False) + + def _update_style_state(self) -> None: + state = "" + if not self._used_parent_active(): + state = "disabled" + elif not self._has_valid_context: + state = "invalid" + + if state == self._state: + return + self._state = state + self._instance_label_widget.setProperty("state", state) + self._instance_label_widget.style().polish(self._instance_label_widget) + + def _update_checkbox_state(self) -> None: + parent_enabled = self._used_parent_active() + + self._active_checkbox.setEnabled( + self._toggle_is_enabled + and not self._is_mandatory + and parent_enabled + ) + # Hide checkbox for mandatory instances + self._active_checkbox.setVisible(not self._is_mandatory) + + # Visually disable instance if parent is disabled + checked = parent_enabled and self._instance_is_active + self._set_checked(checked) def _on_active_change(self): self.active_changed.emit( self._instance_id, self._active_checkbox.isChecked() ) - def set_active_toggle_enabled(self, enabled): - self._active_checkbox.setEnabled(enabled) - class ListContextWidget(QtWidgets.QFrame): """Context (or global attributes) widget.""" @@ -229,43 +328,33 @@ class ListContextWidget(QtWidgets.QFrame): self.double_clicked.emit() -class InstanceListGroupWidget(QtWidgets.QFrame): +class InstanceListGroupWidget(BaseClickableFrame): """Widget representing group of instances. - Has collapse/expand indicator, label of group and checkbox modifying all - of its children. + Has label of group and checkbox modifying all of its children. """ - expand_changed = QtCore.Signal(str, bool) toggle_requested = QtCore.Signal(str, int) + expand_change_requested = QtCore.Signal(str) def __init__(self, group_name, parent): super().__init__(parent) self.setObjectName("InstanceListGroupWidget") self.group_name = group_name - self._expanded = False - - expand_btn = QtWidgets.QToolButton(self) - expand_btn.setObjectName("ArrowBtn") - expand_btn.setArrowType(QtCore.Qt.RightArrow) - expand_btn.setMaximumWidth(14) name_label = QtWidgets.QLabel(group_name, self) toggle_checkbox = NiceCheckbox(parent=self) layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(5, 0, 2, 0) - layout.addWidget(expand_btn) + layout.setContentsMargins(2, 0, 2, 0) layout.addWidget( name_label, 1, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter ) layout.addWidget(toggle_checkbox, 0) name_label.setAttribute(QtCore.Qt.WA_TranslucentBackground) - expand_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground) - expand_btn.clicked.connect(self._on_expand_clicked) toggle_checkbox.stateChanged.connect(self._on_checkbox_change) self._ignore_state_change = False @@ -273,7 +362,6 @@ class InstanceListGroupWidget(QtWidgets.QFrame): self._expected_checkstate = None self.name_label = name_label - self.expand_btn = expand_btn self.toggle_checkbox = toggle_checkbox def set_checkstate(self, state): @@ -295,26 +383,15 @@ class InstanceListGroupWidget(QtWidgets.QFrame): return self.toggle_checkbox.checkState() + def set_active_toggle_enabled(self, enabled): + self.toggle_checkbox.setEnabled(enabled) + def _on_checkbox_change(self, state): if not self._ignore_state_change: self.toggle_requested.emit(self.group_name, state) - def _on_expand_clicked(self): - self.expand_changed.emit(self.group_name, not self._expanded) - - def set_expanded(self, expanded): - """Change icon of collapse/expand identifier.""" - if self._expanded == expanded: - return - - self._expanded = expanded - if expanded: - self.expand_btn.setArrowType(QtCore.Qt.DownArrow) - else: - self.expand_btn.setArrowType(QtCore.Qt.RightArrow) - - def set_active_toggle_enabled(self, enabled): - self.toggle_checkbox.setEnabled(enabled) + def _mouse_release_callback(self): + self.expand_change_requested.emit(self.group_name) class InstanceTreeView(QtWidgets.QTreeView): @@ -327,24 +404,11 @@ class InstanceTreeView(QtWidgets.QTreeView): self.setObjectName("InstanceListView") self.setHeaderHidden(True) - self.setIndentation(0) self.setExpandsOnDoubleClick(False) self.setSelectionMode( QtWidgets.QAbstractItemView.ExtendedSelection ) self.viewport().setMouseTracking(True) - self._pressed_group_index = None - - def _expand_item(self, index, expand=None): - is_expanded = self.isExpanded(index) - if expand is None: - expand = not is_expanded - - if expand != is_expanded: - if expand: - self.expand(index) - else: - self.collapse(index) def get_selected_instance_ids(self): """Ids of selected instances.""" @@ -376,53 +440,6 @@ class InstanceTreeView(QtWidgets.QTreeView): return super().event(event) - def _mouse_press(self, event): - """Store index of pressed group. - - This is to be able to change state of group and process mouse - "double click" as 2x "single click". - """ - if event.button() != QtCore.Qt.LeftButton: - return - - pressed_group_index = None - pos_index = self.indexAt(event.pos()) - if pos_index.data(IS_GROUP_ROLE): - pressed_group_index = pos_index - - self._pressed_group_index = pressed_group_index - - def mousePressEvent(self, event): - self._mouse_press(event) - super().mousePressEvent(event) - - def mouseDoubleClickEvent(self, event): - self._mouse_press(event) - super().mouseDoubleClickEvent(event) - - def _mouse_release(self, event, pressed_index): - if event.button() != QtCore.Qt.LeftButton: - return False - - pos_index = self.indexAt(event.pos()) - if not pos_index.data(IS_GROUP_ROLE) or pressed_index != pos_index: - return False - - if self.state() == QtWidgets.QTreeView.State.DragSelectingState: - indexes = self.selectionModel().selectedIndexes() - if len(indexes) != 1 or indexes[0] != pos_index: - return False - - self._expand_item(pos_index) - return True - - def mouseReleaseEvent(self, event): - pressed_index = self._pressed_group_index - self._pressed_group_index = None - result = self._mouse_release(event, pressed_index) - if not result: - super().mouseReleaseEvent(event) - class InstanceListView(AbstractInstanceView): """Widget providing abstract methods of AbstractInstanceView for list view. @@ -460,18 +477,21 @@ class InstanceListView(AbstractInstanceView): instance_view.selectionModel().selectionChanged.connect( self._on_selection_change ) - instance_view.collapsed.connect(self._on_collapse) - instance_view.expanded.connect(self._on_expand) instance_view.toggle_requested.connect(self._on_toggle_request) instance_view.double_clicked.connect(self.double_clicked) self._group_items = {} self._group_widgets = {} - self._widgets_by_id = {} + self._widgets_by_id: dict[str, InstanceListItemWidget] = {} + self._items_by_id = {} + self._parent_id_by_id = {} + self._instance_ids_by_parent_id = collections.defaultdict(set) # Group by instance id for handling of active state self._group_by_instance_id = {} self._context_item = None self._context_widget = None + self._missing_parent_item = None + self._parent_grouping = True self._convertor_group_item = None self._convertor_group_widget = None @@ -484,47 +504,17 @@ class InstanceListView(AbstractInstanceView): self._active_toggle_enabled = True - def _on_expand(self, index): - self._update_widget_expand_state(index, True) - - def _on_collapse(self, index): - self._update_widget_expand_state(index, False) - - def _update_widget_expand_state(self, index, expanded): - group_name = index.data(GROUP_ROLE) - if group_name == CONVERTOR_ITEM_GROUP: - group_widget = self._convertor_group_widget - else: - group_widget = self._group_widgets.get(group_name) - - if group_widget: - group_widget.set_expanded(expanded) - - def _on_toggle_request(self, toggle): + def _on_toggle_request(self, toggle: int) -> None: if not self._active_toggle_enabled: return - selected_instance_ids = self._instance_view.get_selected_instance_ids() if toggle == -1: active = None elif toggle == 1: active = True else: active = False - - group_names = set() - for instance_id in selected_instance_ids: - widget = self._widgets_by_id.get(instance_id) - if widget is None: - continue - - widget.set_active(active) - group_name = self._group_by_instance_id.get(instance_id) - if group_name is not None: - group_names.add(group_name) - - for group_name in group_names: - self._update_group_checkstate(group_name) + self._toggle_active_state(active) def _update_group_checkstate(self, group_name): """Update checkstate of one group.""" @@ -533,8 +523,10 @@ class InstanceListView(AbstractInstanceView): return activity = None - for instance_id, _group_name in self._group_by_instance_id.items(): - if _group_name != group_name: + for ( + instance_id, instance_group_name + ) in self._group_by_instance_id.items(): + if instance_group_name != group_name: continue instance_widget = self._widgets_by_id.get(instance_id) @@ -571,14 +563,29 @@ class InstanceListView(AbstractInstanceView): self._update_convertor_items_group() context_info_by_id = self._controller.get_instances_context_info() - + instance_items = self._controller.get_instance_items() # Prepare instances by their groups instances_by_group_name = collections.defaultdict(list) + instances_by_parent_id = collections.defaultdict(list) + instance_ids_by_parent_id = collections.defaultdict(set) group_names = set() - for instance in self._controller.get_instance_items(): + instance_ids = set() + for instance in instance_items: + instance_ids.add(instance.id) + instance_ids_by_parent_id[instance.parent_instance_id].add( + instance.id + ) + if instance.parent_instance_id: + instances_by_parent_id[instance.parent_instance_id].append( + instance + ) + if self._parent_grouping: + continue + group_label = instance.group_label group_names.add(group_label) instances_by_group_name[group_label].append(instance) + self._group_by_instance_id[instance.id] = group_label # Create new groups based on prepared `instances_by_group_name` if self._make_sure_groups_exists(group_names): @@ -586,95 +593,88 @@ class InstanceListView(AbstractInstanceView): # Remove groups that are not available anymore self._remove_groups_except(group_names) + self._remove_instances_except(instance_items) - # Store which groups should be expanded at the end - expand_groups = set() + expand_to_items = [] + widgets_by_id = {} + group_items = [ + ( + self._group_widgets[group_name], + instances_by_group_name[group_name], + group_item, + ) + for group_name, group_item in self._group_items.items() + ] + + # Handle orphaned instances + missing_parent_ids = set(instances_by_parent_id) - instance_ids + if not missing_parent_ids: + # Make sure the item is not in view if there are no orhpaned items + self._remove_missing_parent_item() + else: + # Add orphaned group item and append them to 'group_items' + orphans_item = self._add_missing_parent_item() + for instance_id in missing_parent_ids: + group_items.append(( + None, + instances_by_parent_id[instance_id], + orphans_item, + )) + + items_with_instance = {} # Process changes in each group item # - create new instance, update existing and remove not existing - for group_name, group_item in self._group_items.items(): - # Instance items to remove - # - will contain all existing instance ids at the start - # - instance ids may be removed when existing instances are checked - to_remove = set() - # Mapping of existing instances under group item - existing_mapping = {} + for group_widget, group_instances, group_item in group_items: + # Group widget is not set if is orphaned + # - This might need to be changed in future if widget could + # be 'None' + is_orpaned_item = group_widget is None - # Get group index to be able to get children indexes - group_index = self._instance_model.index( - group_item.row(), group_item.column() - ) + # Collect all new instances by parent id + # - 'None' is used if parent is group item + new_items = collections.defaultdict(list) + # Tuples of model item and instance itself + for instance in group_instances: + _queue = collections.deque() + _queue.append((instance, group_item, None)) + while _queue: + instance, parent_item, parent_id = _queue.popleft() + instance_id = instance.id + # Remove group name from groups mapping + if parent_id is not None: + self._group_by_instance_id.pop(instance_id, None) - # Iterate over children indexes of group item - for idx in range(group_item.rowCount()): - index = self._instance_model.index(idx, 0, group_index) - instance_id = index.data(INSTANCE_ID_ROLE) - # Add all instance into `to_remove` set - to_remove.add(instance_id) - existing_mapping[instance_id] = idx + # Create new item and store it as new + item = self._items_by_id.get(instance_id) + if item is None: + item = QtGui.QStandardItem() + item.setData(instance_id, INSTANCE_ID_ROLE) + self._items_by_id[instance_id] = item + new_items[parent_id].append(item) - # Collect all new instances that are not existing under group - # New items - new_items = [] - # Tuples of new instance and instance itself - new_items_with_instance = [] - # Group activity (should be {-1;0;1} at the end) - # - 0 when all instances are disabled - # - 1 when all instances are enabled - # - -1 when it's mixed - activity = None - for instance in instances_by_group_name[group_name]: - instance_id = instance.id - # Handle group activity - if activity is None: - activity = int(instance.is_active) - elif activity == -1: - pass - elif activity != instance.is_active: - activity = -1 + elif item.parent() is not parent_item: + current_parent = item.parent() + if current_parent is not None: + current_parent.takeRow(item.row()) + new_items[parent_id].append(item) - context_info = context_info_by_id[instance_id] + self._parent_id_by_id[instance_id] = parent_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, context_info) - continue + items_with_instance[instance.id] = ( + item, + instance, + is_orpaned_item, + ) - # Create new item and store it as new - item = QtGui.QStandardItem() - item.setData(instance.product_name, SORT_VALUE_ROLE) - item.setData(instance.product_name, GROUP_ROLE) - item.setData(instance_id, INSTANCE_ID_ROLE) - new_items.append(item) - new_items_with_instance.append((item, instance)) + item.setData(instance.product_name, SORT_VALUE_ROLE) + item.setData(instance.product_name, GROUP_ROLE) - # Set checkstate of group checkbox - state = QtCore.Qt.PartiallyChecked - if activity == 0: - state = QtCore.Qt.Unchecked - elif activity == 1: - state = QtCore.Qt.Checked + if not self._parent_grouping: + continue - widget = self._group_widgets[group_name] - widget.set_checkstate(state) - - # Remove items that were not found - idx_to_remove = [] - for instance_id in to_remove: - idx_to_remove.append(existing_mapping[instance_id]) - - # Remove them in reverse order to prevent row index changes - for idx in reversed(sorted(idx_to_remove)): - group_item.removeRows(idx, 1) - - # Cleanup instance related widgets - for instance_id in to_remove: - self._group_by_instance_id.pop(instance_id) - widget = self._widgets_by_id.pop(instance_id) - widget.deleteLater() + children = instances_by_parent_id.pop(instance_id, []) + for child in children: + _queue.append((child, item, instance_id)) # Process new instance items and add them to model and create # their widgets @@ -683,41 +683,106 @@ class InstanceListView(AbstractInstanceView): sort_at_the_end = True # Add items under group item - group_item.appendRows(new_items) + for parent_id, items in new_items.items(): + if parent_id is None or not self._parent_grouping: + parent_item = group_item + else: + parent_item = self._items_by_id[parent_id] - for item, instance in new_items_with_instance: - 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(), - item.column(), - group_index - ) - proxy_index = self._proxy_model.mapFromSource(item_index) - widget = InstanceListItemWidget( - instance, context_info, self._instance_view - ) - widget.set_active_toggle_enabled( - self._active_toggle_enabled - ) - widget.active_changed.connect(self._on_active_changed) - widget.double_clicked.connect(self.double_clicked) - self._instance_view.setIndexWidget(proxy_index, widget) - self._widgets_by_id[instance.id] = widget + parent_item.appendRows(items) - # Trigger sort at the end of refresh - if sort_at_the_end: - self._proxy_model.sort(0) + ids_order = [] + ids_queue = collections.deque() + ids_queue.extend(instance_ids_by_parent_id[None]) + while ids_queue: + parent_id = ids_queue.popleft() + ids_order.append(parent_id) + ids_queue.extend(instance_ids_by_parent_id[parent_id]) + ids_order.extend(set(items_with_instance) - set(ids_order)) - # Expand groups marked for expanding - for group_name in expand_groups: - group_item = self._group_items[group_name] - proxy_index = self._proxy_model.mapFromSource(group_item.index()) + for instance_id in ids_order: + item, instance, is_orpaned_item = items_with_instance[instance_id] + context_info = context_info_by_id[instance.id] + # TODO expand all parents + if not context_info.is_valid: + expand_to_items.append(item) + parent_active = True + if is_orpaned_item: + parent_active = False + + parent_id = instance.parent_instance_id + if parent_id: + parent_widget = widgets_by_id.get(parent_id) + parent_active = False + if parent_widget is not None: + parent_active = parent_widget.is_active() + item_index = self._instance_model.indexFromItem(item) + proxy_index = self._proxy_model.mapFromSource(item_index) + widget = self._instance_view.indexWidget(proxy_index) + if isinstance(widget, InstanceListItemWidget): + widget.update_instance( + instance, + context_info, + parent_active, + ) + else: + widget = InstanceListItemWidget( + instance, + context_info, + parent_active, + self._instance_view + ) + widget.active_changed.connect(self._on_active_changed) + widget.double_clicked.connect(self.double_clicked) + self._instance_view.setIndexWidget(proxy_index, widget) + widget.set_active_toggle_enabled( + self._active_toggle_enabled + ) + + widgets_by_id[instance.id] = widget + self._widgets_by_id.pop(instance.id, None) + + for widget in self._widgets_by_id.values(): + widget.setVisible(False) + widget.deleteLater() + + self._widgets_by_id = widgets_by_id + self._instance_ids_by_parent_id = instance_ids_by_parent_id + + # Set checkstate of group checkbox + for group_name in self._group_items: + self._update_group_checkstate(group_name) + + # Expand items marked for expanding + items_to_expand = [] + _marked_ids = set() + for item in expand_to_items: + parent = item.parent() + _items = [] + while True: + # Parent is not set or is group (groups are separate) + if parent is None or parent.data(IS_GROUP_ROLE): + break + instance_id = parent.data(INSTANCE_ID_ROLE) + # Parent was already marked for expanding + if instance_id in _marked_ids: + break + _marked_ids.add(instance_id) + _items.append(parent) + parent = parent.parent() + + items_to_expand.extend(reversed(_items)) + + for item in items_to_expand: + proxy_index = self._proxy_model.mapFromSource(item.index()) self._instance_view.expand(proxy_index) - def _make_sure_context_item_exists(self): + # Trigger sort at the end of refresh + if sort_at_the_end: + self._proxy_model.sort(0) + + def _make_sure_context_item_exists(self) -> bool: if self._context_item is not None: return False @@ -740,7 +805,7 @@ class InstanceListView(AbstractInstanceView): self._context_item = context_item return True - def _update_convertor_items_group(self): + def _update_convertor_items_group(self) -> bool: created_new_items = False convertor_items_by_id = self._controller.get_convertor_items() group_item = self._convertor_group_item @@ -749,7 +814,7 @@ class InstanceListView(AbstractInstanceView): root_item = self._instance_model.invisibleRootItem() if not convertor_items_by_id: - root_item.removeRow(group_item.row()) + root_item.takeRow(group_item.row()) self._convertor_group_widget.deleteLater() self._convertor_group_widget = None self._convertor_items_by_id = {} @@ -773,9 +838,7 @@ class InstanceListView(AbstractInstanceView): CONVERTOR_ITEM_GROUP, self._instance_view ) widget.toggle_checkbox.setVisible(False) - widget.expand_changed.connect( - self._on_convertor_group_expand_request - ) + self._instance_view.setIndexWidget(proxy_index, widget) self._convertor_group_item = group_item @@ -786,7 +849,7 @@ class InstanceListView(AbstractInstanceView): child_identifier = child_item.data(CONVERTER_IDENTIFIER_ROLE) if child_identifier not in convertor_items_by_id: self._convertor_items_by_id.pop(child_identifier, None) - group_item.removeRows(row, 1) + group_item.takeRow(row) new_items = [] for identifier, convertor_item in convertor_items_by_id.items(): @@ -808,7 +871,7 @@ class InstanceListView(AbstractInstanceView): return created_new_items - def _make_sure_groups_exists(self, group_names): + def _make_sure_groups_exists(self, group_names: set[str]) -> bool: new_group_items = [] for group_name in group_names: if group_name in self._group_items: @@ -841,14 +904,16 @@ class InstanceListView(AbstractInstanceView): widget.set_active_toggle_enabled( self._active_toggle_enabled ) - widget.expand_changed.connect(self._on_group_expand_request) widget.toggle_requested.connect(self._on_group_toggle_request) + widget.expand_change_requested.connect( + self._on_expand_toggle_request + ) self._group_widgets[group_name] = widget self._instance_view.setIndexWidget(proxy_index, widget) return True - def _remove_groups_except(self, group_names): + def _remove_groups_except(self, group_names: set[str]) -> None: # Remove groups that are not available anymore root_item = self._instance_model.invisibleRootItem() for group_name in tuple(self._group_items.keys()): @@ -856,42 +921,197 @@ class InstanceListView(AbstractInstanceView): continue group_item = self._group_items.pop(group_name) - root_item.removeRow(group_item.row()) + root_item.takeRow(group_item.row()) widget = self._group_widgets.pop(group_name) + widget.setVisible(False) widget.deleteLater() + def _remove_instances_except(self, instance_items: list[InstanceItem]): + parent_id_by_id = { + item.id: item.parent_instance_id + for item in instance_items + } + instance_ids = set(parent_id_by_id) + all_removed_ids = set(self._items_by_id) - instance_ids + queue = collections.deque() + for group_item in self._group_items.values(): + queue.append((group_item, None)) + while queue: + parent_item, parent_id = queue.popleft() + children = [ + parent_item.child(row) + for row in range(parent_item.rowCount()) + ] + for child in children: + instance_id = child.data(INSTANCE_ID_ROLE) + if instance_id not in parent_id_by_id: + parent_item.takeRow(child.row()) + elif parent_id != parent_id_by_id[instance_id]: + parent_item.takeRow(child.row()) + + queue.append((child, instance_id)) + + for instance_id in all_removed_ids: + self._items_by_id.pop(instance_id) + self._parent_id_by_id.pop(instance_id) + self._group_by_instance_id.pop(instance_id, None) + widget = self._widgets_by_id.pop(instance_id, None) + if widget is not None: + widget.setVisible(False) + widget.deleteLater() + + def _add_missing_parent_item(self) -> QtGui.QStandardItem: + label = "! Orphaned instances !" + if self._missing_parent_item is None: + item = QtGui.QStandardItem() + item.setData(label, GROUP_ROLE) + item.setData("_", SORT_VALUE_ROLE) + item.setData(True, IS_GROUP_ROLE) + item.setFlags(QtCore.Qt.ItemIsEnabled) + self._missing_parent_item = item + + if self._missing_parent_item.row() < 0: + root_item = self._instance_model.invisibleRootItem() + root_item.appendRow(self._missing_parent_item) + index = self._missing_parent_item.index() + proxy_index = self._proxy_model.mapFromSource(index) + widget = InstanceListGroupWidget(label, self._instance_view) + widget.toggle_checkbox.setVisible(False) + self._instance_view.setIndexWidget(proxy_index, widget) + return self._missing_parent_item + + def _remove_missing_parent_item(self) -> None: + if self._missing_parent_item is None: + return + + row = self._missing_parent_item.row() + if row < 0: + return + + parent = self._missing_parent_item.parent() + if parent is None: + parent = self._instance_model.invisibleRootItem() + index = self._missing_parent_item.index() + proxy_index = self._proxy_model.mapFromSource(index) + widget = self._instance_view.indexWidget(proxy_index) + if widget is not None: + widget.setVisible(False) + widget.deleteLater() + parent.takeRow(self._missing_parent_item.row()) + _queue = collections.deque() + _queue.append(self._missing_parent_item) + while _queue: + item = _queue.popleft() + for _ in range(item.rowCount()): + child = item.child(0) + _queue.append(child) + item.takeRow(0) + + self._missing_parent_item = None + def refresh_instance_states(self, instance_ids=None): """Trigger update of all instances.""" if instance_ids is not None: instance_ids = set(instance_ids) - context_info_by_id = self._controller.get_instances_context_info() + + context_info_by_id = self._controller.get_instances_context_info( + instance_ids + ) instance_items_by_id = self._controller.get_instance_items_by_id( instance_ids ) - for instance_id, widget in self._widgets_by_id.items(): - if instance_ids is not None and instance_id not in instance_ids: - continue - widget.update_instance( - instance_items_by_id[instance_id], - context_info_by_id[instance_id], - ) + instance_ids = set(instance_items_by_id) + available_ids = set(instance_ids) + + _queue = collections.deque() + _queue.append((set(self._instance_ids_by_parent_id[None]), True)) + + discarted_ids = set() + while _queue: + if not instance_ids: + break + + children_ids, parent_active = _queue.popleft() + for instance_id in children_ids: + widget = self._widgets_by_id[instance_id] + # Parent active state changed -> traverse children too + add_children = False + if instance_id in instance_ids: + add_children = ( + parent_active is not widget.is_parent_active() + ) + if instance_id in available_ids: + available_ids.discard(instance_id) + widget.update_instance( + instance_items_by_id[instance_id], + context_info_by_id[instance_id], + parent_active, + ) + + instance_ids.discard(instance_id) + discarted_ids.add(instance_id) + + if parent_active is not widget.is_parent_active(): + widget.set_parent_is_active(parent_active) + add_children = True + + if not add_children: + if not instance_ids: + break + continue + + _children = set(self._instance_ids_by_parent_id[instance_id]) + if _children: + instance_ids |= _children + _queue.append((_children, widget.is_active())) + + if not instance_ids: + break + + def parent_grouping_enabled(self) -> bool: + return self._parent_grouping + + def set_parent_grouping(self, parent_grouping: bool) -> None: + self._parent_grouping = parent_grouping def _on_active_changed(self, changed_instance_id, new_value): - selected_instance_ids, _, _ = self.get_selected_items() + self._toggle_active_state(new_value, changed_instance_id) + + def _toggle_active_state( + self, + new_value: Optional[bool], + active_id: Optional[str] = None, + instance_ids: Optional[set[str]] = None, + ) -> None: + if instance_ids is None: + instance_ids, _, _ = self.get_selected_items() + if active_id and active_id not in instance_ids: + instance_ids = {active_id} active_by_id = {} - found = False - for instance_id in selected_instance_ids: - active_by_id[instance_id] = new_value - if not found and instance_id == changed_instance_id: - found = True + _queue = collections.deque() + _queue.append((set(self._instance_ids_by_parent_id[None]), True)) - if not found: - active_by_id = {changed_instance_id: new_value} + while _queue: + children_ids, parent_active = _queue.popleft() + for instance_id in children_ids: + widget = self._widgets_by_id[instance_id] + widget.set_parent_is_active(parent_active) + if instance_id in instance_ids: + value = new_value + if value is None: + value = not widget.is_active() + widget.set_active(value) + active_by_id[instance_id] = value + + children = set( + self._instance_ids_by_parent_id[instance_id] + ) + if children: + _queue.append((children, widget.is_active())) self._controller.set_instances_active_state(active_by_id) - self._change_active_instances(active_by_id, new_value) group_names = set() for instance_id in active_by_id: group_name = self._group_by_instance_id.get(instance_id) @@ -901,83 +1121,55 @@ class InstanceListView(AbstractInstanceView): for group_name in group_names: self._update_group_checkstate(group_name) - def _change_active_instances(self, instance_ids, new_value): - if not instance_ids: - return - - for instance_id in instance_ids: - widget = self._widgets_by_id.get(instance_id) - if widget: - widget.set_active(new_value) - def _on_selection_change(self, *_args): self.selection_changed.emit() - def _on_group_expand_request(self, group_name, expanded): + def _on_expand_toggle_request(self, group_name): group_item = self._group_items.get(group_name) if not group_item: return - - group_index = self._instance_model.index( - group_item.row(), group_item.column() - ) - proxy_index = self._proxy_model.mapFromSource(group_index) - self._instance_view.setExpanded(proxy_index, expanded) - - def _on_convertor_group_expand_request(self, _, expanded): - group_item = self._convertor_group_item - if not group_item: - return - group_index = self._instance_model.index( - group_item.row(), group_item.column() - ) - proxy_index = self._proxy_model.mapFromSource(group_index) - self._instance_view.setExpanded(proxy_index, expanded) + proxy_index = self._proxy_model.mapFromSource(group_item.index()) + new_state = not self._instance_view.isExpanded(proxy_index) + self._instance_view.setExpanded(proxy_index, new_state) def _on_group_toggle_request(self, group_name, state): state = checkstate_int_to_enum(state) if state == QtCore.Qt.PartiallyChecked: return - if state == QtCore.Qt.Checked: - active = True - else: - active = False - group_item = self._group_items.get(group_name) if not group_item: return - active_by_id = {} + active = state == QtCore.Qt.Checked + + instance_ids = set() for row in range(group_item.rowCount()): - item = group_item.child(row) - instance_id = item.data(INSTANCE_ID_ROLE) - if instance_id is not None: - active_by_id[instance_id] = active + child = group_item.child(row) + instance_id = child.data(INSTANCE_ID_ROLE) + instance_ids.add(instance_id) - self._controller.set_instances_active_state(active_by_id) - - self._change_active_instances(active_by_id, active) + self._toggle_active_state(active, instance_ids=instance_ids) proxy_index = self._proxy_model.mapFromSource(group_item.index()) if not self._instance_view.isExpanded(proxy_index): self._instance_view.expand(proxy_index) - def has_items(self): + def has_items(self) -> bool: if self._convertor_group_widget is not None: return True if self._group_items: return True return False - def get_selected_items(self): + def get_selected_items(self) -> tuple[list[str], bool, list[str]]: """Get selected instance ids and context selection. Returns: - tuple: Selected instance ids and boolean if context - is selected. - """ + tuple[list[str], bool, list[str]]: Selected instance ids, + boolean if context is selected and selected convertor ids. + """ instance_ids = [] convertor_identifiers = [] context_selected = False @@ -1101,7 +1293,7 @@ class InstanceListView(AbstractInstanceView): | QtCore.QItemSelectionModel.Rows ) - def set_active_toggle_enabled(self, enabled): + def set_active_toggle_enabled(self, enabled: bool) -> None: if self._active_toggle_enabled is enabled: return diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index c6c3b774f0..01799ac908 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import Generator + from qtpy import QtWidgets, QtCore from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend @@ -6,6 +10,7 @@ from .border_label_widget import BorderedLabelWidget from .card_view_widgets import InstanceCardView from .list_view_widgets import InstanceListView from .widgets import ( + AbstractInstanceView, CreateInstanceBtn, RemoveInstanceBtn, ChangeViewBtn, @@ -43,10 +48,16 @@ class OverviewWidget(QtWidgets.QFrame): product_view_cards = InstanceCardView(controller, product_views_widget) product_list_view = InstanceListView(controller, product_views_widget) + product_list_view.set_parent_grouping(False) + product_list_view_grouped = InstanceListView( + controller, product_views_widget + ) + product_list_view_grouped.set_parent_grouping(True) product_views_layout = QtWidgets.QStackedLayout() product_views_layout.addWidget(product_view_cards) product_views_layout.addWidget(product_list_view) + product_views_layout.addWidget(product_list_view_grouped) product_views_layout.setCurrentWidget(product_view_cards) # Buttons at the bottom of product view @@ -118,6 +129,12 @@ class OverviewWidget(QtWidgets.QFrame): product_list_view.double_clicked.connect( self.publish_tab_requested ) + product_list_view_grouped.selection_changed.connect( + self._on_product_change + ) + product_list_view_grouped.double_clicked.connect( + self.publish_tab_requested + ) product_view_cards.selection_changed.connect( self._on_product_change ) @@ -155,16 +172,26 @@ class OverviewWidget(QtWidgets.QFrame): "create.model.instances.context.changed", self._on_instance_context_change ) + controller.register_event_callback( + "create.model.instance.requirement.changed", + self._on_instance_requirement_changed + ) + controller.register_event_callback( + "create.model.instance.parent.changed", + self._on_instance_parent_changed + ) self._product_content_widget = product_content_widget self._product_content_layout = product_content_layout self._product_view_cards = product_view_cards self._product_list_view = product_list_view + self._product_list_view_grouped = product_list_view_grouped self._product_views_layout = product_views_layout self._create_btn = create_btn self._delete_btn = delete_btn + self._change_view_btn = change_view_btn self._product_attributes_widget = product_attributes_widget self._create_widget = create_widget @@ -242,7 +269,7 @@ class OverviewWidget(QtWidgets.QFrame): ) def has_items(self): - view = self._product_views_layout.currentWidget() + view = self._get_current_view() return view.has_items() def _on_create_clicked(self): @@ -352,16 +379,23 @@ class OverviewWidget(QtWidgets.QFrame): ) def _on_instance_context_change(self, event): - current_idx = self._product_views_layout.currentIndex() - for idx in range(self._product_views_layout.count()): - if idx == current_idx: - continue - widget = self._product_views_layout.widget(idx) - if widget.refreshed: - widget.set_refreshed(False) + self._refresh_instance_states(event["instance_ids"]) - current_widget = self._product_views_layout.widget(current_idx) - current_widget.refresh_instance_states(event["instance_ids"]) + def _on_instance_requirement_changed(self, event): + self._refresh_instance_states(event["instance_ids"]) + + def _on_instance_parent_changed(self, event): + self._refresh_instance_states(event["instance_ids"]) + + def _refresh_instance_states(self, instance_ids): + current_view = self._get_current_view() + for view in self._iter_views(): + if view is current_view: + current_view = view + elif view.refreshed: + view.set_refreshed(False) + + current_view.refresh_instance_states(instance_ids) def _on_convert_requested(self): self.convert_requested.emit() @@ -375,7 +409,7 @@ class OverviewWidget(QtWidgets.QFrame): convertor plugins. """ - view = self._product_views_layout.currentWidget() + view = self._get_current_view() return view.get_selected_items() def get_selected_legacy_convertors(self): @@ -390,12 +424,12 @@ class OverviewWidget(QtWidgets.QFrame): return convertor_identifiers def _change_view_type(self): + old_view = self._get_current_view() + idx = self._product_views_layout.currentIndex() new_idx = (idx + 1) % self._product_views_layout.count() - old_view = self._product_views_layout.currentWidget() - new_view = self._product_views_layout.widget(new_idx) - + new_view = self._get_view_by_idx(new_idx) if not new_view.refreshed: new_view.refresh() new_view.set_refreshed(True) @@ -408,22 +442,52 @@ class OverviewWidget(QtWidgets.QFrame): new_view.set_selected_items( instance_ids, context_selected, convertor_identifiers ) + view_type = "list" + if new_view is self._product_list_view_grouped: + view_type = "card" + elif new_view is self._product_list_view: + view_type = "list-parent-grouping" + self._change_view_btn.set_view_type(view_type) self._product_views_layout.setCurrentIndex(new_idx) self._on_product_change() + def _iter_views(self) -> Generator[AbstractInstanceView, None, None]: + for idx in range(self._product_views_layout.count()): + widget = self._product_views_layout.widget(idx) + if not isinstance(widget, AbstractInstanceView): + raise TypeError( + "Current widget is not instance of 'AbstractInstanceView'" + ) + yield widget + + def _get_current_view(self) -> AbstractInstanceView: + widget = self._product_views_layout.currentWidget() + if isinstance(widget, AbstractInstanceView): + return widget + raise TypeError( + "Current widget is not instance of 'AbstractInstanceView'" + ) + + def _get_view_by_idx(self, idx: int) -> AbstractInstanceView: + widget = self._product_views_layout.widget(idx) + if isinstance(widget, AbstractInstanceView): + return widget + raise TypeError( + "Current widget is not instance of 'AbstractInstanceView'" + ) + def _refresh_instances(self): if self._refreshing_instances: return self._refreshing_instances = True - for idx in range(self._product_views_layout.count()): - widget = self._product_views_layout.widget(idx) - widget.set_refreshed(False) + for view in self._iter_views(): + view.set_refreshed(False) - view = self._product_views_layout.currentWidget() + view = self._get_current_view() view.refresh() view.set_refreshed(True) @@ -434,25 +498,22 @@ class OverviewWidget(QtWidgets.QFrame): # Give a change to process Resize Request QtWidgets.QApplication.processEvents() - # Trigger update geometry of - widget = self._product_views_layout.currentWidget() - widget.updateGeometry() + # Trigger update geometry + view.updateGeometry() def _on_publish_start(self): """Publish started.""" self._create_btn.setEnabled(False) self._product_attributes_wrap.setEnabled(False) - for idx in range(self._product_views_layout.count()): - widget = self._product_views_layout.widget(idx) - widget.set_active_toggle_enabled(False) + for view in self._iter_views(): + view.set_active_toggle_enabled(False) def _on_controller_reset_start(self): """Controller reset started.""" - for idx in range(self._product_views_layout.count()): - widget = self._product_views_layout.widget(idx) - widget.set_active_toggle_enabled(True) + for view in self._iter_views(): + view.set_active_toggle_enabled(True) def _on_publish_reset(self): """Context in controller has been reseted.""" @@ -467,7 +528,19 @@ class OverviewWidget(QtWidgets.QFrame): self._refresh_instances() def _on_instances_added(self): + view = self._get_current_view() + is_card_view = False + count = 0 + if isinstance(view, InstanceCardView): + is_card_view = True + count = view.get_current_instance_count() + self._refresh_instances() + if is_card_view and count < 10: + new_count = view.get_current_instance_count() + if new_count > count and new_count >= 10: + self._change_view_type() + def _on_instances_removed(self): self._refresh_instances() diff --git a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py index 0706299f32..e9749c5b07 100644 --- a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py +++ b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py @@ -348,8 +348,6 @@ class ScreenMarquee(QtCore.QObject): # 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 diff --git a/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py b/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py index 261dcfb43d..f767fdf325 100644 --- a/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py +++ b/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py @@ -135,8 +135,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing pix_painter.setRenderHints(render_hints) pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) @@ -171,8 +169,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing pix_painter.setRenderHints(render_hints) tiled_rect = QtCore.QRectF( @@ -265,8 +261,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing final_painter.setRenderHints(render_hints) diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index a9d34c4c66..793b0f501b 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -10,6 +10,7 @@ from ayon_core.tools.flickcharm import FlickCharm from ayon_core.tools.utils import ( IconButton, PixmapLabel, + get_qt_icon, ) from ayon_core.tools.publisher.constants import ResetKeySequence @@ -287,12 +288,32 @@ class RemoveInstanceBtn(PublishIconBtn): self.setToolTip("Remove selected instances") -class ChangeViewBtn(PublishIconBtn): - """Create toggle view button.""" +class ChangeViewBtn(IconButton): + """Toggle views button.""" def __init__(self, parent=None): - icon_path = get_icon_path("change_view") - super().__init__(icon_path, parent) - self.setToolTip("Swap between views") + super().__init__(parent) + self.set_view_type("list") + + def set_view_type(self, view_type): + if view_type == "list": + # icon_name = "data_table" + icon_name = "dehaze" + tooltip = "Change to list view" + elif view_type == "card": + icon_name = "view_agenda" + tooltip = "Change to card view" + else: + icon_name = "segment" + tooltip = "Change to parent grouping view" + + # "format_align_right" + # "segment" + icon = get_qt_icon({ + "type": "material-symbols", + "name": icon_name, + }) + self.setIcon(icon) + self.setToolTip(tooltip) class AbstractInstanceView(QtWidgets.QWidget): @@ -370,6 +391,20 @@ class AbstractInstanceView(QtWidgets.QWidget): "{} Method 'set_active_toggle_enabled' is not implemented." ).format(self.__class__.__name__)) + def refresh_instance_states(self, instance_ids=None): + """Refresh instance states. + + Args: + instance_ids: Optional[Iterable[str]]: Instance ids to refresh. + If not passed then all instances are refreshed. + + """ + + raise NotImplementedError( + f"{self.__class__.__name__} Method 'refresh_instance_states'" + " is not implemented." + ) + class ClickableLineEdit(QtWidgets.QLineEdit): """QLineEdit capturing left mouse click. diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index fb080d158b..b4e0d56dfd 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -1,4 +1,5 @@ import threading +from typing import Dict import ayon_api @@ -13,10 +14,11 @@ from .models import ( UserPublishValuesModel, IntegrateModel, ) +from .models.integrate import ProjectPushItemProcess class PushToContextController: - def __init__(self, project_name=None, version_id=None): + def __init__(self, project_name=None, version_ids=None): self._event_system = self._create_event_system() self._projects_model = ProjectsModel(self) @@ -27,18 +29,20 @@ class PushToContextController: self._user_values = UserPublishValuesModel(self) self._src_project_name = None - self._src_version_id = None + self._src_version_ids = [] self._src_folder_entity = None self._src_folder_task_entities = {} - self._src_product_entity = None - self._src_version_entity = None + self._src_version_entities = [] + self._src_product_entities = {} self._src_label = None self._submission_enabled = False self._process_thread = None self._process_item_id = None - self.set_source(project_name, version_id) + self._use_original_name = False + + self.set_source(project_name, version_ids) # Events system def emit_event(self, topic, data=None, source=None): @@ -51,38 +55,47 @@ class PushToContextController: def register_event_callback(self, topic, callback): self._event_system.add_callback(topic, callback) - def set_source(self, project_name, version_id): + def set_source(self, project_name, version_ids): """Set source project and version. + There is currently assumption that tool is working on products of same + folder. + Args: project_name (Union[str, None]): Source project name. - version_id (Union[str, None]): Source version id. + version_ids (Optional[list[str]]): Version ids. """ - + if not project_name or not version_ids: + return if ( project_name == self._src_project_name - and version_id == self._src_version_id + and version_ids == self._src_version_ids ): return self._src_project_name = project_name - self._src_version_id = version_id + self._src_version_ids = version_ids self._src_label = None folder_entity = None task_entities = {} - product_entity = None - version_entity = None - if project_name and version_id: - version_entity = ayon_api.get_version_by_id( - project_name, version_id + product_entities = [] + version_entities = [] + if project_name and self._src_version_ids: + version_entities = list(ayon_api.get_versions( + project_name, version_ids=self._src_version_ids)) + + if version_entities: + product_ids = [ + version_entity["productId"] + for version_entity in version_entities + ] + product_entities = list(ayon_api.get_products( + project_name, product_ids=product_ids) ) - if version_entity: - product_entity = ayon_api.get_product_by_id( - project_name, version_entity["productId"] - ) - - if product_entity: + if product_entities: + # all products for same folder + product_entity = product_entities[0] folder_entity = ayon_api.get_folder_by_id( project_name, product_entity["folderId"] ) @@ -97,15 +110,18 @@ class PushToContextController: self._src_folder_entity = folder_entity self._src_folder_task_entities = task_entities - self._src_product_entity = product_entity - self._src_version_entity = version_entity + self._src_version_entities = version_entities + self._src_product_entities = { + product["id"]: product + for product in product_entities + } if folder_entity: self._user_values.set_new_folder_name(folder_entity["name"]) variant = self._get_src_variant() if variant: self._user_values.set_variant(variant) - comment = version_entity["attrib"].get("comment") + comment = version_entities[0]["attrib"].get("comment") if comment: self._user_values.set_comment(comment) @@ -113,7 +129,7 @@ class PushToContextController: "source.changed", { "project_name": project_name, - "version_id": version_id + "version_ids": self._src_version_ids } ) @@ -142,6 +158,14 @@ class PushToContextController: def get_user_values(self): return self._user_values.get_data() + def original_names_required(self): + """Checks if original product names must be used. + + Currently simple check if multiple versions, but if multiple products + with different product_type were used, it wouldn't be necessary. + """ + return len(self._src_version_entities) > 1 + def set_user_value_folder_name(self, folder_name): self._user_values.set_new_folder_name(folder_name) self._invalidate() @@ -165,8 +189,9 @@ class PushToContextController: def set_selected_task(self, task_id, task_name): self._selection_model.set_selected_task(task_id, task_name) - def get_process_item_status(self, item_id): - return self._integrate_model.get_item_status(item_id) + def get_process_items(self) -> Dict[str, ProjectPushItemProcess]: + """Returns dict of all ProjectPushItemProcess items """ + return self._integrate_model.get_items() # Processing methods def submit(self, wait=True): @@ -176,29 +201,33 @@ class PushToContextController: if self._process_thread is not None: return - item_id = self._integrate_model.create_process_item( - self._src_project_name, - self._src_version_id, - self._selection_model.get_selected_project_name(), - self._selection_model.get_selected_folder_id(), - self._selection_model.get_selected_task_name(), - self._user_values.variant, - comment=self._user_values.comment, - new_folder_name=self._user_values.new_folder_name, - dst_version=1 - ) + item_ids = [] + for src_version_entity in self._src_version_entities: + item_id = self._integrate_model.create_process_item( + self._src_project_name, + src_version_entity["id"], + self._selection_model.get_selected_project_name(), + self._selection_model.get_selected_folder_id(), + self._selection_model.get_selected_task_name(), + self._user_values.variant, + comment=self._user_values.comment, + new_folder_name=self._user_values.new_folder_name, + dst_version=1, + use_original_name=self._use_original_name, + ) + item_ids.append(item_id) - self._process_item_id = item_id + self._process_item_ids = item_ids self._emit_event("submit.started") if wait: self._submit_callback() - self._process_item_id = None + self._process_item_ids = [] return item_id thread = threading.Thread(target=self._submit_callback) self._process_thread = thread thread.start() - return item_id + return item_ids def wait_for_process_thread(self): if self._process_thread is None: @@ -207,7 +236,7 @@ class PushToContextController: self._process_thread = None def _prepare_source_label(self): - if not self._src_project_name or not self._src_version_id: + if not self._src_project_name or not self._src_version_ids: return "Source is not defined" folder_entity = self._src_folder_entity @@ -215,14 +244,21 @@ class PushToContextController: return "Source is invalid" folder_path = folder_entity["path"] - product_entity = self._src_product_entity - version_entity = self._src_version_entity - return "Source: {}{}/{}/v{:0>3}".format( - self._src_project_name, - folder_path, - product_entity["name"], - version_entity["version"] - ) + src_labels = [] + for version_entity in self._src_version_entities: + product_entity = self._src_product_entities.get( + version_entity["productId"] + ) + src_labels.append( + "Source: {}{}/{}/v{:0>3}".format( + self._src_project_name, + folder_path, + product_entity["name"], + version_entity["version"], + ) + ) + + return "\n".join(src_labels) def _get_task_info_from_repre_entities( self, task_entities, repre_entities @@ -256,7 +292,8 @@ class PushToContextController: def _get_src_variant(self): project_name = self._src_project_name - version_entity = self._src_version_entity + # parse variant only from first version + version_entity = self._src_version_entities[0] task_entities = self._src_folder_task_entities repre_entities = ayon_api.get_representations( project_name, version_ids={version_entity["id"]} @@ -264,9 +301,12 @@ class PushToContextController: task_name, task_type = self._get_task_info_from_repre_entities( task_entities, repre_entities ) + product_entity = self._src_product_entities.get( + version_entity["productId"] + ) project_settings = get_project_settings(project_name) - product_type = self._src_product_entity["productType"] + product_type = product_entity["productType"] template = get_product_name_template( self._src_project_name, product_type, @@ -300,7 +340,7 @@ class PushToContextController: print("Failed format", exc) return "" - product_name = self._src_product_entity["name"] + product_name = product_entity["name"] if ( (product_s and not product_name.startswith(product_s)) or (product_e and not product_name.endswith(product_e)) @@ -314,9 +354,6 @@ class PushToContextController: return product_name def _check_submit_validations(self): - if not self._user_values.is_valid: - return False - if not self._selection_model.get_selected_project_name(): return False @@ -325,6 +362,13 @@ class PushToContextController: and not self._selection_model.get_selected_folder_id() ): return False + + if self._use_original_name: + return True + + if not self._user_values.is_valid: + return False + return True def _invalidate(self): @@ -338,13 +382,14 @@ class PushToContextController: ) def _submit_callback(self): - process_item_id = self._process_item_id - if process_item_id is None: - return - self._integrate_model.integrate_item(process_item_id) + process_item_ids = self._process_item_ids + for process_item_id in process_item_ids: + self._integrate_model.integrate_item(process_item_id) + self._emit_event("submit.finished", {}) - if process_item_id == self._process_item_id: - self._process_item_id = None + + if process_item_ids is self._process_item_ids: + self._process_item_ids = [] def _emit_event(self, topic, data=None): if data is None: diff --git a/client/ayon_core/tools/push_to_project/main.py b/client/ayon_core/tools/push_to_project/main.py index a6ff38c16f..d3c9d3a537 100644 --- a/client/ayon_core/tools/push_to_project/main.py +++ b/client/ayon_core/tools/push_to_project/main.py @@ -4,28 +4,28 @@ from ayon_core.tools.utils import get_ayon_qt_app from ayon_core.tools.push_to_project.ui import PushToContextSelectWindow -def main_show(project_name, version_id): +def main_show(project_name, version_ids): app = get_ayon_qt_app() window = PushToContextSelectWindow() window.show() - window.set_source(project_name, version_id) + window.set_source(project_name, version_ids) app.exec_() @click.command() @click.option("--project", help="Source project name") -@click.option("--version", help="Source version id") -def main(project, version): +@click.option("--versions", help="Source version ids") +def main(project, versions): """Run PushToProject tool to integrate version in different project. Args: project (str): Source project name. - version (str): Version id. + versions (str): comma separated versions for same context """ - main_show(project, version) + main_show(project, versions.split(",")) if __name__ == "__main__": diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 6bd4279219..ef49838152 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -5,6 +5,7 @@ import itertools import sys import traceback import uuid +from typing import Optional, Dict import ayon_api from ayon_api.utils import create_entity_id @@ -21,6 +22,7 @@ from ayon_core.lib import ( source_hash, ) from ayon_core.lib.file_transaction import FileTransaction +from ayon_core.pipeline.thumbnails import get_thumbnail_path from ayon_core.settings import get_project_settings from ayon_core.pipeline import Anatomy from ayon_core.pipeline.version_start import get_versioning_start @@ -88,6 +90,7 @@ class ProjectPushItem: new_folder_name, dst_version, item_id=None, + use_original_name=False ): if not item_id: item_id = uuid.uuid4().hex @@ -102,6 +105,7 @@ class ProjectPushItem: self.comment = comment or "" self.item_id = item_id self._repr_value = None + self.use_original_name = use_original_name @property def _repr(self): @@ -113,7 +117,8 @@ class ProjectPushItem: str(self.dst_folder_id), str(self.new_folder_name), str(self.dst_task_name), - str(self.dst_version) + str(self.dst_version), + self.use_original_name ]) return self._repr_value @@ -132,6 +137,7 @@ class ProjectPushItem: "comment": self.comment, "new_folder_name": self.new_folder_name, "item_id": self.item_id, + "use_original_name": self.use_original_name } @classmethod @@ -311,7 +317,7 @@ class ProjectPushRepreItem: if self._src_files is not None: return self._src_files, self._resource_files - repre_context = self._repre_entity["context"] + repre_context = self.repre_entity["context"] if "frame" in repre_context or "udim" in repre_context: src_files, resource_files = self._get_source_files_with_frames() else: @@ -328,7 +334,7 @@ class ProjectPushRepreItem: udim_placeholder = "__udim__" src_files = [] resource_files = [] - template = self._repre_entity["attrib"]["template"] + template = self.repre_entity["attrib"]["template"] # Remove padding from 'udim' and 'frame' formatting keys # - "{frame:0>4}" -> "{frame}" for key in ("udim", "frame"): @@ -336,7 +342,7 @@ class ProjectPushRepreItem: replacement = "{{{}}}".format(key) template = re.sub(sub_part, replacement, template) - repre_context = self._repre_entity["context"] + repre_context = self.repre_entity["context"] fill_repre_context = copy.deepcopy(repre_context) if "frame" in fill_repre_context: fill_repre_context["frame"] = frame_placeholder @@ -357,7 +363,7 @@ class ProjectPushRepreItem: .replace(udim_placeholder, "(?P[0-9]+)") ) src_basename_regex = re.compile("^{}$".format(src_basename)) - for file_info in self._repre_entity["files"]: + for file_info in self.repre_entity["files"]: filepath_template = self._clean_path(file_info["path"]) filepath = self._clean_path( filepath_template.format(root=self._roots) @@ -371,7 +377,6 @@ class ProjectPushRepreItem: resource_files.append(ResourceFile(filepath, relative_path)) continue - filepath = os.path.join(src_dirpath, basename) frame = None udim = None for item in src_basename_regex.finditer(basename): @@ -389,8 +394,8 @@ class ProjectPushRepreItem: def _get_source_files(self): src_files = [] resource_files = [] - template = self._repre_entity["attrib"]["template"] - repre_context = self._repre_entity["context"] + template = self.repre_entity["attrib"]["template"] + repre_context = self.repre_entity["context"] fill_repre_context = copy.deepcopy(repre_context) fill_roots = fill_repre_context["root"] for root_name in tuple(fill_roots.keys()): @@ -399,7 +404,7 @@ class ProjectPushRepreItem: fill_repre_context) repre_path = self._clean_path(repre_path) src_dirpath = os.path.dirname(repre_path) - for file_info in self._repre_entity["files"]: + for file_info in self.repre_entity["files"]: filepath_template = self._clean_path(file_info["path"]) filepath = self._clean_path( filepath_template.format(root=self._roots)) @@ -492,8 +497,11 @@ class ProjectPushItemProcess: except Exception as exc: _exc, _value, _tb = sys.exc_info() + product_name = self._src_product_entity["name"] self._status.set_failed( - "Unhandled error happened: {}".format(str(exc)), + "Unhandled error happened for `{}`: {}".format( + product_name, str(exc) + ), (_exc, _value, _tb) ) @@ -816,31 +824,34 @@ class ProjectPushItemProcess: self._template_name = template_name def _determine_product_name(self): - product_type = self._product_type - task_info = self._task_info - task_name = task_type = None - if task_info: - task_name = task_info["name"] - task_type = task_info["taskType"] + if self._item.use_original_name: + product_name = self._src_product_entity["name"] + else: + product_type = self._product_type + task_info = self._task_info + task_name = task_type = None + if task_info: + task_name = task_info["name"] + task_type = task_info["taskType"] - try: - product_name = get_product_name( - self._item.dst_project_name, - task_name, - task_type, - self.host_name, - product_type, - self._item.variant, - project_settings=self._project_settings - ) - except TaskNotSetError: - self._status.set_failed( - "Target product name template requires task name. To continue" - " you have to select target task or change settings" - " ayon+settings://core/tools/creator/product_name_profiles" - f"?project={self._item.dst_project_name}." - ) - raise PushToProjectError(self._status.fail_reason) + try: + product_name = get_product_name( + self._item.dst_project_name, + task_name, + task_type, + self.host_name, + product_type, + self._item.variant, + project_settings=self._project_settings + ) + except TaskNotSetError: + self._status.set_failed( + "Target product name template requires task name. To " + "continue you have to select target task or change settings " # noqa: E501 + " ayon+settings://core/tools/creator/product_name_profiles" # noqa: E501 + f"?project={self._item.dst_project_name}." + ) + raise PushToProjectError(self._status.fail_reason) self._log_info( f"Push will be integrating to product with name '{product_name}'" @@ -917,14 +928,19 @@ class ProjectPushItemProcess: task_name=self._task_info["name"], task_type=self._task_info["taskType"], product_type=product_type, - product_name=product_entity["name"] + product_name=product_entity["name"], ) existing_version_entity = ayon_api.get_version_by_name( project_name, version, product_id ) + thumbnail_id = self._copy_version_thumbnail() + # Update existing version if existing_version_entity: + updata_data = {"attrib": dst_attrib} + if thumbnail_id: + updata_data["thumbnailId"] = thumbnail_id self._operations.update_entity( project_name, "version", @@ -939,6 +955,7 @@ class ProjectPushItemProcess: version, product_id, attribs=dst_attrib, + thumbnail_id=thumbnail_id, ) self._operations.create_entity( project_name, "version", version_entity @@ -1005,10 +1022,18 @@ class ProjectPushItemProcess: self, anatomy, template_name, formatting_data, file_template ): processed_repre_items = [] + repre_context = None for repre_item in self._src_repre_items: repre_entity = repre_item.repre_entity repre_name = repre_entity["name"] repre_format_data = copy.deepcopy(formatting_data) + + if not repre_context: + repre_context = self._update_repre_context( + copy.deepcopy(repre_entity), + formatting_data + ) + repre_format_data["representation"] = repre_name for src_file in repre_item.src_files: ext = os.path.splitext(src_file.path)[-1] @@ -1024,7 +1049,6 @@ class ProjectPushItemProcess: "publish", template_name, "directory" ) folder_path = template_obj.format_strict(formatting_data) - repre_context = folder_path.used_values folder_path_rootless = folder_path.rootless repre_filepaths = [] published_path = None @@ -1047,7 +1071,6 @@ class ProjectPushItemProcess: ) if published_path is None or frame == repre_item.frame: published_path = dst_filepath - repre_context.update(filename.used_values) repre_filepaths.append((dst_filepath, dst_rootless_path)) self._file_transaction.add(src_file.path, dst_filepath) @@ -1134,7 +1157,7 @@ class ProjectPushItemProcess: self._item.dst_project_name, "representation", entity_id, - changes + changes, ) existing_repre_names = set(existing_repres_by_low_name.keys()) @@ -1147,6 +1170,45 @@ class ProjectPushItemProcess: {"active": False} ) + def _copy_version_thumbnail(self) -> Optional[str]: + thumbnail_id = self._src_version_entity["thumbnailId"] + if not thumbnail_id: + return None + path = get_thumbnail_path( + self._item.src_project_name, + "version", + self._src_version_entity["id"], + thumbnail_id + ) + if not path: + return None + return ayon_api.create_thumbnail( + self._item.dst_project_name, + path + ) + + def _update_repre_context(self, repre_entity, formatting_data): + """Replace old context value with new ones. + + Folder might change, project definitely changes etc. + """ + repre_context = repre_entity["context"] + for context_key, context_value in repre_context.items(): + if context_value and isinstance(context_value, dict): + for context_sub_key in context_value.keys(): + value_to_update = formatting_data.get(context_key, {}).get( + context_sub_key) + if value_to_update: + repre_context[context_key][ + context_sub_key] = value_to_update + else: + value_to_update = formatting_data.get(context_key) + if value_to_update: + repre_context[context_key] = value_to_update + if "task" not in formatting_data: + repre_context.pop("task") + return repre_context + class IntegrateModel: def __init__(self, controller): @@ -1170,6 +1232,7 @@ class IntegrateModel: comment, new_folder_name, dst_version, + use_original_name ): """Create new item for integration. @@ -1183,6 +1246,7 @@ class IntegrateModel: comment (Union[str, None]): Comment. new_folder_name (Union[str, None]): New folder name. dst_version (int): Destination version number. + use_original_name (bool): If original product names should be used Returns: str: Item id. The id can be used to trigger integration or get @@ -1198,7 +1262,8 @@ class IntegrateModel: variant, comment=comment, new_folder_name=new_folder_name, - dst_version=dst_version + dst_version=dst_version, + use_original_name=use_original_name ) process_item = ProjectPushItemProcess(self, item) self._process_items[item.item_id] = process_item @@ -1216,17 +1281,6 @@ class IntegrateModel: return item.integrate() - def get_item_status(self, item_id): - """Status of an item. - - Args: - item_id (str): Item id for which status should be returned. - - Returns: - dict[str, Any]: Status data. - """ - - item = self._process_items.get(item_id) - if item is not None: - return item.get_status_data() - return None + def get_items(self) -> Dict[str, ProjectPushItemProcess]: + """Returns dict of all ProjectPushItemProcess items """ + return self._process_items diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index a69c512fcd..f382ccce64 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -85,6 +85,13 @@ class PushToContextSelectWindow(QtWidgets.QWidget): header_widget = QtWidgets.QWidget(main_context_widget) + library_only_label = QtWidgets.QLabel( + "Show only libraries", + header_widget + ) + library_only_checkbox = NiceCheckbox( + True, parent=header_widget) + header_label = QtWidgets.QLabel( controller.get_source_label(), header_widget @@ -92,7 +99,9 @@ class PushToContextSelectWindow(QtWidgets.QWidget): header_layout = QtWidgets.QHBoxLayout(header_widget) header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.addWidget(header_label) + header_layout.addWidget(header_label, 1) + header_layout.addWidget(library_only_label, 0) + header_layout.addWidget(library_only_checkbox, 0) main_splitter = QtWidgets.QSplitter( QtCore.Qt.Horizontal, main_context_widget @@ -124,6 +133,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): inputs_widget = QtWidgets.QWidget(main_splitter) new_folder_checkbox = NiceCheckbox(True, parent=inputs_widget) + original_names_checkbox = NiceCheckbox(False, parent=inputs_widget) folder_name_input = PlaceholderLineEdit(inputs_widget) folder_name_input.setPlaceholderText("< Name of new folder >") @@ -142,6 +152,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget): inputs_layout.addRow("Create new folder", new_folder_checkbox) inputs_layout.addRow("New folder name", folder_name_input) inputs_layout.addRow("Variant", variant_input) + inputs_layout.addRow( + "Use original product names", original_names_checkbox) inputs_layout.addRow("Comment", comment_input) main_splitter.addWidget(context_widget) @@ -196,6 +208,10 @@ class PushToContextSelectWindow(QtWidgets.QWidget): show_detail_btn.setToolTip( "Show error detail dialog to copy full error." ) + original_names_checkbox.setToolTip( + "Required for multi copy, doesn't allow changes " + "variant values." + ) overlay_close_btn = QtWidgets.QPushButton( "Close", overlay_btns_widget @@ -240,6 +256,9 @@ class PushToContextSelectWindow(QtWidgets.QWidget): folder_name_input.textChanged.connect(self._on_new_folder_change) variant_input.textChanged.connect(self._on_variant_change) comment_input.textChanged.connect(self._on_comment_change) + library_only_checkbox.stateChanged.connect(self._on_library_only_change) + original_names_checkbox.stateChanged.connect( + self._on_original_names_change) publish_btn.clicked.connect(self._on_select_click) cancel_btn.clicked.connect(self._on_close_click) @@ -288,6 +307,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._new_folder_checkbox = new_folder_checkbox self._folder_name_input = folder_name_input self._comment_input = comment_input + self._use_original_names_checkbox = original_names_checkbox self._publish_btn = publish_btn @@ -316,7 +336,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._main_thread_timer = main_thread_timer self._main_thread_timer_can_stop = True self._last_submit_message = None - self._process_item_id = None self._variant_is_valid = None self._folder_is_valid = None @@ -327,17 +346,17 @@ class PushToContextSelectWindow(QtWidgets.QWidget): overlay_try_btn.setVisible(False) # Support of public api function of controller - def set_source(self, project_name, version_id): + def set_source(self, project_name, version_ids): """Set source project and version. Call the method on controller. Args: project_name (Union[str, None]): Name of project. - version_id (Union[str, None]): Version id. + version_ids (Union[str, None]): comma separated Version ids. """ - self._controller.set_source(project_name, version_id) + self._controller.set_source(project_name, version_ids) def showEvent(self, event): super(PushToContextSelectWindow, self).showEvent(event) @@ -352,10 +371,12 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._folder_name_input.setText(new_folder_name or "") self._variant_input.setText(variant or "") self._invalidate_variant(user_values["is_variant_valid"]) + self._invalidate_use_original_names( + self._use_original_names_checkbox.isChecked()) self._invalidate_new_folder_name( new_folder_name, user_values["is_new_folder_name_valid"] ) - + self._controller._invalidate() self._projects_combobox.refresh() def _on_first_show(self): @@ -394,6 +415,15 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._comment_input_text = text self._user_input_changed_timer.start() + def _on_library_only_change(self, state: int) -> None: + """Change toggle state, reset filter, recalculate dropdown""" + state = bool(state) + self._projects_combobox.set_standard_filter_enabled(state) + + def _on_original_names_change(self, state: int) -> None: + use_original_name = bool(state) + self._invalidate_use_original_names(use_original_name) + def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled folder_name = self._new_folder_name_input_text @@ -456,17 +486,27 @@ class PushToContextSelectWindow(QtWidgets.QWidget): state = "" if folder_name is not None: state = "valid" if is_valid else "invalid" - set_style_property( - self._folder_name_input, "state", state - ) + set_style_property(self._folder_name_input, "state", state) def _invalidate_variant(self, is_valid): - if self._variant_is_valid is is_valid: - return self._variant_is_valid = is_valid state = "valid" if is_valid else "invalid" set_style_property(self._variant_input, "state", state) + def _invalidate_use_original_names(self, use_original_names): + """Checks if original names must be used. + + Invalidates Variant if necessary + """ + if self._controller.original_names_required(): + use_original_names = True + + self._variant_input.setEnabled(not use_original_names) + self._invalidate_variant(not use_original_names) + + self._controller._use_original_name = use_original_names + self._use_original_names_checkbox.setChecked(use_original_names) + def _on_submission_change(self, event): self._publish_btn.setEnabled(event["enabled"]) @@ -495,31 +535,43 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._overlay_label.setText(self._last_submit_message) self._last_submit_message = None - process_status = self._controller.get_process_item_status( - self._process_item_id - ) - push_failed = process_status["failed"] - fail_traceback = process_status["full_traceback"] + failed_pushes = [] + fail_tracebacks = [] + for process_item in self._controller.get_process_items().values(): + process_status = process_item.get_status_data() + if process_status["failed"]: + failed_pushes.append(process_status) + # push_failed = process_status["failed"] + # fail_traceback = process_status["full_traceback"] if self._main_thread_timer_can_stop: self._main_thread_timer.stop() self._overlay_close_btn.setVisible(True) - if push_failed: + if failed_pushes: self._overlay_try_btn.setVisible(True) - if fail_traceback: + fail_tracebacks = [ + process_status["full_traceback"] + for process_status in failed_pushes + if process_status["full_traceback"] + ] + if fail_tracebacks: self._show_detail_btn.setVisible(True) - if push_failed: - reason = process_status["fail_reason"] - if fail_traceback: + if failed_pushes: + reasons = [ + process_status["fail_reason"] + for process_status in failed_pushes + ] + if fail_tracebacks: + reason = "\n".join(reasons) message = ( "Unhandled error happened." " Check error detail for more information." ) self._error_detail_dialog.set_detail( - reason, fail_traceback + reason, "\n".join(fail_tracebacks) ) else: - message = f"Push Failed:\n{reason}" + message = f"Push Failed:\n{reasons}" self._overlay_label.setText(message) set_style_property(self._overlay_close_btn, "state", "error") @@ -534,7 +586,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._main_thread_timer_can_stop = False self._main_thread_timer.start() self._main_layout.setCurrentWidget(self._overlay_widget) - self._overlay_label.setText("Submittion started") + self._overlay_label.setText("Submission started") def _on_controller_submit_end(self): self._main_thread_timer_can_stop = True diff --git a/client/ayon_core/tools/sceneinventory/control.py b/client/ayon_core/tools/sceneinventory/control.py index 60d9bc77a9..45f76a54ac 100644 --- a/client/ayon_core/tools/sceneinventory/control.py +++ b/client/ayon_core/tools/sceneinventory/control.py @@ -1,7 +1,7 @@ import ayon_api from ayon_core.lib.events import QueuedEventSystem -from ayon_core.host import HostBase +from ayon_core.host import ILoadHost from ayon_core.pipeline import ( registered_host, get_current_context, @@ -35,7 +35,7 @@ class SceneInventoryController: self._projects_model = ProjectsModel(self) self._event_system = self._create_event_system() - def get_host(self) -> HostBase: + def get_host(self) -> ILoadHost: return self._host def emit_event(self, topic, data=None, source=None): diff --git a/client/ayon_core/tools/sceneinventory/select_version_dialog.py b/client/ayon_core/tools/sceneinventory/select_version_dialog.py index 68284ad1fe..18a39e495c 100644 --- a/client/ayon_core/tools/sceneinventory/select_version_dialog.py +++ b/client/ayon_core/tools/sceneinventory/select_version_dialog.py @@ -127,6 +127,7 @@ class SelectVersionComboBox(QtWidgets.QComboBox): status_text_rect.setLeft(icon_rect.right() + 2) if status_text_rect.width() <= 0: + painter.restore() return if status_text_rect.width() < metrics.width(status_name): @@ -144,6 +145,7 @@ class SelectVersionComboBox(QtWidgets.QComboBox): QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, status_name ) + painter.restore() def set_current_index(self, index): model = self._combo_view.model() diff --git a/client/ayon_core/tools/subsetmanager/README.md b/client/ayon_core/tools/subsetmanager/README.md deleted file mode 100644 index 35b80ea114..0000000000 --- a/client/ayon_core/tools/subsetmanager/README.md +++ /dev/null @@ -1,19 +0,0 @@ -Subset manager --------------- - -Simple UI showing list of created subset that will be published via Pyblish. -Useful for applications (Photoshop, AfterEffects, TVPaint, Harmony) which are -storing metadata about instance hidden from user. - -This UI allows listing all created subset and removal of them if needed ( -in case use doesn't want to publish anymore, its using workfile as a starting -file for different task and instances should be completely different etc. -) - -Host is expected to implemented: -- `list_instances` - returning list of dictionaries (instances), must contain - unique uuid field - example: - ```[{"uuid":"15","active":true,"subset":"imageBG","family":"image","id":"ayon.create.instance","asset":"Town"}]``` -- `remove_instance(instance)` - removes instance from file's metadata - instance is a dictionary, with uuid field \ No newline at end of file diff --git a/client/ayon_core/tools/subsetmanager/__init__.py b/client/ayon_core/tools/subsetmanager/__init__.py deleted file mode 100644 index 6cfca7db66..0000000000 --- a/client/ayon_core/tools/subsetmanager/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .window import ( - show, - SubsetManagerWindow -) - -__all__ = ( - "show", - "SubsetManagerWindow" -) diff --git a/client/ayon_core/tools/subsetmanager/model.py b/client/ayon_core/tools/subsetmanager/model.py deleted file mode 100644 index 4964abd86d..0000000000 --- a/client/ayon_core/tools/subsetmanager/model.py +++ /dev/null @@ -1,56 +0,0 @@ -import uuid - -from qtpy import QtCore, QtGui - -from ayon_core.pipeline import registered_host - -ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 - - -class InstanceModel(QtGui.QStandardItemModel): - def __init__(self, *args, **kwargs): - super(InstanceModel, self).__init__(*args, **kwargs) - self._instances_by_item_id = {} - - def get_instance_by_id(self, item_id): - return self._instances_by_item_id.get(item_id) - - def refresh(self): - self.clear() - - self._instances_by_item_id = {} - - instances = None - host = registered_host() - list_instances = getattr(host, "list_instances", None) - if list_instances: - instances = list_instances() - - if not instances: - return - - items = [] - for instance_data in instances: - item_id = str(uuid.uuid4()) - product_name = ( - instance_data.get("productName") - or instance_data.get("subset") - ) - label = instance_data.get("label") or product_name - item = QtGui.QStandardItem(label) - item.setEnabled(True) - item.setEditable(False) - item.setData(item_id, ITEM_ID_ROLE) - items.append(item) - self._instances_by_item_id[item_id] = instance_data - - if items: - self.invisibleRootItem().appendRows(items) - - def headerData(self, section, orientation, role): - if role == QtCore.Qt.DisplayRole and section == 0: - return "Instance" - - return super(InstanceModel, self).headerData( - section, orientation, role - ) diff --git a/client/ayon_core/tools/subsetmanager/widgets.py b/client/ayon_core/tools/subsetmanager/widgets.py deleted file mode 100644 index 1067474c44..0000000000 --- a/client/ayon_core/tools/subsetmanager/widgets.py +++ /dev/null @@ -1,110 +0,0 @@ -import json -from qtpy import QtWidgets, QtCore - - -class InstanceDetail(QtWidgets.QWidget): - save_triggered = QtCore.Signal() - - def __init__(self, parent=None): - super(InstanceDetail, self).__init__(parent) - - details_widget = QtWidgets.QPlainTextEdit(self) - details_widget.setObjectName("SubsetManagerDetailsText") - - save_btn = QtWidgets.QPushButton("Save", self) - - self._block_changes = False - self._editable = False - self._item_id = None - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(details_widget, 1) - layout.addWidget(save_btn, 0, QtCore.Qt.AlignRight) - - save_btn.clicked.connect(self._on_save_clicked) - details_widget.textChanged.connect(self._on_text_change) - - self._details_widget = details_widget - self._save_btn = save_btn - - self.set_editable(False) - - def _on_save_clicked(self): - if self.is_valid(): - self.save_triggered.emit() - - def set_editable(self, enabled=True): - self._editable = enabled - self.update_state() - - def update_state(self, valid=None): - editable = self._editable - if not self._item_id: - editable = False - - self._save_btn.setVisible(editable) - self._details_widget.setReadOnly(not editable) - if valid is None: - valid = self.is_valid() - - self._save_btn.setEnabled(valid) - self._set_invalid_detail(valid) - - def _set_invalid_detail(self, valid): - state = "" - if not valid: - state = "invalid" - - current_state = self._details_widget.property("state") - if current_state != state: - self._details_widget.setProperty("state", state) - self._details_widget.style().polish(self._details_widget) - - def set_details(self, container, item_id): - self._item_id = item_id - - text = "Nothing selected" - if item_id: - try: - text = json.dumps(container, indent=4) - except Exception: - text = str(container) - - self._block_changes = True - self._details_widget.setPlainText(text) - self._block_changes = False - - self.update_state() - - def instance_data_from_text(self): - try: - jsoned = json.loads(self._details_widget.toPlainText()) - except Exception: - jsoned = None - return jsoned - - def item_id(self): - return self._item_id - - def is_valid(self): - if not self._item_id: - return True - - value = self._details_widget.toPlainText() - valid = False - try: - jsoned = json.loads(value) - if jsoned and isinstance(jsoned, dict): - valid = True - - except Exception: - pass - return valid - - def _on_text_change(self): - if self._block_changes or not self._item_id: - return - - valid = self.is_valid() - self.update_state(valid) diff --git a/client/ayon_core/tools/subsetmanager/window.py b/client/ayon_core/tools/subsetmanager/window.py deleted file mode 100644 index 164ffa95a7..0000000000 --- a/client/ayon_core/tools/subsetmanager/window.py +++ /dev/null @@ -1,218 +0,0 @@ -import os -import sys - -from qtpy import QtWidgets, QtCore -import qtawesome - -from ayon_core import style -from ayon_core.pipeline import registered_host -from ayon_core.tools.utils import PlaceholderLineEdit -from ayon_core.tools.utils.lib import ( - iter_model_rows, - qt_app_context -) -from ayon_core.tools.utils.models import RecursiveSortFilterProxyModel -from .model import ( - InstanceModel, - ITEM_ID_ROLE -) -from .widgets import InstanceDetail - - -module = sys.modules[__name__] -module.window = None - - -class SubsetManagerWindow(QtWidgets.QDialog): - def __init__(self, parent=None): - super(SubsetManagerWindow, self).__init__(parent=parent) - self.setWindowTitle("Subset Manager 0.1") - self.setObjectName("SubsetManager") - if not parent: - self.setWindowFlags( - self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint - ) - - self.resize(780, 430) - - # Trigger refresh on first called show - self._first_show = True - - left_side_widget = QtWidgets.QWidget(self) - - # Header part - header_widget = QtWidgets.QWidget(left_side_widget) - - # Filter input - filter_input = PlaceholderLineEdit(header_widget) - filter_input.setPlaceholderText("Filter products..") - - # Refresh button - icon = qtawesome.icon("fa.refresh", color="white") - refresh_btn = QtWidgets.QPushButton(header_widget) - refresh_btn.setIcon(icon) - - header_layout = QtWidgets.QHBoxLayout(header_widget) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.addWidget(filter_input) - header_layout.addWidget(refresh_btn) - - # Instances view - view = QtWidgets.QTreeView(left_side_widget) - view.setIndentation(0) - view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - - model = InstanceModel(view) - proxy = RecursiveSortFilterProxyModel() - proxy.setSourceModel(model) - proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - - view.setModel(proxy) - - left_side_layout = QtWidgets.QVBoxLayout(left_side_widget) - left_side_layout.setContentsMargins(0, 0, 0, 0) - left_side_layout.addWidget(header_widget) - left_side_layout.addWidget(view) - - details_widget = InstanceDetail(self) - - layout = QtWidgets.QHBoxLayout(self) - layout.addWidget(left_side_widget, 0) - layout.addWidget(details_widget, 1) - - filter_input.textChanged.connect(proxy.setFilterFixedString) - refresh_btn.clicked.connect(self._on_refresh_clicked) - view.clicked.connect(self._on_activated) - view.customContextMenuRequested.connect(self.on_context_menu) - details_widget.save_triggered.connect(self._on_save) - - self._model = model - self._proxy = proxy - self._view = view - self._details_widget = details_widget - self._refresh_btn = refresh_btn - - def _on_refresh_clicked(self): - self.refresh() - - def _on_activated(self, index): - container = None - item_id = None - if index.isValid(): - item_id = index.data(ITEM_ID_ROLE) - container = self._model.get_instance_by_id(item_id) - - self._details_widget.set_details(container, item_id) - - def _on_save(self): - host = registered_host() - if not hasattr(host, "save_instances"): - print("BUG: Host does not have \"save_instances\" method") - return - - current_index = self._view.selectionModel().currentIndex() - if not current_index.isValid(): - return - - item_id = current_index.data(ITEM_ID_ROLE) - if item_id != self._details_widget.item_id(): - return - - item_data = self._details_widget.instance_data_from_text() - new_instances = [] - for index in iter_model_rows(self._model, 0): - _item_id = index.data(ITEM_ID_ROLE) - if _item_id == item_id: - instance_data = item_data - else: - instance_data = self._model.get_instance_by_id(item_id) - new_instances.append(instance_data) - - host.save_instances(new_instances) - - def on_context_menu(self, point): - point_index = self._view.indexAt(point) - item_id = point_index.data(ITEM_ID_ROLE) - instance_data = self._model.get_instance_by_id(item_id) - if instance_data is None: - return - - # Prepare menu - menu = QtWidgets.QMenu(self) - actions = [] - host = registered_host() - if hasattr(host, "remove_instance"): - action = QtWidgets.QAction("Remove instance", menu) - action.setData(host.remove_instance) - actions.append(action) - - if hasattr(host, "select_instance"): - action = QtWidgets.QAction("Select instance", menu) - action.setData(host.select_instance) - actions.append(action) - - if not actions: - actions.append(QtWidgets.QAction("* Nothing to do", menu)) - - for action in actions: - menu.addAction(action) - - # Show menu under mouse - global_point = self._view.mapToGlobal(point) - action = menu.exec_(global_point) - if not action or not action.data(): - return - - # Process action - # TODO catch exceptions - function = action.data() - function(instance_data) - - # Reset modified data - self.refresh() - - def refresh(self): - self._details_widget.set_details(None, None) - self._model.refresh() - - host = registered_host() - dev_mode = os.environ.get("AVALON_DEVELOP_MODE") or "" - editable = False - if dev_mode.lower() in ("1", "yes", "true", "on"): - editable = hasattr(host, "save_instances") - self._details_widget.set_editable(editable) - - def showEvent(self, *args, **kwargs): - super(SubsetManagerWindow, self).showEvent(*args, **kwargs) - if self._first_show: - self._first_show = False - self.setStyleSheet(style.load_stylesheet()) - self.refresh() - - -def show(root=None, debug=False, parent=None): - """Display Scene Inventory GUI - - Arguments: - debug (bool, optional): Run in debug-mode, - defaults to False - parent (QtCore.QObject, optional): When provided parent the interface - to this QObject. - - """ - - try: - module.window.close() - del module.window - except (RuntimeError, AttributeError): - pass - - with qt_app_context(): - window = SubsetManagerWindow(parent) - window.show() - - module.window = window - - # Pull window to the front. - module.window.raise_() - module.window.activateWindow() diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index aad89b6081..cea8d4f747 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -240,6 +240,16 @@ class TrayManager: self.log.warning("Other tray started meanwhile. Exiting.") self.exit() + project_bundle = os.getenv("AYON_BUNDLE_NAME") + studio_bundle = os.getenv("AYON_STUDIO_BUNDLE_NAME") + if studio_bundle and project_bundle != studio_bundle: + self.log.info( + f"Project bundle '{project_bundle}' is defined, but tray" + " cannot be running in project scope. Restarting tray to use" + " studio bundle." + ) + self.restart() + def get_services_submenu(self): return self._services_submenu @@ -270,11 +280,18 @@ class TrayManager: elif is_staging_enabled(): additional_args.append("--use-staging") + if "--project" in additional_args: + idx = additional_args.index("--project") + additional_args.pop(idx) + additional_args.pop(idx) + args.extend(additional_args) envs = dict(os.environ.items()) for key in { "AYON_BUNDLE_NAME", + "AYON_STUDIO_BUNDLE_NAME", + "AYON_PROJECT_NAME", }: envs.pop(key, None) @@ -329,6 +346,7 @@ class TrayManager: return json_response({ "username": self._cached_username, "bundle": os.getenv("AYON_BUNDLE_NAME"), + "studio_bundle": os.getenv("AYON_STUDIO_BUNDLE_NAME"), "dev_mode": is_dev_mode_enabled(), "staging_mode": is_staging_enabled(), "addons": { @@ -516,6 +534,8 @@ class TrayManager: "AYON_SERVER_URL", "AYON_API_KEY", "AYON_BUNDLE_NAME", + "AYON_STUDIO_BUNDLE_NAME", + "AYON_PROJECT_NAME", }: os.environ.pop(key, None) self.restart() @@ -549,6 +569,8 @@ class TrayManager: envs = dict(os.environ.items()) for key in { "AYON_BUNDLE_NAME", + "AYON_STUDIO_BUNDLE_NAME", + "AYON_PROJECT_NAME", }: envs.pop(key, None) diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 8688430c71..111b7c614b 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -29,6 +29,7 @@ from .widgets import ( from .views import ( DeselectableTreeView, TreeView, + ListView, ) from .error_dialog import ErrorMessageBox from .lib import ( @@ -61,6 +62,7 @@ from .dialogs import ( ) from .projects_widget import ( ProjectsCombobox, + ProjectsWidget, ProjectsQtModel, ProjectSortFilterProxy, PROJECT_NAME_ROLE, @@ -114,6 +116,7 @@ __all__ = ( "DeselectableTreeView", "TreeView", + "ListView", "ErrorMessageBox", @@ -145,6 +148,7 @@ __all__ = ( "PopupUpdateKeys", "ProjectsCombobox", + "ProjectsWidget", "ProjectsQtModel", "ProjectSortFilterProxy", "PROJECT_NAME_ROLE", diff --git a/client/ayon_core/tools/utils/color_widgets/color_inputs.py b/client/ayon_core/tools/utils/color_widgets/color_inputs.py index 795b80fc1e..5a1c2dc50b 100644 --- a/client/ayon_core/tools/utils/color_widgets/color_inputs.py +++ b/client/ayon_core/tools/utils/color_widgets/color_inputs.py @@ -65,7 +65,7 @@ class AlphaSlider(QtWidgets.QSlider): painter.fillRect(event.rect(), QtCore.Qt.transparent) - painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + painter.setRenderHint(QtGui.QPainter.Antialiasing) horizontal = self.orientation() == QtCore.Qt.Horizontal diff --git a/client/ayon_core/tools/utils/color_widgets/color_triangle.py b/client/ayon_core/tools/utils/color_widgets/color_triangle.py index 290a33f0b0..7691c3e78d 100644 --- a/client/ayon_core/tools/utils/color_widgets/color_triangle.py +++ b/client/ayon_core/tools/utils/color_widgets/color_triangle.py @@ -261,7 +261,7 @@ class QtColorTriangle(QtWidgets.QWidget): pix = self.bg_image.copy() pix_painter = QtGui.QPainter(pix) - pix_painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + pix_painter.setRenderHint(QtGui.QPainter.Antialiasing) trigon_path = QtGui.QPainterPath() trigon_path.moveTo(self.point_a) diff --git a/client/ayon_core/tools/utils/host_tools.py b/client/ayon_core/tools/utils/host_tools.py index 3d356555f3..96b7615e3c 100644 --- a/client/ayon_core/tools/utils/host_tools.py +++ b/client/ayon_core/tools/utils/host_tools.py @@ -31,9 +31,7 @@ class HostToolsHelper: # Prepare attributes for all tools self._workfiles_tool = None self._loader_tool = None - self._creator_tool = None self._publisher_tool = None - self._subset_manager_tool = None self._scene_inventory_tool = None self._experimental_tools_dialog = None @@ -96,49 +94,6 @@ class HostToolsHelper: loader_tool.refresh() - def get_creator_tool(self, parent): - """Create, cache and return creator tool window.""" - if self._creator_tool is None: - from ayon_core.tools.creator import CreatorWindow - - creator_window = CreatorWindow(parent=parent or self._parent) - self._creator_tool = creator_window - - return self._creator_tool - - def show_creator(self, parent=None): - """Show tool to create new instantes for publishing.""" - with qt_app_context(): - creator_tool = self.get_creator_tool(parent) - creator_tool.refresh() - creator_tool.show() - - # Pull window to the front. - creator_tool.raise_() - creator_tool.activateWindow() - - def get_subset_manager_tool(self, parent): - """Create, cache and return subset manager tool window.""" - if self._subset_manager_tool is None: - from ayon_core.tools.subsetmanager import SubsetManagerWindow - - subset_manager_window = SubsetManagerWindow( - parent=parent or self._parent - ) - self._subset_manager_tool = subset_manager_window - - return self._subset_manager_tool - - def show_subset_manager(self, parent=None): - """Show tool display/remove existing created instances.""" - with qt_app_context(): - subset_manager_tool = self.get_subset_manager_tool(parent) - subset_manager_tool.show() - - # Pull window to the front. - subset_manager_tool.raise_() - subset_manager_tool.activateWindow() - def get_scene_inventory_tool(self, parent): """Create, cache and return scene inventory tool window.""" if self._scene_inventory_tool is None: @@ -261,35 +216,29 @@ class HostToolsHelper: if tool_name == "workfiles": return self.get_workfiles_tool(parent, *args, **kwargs) - elif tool_name == "loader": + if tool_name == "loader": return self.get_loader_tool(parent, *args, **kwargs) - elif tool_name == "libraryloader": + if tool_name == "libraryloader": return self.get_library_loader_tool(parent, *args, **kwargs) - elif tool_name == "creator": - return self.get_creator_tool(parent, *args, **kwargs) - - elif tool_name == "subsetmanager": - return self.get_subset_manager_tool(parent, *args, **kwargs) - - elif tool_name == "sceneinventory": + if tool_name == "sceneinventory": return self.get_scene_inventory_tool(parent, *args, **kwargs) - elif tool_name == "publish": - self.log.info("Can't return publish tool window.") - - # "new" publisher - elif tool_name == "publisher": + if tool_name == "publisher": return self.get_publisher_tool(parent, *args, **kwargs) - elif tool_name == "experimental_tools": + if tool_name == "experimental_tools": return self.get_experimental_tools_dialog(parent, *args, **kwargs) - else: - self.log.warning( - "Can't show unknown tool name: \"{}\"".format(tool_name) - ) + if tool_name == "publish": + self.log.info("Can't return publish tool window.") + return None + + self.log.warning( + "Can't show unknown tool name: \"{}\"".format(tool_name) + ) + return None def show_tool_by_name(self, tool_name, parent=None, *args, **kwargs): """Show tool by it's name. @@ -305,12 +254,6 @@ class HostToolsHelper: elif tool_name == "libraryloader": self.show_library_loader(parent, *args, **kwargs) - elif tool_name == "creator": - self.show_creator(parent, *args, **kwargs) - - elif tool_name == "subsetmanager": - self.show_subset_manager(parent, *args, **kwargs) - elif tool_name == "sceneinventory": self.show_scene_inventory(parent, *args, **kwargs) @@ -379,14 +322,6 @@ def show_library_loader(parent=None): _SingletonPoint.show_tool_by_name("libraryloader", parent) -def show_creator(parent=None): - _SingletonPoint.show_tool_by_name("creator", parent) - - -def show_subset_manager(parent=None): - _SingletonPoint.show_tool_by_name("subsetmanager", parent) - - def show_scene_inventory(parent=None): _SingletonPoint.show_tool_by_name("sceneinventory", parent) diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index f7919a3317..a99c46199b 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -118,9 +118,6 @@ def paint_image_with_color(image, color): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - # Deprecated since 5.14 - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing painter.setRenderHints(render_hints) painter.setClipRegion(alpha_region) diff --git a/client/ayon_core/tools/utils/nice_checkbox.py b/client/ayon_core/tools/utils/nice_checkbox.py index 3d9d63b6bc..c33533b0e4 100644 --- a/client/ayon_core/tools/utils/nice_checkbox.py +++ b/client/ayon_core/tools/utils/nice_checkbox.py @@ -1,4 +1,5 @@ -from math import floor, sqrt, ceil +from math import floor, ceil + from qtpy import QtWidgets, QtCore, QtGui from ayon_core.style import get_objected_colors @@ -9,12 +10,15 @@ class NiceCheckbox(QtWidgets.QFrame): clicked = QtCore.Signal() _checked_bg_color = None + _checked_bg_color_disabled = None _unchecked_bg_color = None + _unchecked_bg_color_disabled = None _checker_color = None + _checker_color_disabled = None _checker_hover_color = None def __init__(self, checked=False, draw_icons=False, parent=None): - super(NiceCheckbox, self).__init__(parent) + super().__init__(parent) self.setObjectName("NiceCheckbox") self.setAttribute(QtCore.Qt.WA_TranslucentBackground) @@ -48,8 +52,6 @@ class NiceCheckbox(QtWidgets.QFrame): self._pressed = False self._under_mouse = False - self.icon_scale_factor = sqrt(2) / 2 - icon_path_stroker = QtGui.QPainterPathStroker() icon_path_stroker.setCapStyle(QtCore.Qt.RoundCap) icon_path_stroker.setJoinStyle(QtCore.Qt.RoundJoin) @@ -61,35 +63,6 @@ class NiceCheckbox(QtWidgets.QFrame): self._base_size = QtCore.QSize(90, 50) self._load_colors() - @classmethod - def _load_colors(cls): - if cls._checked_bg_color is not None: - return - - colors_info = get_objected_colors("nice-checkbox") - - cls._checked_bg_color = colors_info["bg-checked"].get_qcolor() - cls._unchecked_bg_color = colors_info["bg-unchecked"].get_qcolor() - - cls._checker_color = colors_info["bg-checker"].get_qcolor() - cls._checker_hover_color = colors_info["bg-checker-hover"].get_qcolor() - - @property - def checked_bg_color(self): - return self._checked_bg_color - - @property - def unchecked_bg_color(self): - return self._unchecked_bg_color - - @property - def checker_color(self): - return self._checker_color - - @property - def checker_hover_color(self): - return self._checker_hover_color - def setTristate(self, tristate=True): if self._is_tristate != tristate: self._is_tristate = tristate @@ -121,14 +94,14 @@ class NiceCheckbox(QtWidgets.QFrame): def setFixedHeight(self, *args, **kwargs): self._fixed_height_set = True - super(NiceCheckbox, self).setFixedHeight(*args, **kwargs) + super().setFixedHeight(*args, **kwargs) if not self._fixed_width_set: width = self.get_width_hint_by_height(self.height()) self.setFixedWidth(width) def setFixedWidth(self, *args, **kwargs): self._fixed_width_set = True - super(NiceCheckbox, self).setFixedWidth(*args, **kwargs) + super().setFixedWidth(*args, **kwargs) if not self._fixed_height_set: height = self.get_height_hint_by_width(self.width()) self.setFixedHeight(height) @@ -136,7 +109,7 @@ class NiceCheckbox(QtWidgets.QFrame): def setFixedSize(self, *args, **kwargs): self._fixed_height_set = True self._fixed_width_set = True - super(NiceCheckbox, self).setFixedSize(*args, **kwargs) + super().setFixedSize(*args, **kwargs) def steps(self): return self._steps @@ -242,7 +215,7 @@ class NiceCheckbox(QtWidgets.QFrame): if event.buttons() & QtCore.Qt.LeftButton: self._pressed = True self.repaint() - super(NiceCheckbox, self).mousePressEvent(event) + super().mousePressEvent(event) def mouseReleaseEvent(self, event): if self._pressed and not event.buttons() & QtCore.Qt.LeftButton: @@ -252,7 +225,7 @@ class NiceCheckbox(QtWidgets.QFrame): self.clicked.emit() event.accept() return - super(NiceCheckbox, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) def mouseMoveEvent(self, event): if self._pressed: @@ -261,19 +234,19 @@ class NiceCheckbox(QtWidgets.QFrame): self._under_mouse = under_mouse self.repaint() - super(NiceCheckbox, self).mouseMoveEvent(event) + super().mouseMoveEvent(event) def enterEvent(self, event): self._under_mouse = True if self.isEnabled(): self.repaint() - super(NiceCheckbox, self).enterEvent(event) + super().enterEvent(event) def leaveEvent(self, event): self._under_mouse = False if self.isEnabled(): self.repaint() - super(NiceCheckbox, self).leaveEvent(event) + super().leaveEvent(event) def _on_animation_timeout(self): if self._checkstate == QtCore.Qt.Checked: @@ -302,24 +275,13 @@ class NiceCheckbox(QtWidgets.QFrame): @staticmethod def steped_color(color1, color2, offset_ratio): - red_dif = ( - color1.red() - color2.red() - ) - green_dif = ( - color1.green() - color2.green() - ) - blue_dif = ( - color1.blue() - color2.blue() - ) - red = int(color2.red() + ( - red_dif * offset_ratio - )) - green = int(color2.green() + ( - green_dif * offset_ratio - )) - blue = int(color2.blue() + ( - blue_dif * offset_ratio - )) + red_dif = color1.red() - color2.red() + green_dif = color1.green() - color2.green() + blue_dif = color1.blue() - color2.blue() + + red = int(color2.red() + (red_dif * offset_ratio)) + green = int(color2.green() + (green_dif * offset_ratio)) + blue = int(color2.blue() + (blue_dif * offset_ratio)) return QtGui.QColor(red, green, blue) @@ -334,20 +296,28 @@ class NiceCheckbox(QtWidgets.QFrame): painter = QtGui.QPainter(self) painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.setPen(QtCore.Qt.NoPen) # Draw inner background - if self._current_step == self._steps: - bg_color = self.checked_bg_color + if not self.isEnabled(): + bg_color = ( + self._checked_bg_color_disabled + if self._current_step == self._steps + else self._unchecked_bg_color_disabled + ) + + elif self._current_step == self._steps: + bg_color = self._checked_bg_color elif self._current_step == 0: - bg_color = self.unchecked_bg_color + bg_color = self._unchecked_bg_color else: offset_ratio = float(self._current_step) / self._steps # Animation bg bg_color = self.steped_color( - self.checked_bg_color, - self.unchecked_bg_color, + self._checked_bg_color, + self._unchecked_bg_color, offset_ratio ) @@ -378,14 +348,20 @@ class NiceCheckbox(QtWidgets.QFrame): -margin_size_c, -margin_size_c ) - if checkbox_rect.width() > checkbox_rect.height(): - radius = floor(checkbox_rect.height() * 0.5) - else: - radius = floor(checkbox_rect.width() * 0.5) + slider_rect = QtCore.QRect(checkbox_rect) + slider_offset = int( + ceil(min(slider_rect.width(), slider_rect.height())) * 0.08 + ) + if slider_offset < 1: + slider_offset = 1 + slider_rect.adjust( + slider_offset, slider_offset, + -slider_offset, -slider_offset + ) + radius = floor(min(slider_rect.width(), slider_rect.height()) * 0.5) - painter.setPen(QtCore.Qt.NoPen) painter.setBrush(bg_color) - painter.drawRoundedRect(checkbox_rect, radius, radius) + painter.drawRoundedRect(slider_rect, radius, radius) # Draw checker checker_size = size_without_margins - (margin_size_c * 2) @@ -394,9 +370,8 @@ class NiceCheckbox(QtWidgets.QFrame): - (margin_size_c * 2) - checker_size ) - if self._current_step == 0: - x_offset = 0 - else: + x_offset = 0 + if self._current_step != 0: x_offset = (float(area_width) / self._steps) * self._current_step pos_x = checkbox_rect.x() + x_offset + margin_size_c @@ -404,55 +379,80 @@ class NiceCheckbox(QtWidgets.QFrame): checker_rect = QtCore.QRect(pos_x, pos_y, checker_size, checker_size) - under_mouse = self.isEnabled() and self._under_mouse - if under_mouse: - checker_color = self.checker_hover_color - else: - checker_color = self.checker_color + checker_color = self._checker_color + if not self.isEnabled(): + checker_color = self._checker_color_disabled + elif self._under_mouse: + checker_color = self._checker_hover_color painter.setBrush(checker_color) painter.drawEllipse(checker_rect) if self._draw_icons: painter.setBrush(bg_color) - icon_path = self._get_icon_path(painter, checker_rect) + icon_path = self._get_icon_path(checker_rect) painter.drawPath(icon_path) - # Draw shadow overlay - if not self.isEnabled(): - level = 33 - alpha = 127 - painter.setPen(QtCore.Qt.transparent) - painter.setBrush(QtGui.QColor(level, level, level, alpha)) - painter.drawRoundedRect(checkbox_rect, radius, radius) - painter.end() - def _get_icon_path(self, painter, checker_rect): + @classmethod + def _load_colors(cls): + if cls._checked_bg_color is not None: + return + + colors_info = get_objected_colors("nice-checkbox") + + disabled_color = QtGui.QColor(33, 33, 33, 127) + + cls._checked_bg_color = colors_info["bg-checked"].get_qcolor() + cls._checked_bg_color_disabled = cls._merge_colors( + cls._checked_bg_color, disabled_color + ) + cls._unchecked_bg_color = colors_info["bg-unchecked"].get_qcolor() + cls._unchecked_bg_color_disabled = cls._merge_colors( + cls._unchecked_bg_color, disabled_color + ) + + cls._checker_color = colors_info["bg-checker"].get_qcolor() + cls._checker_color_disabled = cls._merge_colors( + cls._checker_color, disabled_color + ) + cls._checker_hover_color = colors_info["bg-checker-hover"].get_qcolor() + + @staticmethod + def _merge_colors(color_1, color_2): + a = color_2.alphaF() + return QtGui.QColor( + floor((color_1.red() + (color_2.red() * a)) * 0.5), + floor((color_1.green() + (color_2.green() * a)) * 0.5), + floor((color_1.blue() + (color_2.blue() * a)) * 0.5), + color_1.alpha() + ) + + def _get_icon_path(self, checker_rect): self.icon_path_stroker.setWidth(checker_rect.height() / 5) if self._current_step == self._steps: - return self._get_enabled_icon_path(painter, checker_rect) + return self._get_enabled_icon_path(checker_rect) if self._current_step == 0: - return self._get_disabled_icon_path(painter, checker_rect) + return self._get_disabled_icon_path(checker_rect) if self._current_step == self._middle_step: - return self._get_middle_circle_path(painter, checker_rect) + return self._get_middle_circle_path(checker_rect) disabled_step = self._steps - self._current_step enabled_step = self._steps - disabled_step half_steps = self._steps + 1 - ((self._steps + 1) % 2) if enabled_step > disabled_step: return self._get_enabled_icon_path( - painter, checker_rect, enabled_step, half_steps - ) - else: - return self._get_disabled_icon_path( - painter, checker_rect, disabled_step, half_steps + checker_rect, enabled_step, half_steps ) + return self._get_disabled_icon_path( + checker_rect, disabled_step, half_steps + ) - def _get_middle_circle_path(self, painter, checker_rect): + def _get_middle_circle_path(self, checker_rect): width = self.icon_path_stroker.width() path = QtGui.QPainterPath() path.addEllipse(checker_rect.center(), width, width) @@ -460,7 +460,7 @@ class NiceCheckbox(QtWidgets.QFrame): return path def _get_enabled_icon_path( - self, painter, checker_rect, step=None, half_steps=None + self, checker_rect, step=None, half_steps=None ): fifteenth = float(checker_rect.height()) / 15 # Left point @@ -509,7 +509,7 @@ class NiceCheckbox(QtWidgets.QFrame): return self.icon_path_stroker.createStroke(path) def _get_disabled_icon_path( - self, painter, checker_rect, step=None, half_steps=None + self, checker_rect, step=None, half_steps=None ): center_point = QtCore.QPointF( float(checker_rect.width()) / 2, diff --git a/client/ayon_core/tools/utils/projects_widget.py b/client/ayon_core/tools/utils/projects_widget.py index c340be2f83..1c87d79a58 100644 --- a/client/ayon_core/tools/utils/projects_widget.py +++ b/client/ayon_core/tools/utils/projects_widget.py @@ -1,21 +1,69 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from collections.abc import Callable +import typing +from typing import Optional + from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER +from ayon_core.tools.common_models import ( + ProjectItem, + PROJECTS_MODEL_SENDER, +) +from .views import ListView from .lib import RefreshThread, get_qt_icon +if typing.TYPE_CHECKING: + from typing import TypedDict + + class ExpectedProjectSelectionData(TypedDict): + name: Optional[str] + current: Optional[str] + selected: Optional[str] + + class ExpectedSelectionData(TypedDict): + project: ExpectedProjectSelectionData + + PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3 PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4 -LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5 +PROJECT_IS_PINNED_ROLE = QtCore.Qt.UserRole + 5 +LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 6 + + +class AbstractProjectController(ABC): + @abstractmethod + def register_event_callback(self, topic: str, callback: Callable): + pass + + @abstractmethod + def get_project_items( + self, sender: Optional[str] = None + ) -> list[str]: + pass + + @abstractmethod + def set_selected_project(self, project_name: str): + pass + + # These are required only if widget should handle expected selection + @abstractmethod + def expected_project_selected(self, project_name: str): + pass + + @abstractmethod + def get_expected_selection_data(self) -> "ExpectedSelectionData": + pass class ProjectsQtModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() - def __init__(self, controller): - super(ProjectsQtModel, self).__init__() + def __init__(self, controller: AbstractProjectController): + super().__init__() self._controller = controller self._project_items = {} @@ -213,7 +261,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel): else: self.refreshed.emit() - def _fill_items(self, project_items): + def _fill_items(self, project_items: list[ProjectItem]): new_project_names = { project_item.name for project_item in project_items @@ -252,6 +300,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel): item.setData(project_name, PROJECT_NAME_ROLE) item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE) item.setData(project_item.is_library, PROJECT_IS_LIBRARY_ROLE) + item.setData(project_item.is_pinned, PROJECT_IS_PINNED_ROLE) is_current = project_name == self._current_context_project item.setData(is_current, PROJECT_IS_CURRENT_ROLE) self._project_items[project_name] = item @@ -279,7 +328,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel): class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): def __init__(self, *args, **kwargs): - super(ProjectSortFilterProxy, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._filter_inactive = True self._filter_standard = False self._filter_library = False @@ -323,26 +372,51 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): return False # Library separator should be before library projects - result = self._type_sort(left_index, right_index) - if result is not None: - return result + l_is_library = left_index.data(PROJECT_IS_LIBRARY_ROLE) + r_is_library = right_index.data(PROJECT_IS_LIBRARY_ROLE) + l_is_sep = left_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE) + r_is_sep = right_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE) + if l_is_sep: + return bool(r_is_library) - if left_index.data(PROJECT_NAME_ROLE) is None: + if r_is_sep: + return not l_is_library + + # Non project items should be on top + l_project_name = left_index.data(PROJECT_NAME_ROLE) + r_project_name = right_index.data(PROJECT_NAME_ROLE) + if l_project_name is None: return True - - if right_index.data(PROJECT_NAME_ROLE) is None: + if r_project_name is None: return False left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE) right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE) - if right_is_active == left_is_active: - return super(ProjectSortFilterProxy, self).lessThan( - left_index, right_index - ) + if right_is_active != left_is_active: + return left_is_active - if left_is_active: + l_is_pinned = left_index.data(PROJECT_IS_PINNED_ROLE) + r_is_pinned = right_index.data(PROJECT_IS_PINNED_ROLE) + if l_is_pinned is True and not r_is_pinned: return True - return False + + if r_is_pinned is True and not l_is_pinned: + return False + + # Move inactive projects to the end + left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE) + right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE) + if right_is_active != left_is_active: + return left_is_active + + # Move library projects after standard projects + if ( + l_is_library is not None + and r_is_library is not None + and l_is_library != r_is_library + ): + return r_is_library + return super().lessThan(left_index, right_index) def filterAcceptsRow(self, source_row, source_parent): index = self.sourceModel().index(source_row, 0, source_parent) @@ -415,15 +489,153 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): self.invalidate() +class ProjectsDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._pin_icon = None + + def paint(self, painter, option, index): + is_pinned = index.data(PROJECT_IS_PINNED_ROLE) + if not is_pinned: + super().paint(painter, option, index) + return + opt = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + widget = option.widget + if widget is None: + style = QtWidgets.QApplication.style() + else: + style = widget.style() + # CE_ItemViewItem + proxy = style.proxy() + painter.save() + painter.setClipRect(option.rect) + decor_rect = proxy.subElementRect( + QtWidgets.QStyle.SE_ItemViewItemDecoration, opt, widget + ) + text_rect = proxy.subElementRect( + QtWidgets.QStyle.SE_ItemViewItemText, opt, widget + ) + proxy.drawPrimitive( + QtWidgets.QStyle.PE_PanelItemViewItem, opt, painter, widget + ) + mode = QtGui.QIcon.Normal + if not opt.state & QtWidgets.QStyle.State_Enabled: + mode = QtGui.QIcon.Disabled + elif opt.state & QtWidgets.QStyle.State_Selected: + mode = QtGui.QIcon.Selected + state = QtGui.QIcon.Off + if opt.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + + # Draw project icon + opt.icon.paint( + painter, decor_rect, opt.decorationAlignment, mode, state + ) + + # Draw pin icon + if index.data(PROJECT_IS_PINNED_ROLE): + pin_icon = self._get_pin_icon() + pin_rect = QtCore.QRect(decor_rect) + diff = option.rect.width() - pin_rect.width() + pin_rect.moveLeft(diff) + pin_icon.paint( + painter, pin_rect, opt.decorationAlignment, mode, state + ) + + # Draw text + if opt.text: + if not opt.state & QtWidgets.QStyle.State_Enabled: + cg = QtGui.QPalette.Disabled + elif not (opt.state & QtWidgets.QStyle.State_Active): + cg = QtGui.QPalette.Inactive + else: + cg = QtGui.QPalette.Normal + + if opt.state & QtWidgets.QStyle.State_Selected: + painter.setPen( + opt.palette.color(cg, QtGui.QPalette.HighlightedText) + ) + else: + painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text)) + + if opt.state & QtWidgets.QStyle.State_Editing: + painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text)) + painter.drawRect(text_rect.adjusted(0, 0, -1, -1)) + + margin = proxy.pixelMetric( + QtWidgets.QStyle.PM_FocusFrameHMargin, None, widget + ) + 1 + text_rect.adjust(margin, 0, -margin, 0) + # NOTE skipping some steps e.g. word wrapping and elided + # text (adding '...' when too long). + painter.drawText( + text_rect, + opt.displayAlignment, + opt.text + ) + + # Draw focus rect + if opt.state & QtWidgets.QStyle.State_HasFocus: + focus_opt = QtWidgets.QStyleOptionFocusRect() + focus_opt.state = option.state + focus_opt.direction = option.direction + focus_opt.rect = option.rect + focus_opt.fontMetrics = option.fontMetrics + focus_opt.palette = option.palette + + focus_opt.rect = style.subElementRect( + QtWidgets.QCommonStyle.SE_ItemViewItemFocusRect, + option, + option.widget + ) + focus_opt.state |= ( + QtWidgets.QStyle.State_KeyboardFocusChange + | QtWidgets.QStyle.State_Item + ) + focus_opt.backgroundColor = option.palette.color( + ( + QtGui.QPalette.Normal + if option.state & QtWidgets.QStyle.State_Enabled + else QtGui.QPalette.Disabled + ), + ( + QtGui.QPalette.Highlight + if option.state & QtWidgets.QStyle.State_Selected + else QtGui.QPalette.Window + ) + ) + style.drawPrimitive( + QtWidgets.QCommonStyle.PE_FrameFocusRect, + focus_opt, + painter, + option.widget + ) + painter.restore() + + def _get_pin_icon(self): + if self._pin_icon is None: + self._pin_icon = get_qt_icon({ + "type": "material-symbols", + "name": "keep", + }) + return self._pin_icon + + class ProjectsCombobox(QtWidgets.QWidget): refreshed = QtCore.Signal() - selection_changed = QtCore.Signal() + selection_changed = QtCore.Signal(str) - def __init__(self, controller, parent, handle_expected_selection=False): - super(ProjectsCombobox, self).__init__(parent) + def __init__( + self, + controller: AbstractProjectController, + parent: QtWidgets.QWidget, + handle_expected_selection: bool = False, + ): + super().__init__(parent) projects_combobox = QtWidgets.QComboBox(self) - combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox) + combobox_delegate = ProjectsDelegate(projects_combobox) projects_combobox.setItemDelegate(combobox_delegate) projects_model = ProjectsQtModel(controller) projects_proxy_model = ProjectSortFilterProxy() @@ -468,7 +680,7 @@ class ProjectsCombobox(QtWidgets.QWidget): def refresh(self): self._projects_model.refresh() - def set_selection(self, project_name): + def set_selection(self, project_name: str): """Set selection to a given project. Selection change is ignored if project is not found. @@ -480,8 +692,8 @@ class ProjectsCombobox(QtWidgets.QWidget): bool: True if selection was changed, False otherwise. NOTE: Selection may not be changed if project is not found, or if project is already selected. - """ + """ idx = self._projects_combobox.findData( project_name, PROJECT_NAME_ROLE) if idx < 0: @@ -491,7 +703,7 @@ class ProjectsCombobox(QtWidgets.QWidget): return True return False - def set_listen_to_selection_change(self, listen): + def set_listen_to_selection_change(self, listen: bool): """Disable listening to changes of the selection. Because combobox is triggering selection change when it's model @@ -517,11 +729,11 @@ class ProjectsCombobox(QtWidgets.QWidget): return None return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE) - def set_current_context_project(self, project_name): + def set_current_context_project(self, project_name: str): self._projects_model.set_current_context_project(project_name) self._projects_proxy_model.invalidateFilter() - def set_select_item_visible(self, visible): + def set_select_item_visible(self, visible: bool): self._select_item_visible = visible self._projects_model.set_select_item_visible(visible) self._update_select_item_visiblity() @@ -559,7 +771,7 @@ class ProjectsCombobox(QtWidgets.QWidget): idx, PROJECT_NAME_ROLE) self._update_select_item_visiblity(project_name=project_name) self._controller.set_selected_project(project_name) - self.selection_changed.emit() + self.selection_changed.emit(project_name or "") def _on_model_refresh(self): self._projects_proxy_model.sort(0) @@ -614,5 +826,119 @@ class ProjectsCombobox(QtWidgets.QWidget): class ProjectsWidget(QtWidgets.QWidget): - # TODO implement - pass + """Projects widget showing projects in list. + + Warnings: + This widget does not support expected selection handling. + + """ + refreshed = QtCore.Signal() + selection_changed = QtCore.Signal(str) + double_clicked = QtCore.Signal() + + def __init__( + self, + controller: AbstractProjectController, + parent: Optional[QtWidgets.QWidget] = None + ): + super().__init__(parent=parent) + + projects_view = ListView(parent=self) + projects_view.setResizeMode(QtWidgets.QListView.Adjust) + projects_view.setVerticalScrollMode( + QtWidgets.QAbstractItemView.ScrollPerPixel + ) + projects_view.setAlternatingRowColors(False) + projects_view.setWrapping(False) + projects_view.setWordWrap(False) + projects_view.setSpacing(0) + projects_delegate = ProjectsDelegate(projects_view) + projects_view.setItemDelegate(projects_delegate) + projects_view.activate_flick_charm() + projects_view.set_deselectable(True) + + projects_model = ProjectsQtModel(controller) + projects_proxy_model = ProjectSortFilterProxy() + projects_proxy_model.setSourceModel(projects_model) + projects_view.setModel(projects_proxy_model) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(projects_view, 1) + + projects_view.selectionModel().selectionChanged.connect( + self._on_selection_change + ) + projects_view.double_clicked.connect(self.double_clicked) + projects_model.refreshed.connect(self._on_model_refresh) + + controller.register_event_callback( + "projects.refresh.finished", + self._on_projects_refresh_finished + ) + + self._controller = controller + + self._projects_view = projects_view + self._projects_model = projects_model + self._projects_proxy_model = projects_proxy_model + self._projects_delegate = projects_delegate + + def refresh(self): + self._projects_model.refresh() + + def has_content(self) -> bool: + """Model has at least one project. + + Returns: + bool: True if there is any content in the model. + + """ + return self._projects_model.has_content() + + def set_name_filter(self, text: str): + self._projects_proxy_model.setFilterFixedString(text) + + def get_selected_project(self) -> Optional[str]: + selection_model = self._projects_view.selectionModel() + for index in selection_model.selectedIndexes(): + project_name = index.data(PROJECT_NAME_ROLE) + if project_name: + return project_name + return None + + def set_selected_project(self, project_name: Optional[str]): + if project_name is None: + self._projects_view.clearSelection() + self._projects_view.setCurrentIndex(QtCore.QModelIndex()) + return + + index = self._projects_model.get_index_by_project_name(project_name) + if not index.isValid(): + return + proxy_index = self._projects_proxy_model.mapFromSource(index) + if proxy_index.isValid(): + selection_model = self._projects_view.selectionModel() + selection_model.select( + proxy_index, + QtCore.QItemSelectionModel.ClearAndSelect + ) + + def _on_model_refresh(self): + self._projects_proxy_model.sort(0) + self._projects_proxy_model.invalidateFilter() + self.refreshed.emit() + + def _on_selection_change(self, new_selection, _old_selection): + project_name = None + for index in new_selection.indexes(): + name = index.data(PROJECT_NAME_ROLE) + if name: + project_name = name + break + self.selection_changed.emit(project_name or "") + self._controller.set_selected_project(project_name) + + def _on_projects_refresh_finished(self, event): + if event["sender"] != PROJECTS_MODEL_SENDER: + self._projects_model.refresh() diff --git a/client/ayon_core/tools/utils/sliders.py b/client/ayon_core/tools/utils/sliders.py index ea1e01b9ea..c762b6ade0 100644 --- a/client/ayon_core/tools/utils/sliders.py +++ b/client/ayon_core/tools/utils/sliders.py @@ -58,7 +58,7 @@ class NiceSlider(QtWidgets.QSlider): painter.fillRect(event.rect(), QtCore.Qt.transparent) - painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + painter.setRenderHint(QtGui.QPainter.Antialiasing) horizontal = self.orientation() == QtCore.Qt.Horizontal diff --git a/client/ayon_core/tools/utils/thumbnail_paint_widget.py b/client/ayon_core/tools/utils/thumbnail_paint_widget.py index 9dbc2bcdd0..e67b820417 100644 --- a/client/ayon_core/tools/utils/thumbnail_paint_widget.py +++ b/client/ayon_core/tools/utils/thumbnail_paint_widget.py @@ -205,8 +205,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing pix_painter.setRenderHints(render_hints) pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) @@ -241,8 +239,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing pix_painter.setRenderHints(render_hints) tiled_rect = QtCore.QRectF( @@ -335,8 +331,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing final_painter.setRenderHints(render_hints) diff --git a/client/ayon_core/tools/utils/views.py b/client/ayon_core/tools/utils/views.py index d69be9b6a9..2ad1d6c7b5 100644 --- a/client/ayon_core/tools/utils/views.py +++ b/client/ayon_core/tools/utils/views.py @@ -37,7 +37,7 @@ class TreeView(QtWidgets.QTreeView): double_clicked = QtCore.Signal(QtGui.QMouseEvent) def __init__(self, *args, **kwargs): - super(TreeView, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._deselectable = False self._flick_charm_activated = False @@ -60,12 +60,64 @@ class TreeView(QtWidgets.QTreeView): self.clearSelection() # clear the current index self.setCurrentIndex(QtCore.QModelIndex()) - super(TreeView, self).mousePressEvent(event) + super().mousePressEvent(event) def mouseDoubleClickEvent(self, event): self.double_clicked.emit(event) - return super(TreeView, self).mouseDoubleClickEvent(event) + return super().mouseDoubleClickEvent(event) + + def activate_flick_charm(self): + if self._flick_charm_activated: + return + self._flick_charm_activated = True + self._before_flick_scroll_mode = self.verticalScrollMode() + self._flick_charm.activateOn(self) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + + def deactivate_flick_charm(self): + if not self._flick_charm_activated: + return + self._flick_charm_activated = False + self._flick_charm.deactivateFrom(self) + if self._before_flick_scroll_mode is not None: + self.setVerticalScrollMode(self._before_flick_scroll_mode) + + +class ListView(QtWidgets.QListView): + """A tree view that deselects on clicking on an empty area in the view""" + double_clicked = QtCore.Signal(QtGui.QMouseEvent) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._deselectable = False + + self._flick_charm_activated = False + self._flick_charm = FlickCharm(parent=self) + self._before_flick_scroll_mode = None + + def is_deselectable(self): + return self._deselectable + + def set_deselectable(self, deselectable): + self._deselectable = deselectable + + deselectable = property(is_deselectable, set_deselectable) + + def mousePressEvent(self, event): + if self._deselectable: + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + super().mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + self.double_clicked.emit(event) + + return super().mouseDoubleClickEvent(event) def activate_flick_charm(self): if self._flick_charm_activated: diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index af0745af1f..de2c42c91f 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -462,7 +462,7 @@ class BaseClickableFrame(QtWidgets.QFrame): Callback is defined by overriding `_mouse_release_callback`. """ def __init__(self, parent): - super(BaseClickableFrame, self).__init__(parent) + super().__init__(parent) self._mouse_pressed = False @@ -470,17 +470,23 @@ class BaseClickableFrame(QtWidgets.QFrame): pass def mousePressEvent(self, event): + super().mousePressEvent(event) + if event.isAccepted(): + return if event.button() == QtCore.Qt.LeftButton: self._mouse_pressed = True - super(BaseClickableFrame, self).mousePressEvent(event) + event.accept() def mouseReleaseEvent(self, event): - if self._mouse_pressed: - self._mouse_pressed = False - if self.rect().contains(event.pos()): - self._mouse_release_callback() + pressed, self._mouse_pressed = self._mouse_pressed, False + super().mouseReleaseEvent(event) + if event.isAccepted(): + return - super(BaseClickableFrame, self).mouseReleaseEvent(event) + accepted = pressed and self.rect().contains(event.pos()) + if accepted: + event.accept() + self._mouse_release_callback() class ClickableFrame(BaseClickableFrame): @@ -624,8 +630,6 @@ class ClassicExpandBtnLabel(ExpandBtnLabel): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing painter.setRenderHints(render_hints) painter.drawPixmap(QtCore.QPoint(pos_x, pos_y), pixmap) painter.end() @@ -788,8 +792,6 @@ class PixmapButtonPainter(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing painter.setRenderHints(render_hints) if self._cached_pixmap is None: @@ -1189,7 +1191,7 @@ class SquareButton(QtWidgets.QPushButton): """ def __init__(self, *args, **kwargs): - super(SquareButton, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) sp = self.sizePolicy() sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) @@ -1198,17 +1200,17 @@ class SquareButton(QtWidgets.QPushButton): self._ideal_width = None def showEvent(self, event): - super(SquareButton, self).showEvent(event) + super().showEvent(event) self._ideal_width = self.height() self.updateGeometry() def resizeEvent(self, event): - super(SquareButton, self).resizeEvent(event) + super().resizeEvent(event) self._ideal_width = self.height() self.updateGeometry() def sizeHint(self): - sh = super(SquareButton, self).sizeHint() + sh = super().sizeHint() ideal_width = self._ideal_width if ideal_width is None: ideal_width = sh.height() diff --git a/client/ayon_core/tools/workfiles/abstract.py b/client/ayon_core/tools/workfiles/abstract.py index 152ca33d99..863d6bb9bc 100644 --- a/client/ayon_core/tools/workfiles/abstract.py +++ b/client/ayon_core/tools/workfiles/abstract.py @@ -4,76 +4,6 @@ from abc import ABC, abstractmethod from ayon_core.style import get_default_entity_icon_color -class WorkfileInfo: - """Information about workarea file with possible additional from database. - - Args: - folder_id (str): Folder id. - task_id (str): Task id. - filepath (str): Filepath. - filesize (int): File size. - creation_time (float): Creation time (timestamp). - modification_time (float): Modification time (timestamp). - created_by (Union[str, none]): User who created the file. - updated_by (Union[str, none]): User who last updated the file. - note (str): Note. - """ - - def __init__( - self, - folder_id, - task_id, - filepath, - filesize, - creation_time, - modification_time, - created_by, - updated_by, - note, - ): - self.folder_id = folder_id - self.task_id = task_id - self.filepath = filepath - self.filesize = filesize - self.creation_time = creation_time - self.modification_time = modification_time - self.created_by = created_by - self.updated_by = updated_by - self.note = note - - def to_data(self): - """Converts WorkfileInfo item to data. - - Returns: - dict[str, Any]: Folder item data. - """ - - return { - "folder_id": self.folder_id, - "task_id": self.task_id, - "filepath": self.filepath, - "filesize": self.filesize, - "creation_time": self.creation_time, - "modification_time": self.modification_time, - "created_by": self.created_by, - "updated_by": self.updated_by, - "note": self.note, - } - - @classmethod - def from_data(cls, data): - """Re-creates WorkfileInfo item from data. - - Args: - data (dict[str, Any]): Workfile info item data. - - Returns: - WorkfileInfo: Workfile info item. - """ - - return cls(**data) - - class FolderItem: """Item representing folder entity on a server. @@ -87,8 +17,8 @@ class FolderItem: label (str): Folder label. icon_name (str): Name of icon from font awesome. icon_color (str): Hex color string that will be used for icon. - """ + """ def __init__( self, entity_id, parent_id, name, label, icon_name, icon_color ): @@ -104,8 +34,8 @@ class FolderItem: Returns: dict[str, Any]: Folder item data. - """ + """ return { "entity_id": self.entity_id, "parent_id": self.parent_id, @@ -124,8 +54,8 @@ class FolderItem: Returns: FolderItem: Folder item. - """ + """ return cls(**data) @@ -144,8 +74,8 @@ class TaskItem: parent_id (str): Parent folder id. icon_name (str): Name of icon from font awesome. icon_color (str): Hex color string that will be used for icon. - """ + """ def __init__( self, task_id, name, task_type, parent_id, icon_name, icon_color ): @@ -163,8 +93,8 @@ class TaskItem: Returns: str: Task id. - """ + """ return self.task_id @property @@ -173,8 +103,8 @@ class TaskItem: Returns: str: Label of task item. - """ + """ if self._label is None: self._label = "{} ({})".format(self.name, self.task_type) return self._label @@ -184,8 +114,8 @@ class TaskItem: Returns: dict[str, Any]: Task item data. - """ + """ return { "task_id": self.task_id, "name": self.name, @@ -204,116 +134,11 @@ class TaskItem: Returns: TaskItem: Task item. - """ + """ return cls(**data) -class FileItem: - """File item that represents a file. - - Can be used for both Workarea and Published workfile. Workarea file - will always exist on disk which is not the case for Published workfile. - - Args: - dirpath (str): Directory path of file. - filename (str): Filename. - modified (float): Modified timestamp. - created_by (Optional[str]): Username. - representation_id (Optional[str]): Representation id of published - workfile. - filepath (Optional[str]): Prepared filepath. - exists (Optional[bool]): If file exists on disk. - """ - - def __init__( - self, - dirpath, - filename, - modified, - created_by=None, - updated_by=None, - representation_id=None, - filepath=None, - exists=None - ): - self.filename = filename - self.dirpath = dirpath - self.modified = modified - self.created_by = created_by - self.updated_by = updated_by - self.representation_id = representation_id - self._filepath = filepath - self._exists = exists - - @property - def filepath(self): - """Filepath of file. - - Returns: - str: Full path to a file. - """ - - if self._filepath is None: - self._filepath = os.path.join(self.dirpath, self.filename) - return self._filepath - - @property - def exists(self): - """File is available. - - Returns: - bool: If file exists on disk. - """ - - if self._exists is None: - self._exists = os.path.exists(self.filepath) - return self._exists - - def to_data(self): - """Converts file item to data. - - Returns: - dict[str, Any]: File item data. - """ - - return { - "filename": self.filename, - "dirpath": self.dirpath, - "modified": self.modified, - "created_by": self.created_by, - "representation_id": self.representation_id, - "filepath": self.filepath, - "exists": self.exists, - } - - @classmethod - def from_data(cls, data): - """Re-creates file item from data. - - Args: - data (dict[str, Any]): File item data. - - Returns: - FileItem: File item. - """ - - required_keys = { - "filename", - "dirpath", - "modified", - "representation_id" - } - missing_keys = required_keys - set(data.keys()) - if missing_keys: - raise KeyError("Missing keys: {}".format(missing_keys)) - - return cls(**{ - key: data[key] - for key in required_keys - }) - - class WorkareaFilepathResult: """Result of workarea file formatting. @@ -323,8 +148,8 @@ class WorkareaFilepathResult: exists (bool): True if file exists. filepath (str): Filepath. If not provided it will be constructed from root and filename. - """ + """ def __init__(self, root, filename, exists, filepath=None): if not filepath and root and filename: filepath = os.path.join(root, filename) @@ -341,8 +166,8 @@ class AbstractWorkfilesCommon(ABC): Returns: bool: True if host is valid. - """ + """ pass @abstractmethod @@ -353,8 +178,8 @@ class AbstractWorkfilesCommon(ABC): Returns: Iterable[str]: List of extensions. - """ + """ pass @abstractmethod @@ -363,8 +188,8 @@ class AbstractWorkfilesCommon(ABC): Returns: bool: True if save is enabled. - """ + """ pass @abstractmethod @@ -373,8 +198,8 @@ class AbstractWorkfilesCommon(ABC): Args: enabled (bool): Enable save workfile when True. - """ + """ pass @@ -386,6 +211,7 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: str: Name of host. + """ pass @@ -395,8 +221,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: str: Name of project. - """ + """ pass @abstractmethod @@ -406,8 +232,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: Union[str, None]: Folder id or None if host does not have any context. - """ + """ pass @abstractmethod @@ -417,8 +243,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: Union[str, None]: Task name or None if host does not have any context. - """ + """ pass @abstractmethod @@ -428,8 +254,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: Union[str, None]: Path to workfile or None if host does not have opened specific file. - """ + """ pass @property @@ -439,8 +265,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: Anatomy: Project anatomy. - """ + """ pass @property @@ -450,8 +276,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: dict[str, Any]: Project settings. - """ + """ pass @abstractmethod @@ -463,8 +289,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: dict[str, Any]: Project entity data. - """ + """ pass @abstractmethod @@ -477,8 +303,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: dict[str, Any]: Folder entity data. - """ + """ pass @abstractmethod @@ -491,10 +317,24 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: dict[str, Any]: Task entity data. - """ + """ pass + @abstractmethod + def get_workfile_entities(self, task_id: str): + """Workfile entities for given task. + + Args: + task_id (str): Task id. + + Returns: + list[dict[str, Any]]: List of workfile entities. + + """ + pass + + @abstractmethod def emit_event(self, topic, data=None, source=None): """Emit event. @@ -502,8 +342,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): topic (str): Event topic used for callbacks filtering. data (Optional[dict[str, Any]]): Event data. source (Optional[str]): Event source. - """ + """ pass @@ -530,8 +370,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): topic (str): Name of topic. callback (Callable): Callback that will be called when event is triggered. - """ + """ pass @abstractmethod @@ -592,8 +432,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: List[str]: File extensions that can be used as workfile for current host. - """ + """ pass # Selection information @@ -603,8 +443,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: Union[str, None]: Folder id or None if no folder is selected. - """ + """ pass @abstractmethod @@ -616,8 +456,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Args: folder_id (Union[str, None]): Folder id or None if no folder is selected. - """ + """ pass @abstractmethod @@ -626,8 +466,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: Union[str, None]: Task id or None if no folder is selected. - """ + """ pass @abstractmethod @@ -649,8 +489,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): is selected. task_name (Union[str, None]): Task name or None if no task is selected. - """ + """ pass @abstractmethod @@ -659,18 +499,22 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: Union[str, None]: Selected workfile path. - """ + """ pass @abstractmethod - def set_selected_workfile_path(self, path): + def set_selected_workfile_path( + self, rootless_path, path, workfile_entity_id + ): """Change selected workfile path. Args: + rootless_path (Union[str, None]): Selected workfile rootless path. path (Union[str, None]): Selected workfile path. - """ + workfile_entity_id (Union[str, None]): Workfile entity id. + """ pass @abstractmethod @@ -680,8 +524,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: Union[str, None]: Representation id or None if no representation is selected. - """ + """ pass @abstractmethod @@ -691,8 +535,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Args: representation_id (Union[str, None]): Selected workfile representation id. - """ + """ pass def get_selected_context(self): @@ -700,8 +544,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: dict[str, Union[str, None]]: Selected context. - """ + """ return { "folder_id": self.get_selected_folder_id(), "task_id": self.get_selected_task_id(), @@ -737,8 +581,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): files UI element. representation_id (Optional[str]): Representation id. Used for published filed UI element. - """ + """ pass @abstractmethod @@ -750,8 +594,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: dict[str, Any]: Expected selection data. - """ + """ pass @abstractmethod @@ -760,8 +604,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Args: folder_id (str): Folder id which was selected. - """ + """ pass @abstractmethod @@ -771,8 +615,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Args: folder_id (str): Folder id under which task is. task_name (str): Task name which was selected. - """ + """ pass @abstractmethod @@ -785,8 +629,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): folder_id (str): Folder id under which representation is. task_name (str): Task name under which representation is. representation_id (str): Representation id which was selected. - """ + """ pass @abstractmethod @@ -797,8 +641,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): folder_id (str): Folder id under which workfile is. task_name (str): Task name under which workfile is. workfile_name (str): Workfile filename which was selected. - """ + """ pass @abstractmethod @@ -823,8 +667,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: list[FolderItem]: Minimum possible information needed for visualisation of folder hierarchy. - """ + """ pass @abstractmethod @@ -843,8 +687,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: list[TaskItem]: Minimum possible information needed for visualisation of tasks. - """ + """ pass @abstractmethod @@ -853,8 +697,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: bool: Has unsaved changes. - """ + """ pass @abstractmethod @@ -867,8 +711,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: str: Workarea directory. - """ + """ pass @abstractmethod @@ -881,9 +725,9 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): sender (Optional[str]): Who requested workarea file items. Returns: - list[FileItem]: List of workarea file items. - """ + list[WorkfileInfo]: List of workarea file items. + """ pass @abstractmethod @@ -899,8 +743,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: dict[str, Any]: Data for Save As operation. - """ + """ pass @abstractmethod @@ -925,12 +769,12 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: WorkareaFilepathResult: Result of the operation. - """ + """ pass @abstractmethod - def get_published_file_items(self, folder_id, task_id): + def get_published_file_items(self, folder_id: str, task_id: str): """Get published file items. Args: @@ -938,44 +782,52 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): task_id (Union[str, None]): Task id. Returns: - list[FileItem]: List of published file items. - """ + list[PublishedWorkfileInfo]: List of published file items. + """ pass @abstractmethod - def get_workfile_info(self, folder_id, task_name, filepath): + def get_workfile_info(self, folder_id, task_id, rootless_path): """Workfile info from database. Args: folder_id (str): Folder id. - task_name (str): Task id. - filepath (str): Workfile path. + task_id (str): Task id. + rootless_path (str): Workfile path. Returns: - Union[WorkfileInfo, None]: Workfile info or None if was passed + Optional[WorkfileInfo]: Workfile info or None if was passed invalid context. - """ + """ pass @abstractmethod - def save_workfile_info(self, folder_id, task_name, filepath, note): + def save_workfile_info( + self, + task_id, + rootless_path, + version=None, + comment=None, + description=None, + ): """Save workfile info to database. At this moment the only information which can be saved about - workfile is 'note'. + workfile is 'description'. - When 'note' is 'None' it is only validated if workfile info exists, - and if not then creates one with empty note. + If value of 'version', 'comment' or 'description' is 'None' it is not + added/updated to entity. Args: - folder_id (str): Folder id. - task_name (str): Task id. - filepath (str): Workfile path. - note (Union[str, None]): Note. - """ + task_id (str): Task id. + rootless_path (str): Rootless workfile path. + version (Optional[int]): Version of workfile. + comment (Optional[str]): User's comment (subversion). + description (Optional[str]): Workfile description. + """ pass # General commands @@ -985,8 +837,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Triggers 'controller.reset.started' event at the beginning and 'controller.reset.finished' at the end. - """ + """ pass # Controller actions @@ -998,8 +850,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): folder_id (str): Folder id. task_id (str): Task id. filepath (str): Workfile path. - """ + """ pass @abstractmethod @@ -1013,22 +865,27 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): self, folder_id, task_id, + rootless_workdir, workdir, filename, - template_key, - artist_note, + version, + comment, + description, ): """Save current state of workfile to workarea. Args: folder_id (str): Folder id. task_id (str): Task id. - workdir (str): Workarea directory. + rootless_workdir (str): Workarea directory. filename (str): Workarea filename. template_key (str): Template key used to get the workdir and filename. - """ + version (Optional[int]): Version of workfile. + comment (Optional[str]): User's comment (subversion). + description (Optional[str]): Workfile description. + """ pass @abstractmethod @@ -1040,8 +897,10 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): task_id, workdir, filename, - template_key, - artist_note, + rootless_workdir, + version, + comment, + description, ): """Action to copy published workfile representation to workarea. @@ -1055,23 +914,40 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): task_id (str): Task id. workdir (str): Workarea directory. filename (str): Workarea filename. - template_key (str): Template key. - artist_note (str): Artist note. - """ + rootless_workdir (str): Rootless workdir. + version (int): Workfile version. + comment (str): User's comment (subversion). + description (str): Description note. + """ pass @abstractmethod - def duplicate_workfile(self, src_filepath, workdir, filename, artist_note): + def duplicate_workfile( + self, + folder_id, + task_id, + src_filepath, + rootless_workdir, + workdir, + filename, + description, + version, + comment + ): """Duplicate workfile. Workfiles is not opened when done. Args: + folder_id (str): Folder id. + task_id (str): Task id. src_filepath (str): Source workfile path. + rootless_workdir (str): Rootless workdir. workdir (str): Destination workdir. filename (str): Destination filename. - artist_note (str): Artist note. + version (int): Workfile version. + comment (str): User's comment (subversion). + description (str): Workfile description. """ - pass diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index 3a7459da0c..f0e0f0e416 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -1,33 +1,28 @@ import os -import shutil import ayon_api from ayon_core.host import IWorkfileHost -from ayon_core.lib import Logger, emit_event +from ayon_core.lib import Logger, get_ayon_username from ayon_core.lib.events import QueuedEventSystem -from ayon_core.settings import get_project_settings from ayon_core.pipeline import Anatomy, registered_host -from ayon_core.pipeline.context_tools import ( - change_current_context, - get_current_host_name, - get_global_context, -) -from ayon_core.pipeline.workfile import create_workdir_extra_folders - +from ayon_core.pipeline.context_tools import get_global_context +from ayon_core.settings import get_project_settings from ayon_core.tools.common_models import ( - HierarchyModel, HierarchyExpectedSelection, + HierarchyModel, ProjectsModel, UsersModel, ) from .abstract import ( - AbstractWorkfilesFrontend, AbstractWorkfilesBackend, + AbstractWorkfilesFrontend, ) from .models import SelectionModel, WorkfilesModel +NOT_SET = object() + class WorkfilesToolExpectedSelection(HierarchyExpectedSelection): def __init__(self, controller): @@ -140,12 +135,7 @@ class BaseWorkfileController( if host is None: host = registered_host() - host_is_valid = False - if host is not None: - missing_methods = ( - IWorkfileHost.get_missing_workfile_methods(host) - ) - host_is_valid = len(missing_methods) == 0 + host_is_valid = isinstance(host, IWorkfileHost) self._host = host self._host_is_valid = host_is_valid @@ -154,6 +144,7 @@ class BaseWorkfileController( self._project_settings = None self._event_system = None self._log = None + self._username = NOT_SET self._current_project_name = None self._current_folder_path = None @@ -182,7 +173,7 @@ class BaseWorkfileController( return UsersModel(self) def _create_workfiles_model(self): - return WorkfilesModel(self) + return WorkfilesModel(self._host, self) def _create_expected_selection_obj(self): return WorkfilesToolExpectedSelection(self) @@ -293,28 +284,14 @@ class BaseWorkfileController( # Host information def get_workfile_extensions(self): - host = self._host - if isinstance(host, IWorkfileHost): - return host.get_workfile_extensions() - return host.file_extensions() + return self._host.get_workfile_extensions() def has_unsaved_changes(self): - host = self._host - if isinstance(host, IWorkfileHost): - return host.workfile_has_unsaved_changes() - return host.has_unsaved_changes() + return self._host.workfile_has_unsaved_changes() # Current context def get_host_name(self): - host = self._host - if isinstance(host, IWorkfileHost): - return host.name - return get_current_host_name() - - def _get_host_current_context(self): - if hasattr(self._host, "get_current_context"): - return self._host.get_current_context() - return get_global_context() + return self._host.name def get_current_project_name(self): return self._current_project_name @@ -326,10 +303,7 @@ class BaseWorkfileController( return self._current_task_name def get_current_workfile(self): - host = self._host - if isinstance(host, IWorkfileHost): - return host.get_current_workfile() - return host.current_file() + return self._workfiles_model.get_current_workfile() # Selection information def get_selected_folder_id(self): @@ -350,8 +324,12 @@ class BaseWorkfileController( def get_selected_workfile_path(self): return self._selection_model.get_selected_workfile_path() - def set_selected_workfile_path(self, path): - self._selection_model.set_selected_workfile_path(path) + def set_selected_workfile_path( + self, rootless_path, path, workfile_entity_id + ): + self._selection_model.set_selected_workfile_path( + rootless_path, path, workfile_entity_id + ) def get_selected_representation_id(self): return self._selection_model.get_selected_representation_id() @@ -424,7 +402,7 @@ class BaseWorkfileController( def get_workarea_file_items(self, folder_id, task_name, sender=None): task_id = self._get_task_id(folder_id, task_name) return self._workfiles_model.get_workarea_file_items( - folder_id, task_id, task_name + folder_id, task_id ) def get_workarea_save_as_data(self, folder_id, task_id): @@ -450,28 +428,34 @@ class BaseWorkfileController( ) def get_published_file_items(self, folder_id, task_id): - task_name = None - if task_id: - task = self.get_task_entity( - self.get_current_project_name(), task_id - ) - task_name = task.get("name") - return self._workfiles_model.get_published_file_items( - folder_id, task_name) + folder_id, task_id + ) - def get_workfile_info(self, folder_id, task_name, filepath): - task_id = self._get_task_id(folder_id, task_name) + def get_workfile_info(self, folder_id, task_id, rootless_path): return self._workfiles_model.get_workfile_info( - folder_id, task_id, filepath + folder_id, task_id, rootless_path ) - def save_workfile_info(self, folder_id, task_name, filepath, note): - task_id = self._get_task_id(folder_id, task_name) + def save_workfile_info( + self, + task_id, + rootless_path, + version=None, + comment=None, + description=None, + ): self._workfiles_model.save_workfile_info( - folder_id, task_id, filepath, note + task_id, + rootless_path, + version, + comment, + description, ) + def get_workfile_entities(self, task_id): + return self._workfiles_model.get_workfile_entities(task_id) + def reset(self): if not self._host_is_valid: self._emit_event("controller.reset.started") @@ -509,6 +493,7 @@ class BaseWorkfileController( self._projects_model.reset() self._hierarchy_model.reset() + self._workfiles_model.reset() if not expected_folder_id: expected_folder_id = folder_id @@ -528,53 +513,31 @@ class BaseWorkfileController( # Controller actions def open_workfile(self, folder_id, task_id, filepath): - self._emit_event("open_workfile.started") - - failed = False - try: - self._open_workfile(folder_id, task_id, filepath) - - except Exception: - failed = True - self.log.warning("Open of workfile failed", exc_info=True) - - self._emit_event( - "open_workfile.finished", - {"failed": failed}, - ) + self._workfiles_model.open_workfile(folder_id, task_id, filepath) def save_current_workfile(self): - current_file = self.get_current_workfile() - self._host_save_workfile(current_file) + self._workfiles_model.save_current_workfile() def save_as_workfile( self, folder_id, task_id, + rootless_workdir, workdir, filename, - template_key, - artist_note, + version, + comment, + description, ): - self._emit_event("save_as.started") - - failed = False - try: - self._save_as_workfile( - folder_id, - task_id, - workdir, - filename, - template_key, - artist_note=artist_note, - ) - except Exception: - failed = True - self.log.warning("Save as failed", exc_info=True) - - self._emit_event( - "save_as.finished", - {"failed": failed}, + self._workfiles_model.save_as_workfile( + folder_id, + task_id, + rootless_workdir, + workdir, + filename, + version, + comment, + description, ) def copy_workfile_representation( @@ -585,63 +548,61 @@ class BaseWorkfileController( task_id, workdir, filename, - template_key, - artist_note, + rootless_workdir, + version, + comment, + description, ): - self._emit_event("copy_representation.started") - - failed = False - try: - self._save_as_workfile( - folder_id, - task_id, - workdir, - filename, - template_key, - artist_note, - src_filepath=representation_filepath - ) - except Exception: - failed = True - self.log.warning( - "Copy of workfile representation failed", exc_info=True - ) - - self._emit_event( - "copy_representation.finished", - {"failed": failed}, + self._workfiles_model.copy_workfile_representation( + representation_id, + representation_filepath, + folder_id, + task_id, + workdir, + filename, + rootless_workdir, + version, + comment, + description, ) - def duplicate_workfile(self, src_filepath, workdir, filename, artist_note): - self._emit_event("workfile_duplicate.started") - - failed = False - try: - dst_filepath = os.path.join(workdir, filename) - shutil.copy(src_filepath, dst_filepath) - except Exception: - failed = True - self.log.warning("Duplication of workfile failed", exc_info=True) - - self._emit_event( - "workfile_duplicate.finished", - {"failed": failed}, + def duplicate_workfile( + self, + folder_id, + task_id, + src_filepath, + rootless_workdir, + workdir, + filename, + version, + comment, + description + ): + self._workfiles_model.duplicate_workfile( + folder_id, + task_id, + src_filepath, + rootless_workdir, + workdir, + filename, + version, + comment, + description, ) - # Helper host methods that resolve 'IWorkfileHost' interface - def _host_open_workfile(self, filepath): - host = self._host - if isinstance(host, IWorkfileHost): - host.open_workfile(filepath) - else: - host.open_file(filepath) + def get_my_tasks_entity_ids(self, project_name: str): + username = self._get_my_username() + assignees = [] + if username: + assignees.append(username) + return self._hierarchy_model.get_entity_ids_for_assignees( + project_name, assignees + ) - def _host_save_workfile(self, filepath): - host = self._host - if isinstance(host, IWorkfileHost): - host.save_workfile(filepath) - else: - host.save_file(filepath) + def _get_my_username(self): + if self._username is NOT_SET: + self._username = get_ayon_username() + return self._username def _emit_event(self, topic, data=None): self.emit_event(topic, data, "controller") @@ -657,6 +618,11 @@ class BaseWorkfileController( return None return task_item.id + def _get_host_current_context(self): + if hasattr(self._host, "get_current_context"): + return self._host.get_current_context() + return get_global_context() + # Expected selection # - expected selection is used to restore selection after refresh # or when current context should be used @@ -665,123 +631,3 @@ class BaseWorkfileController( "expected_selection_changed", self._expected_selection.get_expected_selection_data(), ) - - def _get_event_context_data( - self, project_name, folder_id, task_id, folder=None, task=None - ): - if folder is None: - folder = self.get_folder_entity(project_name, folder_id) - if task is None: - task = self.get_task_entity(project_name, task_id) - return { - "project_name": project_name, - "folder_id": folder_id, - "folder_path": folder["path"], - "task_id": task_id, - "task_name": task["name"], - "host_name": self.get_host_name(), - } - - def _open_workfile(self, folder_id, task_id, filepath): - project_name = self.get_current_project_name() - event_data = self._get_event_context_data( - project_name, folder_id, task_id - ) - event_data["filepath"] = filepath - - emit_event("workfile.open.before", event_data, source="workfiles.tool") - - # Change context - task_name = event_data["task_name"] - if ( - folder_id != self.get_current_folder_id() - or task_name != self.get_current_task_name() - ): - self._change_current_context(project_name, folder_id, task_id) - - self._host_open_workfile(filepath) - - emit_event("workfile.open.after", event_data, source="workfiles.tool") - - def _save_as_workfile( - self, - folder_id: str, - task_id: str, - workdir: str, - filename: str, - template_key: str, - artist_note: str, - src_filepath=None, - ): - # Trigger before save event - project_name = self.get_current_project_name() - folder = self.get_folder_entity(project_name, folder_id) - task = self.get_task_entity(project_name, task_id) - task_name = task["name"] - - # QUESTION should the data be different for 'before' and 'after'? - event_data = self._get_event_context_data( - project_name, folder_id, task_id, folder, task - ) - event_data.update({ - "filename": filename, - "workdir_path": workdir, - }) - - emit_event("workfile.save.before", event_data, source="workfiles.tool") - - # Create workfiles root folder - if not os.path.exists(workdir): - self.log.debug("Initializing work directory: %s", workdir) - os.makedirs(workdir) - - # Change context - if ( - folder_id != self.get_current_folder_id() - or task_name != self.get_current_task_name() - ): - self._change_current_context( - project_name, folder_id, task_id, template_key - ) - - # Save workfile - dst_filepath = os.path.join(workdir, filename) - if src_filepath: - shutil.copyfile(src_filepath, dst_filepath) - self._host_open_workfile(dst_filepath) - else: - self._host_save_workfile(dst_filepath) - - # Make sure workfile info exists - if not artist_note: - artist_note = None - self.save_workfile_info( - folder_id, task_name, dst_filepath, note=artist_note - ) - - # Create extra folders - create_workdir_extra_folders( - workdir, - self.get_host_name(), - task["taskType"], - task_name, - project_name - ) - - # Trigger after save events - emit_event("workfile.save.after", event_data, source="workfiles.tool") - - def _change_current_context( - self, project_name, folder_id, task_id, template_key=None - ): - # Change current context - folder_entity = self.get_folder_entity(project_name, folder_id) - task_entity = self.get_task_entity(project_name, task_id) - change_current_context( - folder_entity, - task_entity, - template_key=template_key - ) - self._current_folder_id = folder_entity["id"] - self._current_folder_path = folder_entity["path"] - self._current_task_name = task_entity["name"] diff --git a/client/ayon_core/tools/workfiles/models/selection.py b/client/ayon_core/tools/workfiles/models/selection.py index 2f0896842d..9a6440b2a1 100644 --- a/client/ayon_core/tools/workfiles/models/selection.py +++ b/client/ayon_core/tools/workfiles/models/selection.py @@ -62,7 +62,9 @@ class SelectionModel(object): def get_selected_workfile_path(self): return self._workfile_path - def set_selected_workfile_path(self, path): + def set_selected_workfile_path( + self, rootless_path, path, workfile_entity_id + ): if path == self._workfile_path: return @@ -72,9 +74,11 @@ class SelectionModel(object): { "project_name": self._controller.get_current_project_name(), "path": path, + "rootless_path": rootless_path, "folder_id": self._folder_id, "task_name": self._task_name, "task_id": self._task_id, + "workfile_entity_id": workfile_entity_id, }, self.event_source ) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index cc034571f3..5b5591fe43 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -1,13 +1,31 @@ +from __future__ import annotations import os -import re import copy -import uuid +import platform +import typing +from typing import Optional, Any -import arrow import ayon_api -from ayon_api.operations import OperationsSession -from ayon_core.lib import get_ayon_username +from ayon_core.lib import ( + get_ayon_username, + NestedCacheItem, + CacheItem, + Logger, +) +from ayon_core.host import ( + IWorkfileHost, + WorkfileInfo, + PublishedWorkfileInfo, +) +from ayon_core.host.interfaces import ( + OpenWorkfileOptionalData, + ListWorkfilesOptionalData, + ListPublishedWorkfilesOptionalData, + SaveWorkfileOptionalData, + CopyWorkfileOptionalData, + CopyPublishedWorkfileOptionalData, +) from ayon_core.pipeline.template_data import ( get_template_data, get_task_template_data, @@ -16,147 +34,335 @@ from ayon_core.pipeline.template_data import ( from ayon_core.pipeline.workfile import ( get_workdir_with_workdir_data, get_workfile_template_key, - get_last_workfile_with_version, + save_workfile_info, ) from ayon_core.pipeline.version_start import get_versioning_start from ayon_core.tools.workfiles.abstract import ( WorkareaFilepathResult, - FileItem, - WorkfileInfo, + AbstractWorkfilesBackend, ) +if typing.TYPE_CHECKING: + from ayon_core.pipeline import Anatomy + _NOT_SET = object() -class CommentMatcher(object): - """Use anatomy and work file data to parse comments from filenames. +class WorkfilesModel: + """Workfiles model.""" - Args: - extensions (set[str]): Set of extensions. - file_template (AnatomyStringTemplate): File template. - data (dict[str, Any]): Data to fill the template with. + def __init__( + self, + host: IWorkfileHost, + controller: AbstractWorkfilesBackend + ): + self._host: IWorkfileHost = host + self._controller: AbstractWorkfilesBackend = controller - """ - def __init__(self, extensions, file_template, data): - self.fname_regex = None - - if "{comment}" not in file_template: - # Don't look for comment if template doesn't allow it - return - - # Create a regex group for extensions - any_extension = "(?:{})".format( - "|".join(re.escape(ext.lstrip(".")) for ext in extensions) - ) - - # Use placeholders that will never be in the filename - temp_data = copy.deepcopy(data) - temp_data["comment"] = "<>" - temp_data["version"] = "<>" - temp_data["ext"] = "<>" - - fname_pattern = file_template.format_strict(temp_data) - fname_pattern = re.escape(fname_pattern) - - # Replace comment and version with something we can match with regex - replacements = { - "<>": "(.+)", - "<>": "[0-9]+", - "<>": any_extension, - } - for src, dest in replacements.items(): - fname_pattern = fname_pattern.replace(re.escape(src), dest) - - # Match from beginning to end of string to be safe - fname_pattern = "^{}$".format(fname_pattern) - - self.fname_regex = re.compile(fname_pattern) - - def parse_comment(self, filepath): - """Parse the {comment} part from a filename""" - if not self.fname_regex: - return - - fname = os.path.basename(filepath) - match = self.fname_regex.match(fname) - if match: - return match.group(1) - - -class WorkareaModel: - """Workfiles model looking for workfiles in workare folder. - - Workarea folder is usually task and host specific, defined by - anatomy templates. Is looking for files with extensions defined - by host integration. - """ - - def __init__(self, controller): - self._controller = controller + self._log = Logger.get_logger("WorkfilesModel") extensions = None if controller.is_host_valid(): extensions = controller.get_workfile_extensions() - self._extensions = extensions + self._extensions: Optional[set[str]] = extensions + + self._current_username = _NOT_SET + + # Workarea self._base_data = None self._fill_data_by_folder_id = {} self._task_data_by_folder_id = {} self._workdir_by_context = {} + self._workarea_file_items_mapping = {} + self._workarea_file_items_cache = NestedCacheItem( + levels=1, default_factory=list + ) - @property - def project_name(self): - return self._controller.get_current_project_name() + # Published workfiles + self._repre_by_id = {} + self._published_workfile_items_cache = NestedCacheItem( + levels=1, default_factory=list + ) + + # Entities + self._workfile_entities_by_task_id = {} def reset(self): self._base_data = None self._fill_data_by_folder_id = {} self._task_data_by_folder_id = {} + self._workdir_by_context = {} + self._workarea_file_items_mapping = {} + self._workarea_file_items_cache.reset() - def _get_base_data(self): - if self._base_data is None: - base_data = get_template_data( - ayon_api.get_project(self.project_name) - ) - base_data["app"] = self._controller.get_host_name() - self._base_data = base_data - return copy.deepcopy(self._base_data) + self._repre_by_id = {} + self._published_workfile_items_cache.reset() - def _get_folder_data(self, folder_id): - fill_data = self._fill_data_by_folder_id.get(folder_id) - if fill_data is None: - folder = self._controller.get_folder_entity( - self.project_name, folder_id - ) - fill_data = get_folder_template_data(folder, self.project_name) - self._fill_data_by_folder_id[folder_id] = fill_data - return copy.deepcopy(fill_data) + self._workfile_entities_by_task_id = {} - def _get_task_data(self, project_entity, folder_id, task_id): - task_data = self._task_data_by_folder_id.setdefault(folder_id, {}) - if task_id not in task_data: - task = self._controller.get_task_entity( - self.project_name, task_id - ) - if task: - task_data[task_id] = get_task_template_data( - project_entity, task) - return copy.deepcopy(task_data[task_id]) + # Host functionality + def get_current_workfile(self): + return self._host.get_current_workfile() - def _prepare_fill_data(self, folder_id, task_id): - if not folder_id or not task_id: - return {} + def open_workfile(self, folder_id, task_id, filepath): + self._emit_event("open_workfile.started") - base_data = self._get_base_data() - project_name = base_data["project"]["name"] - folder_data = self._get_folder_data(folder_id) + failed = False + try: + self._open_workfile(folder_id, task_id, filepath) + + except Exception: + failed = True + self._log.warning("Open of workfile failed", exc_info=True) + + self._emit_event( + "open_workfile.finished", + {"failed": failed}, + ) + + def save_current_workfile(self): + current_file = self.get_current_workfile() + self._host.save_workfile(current_file) + + def save_as_workfile( + self, + folder_id, + task_id, + rootless_workdir, + workdir, + filename, + version, + comment, + description, + ): + self._emit_event("save_as.started") + + filepath = os.path.join(workdir, filename) + rootless_path = f"{rootless_workdir}/{filename}" + project_name = self._controller.get_current_project_name() project_entity = self._controller.get_project_entity(project_name) - task_data = self._get_task_data(project_entity, folder_id, task_id) + folder_entity = self._controller.get_folder_entity( + project_name, folder_id + ) + task_entity = self._controller.get_task_entity( + project_name, task_id + ) - base_data.update(folder_data) - base_data.update(task_data) + prepared_data = SaveWorkfileOptionalData( + project_entity=project_entity, + anatomy=self._controller.project_anatomy, + project_settings=self._controller.project_settings, + rootless_path=rootless_path, + workfile_entities=self.get_workfile_entities(task_id), + ) + failed = False + try: + self._host.save_workfile_with_context( + filepath, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + prepared_data=prepared_data, + ) + self._update_workfile_info( + task_id, rootless_path, description + ) + self._update_current_context( + folder_id, folder_entity["path"], task_entity["name"] + ) - return base_data + except Exception: + failed = True + self._log.warning("Save as failed", exc_info=True) - def get_workarea_dir_by_context(self, folder_id, task_id): + self._emit_event( + "save_as.finished", + {"failed": failed}, + ) + + def copy_workfile_representation( + self, + representation_id, + representation_filepath, + folder_id, + task_id, + workdir, + filename, + rootless_workdir, + version, + comment, + description, + ): + self._emit_event("copy_representation.started") + + project_name = self._project_name + project_entity = self._controller.get_project_entity(project_name) + folder_entity = self._controller.get_folder_entity( + project_name, folder_id + ) + task_entity = self._controller.get_task_entity( + project_name, task_id + ) + repre_entity = self._repre_by_id.get(representation_id) + dst_filepath = os.path.join(workdir, filename) + rootless_path = f"{rootless_workdir}/{filename}" + + prepared_data = CopyPublishedWorkfileOptionalData( + project_entity=project_entity, + anatomy=self._controller.project_anatomy, + project_settings=self._controller.project_settings, + rootless_path=rootless_path, + representation_path=representation_filepath, + workfile_entities=self.get_workfile_entities(task_id), + src_anatomy=self._controller.project_anatomy, + ) + failed = False + try: + self._host.copy_workfile_representation( + project_name, + repre_entity, + dst_filepath, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + prepared_data=prepared_data, + ) + self._update_workfile_info( + task_id, rootless_path, description + ) + self._update_current_context( + folder_id, folder_entity["path"], task_entity["name"] + ) + + except Exception: + failed = True + self._log.warning( + "Copy of workfile representation failed", exc_info=True + ) + + self._emit_event( + "copy_representation.finished", + {"failed": failed}, + ) + + def duplicate_workfile( + self, + folder_id, + task_id, + src_filepath, + rootless_workdir, + workdir, + filename, + version, + comment, + description + ): + self._emit_event("workfile_duplicate.started") + + project_name = self._controller.get_current_project_name() + project_entity = self._controller.get_project_entity(project_name) + folder_entity = self._controller.get_folder_entity( + project_name, folder_id + ) + task_entity = self._controller.get_task_entity(project_name, task_id) + workfile_entities = self.get_workfile_entities(task_id) + rootless_path = f"{rootless_workdir}/{filename}" + workfile_path = os.path.join(workdir, filename) + + prepared_data = CopyWorkfileOptionalData( + project_entity=project_entity, + project_settings=self._controller.project_settings, + anatomy=self._controller.project_anatomy, + rootless_path=rootless_path, + workfile_entities=workfile_entities, + ) + failed = False + try: + self._host.copy_workfile( + src_filepath, + workfile_path, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + prepared_data=prepared_data, + ) + + except Exception: + failed = True + self._log.warning("Duplication of workfile failed", exc_info=True) + + self._emit_event( + "workfile_duplicate.finished", + {"failed": failed}, + ) + + def get_workfile_entities(self, task_id: str): + if not task_id: + return [] + workfile_entities = self._workfile_entities_by_task_id.get(task_id) + if workfile_entities is None: + workfile_entities = list(ayon_api.get_workfiles_info( + self._project_name, + task_ids=[task_id], + )) + self._workfile_entities_by_task_id[task_id] = workfile_entities + return workfile_entities + + def get_workfile_info( + self, + folder_id: Optional[str], + task_id: Optional[str], + rootless_path: Optional[str] + ): + if not folder_id or not task_id or not rootless_path: + return None + + mapping = self._workarea_file_items_mapping.get(task_id) + if mapping is None: + self._cache_file_items(folder_id, task_id) + mapping = self._workarea_file_items_mapping[task_id] + return mapping.get(rootless_path) + + def save_workfile_info( + self, + task_id: str, + rootless_path: str, + version: Optional[int], + comment: Optional[str], + description: Optional[str], + ): + self._save_workfile_info( + task_id, + rootless_path, + version, + comment, + description, + ) + + self._update_file_description( + task_id, rootless_path, description + ) + + def get_workarea_dir_by_context( + self, folder_id: str, task_id: str + ) -> Optional[str]: + """Workarea dir for passed context. + + The directory path is based on project anatomy templates. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + Optional[str]: Workarea dir path or None for invalid context. + + """ if not folder_id or not task_id: return None folder_mapping = self._workdir_by_context.setdefault(folder_id, {}) @@ -168,164 +374,48 @@ class WorkareaModel: workdir = get_workdir_with_workdir_data( workdir_data, - self.project_name, + self._project_name, anatomy=self._controller.project_anatomy, ) folder_mapping[task_id] = workdir return workdir - def get_file_items(self, folder_id, task_id, task_name): - items = [] - if not folder_id or not task_id: - return items - - workdir = self.get_workarea_dir_by_context(folder_id, task_id) - if not os.path.exists(workdir): - return items - - for filename in os.listdir(workdir): - # We want to support both files and folders. e.g. Silhoutte uses - # folders as its project files. So we do not check whether it is - # a file or not. - filepath = os.path.join(workdir, filename) - - ext = os.path.splitext(filename)[1].lower() - if ext not in self._extensions: - continue - - workfile_info = self._controller.get_workfile_info( - folder_id, task_name, filepath - ) - modified = os.path.getmtime(filepath) - items.append(FileItem( - workdir, - filename, - modified, - workfile_info.created_by, - workfile_info.updated_by, - )) - return items - - def _get_template_key(self, fill_data): - task_type = fill_data.get("task", {}).get("type") - # TODO cache - return get_workfile_template_key( - self.project_name, - task_type, - self._controller.get_host_name(), - ) - - def _get_last_workfile_version( - self, workdir, file_template, fill_data, extensions - ): - """ - - Todos: - Validate if logic of this function is correct. It does return - last version + 1 which might be wrong. + def get_workarea_file_items(self, folder_id, task_id): + """Workfile items for passed context from workarea. Args: - workdir (str): Workdir path. - file_template (str): File template. - fill_data (dict[str, Any]): Fill data. - extensions (set[str]): Extensions. + folder_id (Optional[str]): Folder id. + task_id (Optional[str]): Task id. Returns: - int: Next workfile version. + list[WorkfileInfo]: List of file items matching workarea of passed + context. """ - version = get_last_workfile_with_version( - workdir, file_template, fill_data, extensions - )[1] + return self._cache_file_items(folder_id, task_id) - if version is None: - task_info = fill_data.get("task", {}) - version = get_versioning_start( - self.project_name, - self._controller.get_host_name(), - task_name=task_info.get("name"), - task_type=task_info.get("type"), - product_type="workfile", - project_settings=self._controller.project_settings, - ) - else: - version += 1 - return version - - def _get_comments_from_root( - self, - file_template, - extensions, - fill_data, - root, - current_filename, - ): - """Get comments from root directory. - - Args: - file_template (AnatomyStringTemplate): File template. - extensions (set[str]): Extensions. - fill_data (dict[str, Any]): Fill data. - root (str): Root directory. - current_filename (str): Current filename. - - Returns: - Tuple[list[str], Union[str, None]]: Comment hints and current - comment. - - """ - current_comment = None - filenames = [] - if root and os.path.exists(root): - for filename in os.listdir(root): - path = os.path.join(root, filename) - if not os.path.isfile(path): - continue - - ext = os.path.splitext(filename)[-1].lower() - if ext in extensions: - filenames.append(filename) - - if not filenames: - return [], current_comment - - matcher = CommentMatcher(extensions, file_template, fill_data) - - comment_hints = set() - for filename in filenames: - comment = matcher.parse_comment(filename) - if comment: - comment_hints.add(comment) - if filename == current_filename: - current_comment = comment - - return list(comment_hints), current_comment - - def _get_workdir(self, anatomy, template_key, fill_data): - directory_template = anatomy.get_template_item( - "work", template_key, "directory" - ) - return directory_template.format_strict(fill_data).normalized() - - def get_workarea_save_as_data(self, folder_id, task_id): + def get_workarea_save_as_data( + self, folder_id: Optional[str], task_id: Optional[str] + ) -> dict[str, Any]: folder_entity = None task_entity = None if folder_id: folder_entity = self._controller.get_folder_entity( - self.project_name, folder_id + self._project_name, folder_id ) if folder_entity and task_id: task_entity = self._controller.get_task_entity( - self.project_name, task_id + self._project_name, task_id ) - if not folder_entity or not task_entity: + if not folder_entity or not task_entity or self._extensions is None: return { "template_key": None, "template_has_version": None, "template_has_comment": None, "ext": None, "workdir": None, + "rootless_workdir": None, "comment": None, "comment_hints": None, "last_version": None, @@ -349,6 +439,17 @@ class WorkareaModel: workdir = self._get_workdir(anatomy, template_key, fill_data) + rootless_workdir = workdir + if platform.system().lower() == "windows": + rootless_workdir = rootless_workdir.replace("\\", "/") + + used_roots = workdir.used_values.get("root") + if used_roots: + used_root_name = next(iter(used_roots)) + root_value = used_roots[used_root_name] + workdir_end = rootless_workdir[len(root_value):].lstrip("/") + rootless_workdir = f"{{root[{used_root_name}]}}/{workdir_end}" + file_template = anatomy.get_template_item( "work", template_key, "file" ) @@ -357,15 +458,21 @@ class WorkareaModel: template_has_version = "{version" in file_template_str template_has_comment = "{comment" in file_template_str - comment_hints, comment = self._get_comments_from_root( - file_template, - extensions, - fill_data, - workdir, - current_filename, - ) + file_items = self.get_workarea_file_items(folder_id, task_id) + comment_hints = set() + comment = None + for item in file_items: + filepath = item.filepath + filename = os.path.basename(filepath) + if filename == current_filename: + comment = item.comment + + if item.comment: + comment_hints.add(item.comment) + comment_hints = list(comment_hints) + last_version = self._get_last_workfile_version( - workdir, file_template_str, fill_data, extensions + file_items, task_entity ) return { @@ -374,6 +481,7 @@ class WorkareaModel: "template_has_comment": template_has_comment, "ext": current_ext, "workdir": workdir, + "rootless_workdir": rootless_workdir, "comment": comment, "comment_hints": comment_hints, "last_version": last_version, @@ -382,13 +490,13 @@ class WorkareaModel: def fill_workarea_filepath( self, - folder_id, - task_id, - extension, - use_last_version, - version, - comment, - ): + folder_id: str, + task_id: str, + extension: str, + use_last_version: bool, + version: int, + comment: str, + ) -> WorkareaFilepathResult: """Fill workarea filepath based on context. Args: @@ -415,8 +523,12 @@ class WorkareaModel: ) if use_last_version: + file_items = self.get_workarea_file_items(folder_id, task_id) + task_entity = self._controller.get_task_entity( + self._project_name, task_id + ) version = self._get_last_workfile_version( - workdir, file_template.template, fill_data, self._extensions + file_items, task_entity ) fill_data["version"] = version fill_data["ext"] = extension.lstrip(".") @@ -439,374 +551,350 @@ class WorkareaModel: exists ) - -class WorkfileEntitiesModel: - """Workfile entities model. - - Args: - control (AbstractWorkfileController): Controller object. - """ - - def __init__(self, controller): - self._controller = controller - self._cache = {} - self._items = {} - self._current_username = _NOT_SET - - def _get_workfile_info_identifier( - self, folder_id, task_id, rootless_path - ): - return "_".join([folder_id, task_id, rootless_path]) - - def _get_rootless_path(self, filepath): - anatomy = self._controller.project_anatomy - - workdir, filename = os.path.split(filepath) - _, rootless_dir = anatomy.find_root_template_from_path(workdir) - return "/".join([ - os.path.normpath(rootless_dir).replace("\\", "/"), - filename - ]) - - def _prepare_workfile_info_item( - self, folder_id, task_id, workfile_info, filepath - ): - note = "" - created_by = None - updated_by = None - if workfile_info: - note = workfile_info["attrib"].get("description") or "" - created_by = workfile_info.get("createdBy") - updated_by = workfile_info.get("updatedBy") - - filestat = os.stat(filepath) - return WorkfileInfo( - folder_id, - task_id, - filepath, - filesize=filestat.st_size, - creation_time=filestat.st_ctime, - modification_time=filestat.st_mtime, - created_by=created_by, - updated_by=updated_by, - note=note - ) - - def _get_workfile_info(self, folder_id, task_id, identifier): - workfile_info = self._cache.get(identifier) - if workfile_info is not None: - return workfile_info - - for workfile_info in ayon_api.get_workfiles_info( - self._controller.get_current_project_name(), - task_ids=[task_id], - fields=["id", "path", "attrib", "createdBy", "updatedBy"], - ): - workfile_identifier = self._get_workfile_info_identifier( - folder_id, task_id, workfile_info["path"] - ) - self._cache[workfile_identifier] = workfile_info - return self._cache.get(identifier) - - def get_workfile_info( - self, folder_id, task_id, filepath, rootless_path=None - ): - if not folder_id or not task_id or not filepath: - return None - - if rootless_path is None: - rootless_path = self._get_rootless_path(filepath) - - identifier = self._get_workfile_info_identifier( - folder_id, task_id, rootless_path) - item = self._items.get(identifier) - if item is None: - workfile_info = self._get_workfile_info( - folder_id, task_id, identifier - ) - item = self._prepare_workfile_info_item( - folder_id, task_id, workfile_info, filepath - ) - self._items[identifier] = item - return item - - def save_workfile_info(self, folder_id, task_id, filepath, note): - rootless_path = self._get_rootless_path(filepath) - identifier = self._get_workfile_info_identifier( - folder_id, task_id, rootless_path - ) - workfile_info = self._get_workfile_info( - folder_id, task_id, identifier - ) - if not workfile_info: - self._cache[identifier] = self._create_workfile_info_entity( - task_id, rootless_path, note or "") - self._items.pop(identifier, None) - return - - old_note = workfile_info.get("attrib", {}).get("note") - - new_workfile_info = copy.deepcopy(workfile_info) - update_data = {} - if note is not None and old_note != note: - update_data["attrib"] = {"description": note} - attrib = new_workfile_info.setdefault("attrib", {}) - attrib["description"] = note - - username = self._get_current_username() - # Automatically fix 'createdBy' and 'updatedBy' fields - # NOTE both fields were not automatically filled by server - # until 1.1.3 release. - if workfile_info.get("createdBy") is None: - update_data["createdBy"] = username - new_workfile_info["createdBy"] = username - - if workfile_info.get("updatedBy") != username: - update_data["updatedBy"] = username - new_workfile_info["updatedBy"] = username - - if not update_data: - return - - self._cache[identifier] = new_workfile_info - self._items.pop(identifier, None) - - project_name = self._controller.get_current_project_name() - - session = OperationsSession() - session.update_entity( - project_name, - "workfile", - workfile_info["id"], - update_data, - ) - session.commit() - - def _create_workfile_info_entity(self, task_id, rootless_path, note): - extension = os.path.splitext(rootless_path)[1] - - project_name = self._controller.get_current_project_name() - - username = self._get_current_username() - workfile_info = { - "id": uuid.uuid4().hex, - "path": rootless_path, - "taskId": task_id, - "attrib": { - "extension": extension, - "description": note - }, - # TODO remove 'createdBy' and 'updatedBy' fields when server is - # or above 1.1.3 . - "createdBy": username, - "updatedBy": username, - } - - session = OperationsSession() - session.create_entity(project_name, "workfile", workfile_info) - session.commit() - return workfile_info - - def _get_current_username(self): - if self._current_username is _NOT_SET: - self._current_username = get_ayon_username() - return self._current_username - - -class PublishWorkfilesModel: - """Model for handling of published workfiles. - - Todos: - Cache workfiles products and representations for some time. - Note Representations won't change. Only what can change are - versions. - """ - - def __init__(self, controller): - self._controller = controller - self._cached_extensions = None - self._cached_repre_extensions = None - - @property - def _extensions(self): - if self._cached_extensions is None: - exts = self._controller.get_workfile_extensions() or [] - self._cached_extensions = exts - return self._cached_extensions - - @property - def _repre_extensions(self): - if self._cached_repre_extensions is None: - self._cached_repre_extensions = { - ext.lstrip(".") for ext in self._extensions - } - return self._cached_repre_extensions - - def _file_item_from_representation( - self, repre_entity, project_anatomy, author, task_name=None - ): - if task_name is not None: - task_info = repre_entity["context"].get("task") - if not task_info or task_info["name"] != task_name: - return None - - # Filter by extension - extensions = self._repre_extensions - workfile_path = None - for repre_file in repre_entity["files"]: - ext = ( - os.path.splitext(repre_file["name"])[1] - .lower() - .lstrip(".") - ) - if ext in extensions: - workfile_path = repre_file["path"] - break - - if not workfile_path: - return None - - try: - workfile_path = workfile_path.format( - root=project_anatomy.roots) - except Exception as exc: - print("Failed to format workfile path: {}".format(exc)) - - dirpath, filename = os.path.split(workfile_path) - created_at = arrow.get(repre_entity["createdAt"]).to("local") - return FileItem( - dirpath, - filename, - created_at.float_timestamp, - author, - None, - repre_entity["id"] - ) - - def get_file_items(self, folder_id, task_name): - # TODO refactor to use less server API calls - project_name = self._controller.get_current_project_name() - # Get subset docs of folder - product_entities = ayon_api.get_products( - project_name, - folder_ids={folder_id}, - product_types={"workfile"}, - fields={"id", "name"} - ) - - output = [] - product_ids = {product["id"] for product in product_entities} - if not product_ids: - return output - - # Get version docs of products with their families - version_entities = ayon_api.get_versions( - project_name, - product_ids=product_ids, - fields={"id", "author"} - ) - versions_by_id = { - version["id"]: version - for version in version_entities - } - if not versions_by_id: - return output - - # Query representations of filtered versions and add filter for - # extension - repre_entities = ayon_api.get_representations( - project_name, - version_ids=set(versions_by_id) - ) - project_anatomy = self._controller.project_anatomy - - # Filter queried representations by task name if task is set - file_items = [] - for repre_entity in repre_entities: - version_id = repre_entity["versionId"] - version_entity = versions_by_id[version_id] - file_item = self._file_item_from_representation( - repre_entity, - project_anatomy, - version_entity["author"], - task_name, - ) - if file_item is not None: - file_items.append(file_item) - - return file_items - - -class WorkfilesModel: - """Workfiles model.""" - - def __init__(self, controller): - self._controller = controller - - self._entities_model = WorkfileEntitiesModel(controller) - self._workarea_model = WorkareaModel(controller) - self._published_model = PublishWorkfilesModel(controller) - - def get_workfile_info(self, folder_id, task_id, filepath): - return self._entities_model.get_workfile_info( - folder_id, task_id, filepath - ) - - def save_workfile_info(self, folder_id, task_id, filepath, note): - self._entities_model.save_workfile_info( - folder_id, task_id, filepath, note - ) - - def get_workarea_dir_by_context(self, folder_id, task_id): - """Workarea dir for passed context. - - The directory path is based on project anatomy templates. + def get_published_file_items( + self, folder_id: str, task_id: str + ) -> list[PublishedWorkfileInfo]: + """Published workfiles for passed context. Args: folder_id (str): Folder id. task_id (str): Task id. Returns: - Union[str, None]: Workarea dir path or None for invalid context. + list[PublishedWorkfileInfo]: List of files for published workfiles. + """ + if not folder_id: + return [] - return self._workarea_model.get_workarea_dir_by_context( - folder_id, task_id) + cache = self._published_workfile_items_cache[folder_id] + if not cache.is_valid: + project_name = self._project_name + anatomy = self._controller.project_anatomy - def get_workarea_file_items(self, folder_id, task_id, task_name): - """Workfile items for passed context from workarea. + product_entities = list(ayon_api.get_products( + project_name, + folder_ids={folder_id}, + product_types={"workfile"}, + fields={"id", "name"} + )) - Args: - folder_id (Union[str, None]): Folder id. - task_id (Union[str, None]): Task id. - task_name (Union[str, None]): Task name. + version_entities = [] + product_ids = {product["id"] for product in product_entities} + if product_ids: + # Get version docs of products with their families + version_entities = list(ayon_api.get_versions( + project_name, + product_ids=product_ids, + fields={"id", "author", "taskId"}, + )) - Returns: - list[FileItem]: List of file items matching workarea of passed - context. - """ - return self._workarea_model.get_file_items( - folder_id, task_id, task_name + repre_entities = [] + if version_entities: + repre_entities = list(ayon_api.get_representations( + project_name, + version_ids={v["id"] for v in version_entities} + )) + + self._repre_by_id.update({ + repre_entity["id"]: repre_entity + for repre_entity in repre_entities + }) + project_entity = self._controller.get_project_entity(project_name) + + prepared_data = ListPublishedWorkfilesOptionalData( + project_entity=project_entity, + anatomy=anatomy, + project_settings=self._controller.project_settings, + product_entities=product_entities, + version_entities=version_entities, + repre_entities=repre_entities, + ) + cache.update_data(self._host.list_published_workfiles( + project_name, + folder_id, + prepared_data=prepared_data, + )) + + items = cache.get_data() + + if task_id: + items = [ + item + for item in items + if item.task_id == task_id + ] + return items + + @property + def _project_name(self) -> str: + return self._controller.get_current_project_name() + + @property + def _host_name(self) -> str: + return self._host.name + + def _emit_event(self, topic, data=None): + self._controller.emit_event(topic, data, "workfiles") + + def _get_current_username(self) -> str: + if self._current_username is _NOT_SET: + self._current_username = get_ayon_username() + return self._current_username + + # --- Host --- + def _open_workfile(self, folder_id: str, task_id: str, filepath: str): + # TODO move to workfiles pipeline + project_name = self._project_name + project_entity = self._controller.get_project_entity(project_name) + folder_entity = self._controller.get_folder_entity( + project_name, folder_id + ) + task_entity = self._controller.get_task_entity( + project_name, task_id + ) + prepared_data = OpenWorkfileOptionalData( + project_entity=project_entity, + anatomy=self._controller.project_anatomy, + project_settings=self._controller.project_settings, + ) + self._host.open_workfile_with_context( + filepath, folder_entity, task_entity, prepared_data=prepared_data + ) + self._update_current_context( + folder_id, folder_entity["path"], task_entity["name"] ) - def get_workarea_save_as_data(self, folder_id, task_id): - return self._workarea_model.get_workarea_save_as_data( - folder_id, task_id) + def _update_current_context(self, folder_id, folder_path, task_name): + self._current_folder_id = folder_id + self._current_folder_path = folder_path + self._current_task_name = task_name - def fill_workarea_filepath(self, *args, **kwargs): - return self._workarea_model.fill_workarea_filepath( - *args, **kwargs + # --- Workarea --- + def _reset_workarea_file_items(self, task_id: str): + cache: CacheItem = self._workarea_file_items_cache[task_id] + cache.set_invalid() + self._workarea_file_items_mapping.pop(task_id, None) + + def _get_base_data(self) -> dict[str, Any]: + if self._base_data is None: + base_data = get_template_data( + ayon_api.get_project(self._project_name), + host_name=self._host_name, + ) + self._base_data = base_data + return copy.deepcopy(self._base_data) + + def _get_folder_data(self, folder_id: str) -> dict[str, Any]: + fill_data = self._fill_data_by_folder_id.get(folder_id) + if fill_data is None: + folder = self._controller.get_folder_entity( + self._project_name, folder_id + ) + fill_data = get_folder_template_data(folder, self._project_name) + self._fill_data_by_folder_id[folder_id] = fill_data + return copy.deepcopy(fill_data) + + def _get_task_data( + self, + project_entity: dict[str, Any], + folder_id: str, + task_id: str + ) -> dict[str, Any]: + task_data = self._task_data_by_folder_id.setdefault(folder_id, {}) + if task_id not in task_data: + task_entity = self._controller.get_task_entity( + self._project_name, task_id + ) + if task_entity: + task_data[task_id] = get_task_template_data( + project_entity, task_entity + ) + return copy.deepcopy(task_data[task_id]) + + def _prepare_fill_data( + self, folder_id: str, task_id: str + ) -> dict[str, Any]: + if not folder_id or not task_id: + return {} + + base_data = self._get_base_data() + project_name = base_data["project"]["name"] + folder_data = self._get_folder_data(folder_id) + project_entity = self._controller.get_project_entity(project_name) + task_data = self._get_task_data(project_entity, folder_id, task_id) + + base_data.update(folder_data) + base_data.update(task_data) + + return base_data + + def _cache_file_items( + self, folder_id: Optional[str], task_id: Optional[str] + ) -> list[WorkfileInfo]: + if not folder_id or not task_id: + return [] + + cache: CacheItem = self._workarea_file_items_cache[task_id] + if cache.is_valid: + return cache.get_data() + + project_entity = self._controller.get_project_entity( + self._project_name + ) + folder_entity = self._controller.get_folder_entity( + self._project_name, folder_id + ) + task_entity = self._controller.get_task_entity( + self._project_name, task_id + ) + anatomy = self._controller.project_anatomy + project_settings = self._controller.project_settings + workfile_entities = self._controller.get_workfile_entities(task_id) + + fill_data = self._prepare_fill_data(folder_id, task_id) + template_key = self._get_template_key(fill_data) + + prepared_data = ListWorkfilesOptionalData( + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + template_key=template_key, + workfile_entities=workfile_entities, ) - def get_published_file_items(self, folder_id, task_name): - """Published workfiles for passed context. + items = self._host.list_workfiles( + self._project_name, + folder_entity, + task_entity, + prepared_data=prepared_data, + ) + cache.update_data(items) - Args: - folder_id (str): Folder id. - task_name (str): Task name. + # Cache items by entity ids and rootless path + self._workarea_file_items_mapping[task_id] = { + item.rootless_path: item + for item in items + } - Returns: - list[FileItem]: List of files for published workfiles. + return items + + def _get_template_key(self, fill_data: dict[str, Any]) -> str: + task_type = fill_data.get("task", {}).get("type") + # TODO cache + return get_workfile_template_key( + self._project_name, + task_type, + self._host_name, + project_settings=self._controller.project_settings, + ) + + def _get_last_workfile_version( + self, file_items: list[WorkfileInfo], task_entity: dict[str, Any] + ) -> int: """ - return self._published_model.get_file_items(folder_id, task_name) + Todos: + Validate if logic of this function is correct. It does return + last version + 1 which might be wrong. + + Args: + file_items (list[WorkfileInfo]): Workfile items. + task_entity (dict[str, Any]): Task entity. + + Returns: + int: Next workfile version. + + """ + versions = { + item.version + for item in file_items + if item.version is not None + } + if versions: + return max(versions) + 1 + + return get_versioning_start( + self._project_name, + self._host_name, + task_name=task_entity["name"], + task_type=task_entity["taskType"], + product_type="workfile", + project_settings=self._controller.project_settings, + ) + + def _get_workdir( + self, anatomy: "Anatomy", template_key: str, fill_data: dict[str, Any] + ): + directory_template = anatomy.get_template_item( + "work", template_key, "directory" + ) + return directory_template.format_strict(fill_data).normalized() + + def _update_workfile_info( + self, + task_id: str, + rootless_path: str, + description: str, + ): + self._update_file_description(task_id, rootless_path, description) + self._reset_workarea_file_items(task_id) + + # Update workfile entity cache if are cached + if task_id in self._workfile_entities_by_task_id: + workfile_entities = self.get_workfile_entities(task_id) + + target_workfile_entity = None + for workfile_entity in workfile_entities: + if rootless_path == workfile_entity["path"]: + target_workfile_entity = workfile_entity + break + + if target_workfile_entity is None: + self._workfile_entities_by_task_id.pop(task_id, None) + self.get_workfile_entities(task_id) + else: + target_workfile_entity["attrib"]["description"] = description + + def _update_file_description( + self, task_id: str, rootless_path: str, description: str + ): + mapping = self._workarea_file_items_mapping.get(task_id) + if not mapping: + return + item = mapping.get(rootless_path) + if item is not None: + item.description = description + + # --- Workfile entities --- + def _save_workfile_info( + self, + task_id: str, + rootless_path: str, + version: Optional[int], + comment: Optional[str], + description: Optional[str], + ): + workfile_entity = save_workfile_info( + self._controller.get_current_project_name(), + task_id, + rootless_path, + self._controller.get_host_name(), + version=version, + comment=comment, + description=description, + workfile_entities=self.get_workfile_entities(task_id), + ) + # Update cache + workfile_entities = self.get_workfile_entities(task_id) + match_idx = None + for idx, entity in enumerate(workfile_entities): + if entity["id"] == workfile_entity["id"]: + # Update existing entity + match_idx = idx + break + + if match_idx is None: + workfile_entities.append(workfile_entity) + else: + workfile_entities[match_idx] = workfile_entity diff --git a/client/ayon_core/tools/workfiles/widgets/files_widget.py b/client/ayon_core/tools/workfiles/widgets/files_widget.py index f0b74f4289..9c12fa575c 100644 --- a/client/ayon_core/tools/workfiles/widgets/files_widget.py +++ b/client/ayon_core/tools/workfiles/widgets/files_widget.py @@ -200,6 +200,9 @@ class FilesWidget(QtWidgets.QWidget): self._open_workfile(folder_id, task_id, path) def _on_current_open_requests(self): + # TODO validate if item under mouse is enabled + # - this uses selected item, but that does not have to be the one + # under mouse self._on_workarea_open_clicked() def _on_duplicate_request(self): @@ -210,11 +213,18 @@ class FilesWidget(QtWidgets.QWidget): result = self._exec_save_as_dialog() if result is None: return + folder_id = self._selected_folder_id + task_id = self._selected_task_id self._controller.duplicate_workfile( + folder_id, + task_id, filepath, + result["rootless_workdir"], result["workdir"], result["filename"], - artist_note=result["artist_note"] + version=result["version"], + comment=result["comment"], + description=result["description"] ) def _on_workarea_browse_clicked(self): @@ -259,10 +269,12 @@ class FilesWidget(QtWidgets.QWidget): self._controller.save_as_workfile( result["folder_id"], result["task_id"], + result["rootless_workdir"], result["workdir"], result["filename"], - result["template_key"], - artist_note=result["artist_note"] + version=result["version"], + comment=result["comment"], + description=result["description"] ) def _on_workarea_path_changed(self, event): @@ -275,10 +287,11 @@ class FilesWidget(QtWidgets.QWidget): def _update_published_btns_state(self): enabled = ( self._valid_representation_id - and self._valid_selected_context and self._is_save_enabled ) - self._published_btn_copy_n_open.setEnabled(enabled) + self._published_btn_copy_n_open.setEnabled( + enabled and self._valid_selected_context + ) self._published_btn_change_context.setEnabled(enabled) def _update_workarea_btns_state(self): @@ -314,12 +327,16 @@ class FilesWidget(QtWidgets.QWidget): result["task_id"], result["workdir"], result["filename"], - result["template_key"], - artist_note=result["artist_note"] + result["rootless_workdir"], + version=result["version"], + comment=result["comment"], + description=result["description"], ) def _on_save_as_request(self): - self._on_published_save_clicked() + # Make sure the save is enabled + if self._is_save_enabled and self._valid_selected_context: + self._on_published_save_clicked() def _set_select_contex_mode(self, enabled): if self._select_context_mode is enabled: diff --git a/client/ayon_core/tools/workfiles/widgets/files_widget_published.py b/client/ayon_core/tools/workfiles/widgets/files_widget_published.py index 07122046be..250204a7d7 100644 --- a/client/ayon_core/tools/workfiles/widgets/files_widget_published.py +++ b/client/ayon_core/tools/workfiles/widgets/files_widget_published.py @@ -1,3 +1,5 @@ +import os + import qtawesome from qtpy import QtWidgets, QtCore, QtGui @@ -205,24 +207,25 @@ class PublishedFilesModel(QtGui.QStandardItemModel): new_items.append(item) item.setColumnCount(self.columnCount()) item.setData(self._file_icon, QtCore.Qt.DecorationRole) - item.setData(file_item.filename, QtCore.Qt.DisplayRole) item.setData(repre_id, REPRE_ID_ROLE) - if file_item.exists: + if file_item.available: flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable else: flags = QtCore.Qt.NoItemFlags - author = file_item.created_by + author = file_item.author user_item = user_items_by_name.get(author) if user_item is not None and user_item.full_name: author = user_item.full_name - item.setFlags(flags) + filename = os.path.basename(file_item.filepath) + item.setFlags(flags) + item.setData(filename, QtCore.Qt.DisplayRole) item.setData(file_item.filepath, FILEPATH_ROLE) item.setData(author, AUTHOR_ROLE) - item.setData(file_item.modified, DATE_MODIFIED_ROLE) + item.setData(file_item.file_modified, DATE_MODIFIED_ROLE) self._items_by_id[repre_id] = item diff --git a/client/ayon_core/tools/workfiles/widgets/files_widget_workarea.py b/client/ayon_core/tools/workfiles/widgets/files_widget_workarea.py index 7f76b6a8ab..47d4902812 100644 --- a/client/ayon_core/tools/workfiles/widgets/files_widget_workarea.py +++ b/client/ayon_core/tools/workfiles/widgets/files_widget_workarea.py @@ -1,3 +1,5 @@ +import os + import qtawesome from qtpy import QtWidgets, QtCore, QtGui @@ -10,8 +12,10 @@ from ayon_core.tools.utils.delegates import PrettyTimeDelegate FILENAME_ROLE = QtCore.Qt.UserRole + 1 FILEPATH_ROLE = QtCore.Qt.UserRole + 2 -AUTHOR_ROLE = QtCore.Qt.UserRole + 3 -DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 4 +ROOTLESS_PATH_ROLE = QtCore.Qt.UserRole + 3 +AUTHOR_ROLE = QtCore.Qt.UserRole + 4 +DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 5 +WORKFILE_ENTITY_ID_ROLE = QtCore.Qt.UserRole + 6 class WorkAreaFilesModel(QtGui.QStandardItemModel): @@ -198,7 +202,7 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): items_to_remove = set(self._items_by_filename.keys()) new_items = [] for file_item in file_items: - filename = file_item.filename + filename = os.path.basename(file_item.filepath) if filename in self._items_by_filename: items_to_remove.discard(filename) item = self._items_by_filename[filename] @@ -206,23 +210,28 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem() new_items.append(item) item.setColumnCount(self.columnCount()) - item.setFlags( - QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - ) item.setData(self._file_icon, QtCore.Qt.DecorationRole) - item.setData(file_item.filename, QtCore.Qt.DisplayRole) - item.setData(file_item.filename, FILENAME_ROLE) + item.setData(filename, QtCore.Qt.DisplayRole) + item.setData(filename, FILENAME_ROLE) + flags = QtCore.Qt.ItemIsSelectable + if file_item.available: + flags |= QtCore.Qt.ItemIsEnabled + item.setFlags(flags) updated_by = file_item.updated_by user_item = user_items_by_name.get(updated_by) if user_item is not None and user_item.full_name: updated_by = user_item.full_name + item.setData( + file_item.workfile_entity_id, WORKFILE_ENTITY_ID_ROLE + ) item.setData(file_item.filepath, FILEPATH_ROLE) + item.setData(file_item.rootless_path, ROOTLESS_PATH_ROLE) + item.setData(file_item.file_modified, DATE_MODIFIED_ROLE) item.setData(updated_by, AUTHOR_ROLE) - item.setData(file_item.modified, DATE_MODIFIED_ROLE) - self._items_by_filename[file_item.filename] = item + self._items_by_filename[filename] = item if new_items: root_item.appendRows(new_items) @@ -354,14 +363,18 @@ class WorkAreaFilesWidget(QtWidgets.QWidget): def _get_selected_info(self): selection_model = self._view.selectionModel() - filepath = None - filename = None + workfile_entity_id = filename = rootless_path = filepath = None for index in selection_model.selectedIndexes(): filepath = index.data(FILEPATH_ROLE) + rootless_path = index.data(ROOTLESS_PATH_ROLE) filename = index.data(FILENAME_ROLE) + workfile_entity_id = index.data(WORKFILE_ENTITY_ID_ROLE) + return { "filepath": filepath, + "rootless_path": rootless_path, "filename": filename, + "workfile_entity_id": workfile_entity_id, } def get_selected_path(self): @@ -374,8 +387,12 @@ class WorkAreaFilesWidget(QtWidgets.QWidget): return self._get_selected_info()["filepath"] def _on_selection_change(self): - filepath = self.get_selected_path() - self._controller.set_selected_workfile_path(filepath) + info = self._get_selected_info() + self._controller.set_selected_workfile_path( + info["rootless_path"], + info["filepath"], + info["workfile_entity_id"], + ) def _on_mouse_double_click(self, event): if event.button() == QtCore.Qt.LeftButton: @@ -430,19 +447,25 @@ class WorkAreaFilesWidget(QtWidgets.QWidget): ) def _on_model_refresh(self): - if ( - not self._change_selection_on_refresh - or self._proxy_model.rowCount() < 1 - ): + if not self._change_selection_on_refresh: return # Find the row with latest date modified + indexes = [ + self._proxy_model.index(idx, 0) + for idx in range(self._proxy_model.rowCount()) + ] + filtered_indexes = [ + index + for index in indexes + if self._proxy_model.flags(index) & QtCore.Qt.ItemIsEnabled + ] + if not filtered_indexes: + return + latest_index = max( - ( - self._proxy_model.index(idx, 0) - for idx in range(self._proxy_model.rowCount()) - ), - key=lambda model_index: model_index.data(DATE_MODIFIED_ROLE) + filtered_indexes, + key=lambda model_index: model_index.data(DATE_MODIFIED_ROLE) or 0 ) # Select row of latest modified diff --git a/client/ayon_core/tools/workfiles/widgets/save_as_dialog.py b/client/ayon_core/tools/workfiles/widgets/save_as_dialog.py index bddff816fe..24d64319ca 100644 --- a/client/ayon_core/tools/workfiles/widgets/save_as_dialog.py +++ b/client/ayon_core/tools/workfiles/widgets/save_as_dialog.py @@ -108,6 +108,7 @@ class SaveAsDialog(QtWidgets.QDialog): self._ext_value = None self._filename = None self._workdir = None + self._rootless_workdir = None self._result = None @@ -144,8 +145,8 @@ class SaveAsDialog(QtWidgets.QDialog): version_layout.addWidget(last_version_check) # Artist note widget - artist_note_input = PlaceholderPlainTextEdit(inputs_widget) - artist_note_input.setPlaceholderText( + description_input = PlaceholderPlainTextEdit(inputs_widget) + description_input.setPlaceholderText( "Provide a note about this workfile.") # Preview widget @@ -166,7 +167,7 @@ class SaveAsDialog(QtWidgets.QDialog): subversion_label = QtWidgets.QLabel("Subversion:", inputs_widget) extension_label = QtWidgets.QLabel("Extension:", inputs_widget) preview_label = QtWidgets.QLabel("Preview:", inputs_widget) - artist_note_label = QtWidgets.QLabel("Artist Note:", inputs_widget) + description_label = QtWidgets.QLabel("Artist Note:", inputs_widget) # Build inputs inputs_layout = QtWidgets.QGridLayout(inputs_widget) @@ -178,8 +179,8 @@ class SaveAsDialog(QtWidgets.QDialog): inputs_layout.addWidget(extension_combobox, 2, 1) inputs_layout.addWidget(preview_label, 3, 0) inputs_layout.addWidget(preview_widget, 3, 1) - inputs_layout.addWidget(artist_note_label, 4, 0, 1, 2) - inputs_layout.addWidget(artist_note_input, 5, 0, 1, 2) + inputs_layout.addWidget(description_label, 4, 0, 1, 2) + inputs_layout.addWidget(description_input, 5, 0, 1, 2) # Build layout main_layout = QtWidgets.QVBoxLayout(self) @@ -214,13 +215,13 @@ class SaveAsDialog(QtWidgets.QDialog): self._extension_combobox = extension_combobox self._subversion_input = subversion_input self._preview_widget = preview_widget - self._artist_note_input = artist_note_input + self._description_input = description_input self._version_label = version_label self._subversion_label = subversion_label self._extension_label = extension_label self._preview_label = preview_label - self._artist_note_label = artist_note_label + self._description_label = description_label # Post init setup @@ -255,6 +256,7 @@ class SaveAsDialog(QtWidgets.QDialog): self._folder_id = folder_id self._task_id = task_id self._workdir = data["workdir"] + self._rootless_workdir = data["rootless_workdir"] self._comment_value = data["comment"] self._ext_value = data["ext"] self._template_key = data["template_key"] @@ -329,10 +331,13 @@ class SaveAsDialog(QtWidgets.QDialog): self._result = { "filename": self._filename, "workdir": self._workdir, + "rootless_workdir": self._rootless_workdir, "folder_id": self._folder_id, "task_id": self._task_id, "template_key": self._template_key, - "artist_note": self._artist_note_input.toPlainText(), + "version": self._version_value, + "comment": self._comment_value, + "description": self._description_input.toPlainText(), } self.close() diff --git a/client/ayon_core/tools/workfiles/widgets/side_panel.py b/client/ayon_core/tools/workfiles/widgets/side_panel.py index 7ba60b5544..b1b91d9721 100644 --- a/client/ayon_core/tools/workfiles/widgets/side_panel.py +++ b/client/ayon_core/tools/workfiles/widgets/side_panel.py @@ -4,6 +4,8 @@ from qtpy import QtWidgets, QtCore def file_size_to_string(file_size): + if not file_size: + return "N/A" size = 0 size_ending_mapping = { "KB": 1024 ** 1, @@ -43,44 +45,47 @@ class SidePanelWidget(QtWidgets.QWidget): details_input = QtWidgets.QPlainTextEdit(self) details_input.setReadOnly(True) - artist_note_widget = QtWidgets.QWidget(self) - note_label = QtWidgets.QLabel("Artist note", artist_note_widget) - note_input = QtWidgets.QPlainTextEdit(artist_note_widget) - btn_note_save = QtWidgets.QPushButton("Save note", artist_note_widget) + description_widget = QtWidgets.QWidget(self) + description_label = QtWidgets.QLabel("Artist note", description_widget) + description_input = QtWidgets.QPlainTextEdit(description_widget) + btn_description_save = QtWidgets.QPushButton( + "Save note", description_widget + ) - artist_note_layout = QtWidgets.QVBoxLayout(artist_note_widget) - artist_note_layout.setContentsMargins(0, 0, 0, 0) - artist_note_layout.addWidget(note_label, 0) - artist_note_layout.addWidget(note_input, 1) - artist_note_layout.addWidget( - btn_note_save, 0, alignment=QtCore.Qt.AlignRight + description_layout = QtWidgets.QVBoxLayout(description_widget) + description_layout.setContentsMargins(0, 0, 0, 0) + description_layout.addWidget(description_label, 0) + description_layout.addWidget(description_input, 1) + description_layout.addWidget( + btn_description_save, 0, alignment=QtCore.Qt.AlignRight ) main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(details_label, 0) main_layout.addWidget(details_input, 1) - main_layout.addWidget(artist_note_widget, 1) + main_layout.addWidget(description_widget, 1) - note_input.textChanged.connect(self._on_note_change) - btn_note_save.clicked.connect(self._on_save_click) + description_input.textChanged.connect(self._on_description_change) + btn_description_save.clicked.connect(self._on_save_click) controller.register_event_callback( "selection.workarea.changed", self._on_selection_change ) self._details_input = details_input - self._artist_note_widget = artist_note_widget - self._note_input = note_input - self._btn_note_save = btn_note_save + self._description_widget = description_widget + self._description_input = description_input + self._btn_description_save = btn_description_save self._folder_id = None - self._task_name = None + self._task_id = None self._filepath = None - self._orig_note = "" + self._rootless_path = None + self._orig_description = "" self._controller = controller - self._set_context(None, None, None) + self._set_context(None, None, None, None) def set_published_mode(self, published_mode): """Change published mode. @@ -89,64 +94,69 @@ class SidePanelWidget(QtWidgets.QWidget): published_mode (bool): Published mode enabled. """ - self._artist_note_widget.setVisible(not published_mode) + self._description_widget.setVisible(not published_mode) def _on_selection_change(self, event): folder_id = event["folder_id"] - task_name = event["task_name"] + task_id = event["task_id"] filepath = event["path"] + rootless_path = event["rootless_path"] - self._set_context(folder_id, task_name, filepath) + self._set_context(folder_id, task_id, rootless_path, filepath) - def _on_note_change(self): - text = self._note_input.toPlainText() - self._btn_note_save.setEnabled(self._orig_note != text) + def _on_description_change(self): + text = self._description_input.toPlainText() + self._btn_description_save.setEnabled(self._orig_description != text) def _on_save_click(self): - note = self._note_input.toPlainText() + description = self._description_input.toPlainText() self._controller.save_workfile_info( - self._folder_id, - self._task_name, - self._filepath, - note + self._task_id, + self._rootless_path, + description=description, ) - self._orig_note = note - self._btn_note_save.setEnabled(False) + self._orig_description = description + self._btn_description_save.setEnabled(False) - def _set_context(self, folder_id, task_name, filepath): + def _set_context(self, folder_id, task_id, rootless_path, filepath): workfile_info = None # Check if folder, task and file are selected - if bool(folder_id) and bool(task_name) and bool(filepath): + if folder_id and task_id and rootless_path: workfile_info = self._controller.get_workfile_info( - folder_id, task_name, filepath + folder_id, task_id, rootless_path ) enabled = workfile_info is not None self._details_input.setEnabled(enabled) - self._note_input.setEnabled(enabled) - self._btn_note_save.setEnabled(enabled) + self._description_input.setEnabled(enabled) + self._btn_description_save.setEnabled(enabled) self._folder_id = folder_id - self._task_name = task_name + self._task_id = task_id self._filepath = filepath + self._rootless_path = rootless_path # Disable inputs and remove texts if any required arguments are # missing if not enabled: - self._orig_note = "" + self._orig_description = "" self._details_input.setPlainText("") - self._note_input.setPlainText("") + self._description_input.setPlainText("") return - note = workfile_info.note - size_value = file_size_to_string(workfile_info.filesize) + description = workfile_info.description + size_value = file_size_to_string(workfile_info.file_size) # Append html string datetime_format = "%b %d %Y %H:%M:%S" - creation_time = datetime.datetime.fromtimestamp( - workfile_info.creation_time) - modification_time = datetime.datetime.fromtimestamp( - workfile_info.modification_time) + file_created = workfile_info.file_created + modification_time = workfile_info.file_modified + if file_created: + file_created = datetime.datetime.fromtimestamp(file_created) + + if modification_time: + modification_time = datetime.datetime.fromtimestamp( + modification_time) user_items_by_name = self._controller.get_user_items_by_name() @@ -156,33 +166,38 @@ class SidePanelWidget(QtWidgets.QWidget): return user_item.full_name return username - created_lines = [ - creation_time.strftime(datetime_format) - ] + created_lines = [] if workfile_info.created_by: - created_lines.insert( - 0, convert_username(workfile_info.created_by) + created_lines.append( + convert_username(workfile_info.created_by) ) + if file_created: + created_lines.append(file_created.strftime(datetime_format)) - modified_lines = [ - modification_time.strftime(datetime_format) - ] + if created_lines: + created_lines.insert(0, "Created:") + + modified_lines = [] if workfile_info.updated_by: - modified_lines.insert( - 0, convert_username(workfile_info.updated_by) + modified_lines.append( + convert_username(workfile_info.updated_by) ) + if modification_time: + modified_lines.append( + modification_time.strftime(datetime_format) + ) + if modified_lines: + modified_lines.insert(0, "Modified:") lines = ( "Size:", size_value, - "Created:", "
".join(created_lines), - "Modified:", "
".join(modified_lines), ) - self._orig_note = note - self._note_input.setPlainText(note) + self._orig_description = description + self._description_input.setPlainText(description) # Set as empty string self._details_input.setPlainText("") - self._details_input.appendHtml("
".join(lines)) + self._details_input.appendHtml("
".join(lines)) diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index 1649a059cb..3f96f0bb15 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -1,21 +1,21 @@ -from qtpy import QtCore, QtWidgets, QtGui -from ayon_core import style, resources -from ayon_core.tools.utils import ( - PlaceholderLineEdit, - MessageOverlayObject, -) +from qtpy import QtCore, QtGui, QtWidgets -from ayon_core.tools.workfiles.control import BaseWorkfileController +from ayon_core import resources, style from ayon_core.tools.utils import ( - GoToCurrentButton, - RefreshButton, FoldersWidget, + GoToCurrentButton, + MessageOverlayObject, + NiceCheckbox, + PlaceholderLineEdit, + RefreshButton, TasksWidget, ) +from ayon_core.tools.utils.lib import checkstate_int_to_enum +from ayon_core.tools.workfiles.control import BaseWorkfileController -from .side_panel import SidePanelWidget from .files_widget import FilesWidget +from .side_panel import SidePanelWidget from .utils import BaseOverlayFrame @@ -107,7 +107,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget): split_widget.addWidget(tasks_widget) split_widget.addWidget(col_3_widget) split_widget.addWidget(side_panel) - split_widget.setSizes([255, 175, 550, 190]) + split_widget.setSizes([350, 175, 550, 190]) body_layout.addWidget(split_widget) @@ -157,6 +157,8 @@ class WorkfilesToolWindow(QtWidgets.QWidget): self._home_body_widget = home_body_widget self._split_widget = split_widget + self._project_name = self._controller.get_current_project_name() + self._tasks_widget = tasks_widget self._side_panel = side_panel @@ -186,11 +188,24 @@ class WorkfilesToolWindow(QtWidgets.QWidget): controller, col_widget, handle_expected_selection=True ) + my_tasks_tooltip = ( + "Filter folders and task to only those you are assigned to." + ) + + my_tasks_label = QtWidgets.QLabel("My tasks") + my_tasks_label.setToolTip(my_tasks_tooltip) + + my_tasks_checkbox = NiceCheckbox(folder_widget) + my_tasks_checkbox.setChecked(False) + my_tasks_checkbox.setToolTip(my_tasks_tooltip) + header_layout = QtWidgets.QHBoxLayout(header_widget) header_layout.setContentsMargins(0, 0, 0, 0) header_layout.addWidget(folder_filter_input, 1) header_layout.addWidget(go_to_current_btn, 0) header_layout.addWidget(refresh_btn, 0) + header_layout.addWidget(my_tasks_label, 0) + header_layout.addWidget(my_tasks_checkbox, 0) col_layout = QtWidgets.QVBoxLayout(col_widget) col_layout.setContentsMargins(0, 0, 0, 0) @@ -200,6 +215,9 @@ class WorkfilesToolWindow(QtWidgets.QWidget): folder_filter_input.textChanged.connect(self._on_folder_filter_change) go_to_current_btn.clicked.connect(self._on_go_to_current_clicked) refresh_btn.clicked.connect(self._on_refresh_clicked) + my_tasks_checkbox.stateChanged.connect( + self._on_my_tasks_checkbox_state_changed + ) self._folder_filter_input = folder_filter_input self._folders_widget = folder_widget @@ -385,3 +403,16 @@ class WorkfilesToolWindow(QtWidgets.QWidget): ) else: self.close() + + def _on_my_tasks_checkbox_state_changed(self, state): + folder_ids = None + task_ids = None + state = checkstate_int_to_enum(state) + if state == QtCore.Qt.Checked: + entity_ids = self._controller.get_my_tasks_entity_ids( + self._project_name + ) + folder_ids = entity_ids["folder_ids"] + task_ids = entity_ids["task_ids"] + self._folders_widget.set_folder_ids_filter(folder_ids) + self._tasks_widget.set_task_ids_filter(task_ids) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 11fc31799b..f2aa94020f 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.3.2+dev" +__version__ = "1.5.3+dev" diff --git a/package.py b/package.py index 908d34ffa8..4393b7be40 100644 --- a/package.py +++ b/package.py @@ -1,11 +1,13 @@ name = "core" title = "Core" -version = "1.3.2+dev" +version = "1.5.3+dev" client_dir = "ayon_core" plugin_for = ["ayon_server"] +project_can_override_addon_version = True + ayon_server_version = ">=1.8.4,<2.0.0" ayon_launcher_version = ">=1.0.2" ayon_required_addons = {} diff --git a/pyproject.toml b/pyproject.toml index 246781b12f..ee6c35b50b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.3.2+dev" +version = "1.5.3+dev" description = "" authors = ["Ynput Team "] readme = "README.md" @@ -19,16 +19,14 @@ python = ">=3.9.1,<3.10" pytest = "^8.0" pytest-print = "^1.0" ayon-python-api = "^1.0" +arrow = "0.17.0" # linting dependencies -ruff = "0.11.7" -pre-commit = "^3.6.2" +ruff = "^0.11.7" +pre-commit = "^4" codespell = "^2.2.6" semver = "^3.0.2" +mypy = "^1.14.0" mock = "^5.0.0" -attrs = "^25.0.0" -pyblish-base = "^1.8.7" -clique = "^2.0.0" -opentimelineio = "^0.17.0" tomlkit = "^0.13.2" requests = "^2.32.3" mkdocs-material = "^9.6.7" @@ -40,6 +38,17 @@ mdx-gh-links = "^0.4" pymdown-extensions = "^10.14.3" mike = "^2.1.3" mkdocstrings-shell = "^1.0.2" +nxtools = "^1.6" + +[tool.poetry.group.test.dependencies] +attrs = "^25.0.0" +pyblish-base = "^1.8.7" +clique = "^2.0.0" +opentimelineio = "^0.17.0" +speedcopy = "^2.1" +qtpy="^2.4.3" +pyside6 = "^6.5.2" +pytest-ayon = { git = "https://github.com/ynput/pytest-ayon.git", branch = "chore/align-dependencies" } [tool.codespell] # Ignore words that are not in the dictionary. @@ -53,11 +62,13 @@ skip = "./.*,./package/*,*/client/ayon_core/vendor/*" count = true quiet-level = 3 +[tool.mypy] +mypy_path = "$MYPY_CONFIG_FILE_DIR/client" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" - [tool.pytest.ini_options] log_cli = true log_cli_level = "INFO" @@ -65,3 +76,11 @@ addopts = "-ra -q" testpaths = [ "client/ayon_core/tests" ] +markers = [ + "unit: Unit tests", + "integration: Integration tests", + "api: API tests", + "cli: CLI tests", + "slow: Slow tests", + "server: Tests that require a running AYON server", +] diff --git a/ruff.toml b/ruff.toml index f9b073e818..c0a501a5dc 100644 --- a/ruff.toml +++ b/ruff.toml @@ -57,6 +57,7 @@ exclude = [ [lint.per-file-ignores] "client/ayon_core/lib/__init__.py" = ["E402"] +"tests/*.py" = ["S101", "PLR2004"] # allow asserts and magical values [format] # Like Black, use double quotes for strings. diff --git a/server/__init__.py b/server/__init__.py index d60f50f471..620cb3285c 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -1,6 +1,15 @@ from typing import Any from ayon_server.addons import BaseServerAddon +from ayon_server.actions import ( + ActionExecutor, + ExecuteResponseModel, + SimpleActionManifest, +) +try: + from ayon_server.logging import logger +except ImportError: + from nxtools import logging as logger from .settings import ( CoreSettings, @@ -26,3 +35,67 @@ class CoreAddon(BaseServerAddon): return await super().convert_settings_overrides( source_version, overrides ) + + async def get_simple_actions( + self, + project_name: str | None = None, + variant: str = "production", + ) -> list[SimpleActionManifest]: + """Return a list of simple actions provided by the addon""" + output = [] + + if project_name: + # Add 'Create Project Folder Structure' action to folders. + output.append( + SimpleActionManifest( + identifier="core.createprojectstructure", + label="Create Project Folder Structure", + icon={ + "type": "material-symbols", + "name": "create_new_folder", + }, + order=100, + entity_type="project", + entity_subtypes=None, + allow_multiselection=False, + ) + ) + + return output + + async def execute_action( + self, + executor: ActionExecutor, + ) -> ExecuteResponseModel: + """Execute webactions.""" + + project_name = executor.context.project_name + + if executor.identifier == "core.createprojectstructure": + if not project_name: + logger.error( + f"Can't execute {executor.identifier} because" + " of missing project name." + ) + # Works since AYON server 1.8.3 + if hasattr(executor, "get_simple_response"): + return await executor.get_simple_response( + "Missing project name", success=False + ) + return + + args = [ + "create-project-structure", "--project", project_name, + ] + # Works since AYON server 1.8.3 + if hasattr(executor, "get_launcher_response"): + return await executor.get_launcher_response(args) + + return await executor.get_launcher_action_response(args) + + logger.debug(f"Unknown action: {executor.identifier}") + # Works since AYON server 1.8.3 + if hasattr(executor, "get_simple_response"): + return await executor.get_simple_response( + "Unknown action", success=False + ) diff --git a/server/settings/main.py b/server/settings/main.py index dd6af0a104..cca885303f 100644 --- a/server/settings/main.py +++ b/server/settings/main.py @@ -106,7 +106,7 @@ class FallbackProductModel(BaseSettingsModel): fallback_type: str = SettingsField( title="Fallback config type", enum_resolver=_fallback_ocio_config_profile_types, - conditionalEnum=True, + conditional_enum=True, default="builtin_path", description=( "Type of config which needs to be used in case published " @@ -162,7 +162,7 @@ class CoreImageIOConfigProfilesModel(BaseSettingsModel): type: str = SettingsField( title="Profile type", enum_resolver=_ocio_config_profile_types, - conditionalEnum=True, + conditional_enum=True, default="builtin_path", section="---", ) @@ -319,6 +319,10 @@ class CoreSettings(BaseSettingsModel): "{}", widget="textarea", title="Project folder structure", + description=( + "Defines project folders to create on disk" + " for 'Create project folders' action." + ), section="---" ) project_environments: str = SettingsField( diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 793ca659e5..ee422a0acf 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -340,7 +340,7 @@ class ResizeModel(BaseSettingsModel): title="Type", description="Type of resizing", enum_resolver=lambda: _resize_types_enum, - conditionalEnum=True, + conditional_enum=True, default="source" ) @@ -373,7 +373,7 @@ class ExtractThumbnailOIIODefaultsModel(BaseSettingsModel): title="Type", description="Transcoding type", enum_resolver=lambda: _thumbnail_oiio_transcoding_type, - conditionalEnum=True, + conditional_enum=True, default="colorspace" ) @@ -476,7 +476,7 @@ class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): "colorspace", title="Transcoding type", enum_resolver=_extract_oiio_transcoding_type, - conditionalEnum=True, + conditional_enum=True, description=( "Select the transcoding type for your output, choosing either " "*Colorspace* or *Display&View* transform." @@ -747,6 +747,11 @@ class ExtractReviewProfileModel(BaseSettingsModel): hosts: 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, + ) outputs: list[ExtractReviewOutputDefModel] = SettingsField( default_factory=list, title="Output Definitions" ) @@ -1348,6 +1353,7 @@ DEFAULT_PUBLISH_VALUES = { { "product_types": [], "hosts": [], + "task_types": [], "outputs": [ { "name": "png", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..d420712d8b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests.""" diff --git a/tests/client/ayon_core/pipeline/load/test_loaders.py b/tests/client/ayon_core/pipeline/load/test_loaders.py new file mode 100644 index 0000000000..490efe1b1e --- /dev/null +++ b/tests/client/ayon_core/pipeline/load/test_loaders.py @@ -0,0 +1,88 @@ +"""Test loaders in the pipeline module.""" + +from ayon_core.pipeline.load import LoaderPlugin + + +def test_is_compatible_loader(): + """Test if a loader is compatible with a given representation.""" + from ayon_core.pipeline.load import is_compatible_loader + + # Create a mock representation context + context = { + "loader": "test_loader", + "representation": {"name": "test_representation"}, + } + + # Create a mock loader plugin + class MockLoader(LoaderPlugin): + name = "test_loader" + version = "1.0.0" + + def is_compatible_loader(self, context): + return True + + # Check compatibility + assert is_compatible_loader(MockLoader(), context) is True + + +def test_complex_is_compatible_loader(): + """Test if a loader is compatible with a complex representation.""" + from ayon_core.pipeline.load import is_compatible_loader + + # Create a mock complex representation context + context = { + "loader": "complex_loader", + "representation": { + "name": "complex_representation", + "extension": "exr" + }, + "additional_data": {"key": "value"}, + "product": { + "name": "complex_product", + "productType": "foo", + "productBaseType": "bar", + }, + } + + # Create a mock loader plugin + class ComplexLoaderA(LoaderPlugin): + name = "complex_loaderA" + + # False because the loader doesn't specify any compatibility (missing + # wildcard for product type and product base type) + assert is_compatible_loader(ComplexLoaderA(), context) is False + + class ComplexLoaderB(LoaderPlugin): + name = "complex_loaderB" + product_types = {"*"} + representations = {"*"} + + # True, it is compatible with any product type + assert is_compatible_loader(ComplexLoaderB(), context) is True + + class ComplexLoaderC(LoaderPlugin): + name = "complex_loaderC" + product_base_types = {"*"} + representations = {"*"} + + # True, it is compatible with any product base type + assert is_compatible_loader(ComplexLoaderC(), context) is True + + class ComplexLoaderD(LoaderPlugin): + name = "complex_loaderD" + product_types = {"foo"} + representations = {"*"} + + # legacy loader defining compatibility only with product type + # is compatible provided the same product type is defined in context + assert is_compatible_loader(ComplexLoaderD(), context) is False + + class ComplexLoaderE(LoaderPlugin): + name = "complex_loaderE" + product_types = {"foo"} + representations = {"*"} + + # remove productBaseType from context to simulate legacy behavior + context["product"].pop("productBaseType", None) + + assert is_compatible_loader(ComplexLoaderE(), context) is True diff --git a/tests/client/ayon_core/pipeline/traits/__init__.py b/tests/client/ayon_core/pipeline/traits/__init__.py new file mode 100644 index 0000000000..ead0593ced --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/__init__.py @@ -0,0 +1 @@ +"""Tests for the representation traits.""" diff --git a/tests/client/ayon_core/pipeline/traits/lib/__init__.py b/tests/client/ayon_core/pipeline/traits/lib/__init__.py new file mode 100644 index 0000000000..d7ea7ae0ad --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/lib/__init__.py @@ -0,0 +1,25 @@ +"""Metadata traits.""" +from typing import ClassVar + +from ayon_core.pipeline.traits import TraitBase + + +class NewTestTrait(TraitBase): + """New Test trait model. + + This model represents a tagged trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + """ + + name: ClassVar[str] = "New Test Trait" + description: ClassVar[str] = ( + "This test trait is used for testing updating." + ) + id: ClassVar[str] = "ayon.test.NewTestTrait.v999" + + +__all__ = ["NewTestTrait"] diff --git a/tests/client/ayon_core/pipeline/traits/test_content_traits.py b/tests/client/ayon_core/pipeline/traits/test_content_traits.py new file mode 100644 index 0000000000..3fcbd04ac0 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_content_traits.py @@ -0,0 +1,184 @@ +"""Tests for the content traits.""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from ayon_core.pipeline.traits import ( + Bundle, + FileLocation, + FileLocations, + FrameRanged, + Image, + MimeType, + PixelBased, + Planar, + Representation, + Sequence, +) +from ayon_core.pipeline.traits.trait import TraitValidationError + + +def test_bundles() -> None: + """Test bundle trait.""" + diffuse_texture = [ + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + FileLocation( + file_path=Path("/path/to/diffuse.jpg"), + file_size=1024, + file_hash=None), + MimeType(mime_type="image/jpeg"), + ] + bump_texture = [ + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + FileLocation( + file_path=Path("/path/to/bump.tif"), + file_size=1024, + file_hash=None), + MimeType(mime_type="image/tiff"), + ] + bundle = Bundle(items=[diffuse_texture, bump_texture]) + representation = Representation(name="test_bundle", traits=[bundle]) + + if representation.contains_trait(trait=Bundle): + assert representation.get_trait(trait=Bundle).items == [ + diffuse_texture, bump_texture + ] + + for item in representation.get_trait(trait=Bundle).items: + sub_representation = Representation(name="test", traits=item) + assert sub_representation.contains_trait(trait=Image) + sub: MimeType = sub_representation.get_trait(trait=MimeType) + assert sub.mime_type in { + "image/jpeg", "image/tiff" + } + + +def test_file_locations_validation() -> None: + """Test FileLocations trait validation.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list), + Sequence(frame_padding=4), + ]) + + file_locations_trait: FileLocations = FileLocations( + file_paths=file_locations_list) + + # this should be valid trait + file_locations_trait.validate_trait(representation) + + # add valid FrameRanged trait + frameranged_trait = FrameRanged( + frame_start=1001, + frame_end=1050, + frames_per_second="25" + ) + representation.add_trait(frameranged_trait) + + # it should still validate fine + file_locations_trait.validate_trait(representation) + + # create empty file locations trait + empty_file_locations_trait = FileLocations(file_paths=[]) + representation = Representation(name="test", traits=[ + empty_file_locations_trait + ]) + with pytest.raises(TraitValidationError): + empty_file_locations_trait.validate_trait(representation) + + # create valid file locations trait but with not matching + # frame range trait + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list), + Sequence(frame_padding=4), + ]) + invalid_sequence_trait = FrameRanged( + frame_start=1001, + frame_end=1051, + frames_per_second="25" + ) + + representation.add_trait(invalid_sequence_trait) + with pytest.raises(TraitValidationError): + file_locations_trait.validate_trait(representation) + + # invalid representation with multiple file locations but + # unrelated to either Sequence or Bundle traits + representation = Representation(name="test", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path("/path/to/file_foo.exr"), + file_size=1024, + file_hash=None, + ), + FileLocation( + file_path=Path("/path/to/anotherfile.obj"), + file_size=1234, + file_hash=None, + ) + ]) + ]) + + with pytest.raises(TraitValidationError): + representation.validate() + + +def test_get_file_location_from_frame() -> None: + """Test get_file_location_from_frame method.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + file_locations_trait: FileLocations = FileLocations( + file_paths=file_locations_list) + + assert file_locations_trait.get_file_location_for_frame(frame=1001) == \ + file_locations_list[0] + assert file_locations_trait.get_file_location_for_frame(frame=1050) == \ + file_locations_list[-1] + assert file_locations_trait.get_file_location_for_frame(frame=1100) is None + + # test with custom regex + sequence = Sequence( + frame_padding=4, + frame_regex=re.compile(r"boo_(?P(?P0*)\d+)\.exr")) + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/boo_{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + file_locations_trait = FileLocations( + file_paths=file_locations_list) + + assert file_locations_trait.get_file_location_for_frame( + frame=1001, sequence_trait=sequence) == \ + file_locations_list[0] diff --git a/tests/client/ayon_core/pipeline/traits/test_time_traits.py b/tests/client/ayon_core/pipeline/traits/test_time_traits.py new file mode 100644 index 0000000000..28ace89910 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_time_traits.py @@ -0,0 +1,248 @@ +"""Tests for the time related traits.""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from ayon_core.pipeline.traits import ( + FileLocation, + FileLocations, + FrameRanged, + Handles, + Representation, + Sequence, +) +from ayon_core.pipeline.traits.trait import TraitValidationError + + +def test_sequence_validations() -> None: + """Test Sequence trait validation.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1010 + 1) # because range is zero based + ] + + file_locations_list += [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1015, 1020 + 1) + ] + + file_locations_list += [ + FileLocation + ( + file_path=Path("/path/to/file.1100.exr"), + file_size=1024, + file_hash=None, + ) + ] + + representation = Representation(name="test_1", traits=[ + FileLocations(file_paths=file_locations_list), + FrameRanged( + frame_start=1001, + frame_end=1100, frames_per_second="25"), + Sequence( + frame_padding=4, + frame_spec="1001-1010,1015-1020,1100") + ]) + + representation.get_trait(Sequence).validate_trait(representation) + + # here we set handles and set them as inclusive, so this should pass + representation = Representation(name="test_2", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1100 + 1) # because range is zero based + ]), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=True + ), + FrameRanged( + frame_start=1001, + frame_end=1100, frames_per_second="25"), + Sequence(frame_padding=4) + ]) + + representation.validate() + + # do the same but set handles as exclusive + representation = Representation(name="test_3", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(996, 1105 + 1) # because range is zero based + ]), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ), + FrameRanged( + frame_start=1001, + frame_end=1100, frames_per_second="25"), + Sequence(frame_padding=4) + ]) + + representation.validate() + + # invalid representation with file range not extended for handles + representation = Representation(name="test_4", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1050 + 1) # because range is zero based + ]), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ), + FrameRanged( + frame_start=1001, + frame_end=1050, frames_per_second="25"), + Sequence(frame_padding=4) + ]) + + with pytest.raises(TraitValidationError): + representation.validate() + + # invalid representation with frame spec not matching the files + del representation + representation = Representation(name="test_5", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1050 + 1) # because range is zero based + ]), + FrameRanged( + frame_start=1001, + frame_end=1050, frames_per_second="25"), + Sequence(frame_padding=4, frame_spec="1001-1010,1012-2000") + ]) + with pytest.raises(TraitValidationError): + representation.validate() + + representation = Representation(name="test_6", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1050 + 1) # because range is zero based + ]), + Sequence(frame_padding=4, frame_spec="1-1010,1012-1050"), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ) + ]) + with pytest.raises(TraitValidationError): + representation.validate() + + representation = Representation(name="test_6", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(996, 1050 + 1) # because range is zero based + ]), + Sequence(frame_padding=4, frame_spec="1001-1010,1012-2000"), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ) + ]) + with pytest.raises(TraitValidationError): + representation.validate() + + representation = Representation(name="test_7", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(996, 1050 + 1) # because range is zero based + ]), + Sequence( + frame_padding=4, + frame_regex=re.compile( + r"img\.(?P(?P0*)\d{4})\.png$")), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ) + ]) + representation.validate() + + +def test_list_spec_to_frames() -> None: + """Test converting list specification to frames.""" + assert Sequence.list_spec_to_frames("1-10,20-30,55") == [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 55 + ] + assert Sequence.list_spec_to_frames("1,2,3,4,5") == [ + 1, 2, 3, 4, 5 + ] + assert Sequence.list_spec_to_frames("1-10") == [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 + ] + test_list = list(range(1001, 1011)) + test_list += list(range(1012, 2001)) + assert Sequence.list_spec_to_frames("1001-1010,1012-2000") == test_list + + assert Sequence.list_spec_to_frames("1") == [1] + with pytest.raises( + ValueError, + match=r"Invalid frame number in the list: .*"): + Sequence.list_spec_to_frames("a") + + +def test_sequence_get_frame_padding() -> None: + """Test getting frame padding from FileLocations trait.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list) + ]) + + assert Sequence.get_frame_padding( + file_locations=representation.get_trait(FileLocations)) == 4 diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py new file mode 100644 index 0000000000..e4aef1ba18 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -0,0 +1,405 @@ +"""Tests for the representation traits.""" +from __future__ import annotations + +from pathlib import Path + +import pytest +from ayon_core.pipeline.traits import ( + Bundle, + FileLocation, + Image, + MimeType, + Overscan, + PixelBased, + Planar, + Representation, + TraitBase, +) + +REPRESENTATION_DATA: dict = { + FileLocation.id: { + "file_path": Path("/path/to/file"), + "file_size": 1024, + "file_hash": None, + # "persistent": True, + }, + Image.id: {}, + PixelBased.id: { + "display_window_width": 1920, + "display_window_height": 1080, + "pixel_aspect_ratio": 1.0, + # "persistent": True, + }, + Planar.id: { + "planar_configuration": "RGB", + # "persistent": True, + }, + } + + +class UpgradedImage(Image): + """Upgraded image class.""" + id = "ayon.2d.Image.v2" + + @classmethod + def upgrade(cls, data: dict) -> UpgradedImage: # noqa: ARG003 + """Upgrade the trait. + + Returns: + UpgradedImage: Upgraded image instance. + + """ + return cls() + + +class InvalidTrait: + """Invalid trait class.""" + foo = "bar" + + +@pytest.fixture +def representation() -> Representation: + """Return a traits data instance.""" + return Representation(name="test", traits=[ + FileLocation(**REPRESENTATION_DATA[FileLocation.id]), + Image(), + PixelBased(**REPRESENTATION_DATA[PixelBased.id]), + Planar(**REPRESENTATION_DATA[Planar.id]), + ]) + + +def test_representation_errors(representation: Representation) -> None: + """Test errors in representation.""" + with pytest.raises(ValueError, + match=r"Invalid trait .* - ID is required."): + representation.add_trait(InvalidTrait()) + + with pytest.raises(ValueError, + match=f"Trait with ID {Image.id} already exists."): + representation.add_trait(Image()) + + with pytest.raises(ValueError, + match=r"Trait with ID .* not found."): + representation.remove_trait_by_id("foo") + + +def test_representation_traits(representation: Representation) -> None: + """Test setting and getting traits.""" + assert representation.get_trait_by_id( + "ayon.2d.PixelBased").get_version() == 1 + + assert len(representation) == len(REPRESENTATION_DATA) + assert representation.get_trait_by_id(FileLocation.id) + assert representation.get_trait_by_id(Image.id) + assert representation.get_trait_by_id(trait_id="ayon.2d.Image.v1") + assert representation.get_trait_by_id(PixelBased.id) + assert representation.get_trait_by_id(trait_id="ayon.2d.PixelBased.v1") + assert representation.get_trait_by_id(Planar.id) + assert representation.get_trait_by_id(trait_id="ayon.2d.Planar.v1") + + assert representation.get_trait(FileLocation) + assert representation.get_trait(Image) + assert representation.get_trait(PixelBased) + assert representation.get_trait(Planar) + + assert issubclass( + type(representation.get_trait(FileLocation)), TraitBase) + + assert representation.get_trait(FileLocation) == \ + representation.get_trait_by_id(FileLocation.id) + assert representation.get_trait(Image) == \ + representation.get_trait_by_id(Image.id) + assert representation.get_trait(PixelBased) == \ + representation.get_trait_by_id(PixelBased.id) + assert representation.get_trait(Planar) == \ + representation.get_trait_by_id(Planar.id) + + assert representation.get_trait_by_id( + "ayon.2d.PixelBased.v1").display_window_width == \ + REPRESENTATION_DATA[PixelBased.id]["display_window_width"] + assert representation.get_trait( + trait=PixelBased).display_window_height == \ + REPRESENTATION_DATA[PixelBased.id]["display_window_height"] + + repre_dict = { + FileLocation.id: FileLocation(**REPRESENTATION_DATA[FileLocation.id]), + Image.id: Image(), + PixelBased.id: PixelBased(**REPRESENTATION_DATA[PixelBased.id]), + Planar.id: Planar(**REPRESENTATION_DATA[Planar.id]), + } + assert representation.get_traits() == repre_dict + + assert representation.get_traits_by_ids( + trait_ids=[FileLocation.id, Image.id, PixelBased.id, Planar.id]) == \ + repre_dict + assert representation.get_traits( + [FileLocation, Image, PixelBased, Planar]) == \ + repre_dict + + assert representation.has_traits() is True + empty_representation: Representation = Representation( + name="test", traits=[]) + assert empty_representation.has_traits() is False + + assert representation.contains_trait(trait=FileLocation) is True + assert representation.contains_traits([Image, FileLocation]) is True + assert representation.contains_trait_by_id(FileLocation.id) is True + assert representation.contains_traits_by_id( + trait_ids=[FileLocation.id, Image.id]) is True + + assert representation.contains_trait(trait=Bundle) is False + assert representation.contains_traits([Image, Bundle]) is False + assert representation.contains_trait_by_id(Bundle.id) is False + assert representation.contains_traits_by_id( + trait_ids=[FileLocation.id, Bundle.id]) is False + + +def test_trait_removing(representation: Representation) -> None: + """Test removing traits.""" + assert representation.contains_trait_by_id("nonexistent") is False + with pytest.raises( + ValueError, match=r"Trait with ID nonexistent not found."): + representation.remove_trait_by_id("nonexistent") + + assert representation.contains_trait(trait=FileLocation) is True + representation.remove_trait(trait=FileLocation) + assert representation.contains_trait(trait=FileLocation) is False + + assert representation.contains_trait_by_id(Image.id) is True + representation.remove_trait_by_id(Image.id) + assert representation.contains_trait_by_id(Image.id) is False + + assert representation.contains_traits([PixelBased, Planar]) is True + representation.remove_traits([Planar, PixelBased]) + assert representation.contains_traits([PixelBased, Planar]) is False + + assert representation.has_traits() is False + + with pytest.raises( + ValueError, match=f"Trait with ID {Image.id} not found."): + representation.remove_trait(Image) + + +def test_representation_dict_properties( + representation: Representation) -> None: + """Test representation as dictionary.""" + representation = Representation(name="test") + representation[Image.id] = Image() + assert Image.id in representation + image = representation[Image.id] + assert image == Image() + for trait_id, trait in representation.items(): + assert trait_id == Image.id + assert trait == Image() + + +def test_getting_traits_data(representation: Representation) -> None: + """Test getting a batch of traits.""" + result = representation.get_traits_by_ids( + trait_ids=[FileLocation.id, Image.id, PixelBased.id, Planar.id]) + assert result == { + "ayon.2d.Image.v1": Image(), + "ayon.2d.PixelBased.v1": PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + "ayon.2d.Planar.v1": Planar(planar_configuration="RGB"), + "ayon.content.FileLocation.v1": FileLocation( + file_path=Path("/path/to/file"), + file_size=1024, + file_hash=None) + } + + +def test_traits_data_to_dict(representation: Representation) -> None: + """Test converting traits data to dictionary.""" + result = representation.traits_as_dict() + assert result == REPRESENTATION_DATA + + +def test_get_version_from_id() -> None: + """Test getting version from trait ID.""" + assert Image().get_version() == 1 + + class TestOverscan(Overscan): + id = "ayon.2d.Overscan.v2" + + assert TestOverscan( + left=0, + right=0, + top=0, + bottom=0 + ).get_version() == 2 + + class TestMimeType(MimeType): + id = "ayon.content.MimeType" + + assert TestMimeType(mime_type="foo/bar").get_version() is None + + +def test_get_versionless_id() -> None: + """Test getting versionless trait ID.""" + assert Image().get_versionless_id() == "ayon.2d.Image" + + class TestOverscan(Overscan): + id = "ayon.2d.Overscan.v2" + + assert TestOverscan( + left=0, + right=0, + top=0, + bottom=0 + ).get_versionless_id() == "ayon.2d.Overscan" + + class TestMimeType(MimeType): + id = "ayon.content.MimeType" + + assert TestMimeType(mime_type="foo/bar").get_versionless_id() == \ + "ayon.content.MimeType" + + +def test_from_dict() -> None: + """Test creating representation from dictionary.""" + traits_data = { + "ayon.content.FileLocation.v1": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + "ayon.2d.Image.v1": {}, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + + assert len(representation) == 2 + assert representation.get_trait_by_id("ayon.content.FileLocation.v1") + assert representation.get_trait_by_id("ayon.2d.Image.v1") + + traits_data = { + "ayon.content.FileLocation.v999": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + } + + with pytest.raises(ValueError, match=r"Trait model with ID .* not found."): + representation = Representation.from_dict( + "test", trait_data=traits_data) + + traits_data = { + "ayon.content.FileLocation": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + + assert len(representation) == 1 + assert representation.get_trait_by_id("ayon.content.FileLocation.v1") + + # this won't work right now because we would need to somewhat mock + # the import + """ + from .lib import NewTestTrait + + traits_data = { + "ayon.test.NewTestTrait.v1": {}, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + """ + + +def test_representation_equality() -> None: + """Test representation equality.""" + # rep_a and rep_b are equal + rep_a = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + rep_b = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + + # rep_c has different value for planar_configuration then rep_a and rep_b + rep_c = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGBA"), + ]) + + rep_d = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + ]) + rep_e = Representation(name="foo", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + ]) + rep_f = Representation(name="foo", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Planar(planar_configuration="RGBA"), + ]) + + # let's assume ids are the same (because ids are randomly generated) + rep_b.representation_id = rep_d.representation_id = rep_a.representation_id + rep_c.representation_id = rep_e.representation_id = rep_a.representation_id + rep_f.representation_id = rep_a.representation_id + assert rep_a == rep_b + + # because of the trait value difference + assert rep_a != rep_c + # because of the type difference + assert rep_a != "foo" + # because of the trait count difference + assert rep_a != rep_d + # because of the name difference + assert rep_d != rep_e + # because of the trait difference + assert rep_d != rep_f + + +def test_get_repre_by_name(): + """Test getting representation by name.""" + rep_a = Representation(name="test_a", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + rep_b = Representation(name="test_b", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + + representations = [rep_a, rep_b] + _ = next(rep for rep in representations if rep.name == "test_a") diff --git a/tests/client/ayon_core/pipeline/traits/test_two_dimensional_traits.py b/tests/client/ayon_core/pipeline/traits/test_two_dimensional_traits.py new file mode 100644 index 0000000000..f09d2b0864 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_two_dimensional_traits.py @@ -0,0 +1,63 @@ +"""Tests for the 2d related traits.""" +from __future__ import annotations + +from pathlib import Path + +from ayon_core.pipeline.traits import ( + UDIM, + FileLocation, + FileLocations, + Representation, +) + + +def test_get_file_location_for_udim() -> None: + """Test get_file_location_for_udim.""" + file_locations_list = [ + FileLocation( + file_path=Path("/path/to/file.1001.exr"), + file_size=1024, + file_hash=None, + ), + FileLocation( + file_path=Path("/path/to/file.1002.exr"), + file_size=1024, + file_hash=None, + ), + FileLocation( + file_path=Path("/path/to/file.1003.exr"), + file_size=1024, + file_hash=None, + ), + ] + + representation = Representation(name="test_1", traits=[ + FileLocations(file_paths=file_locations_list), + UDIM(udim=[1001, 1002, 1003]), + ]) + + udim_trait = representation.get_trait(UDIM) + assert udim_trait.get_file_location_for_udim( + file_locations=representation.get_trait(FileLocations), + udim=1001 + ) == file_locations_list[0] + + +def test_get_udim_from_file_location() -> None: + """Test get_udim_from_file_location.""" + file_location_1 = FileLocation( + file_path=Path("/path/to/file.1001.exr"), + file_size=1024, + file_hash=None, + ) + + file_location_2 = FileLocation( + file_path=Path("/path/to/file.xxxxx.exr"), + file_size=1024, + file_hash=None, + ) + assert UDIM(udim=[1001]).get_udim_from_file_location( + file_location_1) == 1001 + + assert UDIM(udim=[1001]).get_udim_from_file_location( + file_location_2) is None diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py new file mode 100644 index 0000000000..abb605a121 --- /dev/null +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -0,0 +1,451 @@ +"""Tests for the representation traits.""" +from __future__ import annotations + +import base64 +import re +import time +from pathlib import Path +from typing import TYPE_CHECKING + +import pyblish.api +import pytest + +from ayon_core.lib.file_transaction import ( + FileTransaction, +) + +from ayon_core.pipeline.anatomy import Anatomy +from ayon_core.pipeline.traits import ( + Bundle, + FileLocation, + FileLocations, + FrameRanged, + Image, + MimeType, + Persistent, + PixelBased, + Representation, + Sequence, + Transient, +) +from ayon_core.pipeline.version_start import get_versioning_start + +# Tagged, +# TemplatePath, +from ayon_core.plugins.publish.integrate_traits import ( + IntegrateTraits, + TransferItem, +) + +from ayon_core.settings import get_project_settings + +from ayon_api.operations import ( + OperationsSession, +) + +if TYPE_CHECKING: + import pytest_ayon + +PNG_FILE_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVR4AWNgAAAAAgABc3UBGAAAAABJRU5ErkJggg==" # noqa: E501 +SEQUENCE_LENGTH = 10 +CURRENT_TIME = time.time() + + +@pytest.fixture(scope="session") +def single_file(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Return a temporary image file.""" + filename = tmp_path_factory.mktemp("single") / "img.png" + filename.write_bytes(base64.b64decode(PNG_FILE_B64)) + return filename + + +@pytest.fixture(scope="session") +def sequence_files(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: + """Return a sequence of temporary image files.""" + files = [] + dir_name = tmp_path_factory.mktemp("sequence") + for i in range(SEQUENCE_LENGTH): + frame = i + 1 + filename = dir_name / f"img.{frame:04d}.png" + filename.write_bytes(base64.b64decode(PNG_FILE_B64)) + files.append(filename) + return files + + +@pytest.fixture +def mock_context( + project: pytest_ayon.ProjectInfo, + single_file: Path, + sequence_files: list[Path]) -> pyblish.api.Context: + """Return a mock instance. + + This is mocking pyblish context for testing. It is using real AYON project + thanks to the ``project`` fixture. + + Args: + project (object): The project info. It is `ProjectInfo` object + returned by pytest fixture. + single_file (Path): The path to a single image file. + sequence_files (list[Path]): The paths to a sequence of image files. + + """ + anatomy = Anatomy(project.project_name) + context = pyblish.api.Context() + context.data["projectName"] = project.project_name + context.data["hostName"] = "test_host" + context.data["project_settings"] = get_project_settings( + project.project_name) + context.data["anatomy"] = anatomy + context.data["time"] = CURRENT_TIME + context.data["user"] = "test_user" + context.data["machine"] = "test_machine" + context.data["fps"] = 25 + + instance = context.create_instance("mock_instance") + instance.data["source"] = "test_source" + instance.data["families"] = ["render"] + + parents = project.folder_entity["path"].lstrip("/").split("/") + hierarchy = "/".join(parents) if parents else "" + + instance.data["anatomyData"] = { + "project": { + "name": project.project_name, + "code": project.project_code + }, + "task": { + "name": project.task.name, + "type": "test" # pytest-ayon doesn't return the task type yet + }, + "folder": { + "name": project.folder.name, + "type": "test" # pytest-ayon doesn't return the folder type yet + }, + "product": { + "name": project.product.name, + "type": "test" # pytest-ayon doesn't return the product type yet + }, + "hierarchy": hierarchy, + + } + instance.data["folderEntity"] = project.folder_entity + instance.data["productType"] = "test_product" + instance.data["productName"] = project.product.name + instance.data["anatomy"] = anatomy + instance.data["comment"] = "test_comment" + + instance.data["integrate"] = True + instance.data["farm"] = False + + parents = project.folder_entity["path"].lstrip("/").split("/") + + hierarchy = "/".join(parents) if parents else "" + instance.data["hierarchy"] = hierarchy + + version_number = get_versioning_start( + context.data["projectName"], + instance.context.data["hostName"], + task_name=project.task.name, + task_type="test", + product_type=instance.data["productType"], + product_name=instance.data["productName"] + ) + + instance.data["version"] = version_number + + file_size = len(base64.b64decode(PNG_FILE_B64)) + file_locations = [ + FileLocation( + file_path=f, + file_size=file_size) + for f in sequence_files] + + instance.data["representations_with_traits"] = [ + Representation(name="test_single", traits=[ + Persistent(), + FileLocation( + file_path=single_file, + file_size=len(base64.b64decode(PNG_FILE_B64))), + Image(), + MimeType(mime_type="image/png"), + ]), + Representation(name="test_sequence", traits=[ + Persistent(), + FrameRanged( + frame_start=1, + frame_end=SEQUENCE_LENGTH, + frame_in=0, + frame_out=SEQUENCE_LENGTH - 1, + frames_per_second="25", + ), + Sequence( + frame_padding=4, + frame_regex=re.compile( + r"img\.(?P(?P0*)\d{4})\.png$"), + ), + FileLocations( + file_paths=file_locations, + ), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + MimeType(mime_type="image/png"), + ]), + Representation(name="test_bundle", traits=[ + Persistent(), + Bundle( + items=[ + [ + FileLocation( + file_path=single_file, + file_size=len(base64.b64decode(PNG_FILE_B64))), + Image(), + MimeType(mime_type="image/png"), + ], + [ + Persistent(), + FrameRanged( + frame_start=1, + frame_end=SEQUENCE_LENGTH, + frame_in=0, + frame_out=SEQUENCE_LENGTH - 1, + frames_per_second="25", + ), + Sequence( + frame_padding=4, + frame_regex=re.compile( + r"img\.(?P(?P0*)\d{4})\.png$"), + ), + FileLocations( + file_paths=file_locations, + ), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + MimeType(mime_type="image/png"), + ], + ], + ), + ]), + ] + + return context + + +@pytest.mark.server +def test_get_template_name(mock_context: pyblish.api.Context) -> None: + """Test get_template_name. + + TODO (antirotor): this will always return "default" probably, if + there are no studio overrides. To test this properly, we need + to set up the studio overrides in the test environment. + + """ + integrator = IntegrateTraits() + template_name = integrator.get_template_name( + mock_context[0]) + + assert template_name == "default" + + +class TestGetSize: + @staticmethod + def get_size(file_path: Path) -> int: + """Get size of the file. + + Args: + file_path (Path): File path. + + Returns: + int: Size of the file. + + """ + return file_path.stat().st_size + + @pytest.mark.parametrize( + "file_path, expected_size", + [ + (Path("./test_file_1.txt"), 10), # id: happy_path_small_file + (Path("./test_file_2.txt"), 1024), # id: happy_path_medium_file + (Path("./test_file_3.txt"), 10485760) # id: happy_path_large_file + ], + ids=["happy_path_small_file", + "happy_path_medium_file", + "happy_path_large_file"] + ) + def test_get_size_happy_path( + self, file_path: Path, expected_size: int, tmp_path: Path): + # Arrange + file_path = tmp_path / file_path + file_path.write_bytes(b"\0" * expected_size) + + # Act + size = self.get_size(file_path) + + # Assert + assert size == expected_size + + @pytest.mark.parametrize( + "file_path, expected_size", + [ + (Path("./test_file_empty.txt"), 0) # id: edge_case_empty_file + ], + ids=["edge_case_empty_file"] + ) + def test_get_size_edge_cases( + self, file_path: Path, expected_size: int, tmp_path: Path): + # Arrange + file_path = tmp_path / file_path + file_path.touch() # Create an empty file + + # Act + size = self.get_size(file_path) + + # Assert + assert size == expected_size + + @pytest.mark.parametrize( + "file_path, expected_exception", + [ + ( + Path("./non_existent_file.txt"), + FileNotFoundError + ), # id: error_file_not_found + (123, TypeError) # id: error_invalid_input_type + ], + ids=["error_file_not_found", "error_invalid_input_type"] + ) + def test_get_size_error_cases( + self, file_path, expected_exception, tmp_path): + + # Act & Assert + with pytest.raises(expected_exception): + file_path = tmp_path / file_path + self.get_size(file_path) + + +def test_filter_lifecycle() -> None: + """Test filter_lifecycle.""" + integrator = IntegrateTraits() + persistent_representation = Representation( + name="test", + traits=[ + Persistent(), + FileLocation( + file_path=Path("test"), + file_size=1234), + Image(), + MimeType(mime_type="image/png"), + ]) + transient_representation = Representation( + name="test", + traits=[ + Transient(), + Image(), + MimeType(mime_type="image/png"), + ]) + filtered = integrator.filter_lifecycle( + [persistent_representation, transient_representation]) + + assert len(filtered) == 1 + assert filtered[0] == persistent_representation + + +@pytest.mark.server +def test_prepare_product( + project: pytest_ayon.ProjectInfo, + mock_context: pyblish.api.Context) -> None: + """Test prepare_product.""" + integrator = IntegrateTraits() + op_session = OperationsSession() + product = integrator.prepare_product(mock_context[0], op_session) + + assert product == { + "attrib": {}, + "data": { + "families": ["default", "render"], + }, + "folderId": project.folder_entity["id"], + "name": "renderMain", + "productType": "test_product", + "id": project.product_entity["id"], + } + + +@pytest.mark.server +def test_prepare_version( + project: pytest_ayon.ProjectInfo, + mock_context: pyblish.api.Context) -> None: + """Test prepare_version.""" + integrator = IntegrateTraits() + op_session = OperationsSession() + product = integrator.prepare_product(mock_context[0], op_session) + version = integrator.prepare_version( + mock_context[0], op_session, product) + + assert version == { + "attrib": { + "comment": "test_comment", + "families": ["default", "render"], + "fps": 25, + "machine": "test_machine", + "source": "test_source", + }, + "data": { + "author": "test_user", + "time": CURRENT_TIME, + }, + "id": project.version_entity["id"], + "productId": project.product_entity["id"], + "version": 1, + } + + +@pytest.mark.server +def test_get_transfers_from_representation( + mock_context: pyblish.api.Context) -> None: + """Test get_transfers_from_representation. + + This tests getting actual transfers from the representations and + also the legacy files. + + Todo: This test will benefit massively from a proper mocking of the + context. We need to parametrize the test with different + representations and test the output of the function. + + """ + integrator = IntegrateTraits() + + instance = mock_context[0] + representations: list[Representation] = instance.data[ + "representations_with_traits"] + transfers = integrator.get_transfers_from_representations( + instance, representations) + + assert len(representations) == 3 + assert len(transfers) == 22 + + for transfer in transfers: + assert transfer.checksum == TransferItem.get_checksum( + transfer.source) + + file_transactions = FileTransaction( + # Enforce unique transfers + allow_queue_replacements=False) + + for transfer in transfers: + file_transactions.add( + transfer.source.as_posix(), + transfer.destination.as_posix(), + mode=FileTransaction.MODE_COPY, + ) + + file_transactions.process() + + for representation in representations: + _ = integrator._get_legacy_files_for_representation( # noqa: SLF001 + transfers, representation, anatomy=instance.data["anatomy"]) diff --git a/tests/conftest.py b/tests/conftest.py index a3c46a9dd7..33c29d13f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +"""conftest.py: pytest configuration file.""" import sys from pathlib import Path @@ -5,5 +6,3 @@ client_path = Path(__file__).resolve().parent.parent / "client" # add client path to sys.path sys.path.append(str(client_path)) - -print(f"Added {client_path} to sys.path") diff --git a/tools/manage.ps1 b/tools/manage.ps1 index 8324277713..306a61e30d 100755 --- a/tools/manage.ps1 +++ b/tools/manage.ps1 @@ -242,7 +242,7 @@ function Run-From-Code { function Run-Tests { $Poetry = "$RepoRoot\.poetry\bin\poetry.exe" - $RunArgs = @( "run", "pytest", "$($RepoRoot)/tests") + $RunArgs = @( "run", "pytest", "$($RepoRoot)/tests", "-m", "not server") & $Poetry $RunArgs @arguments } diff --git a/tools/manage.sh b/tools/manage.sh index 86ae7155c5..5362374045 100755 --- a/tools/manage.sh +++ b/tools/manage.sh @@ -186,7 +186,7 @@ run_command () { run_tests () { echo -e "${BIGreen}>>>${RST} Running tests..." shift; # will remove first arg ("run-tests") from the "$@" - "$POETRY_HOME/bin/poetry" run pytest ./tests + "$POETRY_HOME/bin/poetry" run pytest ./tests -m "not server" } main () {