Merge pull request #1301 from ynput/enhancement/1294-product-base-types-support-in-loading

🏛️Product base types: add support to loaders
This commit is contained in:
Jakub Trllo 2025-06-24 11:49:06 +02:00 committed by GitHub
commit b2ab8ef5c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 386 additions and 101 deletions

View file

@ -1,24 +1,28 @@
"""Plugins for loading representations and products into host applications."""
from __future__ import annotations
import os
import logging
from typing import Any, Type, Optional
from abc import abstractmethod
from ayon_core.settings import get_project_settings
from abc import abstractmethod
import logging
import os
from typing import Any, Optional, Type
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
@ -61,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
@ -79,7 +83,6 @@ class LoaderPlugin(list):
Returns:
bool: Representation has valid extension
"""
if "*" in cls.extensions:
return True
@ -124,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
@ -143,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):
@ -208,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 []
@ -347,10 +381,8 @@ def discover_loader_plugins(project_name=None):
plugin.apply_settings(project_settings)
except Exception:
log.warning(
"Failed to apply settings to loader {}".format(
plugin.__name__
),
exc_info=True,
f"Failed to apply settings to loader {plugin.__name__}",
exc_info=True
)
compatible_hooks = []
for hook_cls in sorted_hooks:

View file

@ -1,10 +1,13 @@
"""Abstract base classes for loader tool."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import List
from typing import Any, List, Optional
from ayon_core.lib.attribute_definitions import (
AbstractAttrDef,
serialize_attr_defs,
deserialize_attr_defs,
serialize_attr_defs,
)
@ -13,10 +16,10 @@ class ProductTypeItem:
Args:
name (str): Product type name.
icon (dict[str, Any]): Product type icon definition.
icon (dict[str, str]): Product type icon definition.
"""
def __init__(self, name, icon):
def __init__(self, name: str, icon: dict[str, str]):
self.name = name
self.icon = icon
@ -31,6 +34,41 @@ class ProductTypeItem:
return cls(**data)
class ProductBaseTypeItem:
"""Item representing the product base type."""
def __init__(self, name: str, icon: dict[str, str]):
"""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.
@ -38,8 +76,8 @@ class ProductItem:
product_id (str): Product id.
product_type (str): Product type.
product_name (str): Product name.
product_icon (dict[str, Any]): Product icon definition.
product_type_icon (dict[str, Any]): Product type icon definition.
product_icon (dict[str, str]): Product icon definition.
product_type_icon (dict[str, str]): Product type icon definition.
product_in_scene (bool): Is product in scene (only when used in DCC).
group_name (str): Group name.
folder_id (str): Folder id.
@ -49,35 +87,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, str],
product_type_icon: dict[str, str],
product_base_type_icon: dict[str, str],
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,
@ -124,21 +168,21 @@ 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],
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
@ -198,7 +242,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,
@ -218,7 +262,7 @@ class VersionItem:
}
@classmethod
def from_data(cls, data):
def from_data(cls, data: dict[str, Any]) -> VersionItem:
return cls(**data)

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"
@ -70,9 +80,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,9 +99,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(),
@ -103,9 +126,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,11 +139,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",
@ -127,8 +153,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",
@ -136,10 +184,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:
@ -161,6 +227,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 +267,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.
@ -449,11 +542,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 +555,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 +569,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 +589,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,31 +16,32 @@ 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
class ProductsModel(QtGui.QStandardItemModel):
@ -49,6 +50,7 @@ class ProductsModel(QtGui.QStandardItemModel):
column_labels = [
"Product name",
"Product type",
"Product base type",
"Folder",
"Version",
"Status",
@ -79,6 +81,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 +96,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,
@ -432,6 +436,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,
@ -142,6 +143,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 +263,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

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