Merge branch 'develop' into enhancement/1309-loader-tool-add-tags-filtering

# Conflicts:
#	client/ayon_core/tools/common_models/projects.py
#	client/ayon_core/tools/loader/abstract.py
#	client/ayon_core/tools/loader/ui/products_model.py
This commit is contained in:
Jakub Trllo 2025-06-24 11:55:28 +02:00
commit ee761d074a
28 changed files with 1192 additions and 414 deletions

View file

@ -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"],

View file

@ -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",
)

View file

@ -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)
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_<method_name> 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_<method_name> 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)

View file

@ -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

View file

@ -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
@ -17,6 +17,7 @@ import collections
import copy
from abc import ABC, abstractmethod
import ayon_api
from ayon_api import (
get_folders,
get_folder_by_path,
@ -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
@ -823,7 +850,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 +861,6 @@ class AbstractTemplateBuilder(ABC):
"task_names": task_name
}
)
if not profile:
raise TemplateProfileNotFound((
"No matching profile found for task '{}' of type '{}' "
@ -843,6 +868,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 +893,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 +981,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)

View file

@ -58,7 +58,7 @@ class ExtractOIIOTranscode(publish.Extractor):
optional = True
# Supported extensions
supported_exts = ["exr", "jpg", "jpeg", "png", "dpx"]
supported_exts = {"exr", "jpg", "jpeg", "png", "dpx"}
# Configurable by Settings
profiles = None

View file

@ -135,11 +135,11 @@ class ExtractReview(pyblish.api.InstancePlugin):
]
# 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 = []

View file

@ -150,6 +150,7 @@ class TaskTypeItem:
)
@dataclass
class ProjectItem:
"""Item representing folder entity on a server.
@ -160,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:
@ -184,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):
@ -218,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
@ -454,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:

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -1,11 +1,13 @@
"""Abstract base classes for loader tool."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Iterable, Optional
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
@ -18,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
@ -33,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.
@ -51,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,
@ -127,22 +170,22 @@ class VersionItem:
def __init__(
self,
version_id,
version,
is_hero,
product_id,
task_id,
thumbnail_id,
published_time,
tags,
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
@ -203,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,
@ -224,7 +267,7 @@ class VersionItem:
}
@classmethod
def from_data(cls, data):
def from_data(cls, data: dict[str, Any]) -> VersionItem:
return cls(**data)

View file

@ -322,7 +322,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 +339,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 +719,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 +775,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 +806,6 @@ class LoaderActionsModel:
version_contexts,
options=options
)
except Exception as exc:
formatted_traceback = None
if not isinstance(exc, LoadError):

View file

@ -1,19 +1,29 @@
"""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 (
IconData,
ProductTypeItem,
ProductBaseTypeItem,
ProductItem,
VersionItem,
RepreItem,
)
if TYPE_CHECKING:
from ayon_api.typing import ProductBaseTypeDict, ProductDict, VersionDict
PRODUCTS_MODEL_SENDER = "products.model"
@ -72,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,
):
@ -90,9 +101,21 @@ 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_icon = {
product_type_icon = product_type_item.icon
product_base_type_icon = product_base_type_item.icon
product_icon: IconData = {
"type": "awesome-font",
"name": "fa.file-o",
"color": get_default_entity_icon_color(),
@ -105,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"],
@ -116,11 +141,12 @@ 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"]
icon = {
icon: IconData = {
"type": "awesome-font",
"name": "fa.folder",
"color": "#0091B2",
@ -129,8 +155,30 @@ def product_type_item_from_data(product_type_data):
return ProductTypeItem(product_type_data["name"], icon)
def create_default_product_type_item(product_type):
icon = {
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: IconData = {
"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: IconData = {
"type": "awesome-font",
"name": "fa.folder",
"color": "#0091B2",
@ -138,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: IconData = {
"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:
@ -163,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(
@ -201,6 +269,31 @@ 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 = 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.
@ -451,11 +544,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)
@ -463,6 +557,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)
@ -472,7 +571,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"]
@ -486,6 +591,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,
)

View file

@ -16,33 +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
TASK_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 33
VERSION_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 34
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):
@ -51,6 +52,7 @@ class ProductsModel(QtGui.QStandardItemModel):
column_labels = [
"Product name",
"Product type",
"Product base type",
"Folder",
"Version",
"Status",
@ -81,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")
@ -95,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,
@ -456,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)

View file

@ -4,6 +4,7 @@ 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,
@ -169,6 +170,7 @@ class ProductsWidget(QtWidgets.QWidget):
default_widths = (
200, # Product name
90, # Product type
90, # Product base type
130, # Folder label
60, # Version
100, # Status
@ -288,6 +290,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.

View file

@ -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

View file

@ -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)

View file

@ -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",

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -630,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()
@ -794,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:

View file

@ -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