Merge remote-tracking branch 'origin/feature/909-define-basic-trait-type-using-dataclasses' into feature/911-new-traits-based-integrator

This commit is contained in:
Ondřej Samohel 2025-04-07 14:10:54 +02:00
commit d77eab4ae7
No known key found for this signature in database
GPG key ID: 02376E18990A97C6
39 changed files with 1591 additions and 76 deletions

3
.gitignore vendored
View file

@ -84,3 +84,6 @@ mypy.ini
poetry.lock
.github_changelog_generator
# ignore mkdocs build
site/

View file

@ -9,6 +9,7 @@ from .local_settings import (
AYONSettingsRegistry,
get_launcher_local_dir,
get_launcher_storage_dir,
get_addons_resources_dir,
get_local_site_id,
get_ayon_username,
)
@ -142,6 +143,7 @@ __all__ = [
"AYONSettingsRegistry",
"get_launcher_local_dir",
"get_launcher_storage_dir",
"get_addons_resources_dir",
"get_local_site_id",
"get_ayon_username",

View file

@ -96,6 +96,30 @@ def get_launcher_local_dir(*subdirs: str) -> str:
return os.path.join(storage_dir, *subdirs)
def get_addons_resources_dir(addon_name: str, *args) -> str:
"""Get directory for storing resources for addons.
Some addons might need to store ad-hoc resources that are not part of
addon client package (e.g. because of size). Studio might define
dedicated directory to store them with 'AYON_ADDONS_RESOURCES_DIR'
environment variable. By default, is used 'addons_resources' in
launcher storage (might be shared across platforms).
Args:
addon_name (str): Addon name.
*args (str): Subfolders in resources directory.
Returns:
str: Path to resources directory.
"""
addons_resources_dir = os.getenv("AYON_ADDONS_RESOURCES_DIR")
if not addons_resources_dir:
addons_resources_dir = get_launcher_storage_dir("addons_resources")
return os.path.join(addons_resources_dir, addon_name, *args)
class AYONSecureRegistry:
"""Store information using keyring.

View file

@ -59,7 +59,8 @@ class LocatableContent(TraitBase):
description (str): Trait description.
id (str): id should be namespaced trait name with version
location (str): Location.
is_templated (Optional[bool]): Is the location templated? Default is None.
is_templated (Optional[bool]): Is the location templated?
Default is None.
"""
name: ClassVar[str] = "LocatableContent"

View file

@ -22,6 +22,7 @@ class DigitallySigned(TraitBase):
description: ClassVar[str] = "Digitally signed trait."
persistent: ClassVar[bool] = True
@dataclass
class PGPSigned(DigitallySigned):
"""PGP signed trait.

View file

@ -39,6 +39,7 @@ class CollectAudio(pyblish.api.ContextPlugin):
"blender",
"houdini",
"max",
"circuit",
]
audio_product_name = "audioMain"

View file

@ -54,7 +54,8 @@ class ExtractBurnin(publish.Extractor):
"houdini",
"max",
"blender",
"unreal"
"unreal",
"circuit",
]
optional = True

View file

@ -91,7 +91,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
"webpublisher",
"aftereffects",
"flame",
"unreal"
"unreal",
"circuit",
]
# Supported extensions
@ -196,7 +197,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
).format(repre_name))
continue
input_ext = repre["ext"]
input_ext = repre["ext"].lower()
if input_ext.startswith("."):
input_ext = input_ext[1:]

View file

@ -39,7 +39,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
"nuke",
"aftereffects",
"unreal",
"houdini"
"houdini",
"circuit",
]
enabled = False

View file

@ -0,0 +1,138 @@
import copy
import pyblish.api
from typing import List
from ayon_core.lib import EnumDef
from ayon_core.pipeline import OptionalPyblishPluginMixin
class AttachReviewables(
pyblish.api.InstancePlugin, OptionalPyblishPluginMixin
):
"""Attach reviewable to other instances
This pre-integrator plugin allows instances to be 'attached to' other
instances by moving all its representations over to the other instance.
Even though this technically could work for any representation the current
intent is to use for reviewables only, like e.g. `review` or `render`
product type.
When the reviewable is attached to another instance, the instance itself
will not be published as a separate entity. Instead, the representations
will be copied/moved to the instances it is attached to.
"""
families = ["render", "review"]
order = pyblish.api.IntegratorOrder - 0.499
label = "Attach reviewables"
settings_category = "core"
def process(self, instance):
# TODO: Support farm.
# If instance is being submitted to the farm we should pass through
# the 'attached reviewables' metadata to the farm job
# TODO: Reviewable frame range and resolutions
# Because we are attaching the data to another instance, how do we
# correctly propagate the resolution + frame rate to the other
# instance? Do we even need to?
# TODO: If this were to attach 'renders' to another instance that would
# mean there wouldn't necessarily be a render publish separate as a
# result. Is that correct expected behavior?
attr_values = self.get_attr_values_from_data(instance.data)
attach_to = attr_values.get("attach", [])
if not attach_to:
self.log.debug(
"Reviewable is not set to attach to another instance."
)
return
attach_instances: List[pyblish.api.Instance] = []
for attach_instance_id in attach_to:
# Find the `pyblish.api.Instance` matching the `CreatedInstance.id`
# in the `attach_to` list
attach_instance = next(
(
_inst
for _inst in instance.context
if _inst.data.get("instance_id") == attach_instance_id
),
None,
)
if attach_instance is None:
continue
# Skip inactive instances
if not attach_instance.data.get("active", True):
continue
# For now do not support attaching to 'farm' instances until we
# can pass the 'attaching' on to the farm jobs.
if attach_instance.data.get("farm"):
self.log.warning(
"Attaching to farm instances is not supported yet."
)
continue
attach_instances.append(attach_instance)
instances_names = ", ".join(
instance.name for instance in attach_instances
)
self.log.info(
f"Attaching reviewable to other instances: {instances_names}"
)
# Copy the representations of this reviewable instance to the other
# instance
representations = instance.data.get("representations", [])
for attach_instance in attach_instances:
self.log.info(f"Attaching to {attach_instance.name}")
attach_instance.data.setdefault("representations", []).extend(
copy.deepcopy(representations)
)
# Delete representations on the reviewable instance itself
for repre in representations:
self.log.debug(
"Marking representation as deleted because it was "
f"attached to other instances instead: {repre}"
)
repre.setdefault("tags", []).append("delete")
# Stop integrator from trying to integrate this instance
if attach_to:
instance.data["integrate"] = False
@classmethod
def get_attr_defs_for_instance(cls, create_context, instance):
# TODO: Check if instance is actually a 'reviewable'
# Filtering of instance, if needed, can be customized
if not cls.instance_matches_plugin_families(instance):
return []
items = []
for other_instance in create_context.instances:
if other_instance == instance:
continue
# Do not allow attaching to other reviewable instances
if other_instance.data["productType"] in cls.families:
continue
items.append(
{
"label": other_instance.label,
"value": str(other_instance.id),
}
)
return [
EnumDef(
"attach",
label="Attach reviewable",
multiselection=True,
items=items,
tooltip="Attach this reviewable to another instance",
)
]

View file

@ -227,6 +227,9 @@ class HierarchyModel(object):
self._tasks_by_id = NestedCacheItem(
levels=2, default_factory=dict, lifetime=self.lifetime)
self._entity_ids_by_assignee = NestedCacheItem(
levels=2, default_factory=dict, lifetime=self.lifetime)
self._folders_refreshing = set()
self._tasks_refreshing = set()
self._controller = controller
@ -238,6 +241,8 @@ class HierarchyModel(object):
self._task_items.reset()
self._tasks_by_id.reset()
self._entity_ids_by_assignee.reset()
def refresh_project(self, project_name):
"""Force to refresh folder items for a project.
@ -461,6 +466,54 @@ class HierarchyModel(object):
output = self.get_task_entities(project_name, {task_id})
return output[task_id]
def get_entity_ids_for_assignees(
self, project_name: str, assignees: list[str]
):
folder_ids = set()
task_ids = set()
output = {
"folder_ids": folder_ids,
"task_ids": task_ids,
}
assignees = set(assignees)
for assignee in tuple(assignees):
cache = self._entity_ids_by_assignee[project_name][assignee]
if cache.is_valid:
assignees.discard(assignee)
assignee_data = cache.get_data()
folder_ids.update(assignee_data["folder_ids"])
task_ids.update(assignee_data["task_ids"])
if not assignees:
return output
tasks = ayon_api.get_tasks(
project_name,
assignees_all=assignees,
fields={"id", "folderId", "assignees"},
)
tasks_assignee = {}
for task in tasks:
folder_ids.add(task["folderId"])
task_ids.add(task["id"])
for assignee in task["assignees"]:
tasks_assignee.setdefault(assignee, []).append(task)
for assignee, tasks in tasks_assignee.items():
cache = self._entity_ids_by_assignee[project_name][assignee]
assignee_folder_ids = set()
assignee_task_ids = set()
assignee_data = {
"folder_ids": assignee_folder_ids,
"task_ids": assignee_task_ids,
}
for task in tasks:
assignee_folder_ids.add(task["folderId"])
assignee_task_ids.add(task["id"])
cache.update_data(assignee_data)
return output
@contextlib.contextmanager
def _folder_refresh_event_manager(self, project_name, sender):
self._folders_refreshing.add(project_name)

View file

@ -160,8 +160,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
list[FolderItem]: Minimum possible information needed
for visualisation of folder hierarchy.
"""
"""
pass
@abstractmethod
@ -180,8 +180,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
list[TaskItem]: Minimum possible information needed
for visualisation of tasks.
"""
"""
pass
@abstractmethod
@ -190,8 +190,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
Union[str, None]: Selected project name.
"""
"""
pass
@abstractmethod
@ -200,8 +200,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
Union[str, None]: Selected folder id.
"""
"""
pass
@abstractmethod
@ -210,8 +210,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
Union[str, None]: Selected task id.
"""
"""
pass
@abstractmethod
@ -220,8 +220,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
Union[str, None]: Selected task name.
"""
"""
pass
@abstractmethod
@ -238,8 +238,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
dict[str, Union[str, None]]: Selected context.
"""
"""
pass
@abstractmethod
@ -249,8 +249,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Args:
project_name (Union[str, None]): Project nameor None if no project
is selected.
"""
"""
pass
@abstractmethod
@ -260,8 +260,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Args:
folder_id (Union[str, None]): Folder id or None if no folder
is selected.
"""
"""
pass
@abstractmethod
@ -273,8 +273,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
is selected.
task_name (Union[str, None]): Task name or None if no task
is selected.
"""
"""
pass
# Actions
@ -290,8 +290,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
list[ActionItem]: List of action items that should be shown
for given context.
"""
"""
pass
@abstractmethod
@ -303,8 +303,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
action_id (str): Action identifier.
"""
"""
pass
@abstractmethod
@ -317,10 +317,10 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
project_name (Union[str, None]): Project name.
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
action_id (Iterable[str]): Action identifiers.
action_ids (Iterable[str]): Action identifiers.
enabled (bool): New value of force not open workfile.
"""
"""
pass
@abstractmethod
@ -340,5 +340,17 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Triggers 'controller.refresh.actions.started' event at the beginning
and 'controller.refresh.actions.finished' at the end.
"""
pass
@abstractmethod
def get_my_tasks_entity_ids(self, project_name: str):
"""Get entity ids for my tasks.
Args:
project_name (str): Project name.
Returns:
dict[str, Union[list[str]]]: Folder and task ids.
"""
pass

View file

@ -1,4 +1,4 @@
from ayon_core.lib import Logger
from ayon_core.lib import Logger, get_ayon_username
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.settings import get_project_settings
from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
@ -6,6 +6,8 @@ from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend
from .models import LauncherSelectionModel, ActionsModel
NOT_SET = object()
class BaseLauncherController(
AbstractLauncherFrontEnd, AbstractLauncherBackend
@ -15,6 +17,8 @@ class BaseLauncherController(
self._event_system = None
self._log = None
self._username = NOT_SET
self._selection_model = LauncherSelectionModel(self)
self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self)
@ -168,5 +172,19 @@ class BaseLauncherController(
self._emit_event("controller.refresh.actions.finished")
def get_my_tasks_entity_ids(self, project_name: str):
username = self._get_my_username()
assignees = []
if username:
assignees.append(username)
return self._hierarchy_model.get_entity_ids_for_assignees(
project_name, assignees
)
def _get_my_username(self):
if self._username is NOT_SET:
self._username = get_ayon_username()
return self._username
def _emit_event(self, topic, data=None):
self.emit_event(topic, data, "controller")

View file

@ -5,17 +5,17 @@ from ayon_core.tools.utils import (
PlaceholderLineEdit,
SquareButton,
RefreshButton,
)
from ayon_core.tools.utils import (
ProjectsCombobox,
FoldersWidget,
TasksWidget,
NiceCheckbox,
)
from ayon_core.tools.utils.lib import checkstate_int_to_enum
class HierarchyPage(QtWidgets.QWidget):
def __init__(self, controller, parent):
super(HierarchyPage, self).__init__(parent)
super().__init__(parent)
# Header
header_widget = QtWidgets.QWidget(self)
@ -43,23 +43,36 @@ class HierarchyPage(QtWidgets.QWidget):
)
content_body.setOrientation(QtCore.Qt.Horizontal)
# - Folders widget with filter
folders_wrapper = QtWidgets.QWidget(content_body)
# - filters
filters_widget = QtWidgets.QWidget(self)
folders_filter_text = PlaceholderLineEdit(folders_wrapper)
folders_filter_text = PlaceholderLineEdit(filters_widget)
folders_filter_text.setPlaceholderText("Filter folders...")
folders_widget = FoldersWidget(controller, folders_wrapper)
my_tasks_tooltip = (
"Filter folders and task to only those you are assigned to."
)
my_tasks_label = QtWidgets.QLabel("My tasks", filters_widget)
my_tasks_label.setToolTip(my_tasks_tooltip)
folders_wrapper_layout = QtWidgets.QVBoxLayout(folders_wrapper)
folders_wrapper_layout.setContentsMargins(0, 0, 0, 0)
folders_wrapper_layout.addWidget(folders_filter_text, 0)
folders_wrapper_layout.addWidget(folders_widget, 1)
my_tasks_checkbox = NiceCheckbox(filters_widget)
my_tasks_checkbox.setChecked(False)
my_tasks_checkbox.setToolTip(my_tasks_tooltip)
filters_layout = QtWidgets.QHBoxLayout(filters_widget)
filters_layout.setContentsMargins(0, 0, 0, 0)
filters_layout.addWidget(folders_filter_text, 1)
filters_layout.addWidget(my_tasks_label, 0)
filters_layout.addWidget(my_tasks_checkbox, 0)
# - Folders widget
folders_widget = FoldersWidget(controller, content_body)
folders_widget.set_header_visible(True)
# - Tasks widget
tasks_widget = TasksWidget(controller, content_body)
content_body.addWidget(folders_wrapper)
content_body.addWidget(folders_widget)
content_body.addWidget(tasks_widget)
content_body.setStretchFactor(0, 100)
content_body.setStretchFactor(1, 65)
@ -67,20 +80,27 @@ class HierarchyPage(QtWidgets.QWidget):
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(header_widget, 0)
main_layout.addWidget(filters_widget, 0)
main_layout.addWidget(content_body, 1)
btn_back.clicked.connect(self._on_back_clicked)
refresh_btn.clicked.connect(self._on_refresh_clicked)
folders_filter_text.textChanged.connect(self._on_filter_text_changed)
my_tasks_checkbox.stateChanged.connect(
self._on_my_tasks_checkbox_state_changed
)
self._is_visible = False
self._controller = controller
self._btn_back = btn_back
self._projects_combobox = projects_combobox
self._my_tasks_checkbox = my_tasks_checkbox
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
self._project_name = None
# Post init
projects_combobox.set_listen_to_selection_change(self._is_visible)
@ -91,10 +111,14 @@ class HierarchyPage(QtWidgets.QWidget):
self._projects_combobox.set_listen_to_selection_change(visible)
if visible and project_name:
self._projects_combobox.set_selection(project_name)
self._project_name = project_name
def refresh(self):
self._folders_widget.refresh()
self._tasks_widget.refresh()
self._on_my_tasks_checkbox_state_changed(
self._my_tasks_checkbox.checkState()
)
def _on_back_clicked(self):
self._controller.set_selected_project(None)
@ -104,3 +128,16 @@ class HierarchyPage(QtWidgets.QWidget):
def _on_filter_text_changed(self, text):
self._folders_widget.set_name_filter(text)
def _on_my_tasks_checkbox_state_changed(self, state):
folder_ids = None
task_ids = None
state = checkstate_int_to_enum(state)
if state == QtCore.Qt.Checked:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._project_name
)
folder_ids = entity_ids["folder_ids"]
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)

View file

@ -17,7 +17,7 @@ class LauncherWindow(QtWidgets.QWidget):
page_side_anim_interval = 250
def __init__(self, controller=None, parent=None):
super(LauncherWindow, self).__init__(parent)
super().__init__(parent)
if controller is None:
controller = BaseLauncherController()
@ -153,14 +153,14 @@ class LauncherWindow(QtWidgets.QWidget):
self.resize(520, 740)
def showEvent(self, event):
super(LauncherWindow, self).showEvent(event)
super().showEvent(event)
self._window_is_active = True
if not self._actions_refresh_timer.isActive():
self._actions_refresh_timer.start()
self._controller.refresh()
def closeEvent(self, event):
super(LauncherWindow, self).closeEvent(event)
super().closeEvent(event)
self._window_is_active = False
self._actions_refresh_timer.stop()
@ -176,7 +176,7 @@ class LauncherWindow(QtWidgets.QWidget):
self._on_actions_refresh_timeout()
self._actions_refresh_timer.start()
super(LauncherWindow, self).changeEvent(event)
super().changeEvent(event)
def _on_actions_refresh_timeout(self):
# Stop timer if widget is not visible

View file

@ -1,3 +1,4 @@
from __future__ import annotations
from qtpy import QtGui, QtCore
from ._multicombobox import (

View file

@ -1,4 +1,6 @@
from __future__ import annotations
import collections
from typing import Optional
from qtpy import QtWidgets, QtGui, QtCore
@ -33,7 +35,10 @@ class FoldersQtModel(QtGui.QStandardItemModel):
refreshed = QtCore.Signal()
def __init__(self, controller):
super(FoldersQtModel, self).__init__()
super().__init__()
self.setColumnCount(1)
self.setHeaderData(0, QtCore.Qt.Horizontal, "Folders")
self._controller = controller
self._items_by_id = {}
@ -334,6 +339,29 @@ class FoldersQtModel(QtGui.QStandardItemModel):
self.refreshed.emit()
class FoldersProxyModel(RecursiveSortFilterProxyModel):
def __init__(self):
super().__init__()
self._folder_ids_filter = None
def set_folder_ids_filter(self, folder_ids: Optional[list[str]]):
if self._folder_ids_filter == folder_ids:
return
self._folder_ids_filter = folder_ids
self.invalidateFilter()
def filterAcceptsRow(self, row, parent_index):
if self._folder_ids_filter is not None:
if not self._folder_ids_filter:
return False
source_index = self.sourceModel().index(row, 0, parent_index)
folder_id = source_index.data(FOLDER_ID_ROLE)
if folder_id not in self._folder_ids_filter:
return False
return super().filterAcceptsRow(row, parent_index)
class FoldersWidget(QtWidgets.QWidget):
"""Folders widget.
@ -369,13 +397,13 @@ class FoldersWidget(QtWidgets.QWidget):
refreshed = QtCore.Signal()
def __init__(self, controller, parent, handle_expected_selection=False):
super(FoldersWidget, self).__init__(parent)
super().__init__(parent)
folders_view = TreeView(self)
folders_view.setHeaderHidden(True)
folders_model = FoldersQtModel(controller)
folders_proxy_model = RecursiveSortFilterProxyModel()
folders_proxy_model = FoldersProxyModel()
folders_proxy_model.setSourceModel(folders_model)
folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
@ -446,6 +474,18 @@ class FoldersWidget(QtWidgets.QWidget):
if name:
self._folders_view.expandAll()
def set_folder_ids_filter(self, folder_ids: Optional[list[str]]):
"""Set filter of folder ids.
Args:
folder_ids (list[str]): The list of folder ids.
"""
self._folders_proxy_model.set_folder_ids_filter(folder_ids)
def set_header_visible(self, visible: bool):
self._folders_view.setHeaderHidden(not visible)
def refresh(self):
"""Refresh folders model.

View file

@ -286,6 +286,7 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
self._sort_by_type = True
# Disable case sensitivity
self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
self.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
def _type_sort(self, l_index, r_index):
if not self._sort_by_type:

View file

@ -1,3 +1,6 @@
from __future__ import annotations
from typing import Optional
from qtpy import QtWidgets, QtGui, QtCore
from ayon_core.style import (
@ -343,6 +346,29 @@ class TasksQtModel(QtGui.QStandardItemModel):
return self._has_content
class TasksProxyModel(QtCore.QSortFilterProxyModel):
def __init__(self):
super().__init__()
self._task_ids_filter: Optional[set[str]] = None
def set_task_ids_filter(self, task_ids: Optional[set[str]]):
if self._task_ids_filter == task_ids:
return
self._task_ids_filter = task_ids
self.invalidateFilter()
def filterAcceptsRow(self, row, parent_index):
if self._task_ids_filter is not None:
if not self._task_ids_filter:
return False
source_index = self.sourceModel().index(row, 0, parent_index)
task_id = source_index.data(ITEM_ID_ROLE)
if task_id is not None and task_id not in self._task_ids_filter:
return False
return super().filterAcceptsRow(row, parent_index)
class TasksWidget(QtWidgets.QWidget):
"""Tasks widget.
@ -364,7 +390,7 @@ class TasksWidget(QtWidgets.QWidget):
tasks_view.setIndentation(0)
tasks_model = TasksQtModel(controller)
tasks_proxy_model = QtCore.QSortFilterProxyModel()
tasks_proxy_model = TasksProxyModel()
tasks_proxy_model.setSourceModel(tasks_model)
tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
@ -490,6 +516,15 @@ class TasksWidget(QtWidgets.QWidget):
)
return True
def set_task_ids_filter(self, task_ids: Optional[list[str]]):
"""Set filter of folder ids.
Args:
task_ids (list[str]): The list of folder ids.
"""
self._tasks_proxy_model.set_task_ids_filter(task_ids)
def _on_tasks_refresh_finished(self, event):
"""Tasks were refreshed in controller.

View file

@ -1,4 +1,5 @@
import logging
import math
from typing import Optional, List, Set, Any
from qtpy import QtWidgets, QtCore, QtGui
@ -410,10 +411,12 @@ class ExpandingTextEdit(QtWidgets.QTextEdit):
document = self.document().clone()
document.setTextWidth(document_width)
return margins.top() + document.size().height() + margins.bottom()
return math.ceil(
margins.top() + document.size().height() + margins.bottom()
)
def sizeHint(self):
width = super(ExpandingTextEdit, self).sizeHint().width()
width = super().sizeHint().width()
return QtCore.QSize(width, self.heightForWidth(width))

View file

@ -1016,6 +1016,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
workdir,
filename,
template_key,
artist_note,
):
"""Save current state of workfile to workarea.
@ -1040,6 +1041,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
workdir,
filename,
template_key,
artist_note,
):
"""Action to copy published workfile representation to workarea.
@ -1054,12 +1056,13 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
workdir (str): Workarea directory.
filename (str): Workarea filename.
template_key (str): Template key.
artist_note (str): Artist note.
"""
pass
@abstractmethod
def duplicate_workfile(self, src_filepath, workdir, filename):
def duplicate_workfile(self, src_filepath, workdir, filename, artist_note):
"""Duplicate workfile.
Workfiles is not opened when done.
@ -1068,6 +1071,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
src_filepath (str): Source workfile path.
workdir (str): Destination workdir.
filename (str): Destination filename.
artist_note (str): Artist note.
"""
pass

View file

@ -554,6 +554,7 @@ class BaseWorkfileController(
workdir,
filename,
template_key,
artist_note,
):
self._emit_event("save_as.started")
@ -565,6 +566,7 @@ class BaseWorkfileController(
workdir,
filename,
template_key,
artist_note=artist_note,
)
except Exception:
failed = True
@ -584,6 +586,7 @@ class BaseWorkfileController(
workdir,
filename,
template_key,
artist_note,
):
self._emit_event("copy_representation.started")
@ -595,6 +598,7 @@ class BaseWorkfileController(
workdir,
filename,
template_key,
artist_note,
src_filepath=representation_filepath
)
except Exception:
@ -608,7 +612,7 @@ class BaseWorkfileController(
{"failed": failed},
)
def duplicate_workfile(self, src_filepath, workdir, filename):
def duplicate_workfile(self, src_filepath, workdir, filename, artist_note):
self._emit_event("workfile_duplicate.started")
failed = False
@ -701,11 +705,12 @@ class BaseWorkfileController(
def _save_as_workfile(
self,
folder_id,
task_id,
workdir,
filename,
template_key,
folder_id: str,
task_id: str,
workdir: str,
filename: str,
template_key: str,
artist_note: str,
src_filepath=None,
):
# Trigger before save event
@ -748,7 +753,11 @@ class BaseWorkfileController(
self._host_save_workfile(dst_filepath)
# Make sure workfile info exists
self.save_workfile_info(folder_id, task_name, dst_filepath, None)
if not artist_note:
artist_note = None
self.save_workfile_info(
folder_id, task_name, dst_filepath, note=artist_note
)
# Create extra folders
create_workdir_extra_folders(

View file

@ -213,7 +213,8 @@ class FilesWidget(QtWidgets.QWidget):
self._controller.duplicate_workfile(
filepath,
result["workdir"],
result["filename"]
result["filename"],
artist_note=result["artist_note"]
)
def _on_workarea_browse_clicked(self):
@ -261,6 +262,7 @@ class FilesWidget(QtWidgets.QWidget):
result["workdir"],
result["filename"],
result["template_key"],
artist_note=result["artist_note"]
)
def _on_workarea_path_changed(self, event):
@ -313,6 +315,7 @@ class FilesWidget(QtWidgets.QWidget):
result["workdir"],
result["filename"],
result["template_key"],
artist_note=result["artist_note"]
)
def _on_save_as_request(self):

View file

@ -1,6 +1,6 @@
from qtpy import QtWidgets, QtCore
from ayon_core.tools.utils import PlaceholderLineEdit
from ayon_core.tools.utils import PlaceholderLineEdit, PlaceholderPlainTextEdit
class SubversionLineEdit(QtWidgets.QWidget):
@ -143,6 +143,11 @@ class SaveAsDialog(QtWidgets.QDialog):
version_layout.addWidget(version_input)
version_layout.addWidget(last_version_check)
# Artist note widget
artist_note_input = PlaceholderPlainTextEdit(inputs_widget)
artist_note_input.setPlaceholderText(
"Provide a note about this workfile.")
# Preview widget
preview_widget = QtWidgets.QLabel("Preview filename", inputs_widget)
preview_widget.setWordWrap(True)
@ -161,6 +166,7 @@ class SaveAsDialog(QtWidgets.QDialog):
subversion_label = QtWidgets.QLabel("Subversion:", inputs_widget)
extension_label = QtWidgets.QLabel("Extension:", inputs_widget)
preview_label = QtWidgets.QLabel("Preview:", inputs_widget)
artist_note_label = QtWidgets.QLabel("Artist Note:", inputs_widget)
# Build inputs
inputs_layout = QtWidgets.QGridLayout(inputs_widget)
@ -172,6 +178,8 @@ class SaveAsDialog(QtWidgets.QDialog):
inputs_layout.addWidget(extension_combobox, 2, 1)
inputs_layout.addWidget(preview_label, 3, 0)
inputs_layout.addWidget(preview_widget, 3, 1)
inputs_layout.addWidget(artist_note_label, 4, 0, 1, 2)
inputs_layout.addWidget(artist_note_input, 5, 0, 1, 2)
# Build layout
main_layout = QtWidgets.QVBoxLayout(self)
@ -206,11 +214,13 @@ class SaveAsDialog(QtWidgets.QDialog):
self._extension_combobox = extension_combobox
self._subversion_input = subversion_input
self._preview_widget = preview_widget
self._artist_note_input = artist_note_input
self._version_label = version_label
self._subversion_label = subversion_label
self._extension_label = extension_label
self._preview_label = preview_label
self._artist_note_label = artist_note_label
# Post init setup
@ -322,6 +332,7 @@ class SaveAsDialog(QtWidgets.QDialog):
"folder_id": self._folder_id,
"task_id": self._task_id,
"template_key": self._template_key,
"artist_note": self._artist_note_input.toPlainText(),
}
self.close()

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring AYON addon 'core' version."""
__version__ = "1.1.5+dev"
__version__ = "1.1.6+dev"

12
docs/css/custom.css Normal file
View file

@ -0,0 +1,12 @@
[data-md-color-scheme="slate"] {
/* simple slate overrides */
--md-primary-fg-color: hsl(155, 49%, 50%);
--md-accent-fg-color: rgb(93, 200, 156);
--md-typeset-a-color: hsl(155, 49%, 45%) !important;
}
[data-md-color-scheme="default"] {
/* simple default overrides */
--md-primary-fg-color: hsl(155, 49%, 50%);
--md-accent-fg-color: rgb(93, 200, 156);
--md-typeset-a-color: hsl(155, 49%, 45%) !important;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
docs/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

1
docs/index.md Normal file
View file

@ -0,0 +1 @@
--8<-- "README.md"

1
docs/license.md Normal file
View file

@ -0,0 +1 @@
--8<-- "LICENSE"

71
mkdocs.yml Normal file
View file

@ -0,0 +1,71 @@
site_name: ayon-core
repo_url: https://github.com/ynput/ayon-core
nav:
- Home: index.md
- License: license.md
theme:
name: material
palette:
- media: "(prefers-color-scheme: dark)"
scheme: slate
toggle:
icon: material/toggle-switch-off-outline
name: Switch to light mode
- media: "(prefers-color-scheme: light)"
scheme: default
toggle:
icon: material/toggle-switch
name: Switch to dark mode
logo: img/ay-symbol-blackw-full.png
favicon: img/favicon.ico
features:
- navigation.sections
- navigation.path
- navigation.prune
extra:
version:
provider: mike
extra_css: [css/custom.css]
markdown_extensions:
- mdx_gh_links
- pymdownx.snippets
plugins:
- search
- offline
- mkdocs-autoapi:
autoapi_dir: ./
autoapi_add_nav_entry: Reference
autoapi_ignore:
- .*
- docs/**/*
- tests/**/*
- tools/**/*
- stubs/**/* # mocha fix
- ./**/pythonrc.py # houdini fix
- .*/**/*
- ./*.py
- mkdocstrings:
handlers:
python:
paths:
- ./
- client/*
- server/*
- services/*
- minify:
minify_html: true
minify_js: true
minify_css: true
htmlmin_opts:
remove_comments: true
cache_safe: true
- mike
hooks:
- mkdocs_hooks.py

191
mkdocs_hooks.py Normal file
View file

@ -0,0 +1,191 @@
import os
from pathlib import Path
from shutil import rmtree
import json
import glob
import logging
TMP_FILE = "./missing_init_files.json"
NFILES = []
# -----------------------------------------------------------------------------
class ColorFormatter(logging.Formatter):
grey = "\x1b[38;20m"
green = "\x1b[32;20m"
yellow = "\x1b[33;20m"
red = "\x1b[31;20m"
bold_red = "\x1b[31;1m"
reset = "\x1b[0m"
fmt = (
"%(asctime)s - %(name)s - %(levelname)s - %(message)s " # noqa
"(%(filename)s:%(lineno)d)"
)
FORMATS = {
logging.DEBUG: grey + fmt + reset,
logging.INFO: green + fmt + reset,
logging.WARNING: yellow + fmt + reset,
logging.ERROR: red + fmt + reset,
logging.CRITICAL: bold_red + fmt + reset,
}
def format(self, record):
log_fmt = self.FORMATS.get(record.levelno)
formatter = logging.Formatter(log_fmt)
return formatter.format(record)
ch = logging.StreamHandler()
ch.setFormatter(ColorFormatter())
logging.basicConfig(
level=logging.INFO,
handlers=[ch],
)
# -----------------------------------------------------------------------------
def create_init_file(dirpath, msg):
global NFILES
ini_file = f"{dirpath}/__init__.py"
Path(ini_file).touch()
NFILES.append(ini_file)
logging.info(f"{msg}: created '{ini_file}'")
def create_parent_init_files(dirpath: str, rootpath: str, msg: str):
parent_path = dirpath
while parent_path != rootpath:
parent_path = os.path.dirname(parent_path)
parent_init = os.path.join(parent_path, "__init__.py")
if not os.path.exists(parent_init):
create_init_file(parent_path, msg)
else:
break
def add_missing_init_files(*roots, msg=""):
"""
This function takes in one or more root directories as arguments and scans
them for Python files without an `__init__.py` file. It generates a JSON
file named `missing_init_files.json` containing the paths of these files.
Args:
*roots: Variable number of root directories to scan.
Returns:
None
"""
for root in roots:
if not os.path.exists(root):
continue
rootpath = os.path.abspath(root)
for dirpath, dirs, files in os.walk(rootpath):
if "__init__.py" in files:
continue
if "." in dirpath:
continue
if not glob.glob(os.path.join(dirpath, "*.py")):
continue
create_init_file(dirpath, msg)
create_parent_init_files(dirpath, rootpath, msg)
with open(TMP_FILE, "w") as f:
json.dump(NFILES, f)
def remove_missing_init_files(msg=""):
"""
This function removes temporary `__init__.py` files created in the
`add_missing_init_files()` function. It reads the paths of these files from
a JSON file named `missing_init_files.json`.
Args:
None
Returns:
None
"""
global NFILES
nfiles = []
if os.path.exists(TMP_FILE):
with open(TMP_FILE, "r") as f:
nfiles = json.load(f)
else:
nfiles = NFILES
for file in nfiles:
Path(file).unlink()
logging.info(f"{msg}: removed {file}")
os.remove(TMP_FILE)
NFILES = []
def remove_pychache_dirs(msg=""):
"""
This function walks the current directory and removes all existing
'__pycache__' directories.
Args:
msg: An optional message to display during the removal process.
Returns:
None
"""
nremoved = 0
for dirpath, dirs, files in os.walk("."):
if "__pycache__" in dirs:
pydir = Path(f"{dirpath}/__pycache__")
rmtree(pydir)
nremoved += 1
logging.info(f"{msg}: removed '{pydir}'")
if not nremoved:
logging.info(f"{msg}: no __pycache__ dirs found")
# mkdocs hooks ----------------------------------------------------------------
def on_startup(command, dirty):
remove_pychache_dirs(msg="HOOK - on_startup")
def on_pre_build(config):
"""
This function is called before the MkDocs build process begins. It adds
temporary `__init__.py` files to directories that do not contain one, to
make sure mkdocs doesn't ignore them.
"""
try:
add_missing_init_files(
"client",
"server",
"services",
msg="HOOK - on_pre_build",
)
except BaseException as e:
logging.error(e)
remove_missing_init_files(
msg="HOOK - on_post_build: cleaning up on error !"
)
raise
def on_post_build(config):
"""
This function is called after the MkDocs build process ends. It removes
temporary `__init__.py` files that were added in the `on_pre_build()`
function.
"""
remove_missing_init_files(msg="HOOK - on_post_build")

View file

@ -1,6 +1,6 @@
name = "core"
title = "Core"
version = "1.1.5+dev"
version = "1.1.6+dev"
client_dir = "ayon_core"

838
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@
[tool.poetry]
name = "ayon-core"
version = "1.1.5+dev"
version = "1.1.6+dev"
description = ""
authors = ["Ynput Team <team@ynput.io>"]
readme = "README.md"
@ -30,6 +30,17 @@ attrs = "^25.0.0"
pyblish-base = "^1.8.7"
clique = "^2.0.0"
opentimelineio = "^0.17.0"
tomlkit = "^0.13.2"
requests = "^2.32.3"
mkdocs-material = "^9.6.7"
mkdocs-autoapi = "^0.4.0"
mkdocstrings-python = "^1.16.2"
mkdocs-minify-plugin = "^0.8.0"
markdown-checklist = "^0.4.4"
mdx-gh-links = "^0.4"
pymdown-extensions = "^10.14.3"
mike = "^2.1.3"
mkdocstrings-shell = "^1.0.2"
[tool.ruff]
@ -97,8 +108,6 @@ ignore = [
"S404", # subprocess module is possibly insecure
"PLC0415", # import must be on top of the file
"CPY001", # missing copyright header
"UP045"
]
# Allow fix for all enabled rules (when `--fix`) is provided.

View file

@ -12,6 +12,10 @@ from ayon_server.settings import (
from ayon_server.types import ColorRGBA_uint8
class EnabledModel(BaseSettingsModel):
enabled: bool = SettingsField(True)
class ValidateBaseModel(BaseSettingsModel):
_isGroup = True
enabled: bool = SettingsField(True)
@ -1026,6 +1030,17 @@ class PublishPuginsModel(BaseSettingsModel):
default_factory=IntegrateHeroVersionModel,
title="Integrate Hero Version"
)
AttachReviewables: EnabledModel = SettingsField(
default_factory=EnabledModel,
title="Attach Reviewables",
description=(
"When enabled, expose an 'Attach Reviewables' attribute on review"
" and render instances in the publisher to allow including the"
" media to be attached to another instance.\n\n"
"If a reviewable is attached to another instance it will not be "
"published as a render/review product of its own."
)
)
CleanUp: CleanUpModel = SettingsField(
default_factory=CleanUpModel,
title="Clean Up"
@ -1410,6 +1425,9 @@ DEFAULT_PUBLISH_VALUES = {
],
"use_hardlinks": False
},
"AttachReviewables": {
"enabled": True,
},
"CleanUp": {
"paterns": [], # codespell:ignore paterns
"remove_temp_renders": False

View file

@ -122,7 +122,7 @@ def test_file_locations_validation() -> None:
with pytest.raises(TraitValidationError):
file_locations_trait.validate_trait(representation)
# invalid representation with mutliple file locations but
# invalid representation with multiple file locations but
# unrelated to either Sequence or Bundle traits
representation = Representation(name="test", traits=[
FileLocations(file_paths=[

View file

@ -21,18 +21,18 @@ REPRESENTATION_DATA: dict = {
"file_path": Path("/path/to/file"),
"file_size": 1024,
"file_hash": None,
"persistent": True,
# "persistent": True,
},
Image.id: {"persistent": True},
Image.id: {},
PixelBased.id: {
"display_window_width": 1920,
"display_window_height": 1080,
"pixel_aspect_ratio": 1.0,
"persistent": True,
# "persistent": True,
},
Planar.id: {
"planar_configuration": "RGB",
"persistent": True,
# "persistent": True,
},
}