Merge branch 'develop' into enhancement/per-project-bundle

This commit is contained in:
Jakub Trllo 2025-08-14 16:21:14 +02:00 committed by GitHub
commit 742e500e84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 368 additions and 243 deletions

View file

@ -35,6 +35,9 @@ body:
label: Version
description: What version are you running? Look to AYON Tray
options:
- 1.5.3
- 1.5.2
- 1.5.1
- 1.5.0
- 1.4.1
- 1.4.0

View file

@ -8,6 +8,7 @@ import inspect
import logging
import threading
import collections
import warnings
from uuid import uuid4
from abc import ABC, abstractmethod
from typing import Optional
@ -830,10 +831,26 @@ class AddonsManager:
Unknown keys are logged out.
Deprecated:
Use targeted methods 'collect_launcher_action_paths',
'collect_create_plugin_paths', 'collect_load_plugin_paths',
'collect_publish_plugin_paths' and
'collect_inventory_action_paths' to collect plugin paths.
Returns:
dict: Output is dictionary with keys "publish", "create", "load",
"actions" and "inventory" each containing list of paths.
"""
warnings.warn(
"Used deprecated method 'collect_plugin_paths'. Please use"
" targeted methods 'collect_launcher_action_paths',"
" 'collect_create_plugin_paths', 'collect_load_plugin_paths'"
" 'collect_publish_plugin_paths' and"
" 'collect_inventory_action_paths'",
DeprecationWarning,
stacklevel=2
)
# Output structure
output = {
"publish": [],
@ -889,24 +906,28 @@ class AddonsManager:
if not isinstance(addon, IPluginPaths):
continue
paths = None
method = getattr(addon, method_name)
try:
paths = method(*args, **kwargs)
except Exception:
self.log.warning(
(
"Failed to get plugin paths from addon"
" '{}' using '{}'."
).format(addon.__class__.__name__, method_name),
"Failed to get plugin paths from addon"
f" '{addon.name}' using '{method_name}'.",
exc_info=True
)
if not paths:
continue
if paths:
# Convert to list if value is not list
if not isinstance(paths, (list, tuple, set)):
paths = [paths]
output.extend(paths)
if isinstance(paths, str):
paths = [paths]
self.log.warning(
f"Addon '{addon.name}' returned invalid output type"
f" from '{method_name}'."
f" Got 'str' expected 'list[str]'."
)
output.extend(paths)
return output
def collect_launcher_action_paths(self):

View file

@ -1,6 +1,7 @@
"""Addon interfaces for AYON."""
from __future__ import annotations
import warnings
from abc import ABCMeta, abstractmethod
from typing import TYPE_CHECKING, Callable, Optional, Type
@ -39,26 +40,29 @@ class AYONInterface(metaclass=_AYONInterfaceMeta):
class IPluginPaths(AYONInterface):
"""Addon has plugin paths to return.
"""Addon wants to register plugin paths."""
Expected result is dictionary with keys "publish", "create", "load",
"actions" or "inventory" and values as list or string.
{
"publish": ["path/to/publish_plugins"]
}
"""
@abstractmethod
def get_plugin_paths(self) -> dict[str, list[str]]:
"""Return plugin paths for addon.
This method was abstract (required) in the past, so raise the required
'core' addon version when 'get_plugin_paths' is removed from
addon.
Deprecated:
Please implement specific methods 'get_create_plugin_paths',
'get_load_plugin_paths', 'get_inventory_action_paths' and
'get_publish_plugin_paths' to return plugin paths.
Returns:
dict[str, list[str]]: Plugin paths for addon.
"""
return {}
def _get_plugin_paths_by_type(
self, plugin_type: str) -> list[str]:
self, plugin_type: str
) -> list[str]:
"""Get plugin paths by type.
Args:
@ -78,6 +82,24 @@ class IPluginPaths(AYONInterface):
if not isinstance(paths, (list, tuple, set)):
paths = [paths]
new_function_name = "get_launcher_action_paths"
if plugin_type == "create":
new_function_name = "get_create_plugin_paths"
elif plugin_type == "load":
new_function_name = "get_load_plugin_paths"
elif plugin_type == "publish":
new_function_name = "get_publish_plugin_paths"
elif plugin_type == "inventory":
new_function_name = "get_inventory_action_paths"
warnings.warn(
f"Addon '{self.name}' returns '{plugin_type}' paths using"
" 'get_plugin_paths' method. Please implement"
f" '{new_function_name}' instead.",
DeprecationWarning,
stacklevel=2
)
return paths
def get_launcher_action_paths(self) -> list[str]:

View file

@ -944,6 +944,8 @@ class IWorkfileHost:
self._emit_workfile_save_event(event_data, after_save=False)
workdir = os.path.dirname(filepath)
if not os.path.exists(workdir):
os.makedirs(workdir, exist_ok=True)
# Set 'AYON_WORKDIR' environment variable
os.environ["AYON_WORKDIR"] = workdir
@ -1072,10 +1074,13 @@ class IWorkfileHost:
prepared_data=prepared_data,
)
workfile_entities_by_path = {
workfile_entity["path"]: workfile_entity
for workfile_entity in list_workfiles_context.workfile_entities
}
workfile_entities_by_path = {}
for workfile_entity in list_workfiles_context.workfile_entities:
rootless_path = workfile_entity["path"]
path = os.path.normpath(
list_workfiles_context.anatomy.fill_root(rootless_path)
)
workfile_entities_by_path[path] = workfile_entity
workdir_data = get_template_data(
list_workfiles_context.project_entity,
@ -1114,10 +1119,10 @@ class IWorkfileHost:
rootless_path = f"{rootless_workdir}/{filename}"
workfile_entity = workfile_entities_by_path.pop(
rootless_path, None
filepath, None
)
version = comment = None
if workfile_entity:
if workfile_entity is not None:
_data = workfile_entity["data"]
version = _data.get("version")
comment = _data.get("comment")
@ -1137,7 +1142,7 @@ class IWorkfileHost:
)
items.append(item)
for workfile_entity in workfile_entities_by_path.values():
for filepath, workfile_entity in workfile_entities_by_path.items():
# Workfile entity is not in the filesystem
# but it is in the database
rootless_path = workfile_entity["path"]
@ -1154,13 +1159,13 @@ class IWorkfileHost:
version = parsed_data.version
comment = parsed_data.comment
filepath = list_workfiles_context.anatomy.fill_root(rootless_path)
available = os.path.exists(filepath)
items.append(WorkfileInfo.new(
filepath,
rootless_path,
version=version,
comment=comment,
available=False,
available=available,
workfile_entity=workfile_entity,
))

View file

@ -8,6 +8,7 @@ import warnings
from datetime import datetime
from abc import ABC, abstractmethod
from functools import lru_cache
from typing import Optional, Any
import platformdirs
import ayon_api
@ -15,26 +16,31 @@ import ayon_api
_PLACEHOLDER = object()
# TODO should use 'KeyError' or 'Exception' as base
class RegistryItemNotFound(ValueError):
"""Raised when the item is not found in the keyring."""
class _Cache:
username = None
def _get_ayon_appdirs(*args):
def _get_ayon_appdirs(*args: str) -> str:
return os.path.join(
platformdirs.user_data_dir("AYON", "Ynput"),
*args
)
def get_ayon_appdirs(*args):
def get_ayon_appdirs(*args: str) -> str:
"""Local app data directory of AYON client.
Deprecated:
Use 'get_launcher_local_dir' or 'get_launcher_storage_dir' based on
use-case. Deprecation added 24/08/09 (0.4.4-dev.1).
a use-case. Deprecation added 24/08/09 (0.4.4-dev.1).
Args:
*args (Iterable[str]): Subdirectories/files in local app data dir.
*args (Iterable[str]): Subdirectories/files in the local app data dir.
Returns:
str: Path to directory/file in local app data dir.
@ -52,7 +58,7 @@ def get_ayon_appdirs(*args):
def get_launcher_storage_dir(*subdirs: str) -> str:
"""Get storage directory for launcher.
"""Get a storage directory for launcher.
Storage directory is used for storing shims, addons, dependencies, etc.
@ -77,14 +83,14 @@ def get_launcher_storage_dir(*subdirs: str) -> str:
def get_launcher_local_dir(*subdirs: str) -> str:
"""Get local directory for launcher.
"""Get a local directory for launcher.
Local directory is used for storing machine or user specific data.
Local directory is used for storing machine or user-specific data.
The location is user specific.
The location is user-specific.
Note:
This function should be called at least once on bootstrap.
This function should be called at least once on the bootstrap.
Args:
*subdirs (str): Subdirectories relative to local dir.
@ -101,7 +107,7 @@ def get_launcher_local_dir(*subdirs: str) -> str:
def get_addons_resources_dir(addon_name: str, *args) -> str:
"""Get directory for storing resources for addons.
"""Get a 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
@ -111,7 +117,7 @@ def get_addons_resources_dir(addon_name: str, *args) -> str:
Args:
addon_name (str): Addon name.
*args (str): Subfolders in resources directory.
*args (str): Subfolders in the resources directory.
Returns:
str: Path to resources directory.
@ -124,6 +130,10 @@ def get_addons_resources_dir(addon_name: str, *args) -> str:
return os.path.join(addons_resources_dir, addon_name, *args)
class _FakeException(Exception):
"""Placeholder exception used if real exception is not available."""
class AYONSecureRegistry:
"""Store information using keyring.
@ -134,9 +144,10 @@ class AYONSecureRegistry:
identify which data were created by AYON.
Args:
name(str): Name of registry used as identifier for data.
name(str): Name of registry used as the identifier for data.
"""
def __init__(self, name):
def __init__(self, name: str) -> None:
try:
import keyring
@ -152,13 +163,12 @@ class AYONSecureRegistry:
keyring.set_keyring(Windows.WinVaultKeyring())
# Force "AYON" prefix
self._name = "/".join(("AYON", name))
self._name = f"AYON/{name}"
def set_item(self, name, value):
# type: (str, str) -> None
"""Set sensitive item into system's keyring.
def set_item(self, name: str, value: str) -> None:
"""Set sensitive item into the system's keyring.
This uses `Keyring module`_ to save sensitive stuff into system's
This uses `Keyring module`_ to save sensitive stuff into the system's
keyring.
Args:
@ -172,22 +182,26 @@ class AYONSecureRegistry:
import keyring
keyring.set_password(self._name, name, value)
self.get_item.cache_clear()
@lru_cache(maxsize=32)
def get_item(self, name, default=_PLACEHOLDER):
"""Get value of sensitive item from system's keyring.
def get_item(
self, name: str, default: Any = _PLACEHOLDER
) -> Optional[str]:
"""Get value of sensitive item from the system's keyring.
See also `Keyring module`_
Args:
name (str): Name of the item.
default (Any): Default value if item is not available.
default (Any): Default value if the item is not available.
Returns:
value (str): Value of the item.
Raises:
ValueError: If item doesn't exist and default is not defined.
RegistryItemNotFound: If the item doesn't exist and default
is not defined.
.. _Keyring module:
https://github.com/jaraco/keyring
@ -195,21 +209,29 @@ class AYONSecureRegistry:
"""
import keyring
value = keyring.get_password(self._name, name)
# Capture 'ItemNotFoundException' exception (on linux)
try:
from secretstorage.exceptions import ItemNotFoundException
except ImportError:
ItemNotFoundException = _FakeException
try:
value = keyring.get_password(self._name, name)
except ItemNotFoundException:
value = None
if value is not None:
return value
if default is not _PLACEHOLDER:
return default
# NOTE Should raise `KeyError`
raise ValueError(
"Item {}:{} does not exist in keyring.".format(self._name, name)
raise RegistryItemNotFound(
f"Item {self._name}:{name} not found in keyring."
)
def delete_item(self, name):
# type: (str) -> None
"""Delete value stored in system's keyring.
def delete_item(self, name: str) -> None:
"""Delete value stored in the system's keyring.
See also `Keyring module`_
@ -227,47 +249,38 @@ class AYONSecureRegistry:
class ASettingRegistry(ABC):
"""Abstract class defining structure of **SettingRegistry** class.
It is implementing methods to store secure items into keyring, otherwise
mechanism for storing common items must be implemented in abstract
methods.
Attributes:
_name (str): Registry names.
"""Abstract class to defining structure of registry class.
"""
def __init__(self, name):
# type: (str) -> ASettingRegistry
super(ASettingRegistry, self).__init__()
def __init__(self, name: str) -> None:
self._name = name
self._items = {}
def set_item(self, name, value):
# type: (str, str) -> None
"""Set item to settings registry.
Args:
name (str): Name of the item.
value (str): Value of the item.
"""
self._set_item(name, value)
@abstractmethod
def _set_item(self, name, value):
# type: (str, str) -> None
# Implement it
pass
def _get_item(self, name: str) -> Any:
"""Get item value from registry."""
def __setitem__(self, name, value):
self._items[name] = value
@abstractmethod
def _set_item(self, name: str, value: str) -> None:
"""Set item value to registry."""
@abstractmethod
def _delete_item(self, name: str) -> None:
"""Delete item from registry."""
def __getitem__(self, name: str) -> Any:
return self._get_item(name)
def __setitem__(self, name: str, value: str) -> None:
self._set_item(name, value)
def get_item(self, name):
# type: (str) -> str
def __delitem__(self, name: str) -> None:
self._delete_item(name)
@property
def name(self) -> str:
return self._name
def get_item(self, name: str) -> str:
"""Get item from settings registry.
Args:
@ -277,22 +290,22 @@ class ASettingRegistry(ABC):
value (str): Value of the item.
Raises:
ValueError: If item doesn't exist.
RegistryItemNotFound: If the item doesn't exist.
"""
return self._get_item(name)
@abstractmethod
def _get_item(self, name):
# type: (str) -> str
# Implement it
pass
def set_item(self, name: str, value: str) -> None:
"""Set item to settings registry.
def __getitem__(self, name):
return self._get_item(name)
Args:
name (str): Name of the item.
value (str): Value of the item.
def delete_item(self, name):
# type: (str) -> None
"""
self._set_item(name, value)
def delete_item(self, name: str) -> None:
"""Delete item from settings registry.
Args:
@ -301,16 +314,6 @@ class ASettingRegistry(ABC):
"""
self._delete_item(name)
@abstractmethod
def _delete_item(self, name):
# type: (str) -> None
"""Delete item from settings."""
pass
def __delitem__(self, name):
del self._items[name]
self._delete_item(name)
class IniSettingRegistry(ASettingRegistry):
"""Class using :mod:`configparser`.
@ -318,20 +321,17 @@ class IniSettingRegistry(ASettingRegistry):
This class is using :mod:`configparser` (ini) files to store items.
"""
def __init__(self, name, path):
# type: (str, str) -> IniSettingRegistry
super(IniSettingRegistry, self).__init__(name)
def __init__(self, name: str, path: str) -> None:
super().__init__(name)
# get registry file
self._registry_file = os.path.join(path, "{}.ini".format(name))
self._registry_file = os.path.join(path, f"{name}.ini")
if not os.path.exists(self._registry_file):
with open(self._registry_file, mode="w") as cfg:
print("# Settings registry", cfg)
now = datetime.now().strftime("%d/%m/%Y %H:%M:%S")
print("# {}".format(now), cfg)
print(f"# {now}", cfg)
def set_item_section(self, section, name, value):
# type: (str, str, str) -> None
def set_item_section(self, section: str, name: str, value: str) -> None:
"""Set item to specific section of ini registry.
If section doesn't exists, it is created.
@ -354,12 +354,10 @@ class IniSettingRegistry(ASettingRegistry):
with open(self._registry_file, mode="w") as cfg:
config.write(cfg)
def _set_item(self, name, value):
# type: (str, str) -> None
def _set_item(self, name: str, value: str) -> None:
self.set_item_section("MAIN", name, value)
def set_item(self, name, value):
# type: (str, str) -> None
def set_item(self, name: str, value: str) -> None:
"""Set item to settings ini file.
This saves item to ``DEFAULT`` section of ini as each item there
@ -372,10 +370,9 @@ class IniSettingRegistry(ASettingRegistry):
"""
# this does the some, overridden just for different docstring.
# we cast value to str as ini options values must be strings.
super(IniSettingRegistry, self).set_item(name, str(value))
super().set_item(name, str(value))
def get_item(self, name):
# type: (str) -> str
def get_item(self, name: str) -> str:
"""Gets item from settings ini file.
This gets settings from ``DEFAULT`` section of ini file as each item
@ -388,19 +385,18 @@ class IniSettingRegistry(ASettingRegistry):
str: Value of item.
Raises:
ValueError: If value doesn't exist.
RegistryItemNotFound: If value doesn't exist.
"""
return super(IniSettingRegistry, self).get_item(name)
return super().get_item(name)
@lru_cache(maxsize=32)
def get_item_from_section(self, section, name):
# type: (str, str) -> str
def get_item_from_section(self, section: str, name: str) -> str:
"""Get item from section of ini file.
This will read ini file and try to get item value from specified
section. If that section or item doesn't exist, :exc:`ValueError`
is risen.
section. If that section or item doesn't exist,
:exc:`RegistryItemNotFound` is risen.
Args:
section (str): Name of ini section.
@ -410,7 +406,7 @@ class IniSettingRegistry(ASettingRegistry):
str: Item value.
Raises:
ValueError: If value doesn't exist.
RegistryItemNotFound: If value doesn't exist.
"""
config = configparser.ConfigParser()
@ -418,16 +414,15 @@ class IniSettingRegistry(ASettingRegistry):
try:
value = config[section][name]
except KeyError:
raise ValueError(
"Registry doesn't contain value {}:{}".format(section, name))
raise RegistryItemNotFound(
f"Registry doesn't contain value {section}:{name}"
)
return value
def _get_item(self, name):
# type: (str) -> str
def _get_item(self, name: str) -> str:
return self.get_item_from_section("MAIN", name)
def delete_item_from_section(self, section, name):
# type: (str, str) -> None
def delete_item_from_section(self, section: str, name: str) -> None:
"""Delete item from section in ini file.
Args:
@ -435,7 +430,7 @@ class IniSettingRegistry(ASettingRegistry):
name (str): Name of the item.
Raises:
ValueError: If item doesn't exist.
RegistryItemNotFound: If the item doesn't exist.
"""
self.get_item_from_section.cache_clear()
@ -444,8 +439,9 @@ class IniSettingRegistry(ASettingRegistry):
try:
_ = config[section][name]
except KeyError:
raise ValueError(
"Registry doesn't contain value {}:{}".format(section, name))
raise RegistryItemNotFound(
f"Registry doesn't contain value {section}:{name}"
)
config.remove_option(section, name)
# if section is empty, delete it
@ -461,29 +457,28 @@ class IniSettingRegistry(ASettingRegistry):
class JSONSettingRegistry(ASettingRegistry):
"""Class using json file as storage."""
"""Class using a json file as storage."""
def __init__(self, name, path):
# type: (str, str) -> JSONSettingRegistry
super(JSONSettingRegistry, self).__init__(name)
#: str: name of registry file
self._registry_file = os.path.join(path, "{}.json".format(name))
def __init__(self, name: str, path: str) -> None:
super().__init__(name)
self._registry_file = os.path.join(path, f"{name}.json")
now = datetime.now().strftime("%d/%m/%Y %H:%M:%S")
header = {
"__metadata__": {"generated": now},
"registry": {}
}
if not os.path.exists(os.path.dirname(self._registry_file)):
os.makedirs(os.path.dirname(self._registry_file), exist_ok=True)
# Use 'os.path.dirname' in case someone uses slashes in 'name'
dirpath = os.path.dirname(self._registry_file)
if not os.path.exists(dirpath):
os.makedirs(dirpath, exist_ok=True)
if not os.path.exists(self._registry_file):
with open(self._registry_file, mode="w") as cfg:
json.dump(header, cfg, indent=4)
@lru_cache(maxsize=32)
def _get_item(self, name):
# type: (str) -> object
"""Get item value from registry json.
def _get_item(self, name: str) -> str:
"""Get item value from the registry.
Note:
See :meth:`ayon_core.lib.JSONSettingRegistry.get_item`
@ -494,29 +489,13 @@ class JSONSettingRegistry(ASettingRegistry):
try:
value = data["registry"][name]
except KeyError:
raise ValueError(
"Registry doesn't contain value {}".format(name))
raise RegistryItemNotFound(
f"Registry doesn't contain value {name}"
)
return value
def get_item(self, name):
# type: (str) -> object
"""Get item value from registry json.
Args:
name (str): Name of the item.
Returns:
value of the item
Raises:
ValueError: If item is not found in registry file.
"""
return self._get_item(name)
def _set_item(self, name, value):
# type: (str, object) -> None
"""Set item value to registry json.
def _set_item(self, name: str, value: str) -> None:
"""Set item value to the registry.
Note:
See :meth:`ayon_core.lib.JSONSettingRegistry.set_item`
@ -528,41 +507,39 @@ class JSONSettingRegistry(ASettingRegistry):
cfg.truncate(0)
cfg.seek(0)
json.dump(data, cfg, indent=4)
def set_item(self, name, value):
# type: (str, object) -> None
"""Set item and its value into json registry file.
Args:
name (str): name of the item.
value (Any): value of the item.
"""
self._set_item(name, value)
def _delete_item(self, name):
# type: (str) -> None
self._get_item.cache_clear()
def _delete_item(self, name: str) -> None:
with open(self._registry_file, "r+") as cfg:
data = json.load(cfg)
del data["registry"][name]
cfg.truncate(0)
cfg.seek(0)
json.dump(data, cfg, indent=4)
self._get_item.cache_clear()
class AYONSettingsRegistry(JSONSettingRegistry):
"""Class handling AYON general settings registry.
Args:
name (Optional[str]): Name of the registry.
"""
name (Optional[str]): Name of the registry. Using 'None' or not
passing name is deprecated.
def __init__(self, name=None):
"""
def __init__(self, name: Optional[str] = None) -> None:
if not name:
name = "AYON_settings"
warnings.warn(
(
"Used 'AYONSettingsRegistry' without 'name' argument."
" The argument will be required in future versions."
),
DeprecationWarning,
stacklevel=2,
)
path = get_launcher_storage_dir()
super(AYONSettingsRegistry, self).__init__(name, path)
super().__init__(name, path)
def get_local_site_id():

View file

@ -1403,7 +1403,12 @@ def _get_display_view_colorspace_name(config_path, display, view):
"""
config = _get_ocio_config(config_path)
return config.getDisplayViewColorSpaceName(display, view)
colorspace = config.getDisplayViewColorSpaceName(display, view)
# Special token. See https://opencolorio.readthedocs.io/en/latest/guides/authoring/authoring.html#shared-views # noqa
if colorspace == "<USE_DISPLAY_NAME>":
colorspace = display
return colorspace
def _get_ocio_config_colorspaces(config_path):

View file

@ -373,7 +373,7 @@ def discover_loader_plugins(project_name=None):
if not project_name:
project_name = get_current_project_name()
project_settings = get_project_settings(project_name)
plugins = discover(LoaderPlugin)
plugins = discover(LoaderPlugin, allow_duplicates=False)
hooks = discover(LoaderHookPlugin)
sorted_hooks = sorted(hooks, key=lambda hook: hook.order)
for plugin in plugins:

View file

@ -16,6 +16,7 @@ import re
import collections
import copy
from abc import ABC, abstractmethod
from typing import Optional
import ayon_api
from ayon_api import (
@ -201,12 +202,6 @@ class AbstractTemplateBuilder(ABC):
)
return self._current_folder_entity
@property
def linked_folder_entities(self):
if self._linked_folder_entities is _NOT_SET:
self._linked_folder_entities = self._get_linked_folder_entities()
return self._linked_folder_entities
@property
def current_task_entity(self):
if self._current_task_entity is _NOT_SET:
@ -307,13 +302,16 @@ class AbstractTemplateBuilder(ABC):
self._loaders_by_name = get_loaders_by_name()
return self._loaders_by_name
def _get_linked_folder_entities(self):
def get_linked_folder_entities(self, link_type: Optional[str]):
if not link_type:
return []
project_name = self.project_name
folder_entity = self.current_folder_entity
if not folder_entity:
return []
links = get_folder_links(
project_name, folder_entity["id"], link_direction="in"
project_name,
folder_entity["id"], link_types=[link_type], link_direction="in"
)
linked_folder_ids = {
link["entityId"]
@ -1429,10 +1427,27 @@ class PlaceholderLoadMixin(object):
builder_type_enum_items = [
{"label": "Current folder", "value": "context_folder"},
# TODO implement linked folders
# {"label": "Linked folders", "value": "linked_folders"},
{"label": "Linked folders", "value": "linked_folders"},
{"label": "All folders", "value": "all_folders"},
]
link_types = ayon_api.get_link_types(self.builder.project_name)
# Filter link types for folder to folder links
link_types_enum_items = [
{"label": link_type["name"], "value": link_type["linkType"]}
for link_type in link_types
if (
link_type["inputType"] == "folder"
and link_type["outputType"] == "folder"
)
]
if not link_types_enum_items:
link_types_enum_items.append(
{"label": "<No link types>", "value": None}
)
build_type_label = "Folder Builder Type"
build_type_help = (
"Folder Builder Type\n"
@ -1461,6 +1476,16 @@ class PlaceholderLoadMixin(object):
items=builder_type_enum_items,
tooltip=build_type_help
),
attribute_definitions.EnumDef(
"link_type",
label="Link Type",
items=link_types_enum_items,
tooltip=(
"Link Type\n"
"\nDefines what type of link will be used to"
" link the asset to the current folder."
)
),
attribute_definitions.EnumDef(
"product_type",
label="Product type",
@ -1607,10 +1632,7 @@ class PlaceholderLoadMixin(object):
builder_type = placeholder.data["builder_type"]
folder_ids = []
if builder_type == "context_folder":
folder_ids = [current_folder_entity["id"]]
elif builder_type == "all_folders":
if builder_type == "all_folders":
folder_ids = {
folder_entity["id"]
for folder_entity in get_folders(
@ -1620,6 +1642,23 @@ class PlaceholderLoadMixin(object):
)
}
elif builder_type == "context_folder":
folder_ids = [current_folder_entity["id"]]
elif builder_type == "linked_folders":
# link type from placeholder data or default to "template"
link_type = placeholder.data.get("link_type", "template")
# Get all linked folders for the current folder
if hasattr(self, "builder") and isinstance(
self.builder, AbstractTemplateBuilder):
# self.builder: AbstractTemplateBuilder
folder_ids = [
linked_folder_entity["id"]
for linked_folder_entity in (
self.builder.get_linked_folder_entities(
link_type=link_type))
]
if not folder_ids:
return []

View file

@ -39,7 +39,7 @@ class CollectAudio(pyblish.api.ContextPlugin):
"blender",
"houdini",
"max",
"circuit",
"batchdelivery",
]
settings_category = "core"

View file

@ -8,13 +8,7 @@ This module contains a unified plugin that handles:
from pprint import pformat
import opentimelineio as otio
import pyblish.api
from ayon_core.pipeline.editorial import (
get_media_range_with_retimes,
otio_range_to_frame_range,
otio_range_with_handles,
)
def validate_otio_clip(instance, logger):
@ -74,6 +68,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin):
if not validate_otio_clip(instance, self.log):
return
import opentimelineio as otio
otio_clip = instance.data["otioClip"]
# Collect timeline ranges if workfile start frame is available
@ -100,6 +96,11 @@ class CollectOtioRanges(pyblish.api.InstancePlugin):
def _collect_timeline_ranges(self, instance, otio_clip):
"""Collect basic timeline frame ranges."""
from ayon_core.pipeline.editorial import (
otio_range_to_frame_range,
otio_range_with_handles,
)
workfile_start = instance.data["workfileFrameStart"]
# Get timeline ranges
@ -129,6 +130,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin):
def _collect_source_ranges(self, instance, otio_clip):
"""Collect source media frame ranges."""
import opentimelineio as otio
# Get source ranges
otio_src_range = otio_clip.source_range
otio_available_range = otio_clip.available_range()
@ -178,6 +181,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin):
def _collect_retimed_ranges(self, instance, otio_clip):
"""Handle retimed clip frame ranges."""
from ayon_core.pipeline.editorial import get_media_range_with_retimes
retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0)
self.log.debug(f"Retimed attributes: {retimed_attributes}")

View file

@ -55,7 +55,7 @@ class ExtractBurnin(publish.Extractor):
"max",
"blender",
"unreal",
"circuit",
"batchdelivery",
]
settings_category = "core"

View file

@ -7,7 +7,6 @@ from ayon_core.lib import (
get_ffmpeg_tool_args,
run_subprocess
)
from ayon_core.pipeline import editorial
class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
@ -159,6 +158,7 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
"""
# Not all hosts can import this module.
import opentimelineio as otio
from ayon_core.pipeline.editorial import OTIO_EPSILON
output = []
# go trough all audio tracks
@ -177,7 +177,7 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
# Avoid rounding issue on media available range.
if clip_start.almost_equal(
conformed_av_start,
editorial.OTIO_EPSILON
OTIO_EPSILON
):
conformed_av_start = clip_start

View file

@ -25,7 +25,6 @@ from ayon_core.lib import (
)
from ayon_core.pipeline import (
KnownPublishError,
editorial,
publish,
)
@ -359,6 +358,7 @@ class ExtractOTIOReview(
import opentimelineio as otio
from ayon_core.pipeline.editorial import (
trim_media_range,
OTIO_EPSILON,
)
def _round_to_frame(rational_time):
@ -380,7 +380,7 @@ class ExtractOTIOReview(
# Avoid rounding issue on media available range.
if start.almost_equal(
avl_start,
editorial.OTIO_EPSILON
OTIO_EPSILON
):
avl_start = start
@ -406,7 +406,7 @@ class ExtractOTIOReview(
# Avoid rounding issue on media available range.
if end_point.almost_equal(
avl_end_point,
editorial.OTIO_EPSILON
OTIO_EPSILON
):
avl_end_point = end_point

View file

@ -161,7 +161,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
"aftereffects",
"flame",
"unreal",
"circuit",
"batchdelivery",
"photoshop"
]

View file

@ -41,7 +41,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
"photoshop",
"unreal",
"houdini",
"circuit",
"batchdelivery",
]
settings_category = "core"
enabled = False

View file

@ -3,25 +3,26 @@ import os
import ayon_api
from ayon_core.host import IWorkfileHost
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.pipeline import Anatomy, registered_host
from ayon_core.pipeline.context_tools import get_global_context
from ayon_core.settings import get_project_settings
from ayon_core.tools.common_models import (
HierarchyModel,
HierarchyExpectedSelection,
HierarchyModel,
ProjectsModel,
UsersModel,
)
from .abstract import (
AbstractWorkfilesFrontend,
AbstractWorkfilesBackend,
AbstractWorkfilesFrontend,
)
from .models import SelectionModel, WorkfilesModel
NOT_SET = object()
class WorkfilesToolExpectedSelection(HierarchyExpectedSelection):
def __init__(self, controller):
@ -143,6 +144,7 @@ class BaseWorkfileController(
self._project_settings = None
self._event_system = None
self._log = None
self._username = NOT_SET
self._current_project_name = None
self._current_folder_path = None
@ -588,6 +590,20 @@ class BaseWorkfileController(
description,
)
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

@ -287,10 +287,11 @@ class FilesWidget(QtWidgets.QWidget):
def _update_published_btns_state(self):
enabled = (
self._valid_representation_id
and self._valid_selected_context
and self._is_save_enabled
)
self._published_btn_copy_n_open.setEnabled(enabled)
self._published_btn_copy_n_open.setEnabled(
enabled and self._valid_selected_context
)
self._published_btn_change_context.setEnabled(enabled)
def _update_workarea_btns_state(self):

View file

@ -1,21 +1,21 @@
from qtpy import QtCore, QtWidgets, QtGui
from ayon_core import style, resources
from ayon_core.tools.utils import (
PlaceholderLineEdit,
MessageOverlayObject,
)
from qtpy import QtCore, QtGui, QtWidgets
from ayon_core.tools.workfiles.control import BaseWorkfileController
from ayon_core import resources, style
from ayon_core.tools.utils import (
GoToCurrentButton,
RefreshButton,
FoldersWidget,
GoToCurrentButton,
MessageOverlayObject,
NiceCheckbox,
PlaceholderLineEdit,
RefreshButton,
TasksWidget,
)
from ayon_core.tools.utils.lib import checkstate_int_to_enum
from ayon_core.tools.workfiles.control import BaseWorkfileController
from .side_panel import SidePanelWidget
from .files_widget import FilesWidget
from .side_panel import SidePanelWidget
from .utils import BaseOverlayFrame
@ -107,7 +107,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
split_widget.addWidget(tasks_widget)
split_widget.addWidget(col_3_widget)
split_widget.addWidget(side_panel)
split_widget.setSizes([255, 175, 550, 190])
split_widget.setSizes([350, 175, 550, 190])
body_layout.addWidget(split_widget)
@ -157,6 +157,8 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
self._home_body_widget = home_body_widget
self._split_widget = split_widget
self._project_name = self._controller.get_current_project_name()
self._tasks_widget = tasks_widget
self._side_panel = side_panel
@ -186,11 +188,24 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
controller, col_widget, handle_expected_selection=True
)
my_tasks_tooltip = (
"Filter folders and task to only those you are assigned to."
)
my_tasks_label = QtWidgets.QLabel("My tasks")
my_tasks_label.setToolTip(my_tasks_tooltip)
my_tasks_checkbox = NiceCheckbox(folder_widget)
my_tasks_checkbox.setChecked(False)
my_tasks_checkbox.setToolTip(my_tasks_tooltip)
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.addWidget(folder_filter_input, 1)
header_layout.addWidget(go_to_current_btn, 0)
header_layout.addWidget(refresh_btn, 0)
header_layout.addWidget(my_tasks_label, 0)
header_layout.addWidget(my_tasks_checkbox, 0)
col_layout = QtWidgets.QVBoxLayout(col_widget)
col_layout.setContentsMargins(0, 0, 0, 0)
@ -200,6 +215,9 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
folder_filter_input.textChanged.connect(self._on_folder_filter_change)
go_to_current_btn.clicked.connect(self._on_go_to_current_clicked)
refresh_btn.clicked.connect(self._on_refresh_clicked)
my_tasks_checkbox.stateChanged.connect(
self._on_my_tasks_checkbox_state_changed
)
self._folder_filter_input = folder_filter_input
self._folders_widget = folder_widget
@ -385,3 +403,16 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
)
else:
self.close()
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

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

View file

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

View file

@ -5,7 +5,7 @@
[tool.poetry]
name = "ayon-core"
version = "1.5.0+dev"
version = "1.5.3+dev"
description = ""
authors = ["Ynput Team <team@ynput.io>"]
readme = "README.md"