Merge remote-tracking branch 'origin/develop' into enhancement/1297-product-base-types-creation-and-creator-plugins

This commit is contained in:
Ondřej Samohel 2025-09-24 12:41:26 +02:00
commit 8edd6c583d
No known key found for this signature in database
GPG key ID: 02376E18990A97C6
11 changed files with 192 additions and 62 deletions

View file

@ -1,5 +1,5 @@
from .constants import ContextChangeReason
from .abstract import AbstractHost
from .abstract import AbstractHost, ApplicationInformation
from .host import (
HostBase,
ContextChangeData,
@ -21,6 +21,7 @@ __all__ = (
"ContextChangeReason",
"AbstractHost",
"ApplicationInformation",
"HostBase",
"ContextChangeData",

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
import typing
from typing import Optional, Any
@ -13,6 +14,19 @@ if typing.TYPE_CHECKING:
from .typing import HostContextData
@dataclass
class ApplicationInformation:
"""Application information.
Attributes:
app_name (Optional[str]): Application name. e.g. Maya, NukeX, Nuke
app_version (Optional[str]): Application version. e.g. 15.2.1
"""
app_name: Optional[str] = None
app_version: Optional[str] = None
class AbstractHost(ABC):
"""Abstract definition of host implementation."""
@property
@ -26,6 +40,16 @@ class AbstractHost(ABC):
"""Host name."""
pass
@abstractmethod
def get_app_information(self) -> ApplicationInformation:
"""Information about the application where host is running.
Returns:
ApplicationInformation: Application information.
"""
pass
@abstractmethod
def get_current_context(self) -> HostContextData:
"""Get the current context of the host.

View file

@ -12,7 +12,7 @@ import ayon_api
from ayon_core.lib import emit_event
from .constants import ContextChangeReason
from .abstract import AbstractHost
from .abstract import AbstractHost, ApplicationInformation
if typing.TYPE_CHECKING:
from ayon_core.pipeline import Anatomy
@ -96,6 +96,18 @@ class HostBase(AbstractHost):
pass
def get_app_information(self) -> ApplicationInformation:
"""Running application information.
Host integration should override this method and return correct
information.
Returns:
ApplicationInformation: Application information.
"""
return ApplicationInformation()
def install(self):
"""Install host specific functionality.

View file

@ -55,7 +55,7 @@ class _WorkfileOptionalData:
):
if kwargs:
cls_name = self.__class__.__name__
keys = ", ".join(['"{}"'.format(k) for k in kwargs.keys()])
keys = ", ".join([f'"{k}"' for k in kwargs.keys()])
warnings.warn(
f"Unknown keywords passed to {cls_name}: {keys}",
)
@ -1554,6 +1554,27 @@ class IWorkfileHost(AbstractHost):
if platform.system().lower() == "windows":
rootless_path = rootless_path.replace("\\", "/")
# Get application information
app_info = self.get_app_information()
data = {}
if app_info.app_name:
data["app_name"] = app_info.app_name
if app_info.app_version:
data["app_version"] = app_info.app_version
# Use app group and app variant from applications addon (if available)
app_addon_name = os.environ.get("AYON_APP_NAME")
if not app_addon_name:
app_addon_name = None
app_addon_tools_s = os.environ.get("AYON_APP_TOOLS")
app_addon_tools = []
if app_addon_tools_s:
app_addon_tools = app_addon_tools_s.split(";")
data["ayon_app_name"] = app_addon_name
data["ayon_app_tools"] = app_addon_tools
workfile_info = save_workfile_info(
project_name,
save_workfile_context.task_entity["id"],
@ -1562,6 +1583,7 @@ class IWorkfileHost(AbstractHost):
version,
comment,
description,
data=data,
workfile_entities=save_workfile_context.workfile_entities,
)
return workfile_info

View file

@ -2,10 +2,10 @@
from __future__ import annotations
from abc import abstractmethod
import logging
import os
from typing import Any, Optional, Type
from ayon_core.lib import Logger
from ayon_core.pipeline.plugin_discover import (
deregister_plugin,
deregister_plugin_path,
@ -31,8 +31,7 @@ class LoaderPlugin(list):
options = []
log = logging.getLogger("ProductLoader")
log.propagate = True
log = Logger.get_logger("ProductLoader")
@classmethod
def apply_settings(cls, project_settings):

View file

@ -9,7 +9,7 @@ from typing import Optional, Union, Any
import ayon_api
from ayon_core.host import ILoadHost
from ayon_core.host import ILoadHost, AbstractHost
from ayon_core.lib import (
StringTemplate,
TemplateUnsolved,
@ -942,15 +942,21 @@ def any_outdated_containers(host=None, project_name=None):
return False
def get_outdated_containers(host=None, project_name=None):
def get_outdated_containers(
host: Optional[AbstractHost] = None,
project_name: Optional[str] = None,
ignore_locked_versions: bool = False,
):
"""Collect outdated containers from host scene.
Currently registered host and project in global session are used if
arguments are not passed.
Args:
host (ModuleType): Host implementation with 'ls' function available.
project_name (str): Name of project in which context we are.
host (Optional[AbstractHost]): Host implementation.
project_name (Optional[str]): Name of project in which context we are.
ignore_locked_versions (bool): Locked versions are ignored.
"""
from ayon_core.pipeline import registered_host, get_current_project_name
@ -964,7 +970,16 @@ def get_outdated_containers(host=None, project_name=None):
containers = host.get_containers()
else:
containers = host.ls()
return filter_containers(containers, project_name).outdated
outdated_containers = []
for container in filter_containers(containers, project_name).outdated:
if (
not ignore_locked_versions
and container.get("version_locked") is True
):
continue
outdated_containers.append(container)
return outdated_containers
def _is_valid_representation_id(repre_id: Any) -> bool:
@ -985,6 +1000,9 @@ def filter_containers(containers, project_name):
'invalid' are invalid containers (invalid content) and 'not_found' has
some missing entity in database.
Todos:
Respect 'project_name' on containers if is available.
Args:
containers (Iterable[dict]): List of containers referenced into scene.
project_name (str): Name of project in which context shoud look for
@ -993,8 +1011,8 @@ def filter_containers(containers, project_name):
Returns:
ContainersFilterResult: Named tuple with 'latest', 'outdated',
'invalid' and 'not_found' containers.
"""
"""
# Make sure containers is list that won't change
containers = list(containers)
@ -1042,13 +1060,13 @@ def filter_containers(containers, project_name):
hero=True,
fields={"id", "productId", "version"}
)
verisons_by_id = {}
versions_by_id = {}
versions_by_product_id = collections.defaultdict(list)
hero_version_ids = set()
for version_entity in version_entities:
version_id = version_entity["id"]
# Store versions by their ids
verisons_by_id[version_id] = version_entity
versions_by_id[version_id] = version_entity
# There's no need to query products for hero versions
# - they are considered as latest?
if version_entity["version"] < 0:
@ -1083,24 +1101,23 @@ def filter_containers(containers, project_name):
repre_entity = repre_entities_by_id.get(repre_id)
if not repre_entity:
log.debug((
"Container '{}' has an invalid representation."
log.debug(
f"Container '{container_name}' has an invalid representation."
" It is missing in the database."
).format(container_name))
)
not_found_containers.append(container)
continue
version_id = repre_entity["versionId"]
if version_id in outdated_version_ids:
outdated_containers.append(container)
elif version_id not in verisons_by_id:
log.debug((
"Representation on container '{}' has an invalid version."
" It is missing in the database."
).format(container_name))
if version_id not in versions_by_id:
log.debug(
f"Representation on container '{container_name}' has an"
" invalid version. It is missing in the database."
)
not_found_containers.append(container)
elif version_id in outdated_version_ids:
outdated_containers.append(container)
else:
uptodate_containers.append(container)

View file

@ -207,6 +207,7 @@ def save_workfile_info(
comment: Optional[str] = None,
description: Optional[str] = None,
username: Optional[str] = None,
data: Optional[dict[str, Any]] = None,
workfile_entities: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Save workfile info entity for a workfile path.
@ -221,6 +222,7 @@ def save_workfile_info(
description (Optional[str]): Workfile description.
username (Optional[str]): Username of user who saves the workfile.
If not provided, current user is used.
data (Optional[dict[str, Any]]): Additional workfile entity data.
workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched
workfile entities related to task.
@ -246,6 +248,18 @@ def save_workfile_info(
if username is None:
username = get_ayon_username()
attrib = {}
extension = os.path.splitext(rootless_path)[1]
for key, value in (
("extension", extension),
("description", description),
):
if value is not None:
attrib[key] = value
if data is None:
data = {}
if not workfile_entity:
return _create_workfile_info_entity(
project_name,
@ -255,34 +269,38 @@ def save_workfile_info(
username,
version,
comment,
description,
attrib,
data,
)
data = {
key: value
for key, value in (
("host_name", host_name),
("version", version),
("comment", comment),
)
if value is not None
}
old_data = workfile_entity["data"]
for key, value in (
("host_name", host_name),
("version", version),
("comment", comment),
):
if value is not None:
data[key] = value
changed_data = {}
old_data = workfile_entity["data"]
for key, value in data.items():
if key not in old_data or old_data[key] != value:
changed_data[key] = value
workfile_entity["data"][key] = value
changed_attrib = {}
old_attrib = workfile_entity["attrib"]
for key, value in attrib.items():
if key not in old_attrib or old_attrib[key] != value:
changed_attrib[key] = value
workfile_entity["attrib"][key] = value
update_data = {}
if changed_data:
update_data["data"] = changed_data
old_description = workfile_entity["attrib"].get("description")
if description is not None and old_description != description:
update_data["attrib"] = {"description": description}
workfile_entity["attrib"]["description"] = description
if changed_attrib:
update_data["attrib"] = changed_attrib
# Automatically fix 'createdBy' and 'updatedBy' fields
# NOTE both fields were not automatically filled by server
@ -749,7 +767,8 @@ def _create_workfile_info_entity(
username: str,
version: Optional[int],
comment: Optional[str],
description: Optional[str],
attrib: dict[str, Any],
data: dict[str, Any],
) -> dict[str, Any]:
"""Create workfile entity data.
@ -761,27 +780,18 @@ def _create_workfile_info_entity(
username (str): Username.
version (Optional[int]): Workfile version.
comment (Optional[str]): Workfile comment.
description (Optional[str]): Workfile description.
attrib (dict[str, Any]): Workfile entity attributes.
data (dict[str, Any]): Workfile entity data.
Returns:
dict[str, Any]: Created workfile entity data.
"""
extension = os.path.splitext(rootless_path)[1]
attrib = {}
for key, value in (
("extension", extension),
("description", description),
):
if value is not None:
attrib[key] = value
data = {
data.update({
"host_name": host_name,
"version": version,
"comment": comment,
}
})
workfile_info = {
"id": uuid.uuid4().hex,

View file

@ -1,10 +1,14 @@
from qtpy import QtWidgets, QtCore, QtGui
from .model import VERSION_LABEL_ROLE
from ayon_core.tools.utils import get_qt_icon
from .model import VERSION_LABEL_ROLE, CONTAINER_VERSION_LOCKED_ROLE
class VersionDelegate(QtWidgets.QStyledItemDelegate):
"""A delegate that display version integer formatted as version string."""
_locked_icon = None
def paint(self, painter, option, index):
fg_color = index.data(QtCore.Qt.ForegroundRole)
if fg_color:
@ -45,10 +49,35 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
QtWidgets.QStyle.PM_FocusFrameHMargin, option, option.widget
) + 1
text_rect_f = text_rect.adjusted(
text_margin, 0, - text_margin, 0
)
painter.drawText(
text_rect.adjusted(text_margin, 0, - text_margin, 0),
text_rect_f,
option.displayAlignment,
text
)
if index.data(CONTAINER_VERSION_LOCKED_ROLE) is True:
icon = self._get_locked_icon()
size = max(text_rect_f.height() // 2, 16)
margin = (text_rect_f.height() - size) // 2
icon_rect = QtCore.QRect(
text_rect_f.right() - size,
text_rect_f.top() + margin,
size,
size
)
icon.paint(painter, icon_rect)
painter.restore()
def _get_locked_icon(cls):
if cls._locked_icon is None:
cls._locked_icon = get_qt_icon({
"type": "material-symbols",
"name": "lock",
"color": "white",
})
return cls._locked_icon

View file

@ -37,6 +37,7 @@ REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 23
# containers inbetween refresh.
ITEM_UNIQUE_NAME_ROLE = QtCore.Qt.UserRole + 24
PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 25
CONTAINER_VERSION_LOCKED_ROLE = QtCore.Qt.UserRole + 26
class InventoryModel(QtGui.QStandardItemModel):
@ -291,6 +292,10 @@ class InventoryModel(QtGui.QStandardItemModel):
item.setData(container_item.object_name, OBJECT_NAME_ROLE)
item.setData(True, IS_CONTAINER_ITEM_ROLE)
item.setData(unique_name, ITEM_UNIQUE_NAME_ROLE)
item.setData(
container_item.version_locked,
CONTAINER_VERSION_LOCKED_ROLE
)
container_model_items.append(item)
progress = progress_by_id[repre_id]

View file

@ -95,7 +95,8 @@ class ContainerItem:
namespace,
object_name,
item_id,
project_name
project_name,
version_locked,
):
self.representation_id = representation_id
self.loader_name = loader_name
@ -103,6 +104,7 @@ class ContainerItem:
self.namespace = namespace
self.item_id = item_id
self.project_name = project_name
self.version_locked = version_locked
@classmethod
def from_container_data(cls, current_project_name, container):
@ -114,7 +116,8 @@ class ContainerItem:
item_id=uuid.uuid4().hex,
project_name=container.get(
"project_name", current_project_name
)
),
version_locked=container.get("version_locked", False),
)

View file

@ -17,6 +17,7 @@ from ayon_core.tools.utils.lib import (
format_version,
preserve_expanded_rows,
preserve_selection,
get_qt_icon,
)
from ayon_core.tools.utils.delegates import StatusDelegate
@ -46,7 +47,7 @@ class SceneInventoryView(QtWidgets.QTreeView):
hierarchy_view_changed = QtCore.Signal(bool)
def __init__(self, controller, parent):
super(SceneInventoryView, self).__init__(parent=parent)
super().__init__(parent=parent)
# view settings
self.setIndentation(12)
@ -524,7 +525,14 @@ class SceneInventoryView(QtWidgets.QTreeView):
submenu = QtWidgets.QMenu("Actions", self)
for action in custom_actions:
color = action.color or DEFAULT_COLOR
icon = qtawesome.icon("fa.%s" % action.icon, color=color)
icon_def = action.icon
if not isinstance(action.icon, dict):
icon_def = {
"type": "awesome-font",
"name": icon_def,
"color": color,
}
icon = get_qt_icon(icon_def)
action_item = QtWidgets.QAction(icon, action.label, submenu)
action_item.triggered.connect(
partial(
@ -622,7 +630,7 @@ class SceneInventoryView(QtWidgets.QTreeView):
if isinstance(result, (list, set)):
self._select_items_by_action(result)
if isinstance(result, dict):
elif isinstance(result, dict):
self._select_items_by_action(
result["objectNames"], result["options"]
)