diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 9fa4da3f34..8de7bf0c6f 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -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: diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 5d09891e22..c9fe5e5258 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -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}") diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 30a1accb51..dc9915e9c1 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -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(