Merge remote-tracking branch 'origin/feature/AY-7545_product-base-types' into feature/AY-7545_product-base-types

This commit is contained in:
Ondřej Samohel 2025-05-22 09:50:07 +02:00
commit baba002af2
No known key found for this signature in database
GPG key ID: 02376E18990A97C6
3 changed files with 150 additions and 108 deletions

View file

@ -55,6 +55,7 @@ from .exceptions import (
HostMissRequiredMethod,
UnavailableSharedData,
)
from .product_name import ProductContext
from .structures import ConvertorItem, InstanceContextInfo, PublishAttributes
if typing.TYPE_CHECKING:
@ -106,7 +107,7 @@ class CreatorOperationInfo:
creator_identifier: str
creator_label: str
message: str
traceback: str
traceback: Optional[str]
_NOT_SET = object()
@ -247,7 +248,7 @@ class CreateContext: # noqa: PLR0904
# Publish context plugins attributes and it's values
self._publish_attributes = PublishAttributes(self, {})
self._original_context_data = {}
self._original_context_data: dict[str, Any] = {}
# Validate host implementation
# - defines if context is capable of handling context data
@ -280,7 +281,7 @@ class CreateContext: # noqa: PLR0904
self.headless = headless
# Instances by their ID
self._instances_by_id = {}
self._instances_by_id: dict[str, Any] = {}
self.creator_discover_result = None
self.convertor_discover_result = None
@ -419,7 +420,7 @@ class CreateContext: # noqa: PLR0904
return set(IPublishHost.get_missing_publish_methods(host))
@property
def host_is_valid(self):
def host_is_valid(self) -> bool:
"""Is host valid for creation."""
return self._host_is_valid
@ -1204,6 +1205,12 @@ class CreateContext: # noqa: PLR0904
task_entity = ayon_api.get_task_by_name(
project_name, folder_entity["id"], current_task_name
)
# We require task in get_product_name so it is prudent to fail here
# if task is not set. However, AYON itself doesn't require task so
# maybe we should make it optional?
if task_entity is None:
msg = "Task was not found."
raise CreatorError(msg)
if pre_create_data is None:
pre_create_data = {}
@ -1214,27 +1221,25 @@ class CreateContext: # noqa: PLR0904
precreate_attr_defs = creator.get_pre_create_attr_defs()
# Create default values of precreate data
_pre_create_data = get_default_values(precreate_attr_defs)
pre_create_data = get_default_values(precreate_attr_defs)
# Update passed precreate data to default values
# TODO validate types
_pre_create_data.update(pre_create_data)
pre_create_data.update(pre_create_data)
project_entity = self.get_current_project_entity()
args = (
project_name,
folder_entity,
task_entity,
variant,
self.host_name,
context = ProductContext(
project_name=project_name,
task_name=task_entity["name"] if task_entity else None,
task_type=task_entity["taskType"] if task_entity else None,
variant=variant,
host_name=self.host_name,
product_base_type=creator.product_base_type,
)
kwargs = {"project_entity": project_entity}
# Backwards compatibility for 'project_entity' argument
# - 'get_product_name' signature changed 24/07/08
if not is_func_signature_supported(
creator.get_product_name, *args, **kwargs
):
kwargs.pop("project_entity")
product_name = creator.get_product_name(*args, **kwargs)
product_name = creator.get_product_name(
context
)
instance_data = {
"folderPath": folder_entity["path"],
@ -2403,7 +2408,7 @@ class CreateContext: # noqa: PLR0904
def _bulk_publish_attrs_change_finished(
self,
attr_info: Tuple[str, Union[str, None]],
attr_info: Tuple[str, Optional[str]],
sender: Optional[str],
):
if not attr_info:

View file

@ -1,10 +1,11 @@
"""Definition of creator plugins."""
from __future__ import annotations
import collections
import copy
import logging
import os
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Dict, Optional
from typing import TYPE_CHECKING, Any, ClassVar, Optional
from ayon_core.lib import Logger, get_version_from_path
from ayon_core.pipeline.plugin_discover import (
@ -27,6 +28,9 @@ from .utils import get_next_versions_for_instances
if TYPE_CHECKING:
import logging
from ayon_core.host import HostBase
from ayon_core.lib import AbstractAttrDef
from .context import CreateContext, UpdateData # noqa: F401
@ -76,12 +80,18 @@ class ProductConvertorPlugin(ABC):
return self._log
@property
def host(self):
def host(self) -> HostBase:
"""Host definition.
Returns:
HostBase: Host which initialized the plugin.
"""
return self._create_context.host
@property
@abstractmethod
def identifier(self):
def identifier(self) -> str:
"""Converted identifier.
Returns:
@ -89,7 +99,7 @@ class ProductConvertorPlugin(ABC):
"""
@abstractmethod
def find_instances(self):
def find_instances(self) -> None:
"""Look for legacy instances in the scene.
Should call 'add_convertor_item' if there is at least one instance to
@ -97,20 +107,21 @@ class ProductConvertorPlugin(ABC):
"""
@abstractmethod
def convert(self):
def convert(self) -> None:
"""Conversion code."""
@property
def create_context(self):
def create_context(self) -> CreateContext:
"""Quick access to create context.
Returns:
CreateContext: Context which initialized the plugin.
"""
return self._create_context
@property
def collection_shared_data(self):
def collection_shared_data(self) -> dict[str, Any]:
"""Access to shared data that can be used during 'find_instances'.
Returns:
@ -118,23 +129,25 @@ class ProductConvertorPlugin(ABC):
Raises:
UnavailableSharedData: When called out of collection phase.
"""
return self._create_context.collection_shared_data
def add_convertor_item(self, label):
def add_convertor_item(self, label: str) -> None:
"""Add item to CreateContext.
Args:
label (str): Label of item which will show in UI.
"""
self._create_context.add_convertor_item(self.identifier, label)
def remove_convertor_item(self):
def remove_convertor_item(self) -> None:
"""Remove legacy item from create context when conversion finished."""
self._create_context.remove_convertor_item(self.identifier)
class BaseCreator(ABC):
class BaseCreator(ABC): # noqa: PLR0904
"""Plugin that create and modify instance data before publishing process.
We should maybe find better name as creation is only one part of its logic
@ -173,7 +186,7 @@ class BaseCreator(ABC):
# Instance attribute definitions that can be changed per instance
# - returns list of attribute definitions from
# `ayon_core.lib.attribute_definitions`
instance_attr_defs: "list[AbstractAttrDef]" = []
instance_attr_defs: ClassVar[list[AbstractAttrDef]] = []
# Filtering by host name - can be used to be filtered by host name
# - used on all hosts when set to 'None' for Backwards compatibility
@ -188,8 +201,20 @@ class BaseCreator(ABC):
settings_name: Optional[str] = None
def __init__(
self, project_settings, create_context, headless=False
self,
project_settings: dict[str, Any],
create_context: CreateContext,
*,
headless: bool = False
):
"""Base creator constructor.
Args:
project_settings (dict[str, Any]): Project settings.
create_context (CreateContext): Context which initialized creator.
headless (bool): Running in headless mode.
"""
# Reference to CreateContext
self.create_context = create_context
self.project_settings = project_settings
@ -202,7 +227,10 @@ class BaseCreator(ABC):
self.register_callbacks()
@staticmethod
def _get_settings_values(project_settings, category_name, plugin_name):
def _get_settings_values(
project_settings: dict[str, Any],
category_name: str,
plugin_name: str) -> Optional[dict[str, Any]]:
"""Helper method to get settings values.
Args:
@ -223,13 +251,18 @@ class BaseCreator(ABC):
return create_settings.get(plugin_name)
def apply_settings(self, project_settings):
def apply_settings(self, project_settings: dict[str, Any]) -> None:
"""Method called on initialization of plugin to apply settings.
Default implementation tries to auto-apply settings values if are
in expected hierarchy.
in expected hierarchy.
Args:
project_settings (dict[str, Any]): Project settings.
Example:
Data hierarchy to auto-apply settings::
Data hierarchy to auto-apply settings:
{self.settings_category} - Root key in settings
"create" - Hardcoded key
{self.settings_name} | {class name} - Name of plugin
@ -238,7 +271,8 @@ class BaseCreator(ABC):
It is mandatory to define 'settings_category' attribute. Attribute
'settings_name' is optional and class name is used if is not defined.
Example data:
Example data::
ProjectSettings {
"maya": { # self.settings_category
"create": { # Hardcoded key
@ -257,6 +291,7 @@ class BaseCreator(ABC):
Args:
project_settings (dict[str, Any]): Project settings.
"""
settings_category = self.settings_category
if not settings_category:
@ -266,7 +301,9 @@ class BaseCreator(ABC):
settings_name = self.settings_name or cls_name
settings = self._get_settings_values(
project_settings, settings_category, settings_name
project_settings: dict[str, Any],
settings_category: str,
settings_name: str
)
if settings is None:
self.log.debug(f"No settings found for {cls_name}")

View file

@ -1,6 +1,8 @@
"""Get product name template and calculate product name."""
"""Functions for product name resolution."""
from __future__ import annotations
from copy import copy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
import ayon_api
@ -16,31 +18,52 @@ from .constants import DEFAULT_PRODUCT_TEMPLATE
from .exceptions import TaskNotSetError, TemplateFillError
if TYPE_CHECKING:
from ayon_core.pipeline.product_base_types import ProductBaseType
from ayon_core.pipeline.create.base_product_types import BaseProductType
@dataclass
class ProductContext:
"""Product context for product name resolution.
To get the product name, we need to know the context in which the product
is created. This context is defined by the project name, task name,
task type, host name, product base type and variant. The context is
passed to the `get_product_name` function, which uses it to resolve the
product name based on the AYON settings.
Args:
project_name (str): Project name.
task_name (str): Task name.
task_type (str): Task type.
host_name (str): Host name.
product_base_type (BaseProductType): Product base type.
variant (str): Variant value.
product_type (Optional[str]): Product type.
"""
project_name: str
task_name: str
task_type: str
host_name: str
product_base_type: BaseProductType
variant: str
product_type: Optional[str] = None
def get_product_name_template(
project_name: str,
product_type: str,
task_name: str,
task_type: str,
host_name: str,
context: ProductContext,
default_template: Optional[str] = None,
project_settings: Optional[dict] = None
) -> str:
"""Get product name template based on passed context.
Args:
project_name (str): Project on which the context lives.
product_type (str): Product type for which the product name is
calculated.
host_name (str): Name of host in which the product name is calculated.
task_name (str): Name of task in which context the product is created.
task_type (str): Type of task in which context the product is created.
default_template (Union[str, None]): Default template which is used if
context (ProductContext): Product context.
default_template (Optional[str]): Default template which is used if
settings won't find any matching possibility. Constant
'DEFAULT_PRODUCT_TEMPLATE' is used if not defined.
project_settings (Union[Dict[str, Any], None]): Prepared settings for
project_settings (Optional[Dict[str, Any]]): Prepared settings for
project. Settings are queried if not passed.
Returns:
@ -48,33 +71,24 @@ def get_product_name_template(
"""
if project_settings is None:
project_settings = get_project_settings(project_name)
project_settings = get_project_settings(context.project_name)
if not context.product_type:
context.product_type = context.product_base_type.name
tools_settings = project_settings["core"]["tools"]
profiles = tools_settings["creator"]["product_name_profiles"]
filtering_criteria = {
"product_types": product_type,
"hosts": host_name,
"tasks": task_name,
"task_types": task_type
"product_types": context.product_type,
"hosts": context.host_name,
"tasks": context.task_name,
"task_types": context.task_type
}
matching_profile = filter_profiles(profiles, filtering_criteria)
template = None
if matching_profile:
# TODO remove formatting keys replacement
template = (
matching_profile["template"]
.replace("{task}", "{task[name]}")
.replace("{Task}", "{Task[name]}")
.replace("{TASK}", "{TASK[NAME]}")
.replace("{family}", "{product[type]}")
.replace("{Family}", "{Product[type]}")
.replace("{FAMILY}", "{PRODUCT[TYPE]}")
.replace("{asset}", "{folder[name]}")
.replace("{Asset}", "{Folder[name]}")
.replace("{ASSET}", "{FOLDER[NAME]}")
)
template = matching_profile["template"]
# Make sure template is set (matching may have empty string)
if not template:
template = default_template or DEFAULT_PRODUCT_TEMPLATE
@ -82,18 +96,12 @@ def get_product_name_template(
def get_product_name(
project_name: str,
task_name: str,
task_type: str,
host_name: str,
product_type: str,
variant: str,
context: ProductContext,
default_template: Optional[str] = None,
dynamic_data: Optional[dict] = None,
project_settings: Optional[dict] = None,
product_type_filter: Optional[str] = None,
project_entity: Optional[dict] = None,
product_base_type: Optional[ProductBaseType] = None,
) -> str:
"""Calculate product name based on passed context and AYON settings.
@ -105,18 +113,8 @@ def get_product_name(
That's main reason why so many arguments are required to calculate product
name.
Todos:
Find better filtering options to avoid requirement of
argument 'family_filter'.
Args:
project_name (str): Project name.
task_name (Union[str, None]): Task name.
task_type (Union[str, None]): Task type.
host_name (str): Host name.
product_type (str): Product type.
product_base_type (ProductBaseType): Product base type.
variant (str): In most of the cases it is user input during creation.
context (ProductContext): Product context.
default_template (Optional[str]): Default template if any profile does
not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE'
is used if is not passed.
@ -139,56 +137,58 @@ def get_product_name(
is not collected.
"""
if not product_type:
return ""
# Product type was mandatory. If it is missing, use name from base type
# to avoid breaking changes.
if context.product_type is None:
product_type = context.product_base_type.name
template_context = copy(context)
if product_type_filter:
template_context.product_type = product_type_filter
template = get_product_name_template(
project_name,
product_type_filter or product_type,
task_name,
task_type,
host_name,
template_context,
default_template=default_template,
project_settings=project_settings
)
# Simple check of task name existence for template with {task} in
# - missing task should be possible only in Standalone publisher
if not task_name and "{task" in template.lower():
if not context.task_name and "{task" in template.lower():
raise TaskNotSetError
task_value = {
"name": task_name,
"type": task_type,
"name": context.task_name,
"type": context.task_type,
}
# task_value can be for backwards compatibility
# single string or dict
if "{task}" in template.lower():
task_value = task_name # type: ignore[assignment]
task_value = context.task_name # type: ignore[assignment]
elif "{task[short]}" in template.lower():
if project_entity is None:
project_entity = ayon_api.get_project(project_name)
project_entity = ayon_api.get_project(context.project_name)
task_types_by_name = {
task["name"]: task for task in
project_entity["taskTypes"]
}
task_short = task_types_by_name.get(task_type, {}).get("shortName")
task_short = task_types_by_name.get(
context.task_type, {}).get("shortName")
task_value["short"] = task_short
fill_pairs = {
"variant": variant,
"variant": context.variant,
"family": product_type,
"task": task_value,
"product": {
"type": product_type,
"base": product_base_type
"base": context.product_base_type.name
}
}
if dynamic_data:
# Dynamic data may override default values
for key, value in dynamic_data.items():
fill_pairs[key] = value
fill_pairs = dict(dynamic_data.items())
try:
return StringTemplate.format_strict_template(