Merge pull request #1292 from BigRoy/enhancement/workfile_template_builder_allow_published_entities

Templated Workfile Build: support building from an AYON Entity URI instead of filepath
This commit is contained in:
Jakub Trllo 2025-06-18 17:12:27 +02:00 committed by GitHub
commit 15b4f236f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -8,7 +8,7 @@ targeted by task types and names.
Placeholders are created using placeholder plugins which should care about Placeholders are created using placeholder plugins which should care about
logic and data of placeholder items. 'PlaceholderItem' is used to keep track logic and data of placeholder items. 'PlaceholderItem' is used to keep track
about it's progress. about its progress.
""" """
import os import os
@ -17,6 +17,7 @@ import collections
import copy import copy
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import ayon_api
from ayon_api import ( from ayon_api import (
get_folders, get_folders,
get_folder_by_path, get_folder_by_path,
@ -60,6 +61,32 @@ from ayon_core.pipeline.create import (
_NOT_SET = object() _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): class TemplateNotFound(Exception):
"""Exception raised when template does not exist.""" """Exception raised when template does not exist."""
pass pass
@ -823,7 +850,6 @@ class AbstractTemplateBuilder(ABC):
""" """
host_name = self.host_name host_name = self.host_name
project_name = self.project_name
task_name = self.current_task_name task_name = self.current_task_name
task_type = self.current_task_type task_type = self.current_task_type
@ -835,7 +861,6 @@ class AbstractTemplateBuilder(ABC):
"task_names": task_name "task_names": task_name
} }
) )
if not profile: if not profile:
raise TemplateProfileNotFound(( raise TemplateProfileNotFound((
"No matching profile found for task '{}' of type '{}' " "No matching profile found for task '{}' of type '{}' "
@ -843,6 +868,22 @@ class AbstractTemplateBuilder(ABC):
).format(task_name, task_type, host_name)) ).format(task_name, task_type, host_name))
path = profile["path"] 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 # switch to remove placeholders after they are used
keep_placeholder = profile.get("keep_placeholder") keep_placeholder = profile.get("keep_placeholder")
@ -852,44 +893,86 @@ class AbstractTemplateBuilder(ABC):
if keep_placeholder is None: if keep_placeholder is None:
keep_placeholder = True keep_placeholder = True
if not path: return {
raise TemplateLoadFailed(( "path": resolved_path,
"Template path is not set.\n" "keep_placeholder": keep_placeholder,
"Path need to be set in {}\\Template Workfile Build " "create_first_version": create_first_version
"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()
} }
fill_data["root"] = anatomy.roots def resolve_template_path(self, path, fill_data=None) -> str:
fill_data["project"] = { """Resolve the template path.
"name": project_name,
"code": anatomy.project_code,
}
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): if path and os.path.exists(path):
self.log.info("Found template at: '{}'".format(path)) return path
return {
"path": path, # We may have path for another platform, like C:/path/to/file
"keep_placeholder": keep_placeholder, # or a path with template keys, like {project[code]} or both.
"create_first_version": create_first_version # 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: while True:
try: try:
solved_path = anatomy.path_remapper(path) solved_path = anatomy.path_remapper(path)
except KeyError as missing_key: except KeyError as missing_key:
raise KeyError( raise KeyError(
"Could not solve key '{}' in template path '{}'".format( f"Could not solve key '{missing_key}'"
missing_key, path)) f" in template path '{path}'"
)
if solved_path is None: if solved_path is None:
solved_path = path solved_path = path
@ -898,40 +981,7 @@ class AbstractTemplateBuilder(ABC):
path = solved_path path = solved_path
solved_path = os.path.normpath(solved_path) solved_path = os.path.normpath(solved_path)
if not os.path.exists(solved_path): return 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
def emit_event(self, topic, data=None, source=None) -> Event: def emit_event(self, topic, data=None, source=None) -> Event:
return self._event_system.emit(topic, data, source) return self._event_system.emit(topic, data, source)