Merge branch 'develop' into feature/ci-mkdocs

This commit is contained in:
Mustafa Zaky Jafar 2025-09-15 18:51:53 +03:00 committed by GitHub
commit 2f64832491
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 3752 additions and 3288 deletions

View file

@ -35,6 +35,10 @@ body:
label: Version
description: What version are you running? Look to AYON Tray
options:
- 1.6.0
- 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
@ -155,18 +156,33 @@ def load_addons(force=False):
def _get_ayon_bundle_data():
studio_bundle_name = os.environ.get("AYON_STUDIO_BUNDLE_NAME")
project_bundle_name = os.getenv("AYON_BUNDLE_NAME")
bundles = ayon_api.get_bundles()["bundles"]
bundle_name = os.getenv("AYON_BUNDLE_NAME")
return next(
project_bundle = next(
(
bundle
for bundle in bundles
if bundle["name"] == bundle_name
if bundle["name"] == project_bundle_name
),
None
)
studio_bundle = None
if studio_bundle_name and project_bundle_name != studio_bundle_name:
studio_bundle = next(
(
bundle
for bundle in bundles
if bundle["name"] == studio_bundle_name
),
None
)
if project_bundle and studio_bundle:
addons = copy.deepcopy(studio_bundle["addons"])
addons.update(project_bundle["addons"])
project_bundle["addons"] = addons
return project_bundle
def _get_ayon_addons_information(bundle_info):
@ -286,6 +302,11 @@ def _load_ayon_addons(log):
milestone_version = MOVED_ADDON_MILESTONE_VERSIONS.get(addon_name)
if use_dev_path:
addon_dir = dev_addon_info["path"]
if addon_dir:
addon_dir = os.path.expandvars(
addon_dir.format_map(os.environ)
)
if not addon_dir or not os.path.exists(addon_dir):
log.warning((
"Dev addon {} {} path does not exists. Path \"{}\""
@ -815,10 +836,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": [],
@ -874,24 +911,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

@ -27,25 +27,40 @@ from ayon_core.lib.env_tools import (
@click.group(invoke_without_command=True)
@click.pass_context
@click.option("--use-staging", is_flag=True,
expose_value=False, help="use staging variants")
@click.option("--debug", is_flag=True, expose_value=False,
help="Enable debug")
@click.option("--verbose", expose_value=False,
help=("Change AYON log level (debug - critical or 0-50)"))
@click.option("--force", is_flag=True, hidden=True)
def main_cli(ctx, force):
@click.option(
"--use-staging",
is_flag=True,
expose_value=False,
help="use staging variants")
@click.option(
"--debug",
is_flag=True,
expose_value=False,
help="Enable debug")
@click.option(
"--project",
help="Project name")
@click.option(
"--verbose",
expose_value=False,
help="Change AYON log level (debug - critical or 0-50)")
@click.option(
"--use-dev",
is_flag=True,
expose_value=False,
help="use dev bundle")
def main_cli(ctx, *_args, **_kwargs):
"""AYON is main command serving as entry point to pipeline system.
It wraps different commands together.
"""
if ctx.invoked_subcommand is None:
# Print help if headless mode is used
if os.getenv("AYON_HEADLESS_MODE") == "1":
print(ctx.get_help())
sys.exit(0)
else:
ctx.params.pop("project")
ctx.forward(tray)
@ -60,7 +75,6 @@ def tray(force):
Default action of AYON command is to launch tray widget to control basic
aspects of AYON. See documentation for more information.
"""
from ayon_core.tools.tray import main
main(force)
@ -306,6 +320,43 @@ def _add_addons(addons_manager):
)
def _cleanup_project_args():
rem_args = list(sys.argv[1:])
if "--project" not in rem_args:
return
cmd = None
current_ctx = None
parent_name = "ayon"
parent_cmd = main_cli
while hasattr(parent_cmd, "resolve_command"):
if current_ctx is None:
current_ctx = main_cli.make_context(parent_name, rem_args)
else:
current_ctx = parent_cmd.make_context(
parent_name,
rem_args,
parent=current_ctx
)
if not rem_args:
break
cmd_name, cmd, rem_args = parent_cmd.resolve_command(
current_ctx, rem_args
)
parent_name = cmd_name
parent_cmd = cmd
if cmd is None:
return
param_names = {param.name for param in cmd.params}
if "project" in param_names:
return
idx = sys.argv.index("--project")
sys.argv.pop(idx)
sys.argv.pop(idx)
def main(*args, **kwargs):
logging.basicConfig()
@ -332,10 +383,14 @@ def main(*args, **kwargs):
addons_manager = AddonsManager()
_set_addons_environments(addons_manager)
_add_addons(addons_manager)
_cleanup_project_args()
try:
main_cli(
prog_name="ayon",
obj={"addons_manager": addons_manager},
args=(sys.argv[1:]),
)
except Exception: # noqa
exc_info = sys.exc_info()

View file

@ -14,7 +14,7 @@ class OCIOEnvHook(PreLaunchHook):
"fusion",
"blender",
"aftereffects",
"3dsmax",
"max",
"houdini",
"maya",
"nuke",

View file

@ -1,6 +1,8 @@
from .constants import ContextChangeReason
from .abstract import AbstractHost
from .host import (
HostBase,
ContextChangeData,
)
from .interfaces import (
@ -18,7 +20,10 @@ from .dirmap import HostDirmap
__all__ = (
"ContextChangeReason",
"AbstractHost",
"HostBase",
"ContextChangeData",
"IWorkfileHost",
"WorkfileInfo",

View file

@ -0,0 +1,96 @@
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
import typing
from typing import Optional, Any
from .constants import ContextChangeReason
if typing.TYPE_CHECKING:
from ayon_core.pipeline import Anatomy
from .typing import HostContextData
class AbstractHost(ABC):
"""Abstract definition of host implementation."""
@property
@abstractmethod
def log(self) -> logging.Logger:
pass
@property
@abstractmethod
def name(self) -> str:
"""Host name."""
pass
@abstractmethod
def get_current_context(self) -> HostContextData:
"""Get the current context of the host.
Current context is defined by project name, folder path and task name.
Returns:
HostContextData: The current context of the host.
"""
pass
@abstractmethod
def set_current_context(
self,
folder_entity: dict[str, Any],
task_entity: dict[str, Any],
*,
reason: ContextChangeReason = ContextChangeReason.undefined,
project_entity: Optional[dict[str, Any]] = None,
anatomy: Optional[Anatomy] = None,
) -> HostContextData:
"""Change context of the host.
Args:
folder_entity (dict[str, Any]): Folder entity.
task_entity (dict[str, Any]): Task entity.
reason (ContextChangeReason): Reason for change.
project_entity (dict[str, Any]): Project entity.
anatomy (Anatomy): Anatomy entity.
"""
pass
@abstractmethod
def get_current_project_name(self) -> str:
"""Get the current project name.
Returns:
Optional[str]: The current project name.
"""
pass
@abstractmethod
def get_current_folder_path(self) -> Optional[str]:
"""Get the current folder path.
Returns:
Optional[str]: The current folder path.
"""
pass
@abstractmethod
def get_current_task_name(self) -> Optional[str]:
"""Get the current task name.
Returns:
Optional[str]: The current task name.
"""
pass
@abstractmethod
def get_context_title(self) -> str:
"""Get the context title used in UIs."""
pass

View file

@ -3,26 +3,21 @@ from __future__ import annotations
import os
import logging
import contextlib
from abc import ABC, abstractmethod
from dataclasses import dataclass
import typing
from typing import Optional, Any
from dataclasses import dataclass
import ayon_api
from ayon_core.lib import emit_event
from .constants import ContextChangeReason
from .abstract import AbstractHost
if typing.TYPE_CHECKING:
from ayon_core.pipeline import Anatomy
from typing import TypedDict
class HostContextData(TypedDict):
project_name: str
folder_path: Optional[str]
task_name: Optional[str]
from .typing import HostContextData
@dataclass
@ -34,7 +29,7 @@ class ContextChangeData:
anatomy: Anatomy
class HostBase(ABC):
class HostBase(AbstractHost):
"""Base of host implementation class.
Host is pipeline implementation of DCC application. This class should help
@ -109,48 +104,41 @@ class HostBase(ABC):
It is called automatically when 'ayon_core.pipeline.install_host' is
triggered.
"""
"""
pass
@property
def log(self):
def log(self) -> logging.Logger:
if self._log is None:
self._log = logging.getLogger(self.__class__.__name__)
return self._log
@property
@abstractmethod
def name(self) -> str:
"""Host name."""
pass
def get_current_project_name(self):
def get_current_project_name(self) -> str:
"""
Returns:
Union[str, None]: Current project name.
"""
str: Current project name.
return os.environ.get("AYON_PROJECT_NAME")
"""
return os.environ["AYON_PROJECT_NAME"]
def get_current_folder_path(self) -> Optional[str]:
"""
Returns:
Union[str, None]: Current asset name.
"""
Optional[str]: Current asset name.
"""
return os.environ.get("AYON_FOLDER_PATH")
def get_current_task_name(self) -> Optional[str]:
"""
Returns:
Union[str, None]: Current task name.
"""
Optional[str]: Current task name.
"""
return os.environ.get("AYON_TASK_NAME")
def get_current_context(self) -> "HostContextData":
def get_current_context(self) -> HostContextData:
"""Get current context information.
This method should be used to get current context of host. Usage of
@ -159,10 +147,10 @@ class HostBase(ABC):
can't be caught properly.
Returns:
Dict[str, Union[str, None]]: Context with 3 keys 'project_name',
'folder_path' and 'task_name'. All of them can be 'None'.
"""
HostContextData: Current context with 'project_name',
'folder_path' and 'task_name'.
"""
return {
"project_name": self.get_current_project_name(),
"folder_path": self.get_current_folder_path(),
@ -177,7 +165,7 @@ class HostBase(ABC):
reason: ContextChangeReason = ContextChangeReason.undefined,
project_entity: Optional[dict[str, Any]] = None,
anatomy: Optional[Anatomy] = None,
) -> "HostContextData":
) -> HostContextData:
"""Set current context information.
This method should be used to set current context of host. Usage of
@ -290,7 +278,7 @@ class HostBase(ABC):
project_name: str,
folder_path: Optional[str],
task_name: Optional[str],
) -> "HostContextData":
) -> HostContextData:
"""Emit context change event.
Args:
@ -302,7 +290,7 @@ class HostBase(ABC):
HostContextData: Data send to context change event.
"""
data = {
data: HostContextData = {
"project_name": project_name,
"folder_path": folder_path,
"task_name": task_name,

View file

@ -1,9 +1,11 @@
from abc import abstractmethod
from ayon_core.host.abstract import AbstractHost
from .exceptions import MissingMethodsError
class ILoadHost:
class ILoadHost(AbstractHost):
"""Implementation requirements to be able use reference of representations.
The load plugins can do referencing even without implementation of methods
@ -24,7 +26,7 @@ class ILoadHost:
loading. Checks only existence of methods.
Args:
Union[ModuleType, HostBase]: Object of host where to look for
Union[ModuleType, AbstractHost]: Object of host where to look for
required methods.
Returns:
@ -46,7 +48,7 @@ class ILoadHost:
"""Validate implemented methods of "old type" host for load workflow.
Args:
Union[ModuleType, HostBase]: Object of host to validate.
Union[ModuleType, AbstractHost]: Object of host to validate.
Raises:
MissingMethodsError: If there are missing methods on host
@ -83,7 +85,7 @@ class ILoadHost:
return self.get_containers()
class IPublishHost:
class IPublishHost(AbstractHost):
"""Functions related to new creation system in new publisher.
New publisher is not storing information only about each created instance
@ -99,7 +101,7 @@ class IPublishHost:
new publish creation. Checks only existence of methods.
Args:
Union[ModuleType, HostBase]: Host module where to look for
Union[ModuleType, AbstractHost]: Host module where to look for
required methods.
Returns:
@ -127,7 +129,7 @@ class IPublishHost:
"""Validate implemented methods of "old type" host.
Args:
Union[ModuleType, HostBase]: Host module to validate.
Union[ModuleType, AbstractHost]: Host module to validate.
Raises:
MissingMethodsError: If there are missing methods on host

View file

@ -15,6 +15,7 @@ import arrow
from ayon_core.lib import emit_event
from ayon_core.settings import get_project_settings
from ayon_core.host.abstract import AbstractHost
from ayon_core.host.constants import ContextChangeReason
if typing.TYPE_CHECKING:
@ -821,7 +822,7 @@ class PublishedWorkfileInfo:
return PublishedWorkfileInfo(**data)
class IWorkfileHost:
class IWorkfileHost(AbstractHost):
"""Implementation requirements to be able to use workfiles utils and tool.
Some of the methods are pre-implemented as they generally do the same in
@ -944,6 +945,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 +1075,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 +1120,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 +1143,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 +1160,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

@ -0,0 +1,7 @@
from typing import Optional, TypedDict
class HostContextData(TypedDict):
project_name: str
folder_path: Optional[str]
task_name: Optional[str]

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

@ -6,6 +6,8 @@ import collections
import tempfile
import subprocess
import platform
import warnings
import functools
from typing import Optional
import xml.etree.ElementTree
@ -67,6 +69,47 @@ VIDEO_EXTENSIONS = {
}
def deprecated(new_destination):
"""Mark functions as deprecated.
It will result in a warning being emitted when the function is used.
"""
func = None
if callable(new_destination):
func = new_destination
new_destination = None
def _decorator(decorated_func):
if new_destination is None:
warning_message = (
" Please check content of deprecated function to figure out"
" possible replacement."
)
else:
warning_message = " Please replace your usage with '{}'.".format(
new_destination
)
@functools.wraps(decorated_func)
def wrapper(*args, **kwargs):
warnings.simplefilter("always", DeprecationWarning)
warnings.warn(
(
"Call to deprecated function '{}'"
"\nFunction was moved or removed.{}"
).format(decorated_func.__name__, warning_message),
category=DeprecationWarning,
stacklevel=4
)
return decorated_func(*args, **kwargs)
return wrapper
if func is None:
return _decorator
return _decorator(func)
def get_transcode_temp_directory():
"""Creates temporary folder for transcoding.
@ -966,6 +1009,8 @@ def convert_ffprobe_fps_to_float(value):
return dividend / divisor
# --- Deprecated functions ---
@deprecated("oiio_color_convert")
def convert_colorspace(
input_path,
output_path,
@ -977,7 +1022,62 @@ def convert_colorspace(
additional_command_args=None,
logger=None,
):
"""Convert source file from one color space to another.
"""DEPRECATED function use `oiio_color_convert` instead
Args:
input_path (str): Path to input file that should be converted.
output_path (str): Path to output file where result will be stored.
config_path (str): Path to OCIO config file.
source_colorspace (str): OCIO valid color space of source files.
target_colorspace (str, optional): OCIO valid target color space.
If filled, 'view' and 'display' must be empty.
view (str, optional): Name for target viewer space (OCIO valid).
Both 'view' and 'display' must be filled
(if not 'target_colorspace').
display (str, optional): Name for target display-referred
reference space. Both 'view' and 'display' must be filled
(if not 'target_colorspace').
additional_command_args (list, optional): Additional arguments
for oiiotool (like binary depth for .dpx).
logger (logging.Logger, optional): Logger used for logging.
Returns:
None: Function returns None.
Raises:
ValueError: If parameters are misconfigured.
"""
return oiio_color_convert(
input_path,
output_path,
config_path,
source_colorspace,
target_colorspace=target_colorspace,
target_display=display,
target_view=view,
additional_command_args=additional_command_args,
logger=logger,
)
def oiio_color_convert(
input_path,
output_path,
config_path,
source_colorspace,
source_display=None,
source_view=None,
target_colorspace=None,
target_display=None,
target_view=None,
additional_command_args=None,
logger=None,
):
"""Transcode source file to other with colormanagement.
Oiiotool also support additional arguments for transcoding.
For more information, see the official documentation:
https://openimageio.readthedocs.io/en/latest/oiiotool.html
Args:
input_path (str): Path that should be converted. It is expected that
@ -989,17 +1089,26 @@ def convert_colorspace(
sequence in 'file.FRAMESTART-FRAMEEND#.ext', `output.1-3#.tif`)
config_path (str): path to OCIO config file
source_colorspace (str): ocio valid color space of source files
source_display (str, optional): name for source display-referred
reference space (ocio valid). If provided, source_view must also be
provided, and source_colorspace will be ignored
source_view (str, optional): name for source viewer space (ocio valid)
If provided, source_display must also be provided, and
source_colorspace will be ignored
target_colorspace (str): ocio valid target color space
if filled, 'view' and 'display' must be empty
view (str): name for viewer space (ocio valid)
both 'view' and 'display' must be filled (if 'target_colorspace')
display (str): name for display-referred reference space (ocio valid)
target_display (str): name for target display-referred reference space
(ocio valid) both 'view' and 'display' must be filled (if
'target_colorspace')
target_view (str): name for target viewer space (ocio valid)
both 'view' and 'display' must be filled (if 'target_colorspace')
additional_command_args (list): arguments for oiiotool (like binary
depth for .dpx)
logger (logging.Logger): Logger used for logging.
Raises:
ValueError: if misconfigured
"""
if logger is None:
logger = logging.getLogger(__name__)
@ -1024,23 +1133,82 @@ def convert_colorspace(
"--ch", channels_arg
])
if all([target_colorspace, view, display]):
raise ValueError("Colorspace and both screen and display"
" cannot be set together."
"Choose colorspace or screen and display")
if not target_colorspace and not all([view, display]):
raise ValueError("Both screen and display must be set.")
# Validate input parameters
if target_colorspace and target_view and target_display:
raise ValueError(
"Colorspace and both view and display cannot be set together."
"Choose colorspace or screen and display"
)
if not target_colorspace and not target_view and not target_display:
raise ValueError(
"Both view and display must be set if target_colorspace is not "
"provided."
)
if (
(source_view and not source_display)
or (source_display and not source_view)
):
raise ValueError(
"Both source_view and source_display must be provided if using "
"display/view inputs."
)
if source_view and source_display and source_colorspace:
logger.warning(
"Both source display/view and source_colorspace provided. "
"Using source display/view pair and ignoring source_colorspace."
)
if additional_command_args:
oiio_cmd.extend(additional_command_args)
if target_colorspace:
oiio_cmd.extend(["--colorconvert:subimages=0",
source_colorspace,
target_colorspace])
if view and display:
oiio_cmd.extend(["--iscolorspace", source_colorspace])
oiio_cmd.extend(["--ociodisplay:subimages=0", display, view])
# Handle the different conversion cases
# Source view and display are known
if source_view and source_display:
if target_colorspace:
# This is a two-step conversion process since there's no direct
# display/view to colorspace command
# This could be a config parameter or determined from OCIO config
# Use temporarty role space 'scene_linear'
color_convert_args = ("scene_linear", target_colorspace)
elif source_display != target_display or source_view != target_view:
# Complete display/view pair conversion
# - go through a reference space
color_convert_args = (target_display, target_view)
else:
color_convert_args = None
logger.debug(
"Source and target display/view pairs are identical."
" No color conversion needed."
)
if color_convert_args:
oiio_cmd.extend([
"--ociodisplay:inverse=1:subimages=0",
source_display,
source_view,
"--colorconvert:subimages=0",
*color_convert_args
])
elif target_colorspace:
# Standard color space to color space conversion
oiio_cmd.extend([
"--colorconvert:subimages=0",
source_colorspace,
target_colorspace,
])
else:
# Standard conversion from colorspace to display/view
oiio_cmd.extend([
"--iscolorspace",
source_colorspace,
"--ociodisplay:subimages=0",
target_display,
target_view,
])
oiio_cmd.extend(["-o", output_path])

View file

@ -19,11 +19,7 @@ from .create import (
CreatedInstance,
CreatorError,
LegacyCreator,
legacy_create,
discover_creator_plugins,
discover_legacy_creator_plugins,
register_creator_plugin,
deregister_creator_plugin,
register_creator_plugin_path,
@ -141,12 +137,7 @@ __all__ = (
"CreatorError",
# - legacy creation
"LegacyCreator",
"legacy_create",
"discover_creator_plugins",
"discover_legacy_creator_plugins",
"register_creator_plugin",
"deregister_creator_plugin",
"register_creator_plugin_path",

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

@ -13,7 +13,7 @@ import pyblish.api
from pyblish.lib import MessageHandler
from ayon_core import AYON_CORE_ROOT
from ayon_core.host import HostBase
from ayon_core.host import AbstractHost
from ayon_core.lib import (
is_in_tests,
initialize_ayon_connection,
@ -100,16 +100,16 @@ def registered_root():
return _registered_root["_"]
def install_host(host: HostBase) -> None:
def install_host(host: AbstractHost) -> None:
"""Install `host` into the running Python session.
Args:
host (HostBase): A host interface object.
host (AbstractHost): A host interface object.
"""
if not isinstance(host, HostBase):
if not isinstance(host, AbstractHost):
log.error(
f"Host must be a subclass of 'HostBase', got '{type(host)}'."
f"Host must be a subclass of 'AbstractHost', got '{type(host)}'."
)
global _is_installed
@ -310,7 +310,7 @@ def get_current_host_name():
"""
host = registered_host()
if isinstance(host, HostBase):
if isinstance(host, AbstractHost):
return host.name
return os.environ.get("AYON_HOST_NAME")
@ -346,28 +346,28 @@ def get_global_context():
def get_current_context():
host = registered_host()
if isinstance(host, HostBase):
if isinstance(host, AbstractHost):
return host.get_current_context()
return get_global_context()
def get_current_project_name():
host = registered_host()
if isinstance(host, HostBase):
if isinstance(host, AbstractHost):
return host.get_current_project_name()
return get_global_context()["project_name"]
def get_current_folder_path():
host = registered_host()
if isinstance(host, HostBase):
if isinstance(host, AbstractHost):
return host.get_current_folder_path()
return get_global_context()["folder_path"]
def get_current_task_name():
host = registered_host()
if isinstance(host, HostBase):
if isinstance(host, AbstractHost):
return host.get_current_task_name()
return get_global_context()["task_name"]

View file

@ -21,12 +21,14 @@ from .exceptions import (
TemplateFillError,
)
from .structures import (
ParentFlags,
CreatedInstance,
ConvertorItem,
AttributeValues,
CreatorAttributeValues,
PublishAttributeValues,
PublishAttributes,
InstanceContextInfo,
)
from .utils import (
get_last_versions_for_instances,
@ -44,9 +46,6 @@ from .creator_plugins import (
AutoCreator,
HiddenCreator,
discover_legacy_creator_plugins,
get_legacy_creator_by_name,
discover_creator_plugins,
register_creator_plugin,
deregister_creator_plugin,
@ -58,11 +57,6 @@ from .creator_plugins import (
from .context import CreateContext
from .legacy_create import (
LegacyCreator,
legacy_create,
)
__all__ = (
"PRODUCT_NAME_ALLOWED_SYMBOLS",
@ -85,12 +79,14 @@ __all__ = (
"TaskNotSetError",
"TemplateFillError",
"ParentFlags",
"CreatedInstance",
"ConvertorItem",
"AttributeValues",
"CreatorAttributeValues",
"PublishAttributeValues",
"PublishAttributes",
"InstanceContextInfo",
"get_last_versions_for_instances",
"get_next_versions_for_instances",
@ -105,9 +101,6 @@ __all__ = (
"AutoCreator",
"HiddenCreator",
"discover_legacy_creator_plugins",
"get_legacy_creator_by_name",
"discover_creator_plugins",
"register_creator_plugin",
"deregister_creator_plugin",
@ -117,7 +110,4 @@ __all__ = (
"cache_and_get_instances",
"CreateContext",
"LegacyCreator",
"legacy_create",
)

View file

@ -41,7 +41,12 @@ from .exceptions import (
HostMissRequiredMethod,
)
from .changes import TrackChangesItem
from .structures import PublishAttributes, ConvertorItem, InstanceContextInfo
from .structures import (
PublishAttributes,
ConvertorItem,
InstanceContextInfo,
ParentFlags,
)
from .creator_plugins import (
Creator,
AutoCreator,
@ -49,15 +54,12 @@ from .creator_plugins import (
discover_convertor_plugins,
)
if typing.TYPE_CHECKING:
from ayon_core.host import HostBase
from ayon_core.lib import AbstractAttrDef
from ayon_core.lib.events import EventCallback, Event
from .structures import CreatedInstance
from .creator_plugins import BaseCreator
class PublishHost(HostBase, IPublishHost):
pass
# Import of functions and classes that were moved to different file
# TODO Should be removed in future release - Added 24/08/28, 0.4.3-dev.1
@ -80,6 +82,7 @@ INSTANCE_ADDED_TOPIC = "instances.added"
INSTANCE_REMOVED_TOPIC = "instances.removed"
VALUE_CHANGED_TOPIC = "values.changed"
INSTANCE_REQUIREMENT_CHANGED_TOPIC = "instance.requirement.changed"
INSTANCE_PARENT_CHANGED_TOPIC = "instance.parent.changed"
PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC = "pre.create.attr.defs.changed"
CREATE_ATTR_DEFS_CHANGED_TOPIC = "create.attr.defs.changed"
PUBLISH_ATTR_DEFS_CHANGED_TOPIC = "publish.attr.defs.changed"
@ -163,7 +166,7 @@ class CreateContext:
context which should be handled by host.
Args:
host (PublishHost): Host implementation which handles implementation
host (IPublishHost): Host implementation which handles implementation
and global metadata.
headless (bool): Context is created out of UI (Current not used).
reset (bool): Reset context on initialization.
@ -173,7 +176,7 @@ class CreateContext:
def __init__(
self,
host: "PublishHost",
host: IPublishHost,
headless: bool = False,
reset: bool = True,
discover_publish_plugins: bool = True,
@ -262,6 +265,8 @@ class CreateContext:
# - right now used only for 'mandatory' but can be extended
# in future
"requirement_change": BulkInfo(),
# Instance parent changed
"parent_change": BulkInfo(),
}
self._bulk_order = []
@ -1083,6 +1088,35 @@ class CreateContext:
INSTANCE_REQUIREMENT_CHANGED_TOPIC, callback
)
def add_instance_parent_change_callback(
self, callback: Callable
) -> "EventCallback":
"""Register callback to listen to instance parent changes.
Instance changed parent or parent flags.
Data structure of event:
```python
{
"instances": [CreatedInstance, ...],
"create_context": CreateContext
}
```
Args:
callback (Callable): Callback function that will be called when
instance requirement changed.
Returns:
EventCallback: Created callback object which can be used to
stop listening.
"""
return self._event_hub.add_callback(
INSTANCE_PARENT_CHANGED_TOPIC, callback
)
def context_data_to_store(self) -> dict[str, Any]:
"""Data that should be stored by host function.
@ -1364,6 +1398,13 @@ class CreateContext:
) as bulk_info:
yield bulk_info
@contextmanager
def bulk_instance_parent_change(self, sender: Optional[str] = None):
with self._bulk_context(
"parent_change", sender
) as bulk_info:
yield bulk_info
@contextmanager
def bulk_publish_attr_defs_change(self, sender: Optional[str] = None):
with self._bulk_context("publish_attrs_change", sender) as bulk_info:
@ -1444,6 +1485,19 @@ class CreateContext:
with self.bulk_instance_requirement_change() as bulk_item:
bulk_item.append(instance_id)
def instance_parent_changed(self, instance_id: str) -> None:
"""Instance parent changed.
Triggered by `CreatedInstance`.
Args:
instance_id (Optional[str]): Instance id.
"""
if self._is_instance_events_ready(instance_id):
with self.bulk_instance_parent_change() as bulk_item:
bulk_item.append(instance_id)
# --- context change callbacks ---
def publish_attribute_value_changed(
self, plugin_name: str, value: dict[str, Any]
@ -2046,63 +2100,97 @@ class CreateContext:
sender (Optional[str]): Sender of the event.
"""
instance_ids_by_parent_id = collections.defaultdict(set)
for instance in self.instances:
instance_ids_by_parent_id[instance.parent_instance_id].add(
instance.id
)
instances_to_remove = list(instances)
ids_to_remove = {
instance.id
for instance in instances_to_remove
}
_queue = collections.deque()
_queue.extend(instances_to_remove)
# Add children with parent lifetime flag
while _queue:
instance = _queue.popleft()
ids_to_remove.add(instance.id)
children_ids = instance_ids_by_parent_id[instance.id]
for children_id in children_ids:
if children_id in ids_to_remove:
continue
instance = self._instances_by_id[children_id]
if instance.parent_flags & ParentFlags.parent_lifetime:
instances_to_remove.append(instance)
ids_to_remove.add(instance.id)
_queue.append(instance)
instances_by_identifier = collections.defaultdict(list)
for instance in instances:
for instance in instances_to_remove:
identifier = instance.creator_identifier
instances_by_identifier[identifier].append(instance)
# Just remove instances from context if creator is not available
missing_creators = set(instances_by_identifier) - set(self.creators)
instances = []
miss_creator_instances = []
for identifier in missing_creators:
instances.extend(
instance
for instance in instances_by_identifier[identifier]
)
miss_creator_instances.extend(instances_by_identifier[identifier])
self._remove_instances(instances, sender)
with self.bulk_remove_instances(sender):
self._remove_instances(miss_creator_instances, sender)
error_message = "Instances removement of creator \"{}\" failed. {}"
failed_info = []
# Remove instances by creator plugin order
for creator in self.get_sorted_creators(
instances_by_identifier.keys()
):
identifier = creator.identifier
creator_instances = instances_by_identifier[identifier]
error_message = "Instances removement of creator \"{}\" failed. {}"
failed_info = []
# Remove instances by creator plugin order
for creator in self.get_sorted_creators(
instances_by_identifier.keys()
):
identifier = creator.identifier
# Filter instances by current state of 'CreateContext'
# - in case instances were already removed as subroutine of
# previous create plugin.
creator_instances = [
instance
for instance in instances_by_identifier[identifier]
if instance.id in self._instances_by_id
]
if not creator_instances:
continue
label = creator.label
failed = False
add_traceback = False
exc_info = None
try:
creator.remove_instances(creator_instances)
label = creator.label
failed = False
add_traceback = False
exc_info = None
try:
creator.remove_instances(creator_instances)
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, exc_info[1])
)
except (KeyboardInterrupt, SystemExit):
raise
except: # noqa: E722
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, exc_info[1])
)
except (KeyboardInterrupt, SystemExit):
raise
except: # noqa: E722
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
)
)
)
if failed_info:
raise CreatorsRemoveFailed(failed_info)
@ -2305,6 +2393,8 @@ class CreateContext:
self._bulk_publish_attrs_change_finished(data, sender)
elif key == "requirement_change":
self._bulk_instance_requirement_change_finished(data, sender)
elif key == "parent_change":
self._bulk_instance_parent_change_finished(data, sender)
def _bulk_add_instances_finished(
self,
@ -2518,3 +2608,22 @@ class CreateContext:
{"instances": instances},
sender,
)
def _bulk_instance_parent_change_finished(
self,
instance_ids: list[str],
sender: Optional[str],
):
if not instance_ids:
return
instances = [
self.get_instance_by_id(instance_id)
for instance_id in set(instance_ids)
]
self._emit_event(
INSTANCE_PARENT_CHANGED_TOPIC,
{"instances": instances},
sender,
)

View file

@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Optional, Dict, Any
from abc import ABC, abstractmethod
from ayon_core.settings import get_project_settings
from ayon_core.lib import Logger, get_version_from_path
from ayon_core.pipeline.plugin_discover import (
discover,
@ -20,7 +19,6 @@ from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir
from .constants import DEFAULT_VARIANT_VALUE
from .product_name import get_product_name
from .utils import get_next_versions_for_instances
from .legacy_create import LegacyCreator
from .structures import CreatedInstance
if TYPE_CHECKING:
@ -975,62 +973,10 @@ def discover_convertor_plugins(*args, **kwargs):
return discover(ProductConvertorPlugin, *args, **kwargs)
def discover_legacy_creator_plugins():
from ayon_core.pipeline import get_current_project_name
log = Logger.get_logger("CreatorDiscover")
plugins = discover(LegacyCreator)
project_name = get_current_project_name()
project_settings = get_project_settings(project_name)
for plugin in plugins:
try:
plugin.apply_settings(project_settings)
except Exception:
log.warning(
"Failed to apply settings to creator {}".format(
plugin.__name__
),
exc_info=True
)
return plugins
def get_legacy_creator_by_name(creator_name, case_sensitive=False):
"""Find creator plugin by name.
Args:
creator_name (str): Name of creator class that should be returned.
case_sensitive (bool): Match of creator plugin name is case sensitive.
Set to `False` by default.
Returns:
Creator: Return first matching plugin or `None`.
"""
# Lower input creator name if is not case sensitive
if not case_sensitive:
creator_name = creator_name.lower()
for creator_plugin in discover_legacy_creator_plugins():
_creator_name = creator_plugin.__name__
# Lower creator plugin name if is not case sensitive
if not case_sensitive:
_creator_name = _creator_name.lower()
if _creator_name == creator_name:
return creator_plugin
return None
def register_creator_plugin(plugin):
if issubclass(plugin, BaseCreator):
register_plugin(BaseCreator, plugin)
elif issubclass(plugin, LegacyCreator):
register_plugin(LegacyCreator, plugin)
elif issubclass(plugin, ProductConvertorPlugin):
register_plugin(ProductConvertorPlugin, plugin)
@ -1039,22 +985,17 @@ def deregister_creator_plugin(plugin):
if issubclass(plugin, BaseCreator):
deregister_plugin(BaseCreator, plugin)
elif issubclass(plugin, LegacyCreator):
deregister_plugin(LegacyCreator, plugin)
elif issubclass(plugin, ProductConvertorPlugin):
deregister_plugin(ProductConvertorPlugin, plugin)
def register_creator_plugin_path(path):
register_plugin_path(BaseCreator, path)
register_plugin_path(LegacyCreator, path)
register_plugin_path(ProductConvertorPlugin, path)
def deregister_creator_plugin_path(path):
deregister_plugin_path(BaseCreator, path)
deregister_plugin_path(LegacyCreator, path)
deregister_plugin_path(ProductConvertorPlugin, path)

View file

@ -1,216 +0,0 @@
"""Create workflow moved from avalon-core repository.
Renamed classes and functions
- 'Creator' -> 'LegacyCreator'
- 'create' -> 'legacy_create'
"""
import os
import logging
import collections
from ayon_core.pipeline.constants import AYON_INSTANCE_ID
from .product_name import get_product_name
class LegacyCreator:
"""Determine how assets are created"""
label = None
product_type = None
defaults = None
maintain_selection = True
enabled = True
dynamic_product_name_keys = []
log = logging.getLogger("LegacyCreator")
log.propagate = True
def __init__(self, name, folder_path, options=None, data=None):
self.name = name # For backwards compatibility
self.options = options
# Default data
self.data = collections.OrderedDict()
# TODO use 'AYON_INSTANCE_ID' when all hosts support it
self.data["id"] = AYON_INSTANCE_ID
self.data["productType"] = self.product_type
self.data["folderPath"] = folder_path
self.data["productName"] = name
self.data["active"] = True
self.data.update(data or {})
@classmethod
def apply_settings(cls, project_settings):
"""Apply AYON settings to a plugin class."""
host_name = os.environ.get("AYON_HOST_NAME")
plugin_type = "create"
plugin_type_settings = (
project_settings
.get(host_name, {})
.get(plugin_type, {})
)
global_type_settings = (
project_settings
.get("core", {})
.get(plugin_type, {})
)
if not global_type_settings and not plugin_type_settings:
return
plugin_name = cls.__name__
plugin_settings = None
# Look for plugin settings in host specific settings
if plugin_name in plugin_type_settings:
plugin_settings = plugin_type_settings[plugin_name]
# Look for plugin settings in global settings
elif plugin_name in global_type_settings:
plugin_settings = global_type_settings[plugin_name]
if not plugin_settings:
return
cls.log.debug(">>> We have preset for {}".format(plugin_name))
for option, value in plugin_settings.items():
if option == "enabled" and value is False:
cls.log.debug(" - is disabled by preset")
else:
cls.log.debug(" - setting `{}`: `{}`".format(option, value))
setattr(cls, option, value)
def process(self):
pass
@classmethod
def get_dynamic_data(
cls, project_name, folder_entity, task_entity, variant, host_name
):
"""Return dynamic data for current Creator plugin.
By default return keys from `dynamic_product_name_keys` attribute
as mapping to keep formatted template unchanged.
```
dynamic_product_name_keys = ["my_key"]
---
output = {
"my_key": "{my_key}"
}
```
Dynamic keys may override default Creator keys (productType, task,
folderPath, ...) but do it wisely if you need.
All of keys will be converted into 3 variants unchanged, capitalized
and all upper letters. Because of that are all keys lowered.
This method can be modified to prefill some values just keep in mind it
is class method.
Args:
project_name (str): Context's project name.
folder_entity (dict[str, Any]): Folder entity.
task_entity (dict[str, Any]): Task entity.
variant (str): What is entered by user in creator tool.
host_name (str): Name of host.
Returns:
dict: Fill data for product name template.
"""
dynamic_data = {}
for key in cls.dynamic_product_name_keys:
key = key.lower()
dynamic_data[key] = "{" + key + "}"
return dynamic_data
@classmethod
def get_product_name(
cls, project_name, folder_entity, task_entity, variant, host_name=None
):
"""Return product name created with entered arguments.
Logic extracted from Creator tool. This method should give ability
to get product name without the tool.
TODO: Maybe change `variant` variable.
By default is output concatenated product type with variant.
Args:
project_name (str): Context's project name.
folder_entity (dict[str, Any]): Folder entity.
task_entity (dict[str, Any]): Task entity.
variant (str): What is entered by user in creator tool.
host_name (str): Name of host.
Returns:
str: Formatted product name with entered arguments. Should match
config's logic.
"""
dynamic_data = cls.get_dynamic_data(
project_name, folder_entity, task_entity, variant, host_name
)
task_name = task_type = None
if task_entity:
task_name = task_entity["name"]
task_type = task_entity["taskType"]
return get_product_name(
project_name,
task_name,
task_type,
host_name,
cls.product_type,
variant,
dynamic_data=dynamic_data
)
def legacy_create(
Creator, product_name, folder_path, options=None, data=None
):
"""Create a new instance
Associate nodes with a product name and type. These nodes are later
validated, according to their `product type`, and integrated into the
shared environment, relative their `productName`.
Data relative each product type, along with default data, are imprinted
into the resulting objectSet. This data is later used by extractors
and finally asset browsers to help identify the origin of the asset.
Arguments:
Creator (Creator): Class of creator.
product_name (str): Name of product.
folder_path (str): Folder path.
options (dict, optional): Additional options from GUI.
data (dict, optional): Additional data from GUI.
Raises:
NameError on `productName` already exists
KeyError on invalid dynamic property
RuntimeError on host error
Returns:
Name of instance
"""
from ayon_core.pipeline import registered_host
host = registered_host()
plugin = Creator(product_name, folder_path, options, data)
if plugin.maintain_selection is True:
with host.maintained_selection():
print("Running %s with maintained selection" % plugin)
instance = plugin.process()
return instance
print("Running %s" % plugin)
instance = plugin.process()
return instance

View file

@ -1,6 +1,7 @@
import copy
import collections
from uuid import uuid4
from enum import Enum
import typing
from typing import Optional, Dict, List, Any
@ -22,6 +23,23 @@ if typing.TYPE_CHECKING:
from .creator_plugins import BaseCreator
class IntEnum(int, Enum):
"""An int-based Enum class that allows for int comparison."""
def __int__(self) -> int:
return self.value
class ParentFlags(IntEnum):
# Delete instance if parent is deleted
parent_lifetime = 1
# Active state is propagated from parent to children
# - the active state is propagated in collection phase
# NOTE It might be helpful to have a function that would return "real"
# active state for instances
share_active = 1 << 1
class ConvertorItem:
"""Item representing convertor plugin.
@ -507,7 +525,9 @@ class CreatedInstance:
if transient_data is None:
transient_data = {}
self._transient_data = transient_data
self._is_mandatory = False
self._is_mandatory: bool = False
self._parent_instance_id: Optional[str] = None
self._parent_flags: int = 0
# Create a copy of passed data to avoid changing them on the fly
data = copy.deepcopy(data or {})
@ -752,6 +772,39 @@ class CreatedInstance:
self["active"] = True
self._create_context.instance_requirement_changed(self.id)
@property
def parent_instance_id(self) -> Optional[str]:
return self._parent_instance_id
@property
def parent_flags(self) -> int:
return self._parent_flags
def set_parent(
self, instance_id: Optional[str], flags: int
) -> None:
"""Set parent instance id and parenting flags.
Args:
instance_id (Optional[str]): Parent instance id.
flags (int): Parenting flags.
"""
changed = False
if instance_id != self._parent_instance_id:
changed = True
self._parent_instance_id = instance_id
if flags is None:
flags = 0
if self._parent_flags != flags:
self._parent_flags = flags
changed = True
if changed:
self._create_context.instance_parent_changed(self.id)
def changes(self):
"""Calculate and return changes."""

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 (
@ -29,7 +30,7 @@ from ayon_api import (
)
from ayon_core.settings import get_project_settings
from ayon_core.host import IWorkfileHost, HostBase
from ayon_core.host import IWorkfileHost, AbstractHost
from ayon_core.lib import (
Logger,
StringTemplate,
@ -53,7 +54,6 @@ from ayon_core.pipeline.plugin_discover import (
)
from ayon_core.pipeline.create import (
discover_legacy_creator_plugins,
CreateContext,
HiddenCreator,
)
@ -126,15 +126,14 @@ class AbstractTemplateBuilder(ABC):
placeholder population.
Args:
host (Union[HostBase, ModuleType]): Implementation of host.
host (Union[AbstractHost, ModuleType]): Implementation of host.
"""
_log = None
use_legacy_creators = False
def __init__(self, host):
# Get host name
if isinstance(host, HostBase):
if isinstance(host, AbstractHost):
host_name = host.name
else:
host_name = os.environ.get("AYON_HOST_NAME")
@ -162,24 +161,24 @@ class AbstractTemplateBuilder(ABC):
@property
def project_name(self):
if isinstance(self._host, HostBase):
if isinstance(self._host, AbstractHost):
return self._host.get_current_project_name()
return os.getenv("AYON_PROJECT_NAME")
@property
def current_folder_path(self):
if isinstance(self._host, HostBase):
if isinstance(self._host, AbstractHost):
return self._host.get_current_folder_path()
return os.getenv("AYON_FOLDER_PATH")
@property
def current_task_name(self):
if isinstance(self._host, HostBase):
if isinstance(self._host, AbstractHost):
return self._host.get_current_task_name()
return os.getenv("AYON_TASK_NAME")
def get_current_context(self):
if isinstance(self._host, HostBase):
if isinstance(self._host, AbstractHost):
return self._host.get_current_context()
return {
"project_name": self.project_name,
@ -201,12 +200,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:
@ -261,7 +254,7 @@ class AbstractTemplateBuilder(ABC):
"""Access to host implementation.
Returns:
Union[HostBase, ModuleType]: Implementation of host.
Union[AbstractHost, ModuleType]: Implementation of host.
"""
return self._host
@ -307,13 +300,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"]
@ -323,19 +319,6 @@ class AbstractTemplateBuilder(ABC):
return list(get_folders(project_name, folder_ids=linked_folder_ids))
def _collect_legacy_creators(self):
creators_by_name = {}
for creator in discover_legacy_creator_plugins():
if not creator.enabled:
continue
creator_name = creator.__name__
if creator_name in creators_by_name:
raise KeyError(
"Duplicated creator name {} !".format(creator_name)
)
creators_by_name[creator_name] = creator
self._creators_by_name = creators_by_name
def _collect_creators(self):
self._creators_by_name = {
identifier: creator
@ -347,10 +330,7 @@ class AbstractTemplateBuilder(ABC):
def get_creators_by_name(self):
if self._creators_by_name is None:
if self.use_legacy_creators:
self._collect_legacy_creators()
else:
self._collect_creators()
self._collect_creators()
return self._creators_by_name
@ -1429,10 +1409,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 +1458,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 +1614,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 +1624,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 []
@ -1899,8 +1920,6 @@ class PlaceholderCreateMixin(object):
pre_create_data (dict): dictionary of configuration from Creator
configuration in UI
"""
legacy_create = self.builder.use_legacy_creators
creator_name = placeholder.data["creator"]
create_variant = placeholder.data["create_variant"]
active = placeholder.data.get("active")
@ -1940,20 +1959,14 @@ class PlaceholderCreateMixin(object):
# compile product name from variant
try:
if legacy_create:
creator_instance = creator_plugin(
product_name,
folder_path
).process()
else:
creator_instance = self.builder.create_context.create(
creator_plugin.identifier,
create_variant,
folder_entity,
task_entity,
pre_create_data=pre_create_data,
active=active
)
creator_instance = self.builder.create_context.create(
creator_plugin.identifier,
create_variant,
folder_entity,
task_entity,
pre_create_data=pre_create_data,
active=active
)
except: # noqa: E722
failed = True

View file

@ -0,0 +1,630 @@
"""Plugin to create hero version from selected context."""
from __future__ import annotations
import os
import copy
import shutil
import errno
import itertools
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Optional
from speedcopy import copyfile
import clique
import ayon_api
from ayon_api.operations import OperationsSession, new_version_entity
from ayon_api.utils import create_entity_id
from qtpy import QtWidgets, QtCore
from ayon_core import style
from ayon_core.pipeline import load, Anatomy
from ayon_core.lib import create_hard_link, source_hash, StringTemplate
from ayon_core.lib.file_transaction import wait_for_future_errors
from ayon_core.pipeline.publish import get_publish_template_name
from ayon_core.pipeline.template_data import get_template_data
def prepare_changes(old_entity: dict, new_entity: dict) -> dict:
"""Prepare changes dict for update entity operation.
Args:
old_entity (dict): Existing entity data from database.
new_entity (dict): New entity data to compare against old.
Returns:
dict: Changes to apply to old entity to make it like new entity.
"""
changes = {}
for key in set(new_entity.keys()):
if key == "attrib":
continue
if key in new_entity and new_entity[key] != old_entity.get(key):
changes[key] = new_entity[key]
attrib_changes = {}
if "attrib" in new_entity:
for key, value in new_entity["attrib"].items():
if value != old_entity["attrib"].get(key):
attrib_changes[key] = value
if attrib_changes:
changes["attrib"] = attrib_changes
return changes
class CreateHeroVersion(load.ProductLoaderPlugin):
"""Create hero version from selected context."""
is_multiple_contexts_compatible = False
representations = {"*"}
product_types = {"*"}
label = "Create Hero Version"
order = 36
icon = "star"
color = "#ffd700"
ignored_representation_names: list[str] = []
db_representation_context_keys = [
"project", "folder", "asset", "hierarchy", "task", "product",
"subset", "family", "representation", "username", "user", "output"
]
use_hardlinks = False
@staticmethod
def message(text: str) -> None:
"""Show message box with text."""
msgBox = QtWidgets.QMessageBox()
msgBox.setText(text)
msgBox.setStyleSheet(style.load_stylesheet())
msgBox.setWindowFlags(
msgBox.windowFlags() | QtCore.Qt.WindowType.FramelessWindowHint
)
msgBox.exec_()
def load(self, context, name=None, namespace=None, options=None) -> None:
"""Load hero version from context (dict as in context.py)."""
success = True
errors = []
# Extract project, product, version, folder from context
project = context.get("project")
product = context.get("product")
version = context.get("version")
folder = context.get("folder")
task_entity = ayon_api.get_task_by_id(
task_id=version.get("taskId"), project_name=project["name"]
)
anatomy = Anatomy(project["name"])
version_id = version["id"]
project_name = project["name"]
repres = list(
ayon_api.get_representations(
project_name, version_ids={version_id}
)
)
anatomy_data = get_template_data(
project_entity=project,
folder_entity=folder,
task_entity=task_entity,
)
anatomy_data["product"] = {
"name": product["name"],
"type": product["productType"],
}
anatomy_data["version"] = version["version"]
published_representations = {}
for repre in repres:
repre_anatomy = copy.deepcopy(anatomy_data)
if "ext" not in repre_anatomy:
repre_anatomy["ext"] = repre.get("context", {}).get("ext", "")
published_representations[repre["id"]] = {
"representation": repre,
"published_files": [f["path"] for f in repre.get("files", [])],
"anatomy_data": repre_anatomy
}
# get the publish directory
publish_template_key = get_publish_template_name(
project_name,
context.get("hostName"),
product["productType"],
task_name=anatomy_data.get("task", {}).get("name"),
task_type=anatomy_data.get("task", {}).get("type"),
project_settings=context.get("project_settings", {}),
logger=self.log
)
published_template_obj = anatomy.get_template_item(
"publish", publish_template_key, "directory"
)
published_dir = os.path.normpath(
published_template_obj.format_strict(anatomy_data)
)
instance_data = {
"productName": product["name"],
"productType": product["productType"],
"anatomyData": anatomy_data,
"publishDir": published_dir,
"published_representations": published_representations,
"versionEntity": version,
}
try:
self.create_hero_version(instance_data, anatomy, context)
except Exception as exc:
success = False
errors.append(str(exc))
if success:
self.message("Hero version created successfully.")
else:
self.message(
f"Failed to create hero version:\n{chr(10).join(errors)}")
def create_hero_version(
self,
instance_data: dict[str, Any],
anatomy: Anatomy,
context: dict[str, Any]) -> None:
"""Create hero version from instance data.
Args:
instance_data (dict): Instance data with keys:
- productName (str): Name of the product.
- productType (str): Type of the product.
- anatomyData (dict): Anatomy data for templates.
- publishDir (str): Directory where the product is published.
- published_representations (dict): Published representations.
- versionEntity (dict, optional): Source version entity.
anatomy (Anatomy): Anatomy object for the project.
context (dict): Context data with keys:
- hostName (str): Name of the host application.
- project_settings (dict): Project settings.
Raises:
RuntimeError: If any required data is missing or an error occurs
during the hero version creation process.
"""
published_repres = instance_data.get("published_representations")
if not published_repres:
raise RuntimeError("No published representations found.")
project_name = anatomy.project_name
template_key = get_publish_template_name(
project_name,
context.get("hostName"),
instance_data.get("productType"),
instance_data.get("anatomyData", {}).get("task", {}).get("name"),
instance_data.get("anatomyData", {}).get("task", {}).get("type"),
project_settings=context.get("project_settings", {}),
hero=True,
)
hero_template = anatomy.get_template_item(
"hero", template_key, "path", default=None
)
if hero_template is None:
raise RuntimeError("Project anatomy does not have hero "
f"template key: {template_key}")
self.log.info(f"Hero template: {hero_template.template}")
hero_publish_dir = self.get_publish_dir(
instance_data, anatomy, template_key
)
self.log.info(f"Hero publish dir: {hero_publish_dir}")
src_version_entity = instance_data.get("versionEntity")
filtered_repre_ids = []
for repre_id, repre_info in published_repres.items():
repre = repre_info["representation"]
if repre["name"].lower() in self.ignored_representation_names:
filtered_repre_ids.append(repre_id)
for repre_id in filtered_repre_ids:
published_repres.pop(repre_id, None)
if not published_repres:
raise RuntimeError(
"All published representations were filtered by name."
)
if src_version_entity is None:
src_version_entity = self.version_from_representations(
project_name, published_repres)
if not src_version_entity:
raise RuntimeError("Can't find origin version in database.")
if src_version_entity["version"] == 0:
raise RuntimeError("Version 0 cannot have hero version.")
all_copied_files = []
transfers = instance_data.get("transfers", [])
for _src, dst in transfers:
dst = os.path.normpath(dst)
if dst not in all_copied_files:
all_copied_files.append(dst)
hardlinks = instance_data.get("hardlinks", [])
for _src, dst in hardlinks:
dst = os.path.normpath(dst)
if dst not in all_copied_files:
all_copied_files.append(dst)
all_repre_file_paths = []
for repre_info in published_repres.values():
published_files = repre_info.get("published_files") or []
for file_path in published_files:
file_path = os.path.normpath(file_path)
if file_path not in all_repre_file_paths:
all_repre_file_paths.append(file_path)
publish_dir = instance_data.get("publishDir", "")
if not publish_dir:
raise RuntimeError(
"publishDir is empty in instance_data, cannot continue."
)
instance_publish_dir = os.path.normpath(publish_dir)
other_file_paths_mapping = []
for file_path in all_copied_files:
if not file_path.startswith(instance_publish_dir):
continue
if file_path in all_repre_file_paths:
continue
dst_filepath = file_path.replace(
instance_publish_dir, hero_publish_dir
)
other_file_paths_mapping.append((file_path, dst_filepath))
old_version, old_repres = self.current_hero_ents(
project_name, src_version_entity
)
inactive_old_repres_by_name = {}
old_repres_by_name = {}
for repre in old_repres:
low_name = repre["name"].lower()
if repre["active"]:
old_repres_by_name[low_name] = repre
else:
inactive_old_repres_by_name[low_name] = repre
op_session = OperationsSession()
entity_id = old_version["id"] if old_version else None
new_hero_version = new_version_entity(
-src_version_entity["version"],
src_version_entity["productId"],
task_id=src_version_entity.get("taskId"),
data=copy.deepcopy(src_version_entity["data"]),
attribs=copy.deepcopy(src_version_entity["attrib"]),
entity_id=entity_id,
)
if old_version:
update_data = prepare_changes(old_version, new_hero_version)
op_session.update_entity(
project_name, "version", old_version["id"], update_data
)
else:
op_session.create_entity(project_name, "version", new_hero_version)
# Store hero entity to instance_data
instance_data["heroVersionEntity"] = new_hero_version
old_repres_to_replace = {}
for repre_info in published_repres.values():
repre = repre_info["representation"]
repre_name_low = repre["name"].lower()
if repre_name_low in old_repres_by_name:
old_repres_to_replace[repre_name_low] = (
old_repres_by_name.pop(repre_name_low)
)
old_repres_to_delete = old_repres_by_name or {}
backup_hero_publish_dir = None
if os.path.exists(hero_publish_dir):
base_backup_dir = f"{hero_publish_dir}.BACKUP"
max_idx = 10
# Find the first available backup directory name
for idx in range(max_idx + 1):
if idx == 0:
candidate_backup_dir = base_backup_dir
else:
candidate_backup_dir = f"{base_backup_dir}{idx}"
if not os.path.exists(candidate_backup_dir):
backup_hero_publish_dir = candidate_backup_dir
break
else:
raise AssertionError(
f"Backup folders are fully occupied to max index {max_idx}"
)
try:
os.rename(hero_publish_dir, backup_hero_publish_dir)
except PermissionError as e:
raise AssertionError(
"Could not create hero version because it is "
"not possible to replace current hero files."
) from e
try:
src_to_dst_file_paths = []
repre_integrate_data = []
path_template_obj = anatomy.get_template_item(
"hero", template_key, "path")
anatomy_root = {"root": anatomy.roots}
for repre_info in published_repres.values():
published_files = repre_info["published_files"]
if len(published_files) == 0:
continue
anatomy_data = copy.deepcopy(repre_info["anatomy_data"])
anatomy_data.pop("version", None)
template_filled = path_template_obj.format_strict(anatomy_data)
repre_context = template_filled.used_values
for key in self.db_representation_context_keys:
value = anatomy_data.get(key)
if value is not None:
repre_context[key] = value
repre_entity = copy.deepcopy(repre_info["representation"])
repre_entity.pop("id", None)
repre_entity["versionId"] = new_hero_version["id"]
repre_entity["context"] = repre_context
repre_entity["attrib"] = {
"path": str(template_filled),
"template": hero_template.template
}
dst_paths = []
if len(published_files) == 1:
dst_paths.append(str(template_filled))
mapped_published_file = StringTemplate(
published_files[0]).format_strict(
anatomy_root
)
src_to_dst_file_paths.append(
(mapped_published_file, template_filled)
)
self.log.info(
f"Single published file: {mapped_published_file} -> "
f"{template_filled}"
)
else:
collections, remainders = clique.assemble(published_files)
if remainders or not collections or len(collections) > 1:
raise RuntimeError(
(
"Integrity error. Files of published "
"representation is combination of frame "
"collections and single files."
)
)
src_col = collections[0]
frame_splitter = "_-_FRAME_SPLIT_-_"
anatomy_data["frame"] = frame_splitter
_template_filled = path_template_obj.format_strict(
anatomy_data
)
head, tail = _template_filled.split(frame_splitter)
padding = anatomy.templates_obj.frame_padding
dst_col = clique.Collection(
head=head, padding=padding, tail=tail
)
dst_col.indexes.clear()
dst_col.indexes.update(src_col.indexes)
for src_file, dst_file in zip(src_col, dst_col):
src_file = StringTemplate(src_file).format_strict(
anatomy_root
)
src_to_dst_file_paths.append((src_file, dst_file))
dst_paths.append(dst_file)
self.log.info(
f"Collection published file: {src_file} "
f"-> {dst_file}"
)
repre_integrate_data.append((repre_entity, dst_paths))
# Copy files
with ThreadPoolExecutor(max_workers=8) as executor:
futures = [
executor.submit(self.copy_file, src_path, dst_path)
for src_path, dst_path in itertools.chain(
src_to_dst_file_paths, other_file_paths_mapping)
]
wait_for_future_errors(executor, futures)
# Update/create representations
for repre_entity, dst_paths in repre_integrate_data:
repre_files = self.get_files_info(dst_paths, anatomy)
repre_entity["files"] = repre_files
repre_name_low = repre_entity["name"].lower()
if repre_name_low in old_repres_to_replace:
old_repre = old_repres_to_replace.pop(repre_name_low)
repre_entity["id"] = old_repre["id"]
update_data = prepare_changes(old_repre, repre_entity)
op_session.update_entity(
project_name,
"representation",
old_repre["id"],
update_data
)
elif repre_name_low in inactive_old_repres_by_name:
inactive_repre = inactive_old_repres_by_name.pop(
repre_name_low
)
repre_entity["id"] = inactive_repre["id"]
update_data = prepare_changes(inactive_repre, repre_entity)
op_session.update_entity(
project_name,
"representation",
inactive_repre["id"],
update_data
)
else:
op_session.create_entity(
project_name,
"representation",
repre_entity
)
for repre in old_repres_to_delete.values():
op_session.update_entity(
project_name,
"representation",
repre["id"],
{"active": False}
)
op_session.commit()
if backup_hero_publish_dir is not None and os.path.exists(
backup_hero_publish_dir
):
shutil.rmtree(backup_hero_publish_dir)
except Exception:
if backup_hero_publish_dir is not None and os.path.exists(
backup_hero_publish_dir):
if os.path.exists(hero_publish_dir):
shutil.rmtree(hero_publish_dir)
os.rename(backup_hero_publish_dir, hero_publish_dir)
raise
def get_files_info(
self, filepaths: list[str], anatomy: Anatomy) -> list[dict]:
"""Get list of file info dictionaries for given file paths.
Args:
filepaths (list[str]): List of absolute file paths.
anatomy (Anatomy): Anatomy object for the project.
Returns:
list[dict]: List of file info dictionaries.
"""
file_infos = []
for filepath in filepaths:
file_info = self.prepare_file_info(filepath, anatomy)
file_infos.append(file_info)
return file_infos
def prepare_file_info(self, path: str, anatomy: Anatomy) -> dict:
"""Prepare file info dictionary for given path.
Args:
path (str): Absolute file path.
anatomy (Anatomy): Anatomy object for the project.
Returns:
dict: File info dictionary with keys:
- id (str): Unique identifier for the file.
- name (str): Base name of the file.
- path (str): Rootless file path.
- size (int): Size of the file in bytes.
- hash (str): Hash of the file content.
- hash_type (str): Type of the hash used.
"""
return {
"id": create_entity_id(),
"name": os.path.basename(path),
"path": self.get_rootless_path(anatomy, path),
"size": os.path.getsize(path),
"hash": source_hash(path),
"hash_type": "op3",
}
@staticmethod
def get_publish_dir(
instance_data: dict,
anatomy: Anatomy,
template_key: str) -> str:
"""Get publish directory from instance data and anatomy.
Args:
instance_data (dict): Instance data with "anatomyData" key.
anatomy (Anatomy): Anatomy object for the project.
template_key (str): Template key for the hero template.
Returns:
str: Normalized publish directory path.
"""
template_data = copy.deepcopy(instance_data.get("anatomyData", {}))
if "originalBasename" in instance_data:
template_data["originalBasename"] = (
instance_data["originalBasename"]
)
template_obj = anatomy.get_template_item(
"hero", template_key, "directory"
)
return os.path.normpath(template_obj.format_strict(template_data))
@staticmethod
def get_rootless_path(anatomy: Anatomy, path: str) -> str:
"""Get rootless path from absolute path.
Args:
anatomy (Anatomy): Anatomy object for the project.
path (str): Absolute file path.
Returns:
str: Rootless file path if root found, else original path.
"""
success, rootless_path = anatomy.find_root_template_from_path(path)
if success:
path = rootless_path
return path
def copy_file(self, src_path: str, dst_path: str) -> None:
"""Copy file from src to dst with creating directories.
Args:
src_path (str): Source file path.
dst_path (str): Destination file path.
Raises:
OSError: If copying or linking fails.
"""
dirname = os.path.dirname(dst_path)
try:
os.makedirs(dirname)
except OSError as exc:
if exc.errno != errno.EEXIST:
raise
if self.use_hardlinks:
try:
create_hard_link(src_path, dst_path)
return
except OSError as exc:
if exc.errno not in [errno.EXDEV, errno.EINVAL]:
raise
copyfile(src_path, dst_path)
@staticmethod
def version_from_representations(
project_name: str, repres: dict) -> Optional[dict[str, Any]]:
"""Find version from representations.
Args:
project_name (str): Name of the project.
repres (dict): Dictionary of representations info.
Returns:
Optional[dict]: Version entity if found, else None.
"""
for repre_info in repres.values():
version = ayon_api.get_version_by_id(
project_name, repre_info["representation"]["versionId"]
)
if version:
return version
return None
@staticmethod
def current_hero_ents(
project_name: str,
version: dict[str, Any]) -> tuple[Any, list[dict[str, Any]]]:
hero_version = ayon_api.get_hero_version_by_product_id(
project_name, version["productId"]
)
if not hero_version:
return None, []
hero_repres = list(
ayon_api.get_representations(
project_name, version_ids={hero_version["id"]}
)
)
return hero_version, hero_repres

View file

@ -6,15 +6,15 @@ from ayon_core.pipeline import load
from ayon_core.pipeline.load import LoadError
class PushToLibraryProject(load.ProductLoaderPlugin):
"""Export selected versions to folder structure from Template"""
class PushToProject(load.ProductLoaderPlugin):
"""Export selected versions to different project"""
is_multiple_contexts_compatible = True
representations = {"*"}
product_types = {"*"}
label = "Push to Library project"
label = "Push to project"
order = 35
icon = "send"
color = "#d8d8d8"
@ -28,10 +28,12 @@ class PushToLibraryProject(load.ProductLoaderPlugin):
if not filtered_contexts:
raise LoadError("Nothing to push for your selection")
if len(filtered_contexts) > 1:
raise LoadError("Please select only one item")
context = tuple(filtered_contexts)[0]
folder_ids = set(
context["folder"]["id"]
for context in filtered_contexts
)
if len(folder_ids) > 1:
raise LoadError("Please select products from single folder")
push_tool_script_path = os.path.join(
AYON_CORE_ROOT,
@ -39,14 +41,16 @@ class PushToLibraryProject(load.ProductLoaderPlugin):
"push_to_project",
"main.py"
)
project_name = filtered_contexts[0]["project"]["name"]
project_name = context["project"]["name"]
version_id = context["version"]["id"]
version_ids = {
context["version"]["id"]
for context in filtered_contexts
}
args = get_ayon_launcher_args(
"run",
push_tool_script_path,
"--project", project_name,
"--version", version_id
"--versions", ",".join(version_ids)
)
run_detached_process(args)

View file

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

View file

@ -2,11 +2,13 @@
"""
import os
import collections
import pyblish.api
from ayon_core.host import IPublishHost
from ayon_core.pipeline import registered_host
from ayon_core.pipeline.create import CreateContext
from ayon_core.pipeline.create import CreateContext, ParentFlags
class CollectFromCreateContext(pyblish.api.ContextPlugin):
@ -36,18 +38,51 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin):
if project_name:
context.data["projectName"] = project_name
# Separate root instances and parented instances
instances_by_parent_id = collections.defaultdict(list)
root_instances = []
for created_instance in create_context.instances:
parent_id = created_instance.parent_instance_id
if parent_id is None:
root_instances.append(created_instance)
else:
instances_by_parent_id[parent_id].append(created_instance)
# Traverse instances from top to bottom
# - All instances without an existing parent are automatically
# eliminated
filtered_instances = []
_queue = collections.deque()
_queue.append((root_instances, True))
while _queue:
created_instances, parent_is_active = _queue.popleft()
for created_instance in created_instances:
is_active = created_instance["active"]
# Use a parent's active state if parent flags defines that
if (
created_instance.parent_flags & ParentFlags.share_active
and is_active
):
is_active = parent_is_active
if is_active:
filtered_instances.append(created_instance)
children = instances_by_parent_id[created_instance.id]
if children:
_queue.append((children, is_active))
for created_instance in filtered_instances:
instance_data = created_instance.data_to_store()
if instance_data["active"]:
thumbnail_path = thumbnail_paths_by_instance_id.get(
created_instance.id
)
self.create_instance(
context,
instance_data,
created_instance.transient_data,
thumbnail_path
)
thumbnail_path = thumbnail_paths_by_instance_id.get(
created_instance.id
)
self.create_instance(
context,
instance_data,
created_instance.transient_data,
thumbnail_path
)
# Update global data to context
context.data.update(create_context.context_data_to_store())

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

@ -11,7 +11,7 @@ from ayon_core.lib import (
is_oiio_supported,
)
from ayon_core.lib.transcoding import (
convert_colorspace,
oiio_color_convert,
)
from ayon_core.lib.profiles_filtering import filter_profiles
@ -87,6 +87,14 @@ class ExtractOIIOTranscode(publish.Extractor):
new_representations = []
repres = instance.data["representations"]
for idx, repre in enumerate(list(repres)):
# target space, display and view might be defined upstream
# TODO: address https://github.com/ynput/ayon-core/pull/1268#discussion_r2156555474
# Implement upstream logic to handle target_colorspace,
# target_display, target_view in other DCCs
target_colorspace = False
target_display = instance.data.get("colorspaceDisplay")
target_view = instance.data.get("colorspaceView")
self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"]))
if not self._repre_is_valid(repre):
continue
@ -96,6 +104,8 @@ class ExtractOIIOTranscode(publish.Extractor):
colorspace_data = repre["colorspaceData"]
source_colorspace = colorspace_data["colorspace"]
source_display = colorspace_data.get("display")
source_view = colorspace_data.get("view")
config_path = colorspace_data.get("config", {}).get("path")
if not config_path or not os.path.exists(config_path):
self.log.warning("Config file doesn't exist, skipping")
@ -126,7 +136,6 @@ class ExtractOIIOTranscode(publish.Extractor):
transcoding_type = output_def["transcoding_type"]
target_colorspace = view = display = None
# NOTE: we use colorspace_data as the fallback values for
# the target colorspace.
if transcoding_type == "colorspace":
@ -138,18 +147,20 @@ class ExtractOIIOTranscode(publish.Extractor):
colorspace_data.get("colorspace"))
elif transcoding_type == "display_view":
display_view = output_def["display_view"]
view = display_view["view"] or colorspace_data.get("view")
display = (
target_view = (
display_view["view"]
or colorspace_data.get("view"))
target_display = (
display_view["display"]
or colorspace_data.get("display")
)
# both could be already collected by DCC,
# but could be overwritten when transcoding
if view:
new_repre["colorspaceData"]["view"] = view
if display:
new_repre["colorspaceData"]["display"] = display
if target_view:
new_repre["colorspaceData"]["view"] = target_view
if target_display:
new_repre["colorspaceData"]["display"] = target_display
if target_colorspace:
new_repre["colorspaceData"]["colorspace"] = \
target_colorspace
@ -168,16 +179,18 @@ class ExtractOIIOTranscode(publish.Extractor):
new_staging_dir,
output_extension)
convert_colorspace(
input_path,
output_path,
config_path,
source_colorspace,
target_colorspace,
view,
display,
additional_command_args,
self.log
oiio_color_convert(
input_path=input_path,
output_path=output_path,
config_path=config_path,
source_colorspace=source_colorspace,
target_colorspace=target_colorspace,
target_display=target_display,
target_view=target_view,
source_display=source_display,
source_view=source_view,
additional_command_args=additional_command_args,
logger=self.log
)
# cleanup temporary transcoded files

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

@ -15,7 +15,7 @@ from ayon_core.lib import (
path_to_subprocess_arg,
run_subprocess,
)
from ayon_core.lib.transcoding import convert_colorspace
from ayon_core.lib.transcoding import oiio_color_convert
from ayon_core.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS
@ -41,7 +41,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
"photoshop",
"unreal",
"houdini",
"circuit",
"batchdelivery",
]
settings_category = "core"
enabled = False
@ -433,13 +433,15 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
oiio_default_view = display_and_view["view"]
try:
convert_colorspace(
oiio_color_convert(
src_path,
dst_path,
colorspace_data["config"]["path"],
colorspace_data["colorspace"],
display=repre_display or oiio_default_display,
view=repre_view or oiio_default_view,
source_display=colorspace_data.get("display"),
source_view=colorspace_data.get("view"),
target_display=repre_display or oiio_default_display,
target_view=repre_view or oiio_default_view,
target_colorspace=oiio_default_colorspace,
additional_command_args=resolution_arg,
logger=self.log,

View file

@ -5,6 +5,7 @@ import collections
import copy
import time
import warnings
from urllib.parse import urlencode
import ayon_api
@ -36,6 +37,37 @@ class CacheItem:
return time.time() > self._outdate_time
def _get_addons_settings(
studio_bundle_name,
project_bundle_name,
variant,
project_name=None,
):
"""Modified version of `ayon_api.get_addons_settings` function."""
query_values = {
key: value
for key, value in (
("bundle_name", studio_bundle_name),
("variant", variant),
("project_name", project_name),
)
if value
}
if project_bundle_name != studio_bundle_name:
query_values["project_bundle_name"] = project_bundle_name
site_id = ayon_api.get_site_id()
if site_id:
query_values["site_id"] = site_id
response = ayon_api.get(f"settings?{urlencode(query_values)}")
response.raise_for_status()
return {
addon["name"]: addon["settings"]
for addon in response.data["addons"]
}
class _AyonSettingsCache:
use_bundles = None
variant = None
@ -68,53 +100,70 @@ class _AyonSettingsCache:
return _AyonSettingsCache.variant
@classmethod
def _get_bundle_name(cls):
def _get_studio_bundle_name(cls):
bundle_name = os.environ.get("AYON_STUDIO_BUNDLE_NAME")
if bundle_name:
return bundle_name
return os.environ["AYON_BUNDLE_NAME"]
@classmethod
def _get_project_bundle_name(cls):
return os.environ["AYON_BUNDLE_NAME"]
@classmethod
def get_value_by_project(cls, project_name):
cache_item = _AyonSettingsCache.cache_by_project_name[project_name]
if cache_item.is_outdated:
if cls._use_bundles():
value = ayon_api.get_addons_settings(
bundle_name=cls._get_bundle_name(),
cache_item.update_value(
_get_addons_settings(
studio_bundle_name=cls._get_studio_bundle_name(),
project_bundle_name=cls._get_project_bundle_name(),
project_name=project_name,
variant=cls._get_variant()
variant=cls._get_variant(),
)
else:
value = ayon_api.get_addons_settings(project_name)
cache_item.update_value(value)
)
return cache_item.get_value()
@classmethod
def _get_addon_versions_from_bundle(cls):
expected_bundle = cls._get_bundle_name()
studio_bundle_name = cls._get_studio_bundle_name()
project_bundle_name = cls._get_project_bundle_name()
bundles = ayon_api.get_bundles()["bundles"]
bundle = next(
project_bundle = next(
(
bundle
for bundle in bundles
if bundle["name"] == expected_bundle
if bundle["name"] == project_bundle_name
),
None
)
if bundle is not None:
return bundle["addons"]
studio_bundle = None
if studio_bundle_name and project_bundle_name != studio_bundle_name:
studio_bundle = next(
(
bundle
for bundle in bundles
if bundle["name"] == studio_bundle_name
),
None
)
if studio_bundle and project_bundle:
addons = copy.deepcopy(studio_bundle["addons"])
addons.update(project_bundle["addons"])
project_bundle["addons"] = addons
if project_bundle is not None:
return project_bundle["addons"]
return {}
@classmethod
def get_addon_versions(cls):
cache_item = _AyonSettingsCache.addon_versions
if cache_item.is_outdated:
if cls._use_bundles():
addons = cls._get_addon_versions_from_bundle()
else:
settings_data = ayon_api.get_addons_settings(
only_values=False,
variant=cls._get_variant()
)
addons = settings_data["versions"]
cache_item.update_value(addons)
cache_item.update_value(
cls._get_addon_versions_from_bundle()
)
return cache_item.get_value()

View file

@ -97,6 +97,7 @@
},
"publisher": {
"error": "#AA5050",
"disabled": "#5b6779",
"crash": "#FF6432",
"success": "#458056",
"warning": "#ffc671",

View file

@ -1153,6 +1153,10 @@ PixmapButton:disabled {
color: {color:publisher:error};
}
#ListViewProductName[state="disabled"] {
color: {color:publisher:disabled};
}
#PublishInfoFrame {
background: {color:bg};
border-radius: 0.3em;

View file

@ -10,6 +10,7 @@ from .projects import (
PROJECTS_MODEL_SENDER,
FolderTypeItem,
TaskTypeItem,
ProductTypeIconMapping,
)
from .hierarchy import (
FolderItem,
@ -34,6 +35,7 @@ __all__ = (
"PROJECTS_MODEL_SENDER",
"FolderTypeItem",
"TaskTypeItem",
"ProductTypeIconMapping",
"FolderItem",
"TaskItem",

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import contextlib
from abc import ABC, abstractmethod
from typing import Dict, Any
from typing import Any, Optional
from dataclasses import dataclass
import ayon_api
@ -51,7 +51,7 @@ class StatusItem:
self.icon: str = icon
self.state: str = state
def to_data(self) -> Dict[str, Any]:
def to_data(self) -> dict[str, Any]:
return {
"name": self.name,
"color": self.color,
@ -125,16 +125,24 @@ class TaskTypeItem:
icon (str): Icon name in MaterialIcons ("fiber_new").
"""
def __init__(self, name, short, icon):
def __init__(
self,
name: str,
short: str,
icon: str,
color: Optional[str],
):
self.name = name
self.short = short
self.icon = icon
self.color = color
def to_data(self):
return {
"name": self.name,
"short": self.short,
"icon": self.icon,
"color": self.color,
}
@classmethod
@ -147,6 +155,7 @@ class TaskTypeItem:
name=task_type_data["name"],
short=task_type_data["shortName"],
icon=task_type_data["icon"],
color=task_type_data.get("color"),
)
@ -218,6 +227,54 @@ class ProjectItem:
return cls(**data)
class ProductTypeIconMapping:
def __init__(
self,
default: Optional[dict[str, str]] = None,
definitions: Optional[list[dict[str, str]]] = None,
):
self._default = default or {}
self._definitions = definitions or []
self._default_def = None
self._definitions_by_name = None
def get_icon(
self,
product_base_type: Optional[str] = None,
product_type: Optional[str] = None,
) -> dict[str, str]:
defs = self._get_defs_by_name()
icon = defs.get(product_type)
if icon is None:
icon = defs.get(product_base_type)
if icon is None:
icon = self._get_default_def()
return icon.copy()
def _get_default_def(self) -> dict[str, str]:
if self._default_def is None:
self._default_def = {
"type": "material-symbols",
"name": self._default.get("icon", "deployed_code"),
"color": self._default.get("color", "#cccccc"),
}
return self._default_def
def _get_defs_by_name(self) -> dict[str, dict[str, str]]:
if self._definitions_by_name is None:
self._definitions_by_name = {
product_base_type_def["name"]: {
"type": "material-symbols",
"name": product_base_type_def.get("icon", "deployed_code"),
"color": product_base_type_def.get("color", "#cccccc"),
}
for product_base_type_def in self._definitions
}
return self._definitions_by_name
def _get_project_items_from_entitiy(
projects: list[dict[str, Any]]
) -> list[ProjectItem]:
@ -242,6 +299,9 @@ class ProjectsModel(object):
self._projects_by_name = NestedCacheItem(
levels=1, default_factory=list
)
self._product_type_icons_mapping = NestedCacheItem(
levels=1, default_factory=ProductTypeIconMapping
)
self._project_statuses_cache = {}
self._folder_types_cache = {}
self._task_types_cache = {}
@ -255,6 +315,7 @@ class ProjectsModel(object):
self._task_types_cache = {}
self._projects_cache.reset()
self._projects_by_name.reset()
self._product_type_icons_mapping.reset()
def refresh(self):
"""Refresh project items.
@ -390,6 +451,27 @@ class ProjectsModel(object):
self._task_type_items_getter,
)
def get_product_type_icons_mapping(
self, project_name: Optional[str]
) -> ProductTypeIconMapping:
cache = self._product_type_icons_mapping[project_name]
if cache.is_valid:
return cache.get_data()
project_entity = self.get_project_entity(project_name)
icons_mapping = ProductTypeIconMapping()
if project_entity:
product_base_types = (
project_entity["config"].get("productBaseTypes", {})
)
icons_mapping = ProductTypeIconMapping(
product_base_types.get("default"),
product_base_types.get("definitions")
)
cache.update_data(icons_mapping)
return icons_mapping
def _get_project_items(
self, project_name, sender, item_type, cache_obj, getter
):

View file

@ -1,9 +0,0 @@
from .window import (
show,
CreatorWindow
)
__all__ = (
"show",
"CreatorWindow"
)

View file

@ -1,8 +0,0 @@
from qtpy import QtCore
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1
ITEM_ID_ROLE = QtCore.Qt.UserRole + 2
SEPARATOR = "---"
SEPARATORS = {"---", "---separator---"}

View file

@ -1,61 +0,0 @@
import uuid
from qtpy import QtGui, QtCore
from ayon_core.pipeline import discover_legacy_creator_plugins
from . constants import (
PRODUCT_TYPE_ROLE,
ITEM_ID_ROLE
)
class CreatorsModel(QtGui.QStandardItemModel):
def __init__(self, *args, **kwargs):
super(CreatorsModel, self).__init__(*args, **kwargs)
self._creators_by_id = {}
def reset(self):
# TODO change to refresh when clearing is not needed
self.clear()
self._creators_by_id = {}
items = []
creators = discover_legacy_creator_plugins()
for creator in creators:
if not creator.enabled:
continue
item_id = str(uuid.uuid4())
self._creators_by_id[item_id] = creator
label = creator.label or creator.product_type
item = QtGui.QStandardItem(label)
item.setEditable(False)
item.setData(item_id, ITEM_ID_ROLE)
item.setData(creator.product_type, PRODUCT_TYPE_ROLE)
items.append(item)
if not items:
item = QtGui.QStandardItem("No registered create plugins")
item.setEnabled(False)
item.setData(False, QtCore.Qt.ItemIsEnabled)
items.append(item)
items.sort(key=lambda item: item.text())
self.invisibleRootItem().appendRows(items)
def get_creator_by_id(self, item_id):
return self._creators_by_id.get(item_id)
def get_indexes_by_product_type(self, product_type):
indexes = []
for row in range(self.rowCount()):
index = self.index(row, 0)
item_id = index.data(ITEM_ID_ROLE)
creator_plugin = self._creators_by_id.get(item_id)
if creator_plugin and (
creator_plugin.label.lower() == product_type.lower()
or creator_plugin.product_type.lower() == product_type.lower()
):
indexes.append(index)
return indexes

View file

@ -1,275 +0,0 @@
import re
import inspect
from qtpy import QtWidgets, QtCore, QtGui
import qtawesome
from ayon_core.pipeline.create import PRODUCT_NAME_ALLOWED_SYMBOLS
from ayon_core.tools.utils import ErrorMessageBox
if hasattr(QtGui, "QRegularExpressionValidator"):
RegularExpressionValidatorClass = QtGui.QRegularExpressionValidator
RegularExpressionClass = QtCore.QRegularExpression
else:
RegularExpressionValidatorClass = QtGui.QRegExpValidator
RegularExpressionClass = QtCore.QRegExp
class CreateErrorMessageBox(ErrorMessageBox):
def __init__(
self,
product_type,
product_name,
folder_path,
exc_msg,
formatted_traceback,
parent
):
self._product_type = product_type
self._product_name = product_name
self._folder_path = folder_path
self._exc_msg = exc_msg
self._formatted_traceback = formatted_traceback
super(CreateErrorMessageBox, self).__init__("Creation failed", parent)
def _create_top_widget(self, parent_widget):
label_widget = QtWidgets.QLabel(parent_widget)
label_widget.setText(
"<span style='font-size:18pt;'>Failed to create</span>"
)
return label_widget
def _get_report_data(self):
report_message = (
"Failed to create Product: \"{product_name}\""
" Type: \"{product_type}\""
" in Folder: \"{folder_path}\""
"\n\nError: {message}"
).format(
product_name=self._product_name,
product_type=self._product_type,
folder_path=self._folder_path,
message=self._exc_msg
)
if self._formatted_traceback:
report_message += "\n\n{}".format(self._formatted_traceback)
return [report_message]
def _create_content(self, content_layout):
item_name_template = (
"<span style='font-weight:bold;'>{}:</span> {{}}<br>"
"<span style='font-weight:bold;'>{}:</span> {{}}<br>"
"<span style='font-weight:bold;'>{}:</span> {{}}<br>"
).format(
"Product type",
"Product name",
"Folder"
)
exc_msg_template = "<span style='font-weight:bold'>{}</span>"
line = self._create_line()
content_layout.addWidget(line)
item_name_widget = QtWidgets.QLabel(self)
item_name_widget.setText(
item_name_template.format(
self._product_type, self._product_name, self._folder_path
)
)
content_layout.addWidget(item_name_widget)
message_label_widget = QtWidgets.QLabel(self)
message_label_widget.setText(
exc_msg_template.format(self.convert_text_for_html(self._exc_msg))
)
content_layout.addWidget(message_label_widget)
if self._formatted_traceback:
line_widget = self._create_line()
tb_widget = self._create_traceback_widget(
self._formatted_traceback
)
content_layout.addWidget(line_widget)
content_layout.addWidget(tb_widget)
class ProductNameValidator(RegularExpressionValidatorClass):
invalid = QtCore.Signal(set)
pattern = "^[{}]*$".format(PRODUCT_NAME_ALLOWED_SYMBOLS)
def __init__(self):
reg = RegularExpressionClass(self.pattern)
super(ProductNameValidator, self).__init__(reg)
def validate(self, text, pos):
results = super(ProductNameValidator, self).validate(text, pos)
if results[0] == RegularExpressionValidatorClass.Invalid:
self.invalid.emit(self.invalid_chars(text))
return results
def invalid_chars(self, text):
invalid = set()
re_valid = re.compile(self.pattern)
for char in text:
if char == " ":
invalid.add("' '")
continue
if not re_valid.match(char):
invalid.add(char)
return invalid
class VariantLineEdit(QtWidgets.QLineEdit):
report = QtCore.Signal(str)
colors = {
"empty": (QtGui.QColor("#78879b"), ""),
"exists": (QtGui.QColor("#4E76BB"), "border-color: #4E76BB;"),
"new": (QtGui.QColor("#7AAB8F"), "border-color: #7AAB8F;"),
}
def __init__(self, *args, **kwargs):
super(VariantLineEdit, self).__init__(*args, **kwargs)
validator = ProductNameValidator()
self.setValidator(validator)
self.setToolTip("Only alphanumeric characters (A-Z a-z 0-9), "
"'_' and '.' are allowed.")
self._status_color = self.colors["empty"][0]
anim = QtCore.QPropertyAnimation()
anim.setTargetObject(self)
anim.setPropertyName(b"status_color")
anim.setEasingCurve(QtCore.QEasingCurve.InCubic)
anim.setDuration(300)
anim.setStartValue(QtGui.QColor("#C84747")) # `Invalid` status color
self.animation = anim
validator.invalid.connect(self.on_invalid)
def on_invalid(self, invalid):
message = "Invalid character: %s" % ", ".join(invalid)
self.report.emit(message)
self.animation.stop()
self.animation.start()
def as_empty(self):
self._set_border("empty")
self.report.emit("Empty product name ..")
def as_exists(self):
self._set_border("exists")
self.report.emit("Existing product, appending next version.")
def as_new(self):
self._set_border("new")
self.report.emit("New product, creating first version.")
def _set_border(self, status):
qcolor, style = self.colors[status]
self.animation.setEndValue(qcolor)
self.setStyleSheet(style)
def _get_status_color(self):
return self._status_color
def _set_status_color(self, color):
self._status_color = color
self.setStyleSheet("border-color: %s;" % color.name())
status_color = QtCore.Property(
QtGui.QColor, _get_status_color, _set_status_color
)
class ProductTypeDescriptionWidget(QtWidgets.QWidget):
"""A product type description widget.
Shows a product type icon, name and a help description.
Used in creator header.
_______________________
| ____ |
| |icon| PRODUCT TYPE |
| |____| help |
|_______________________|
"""
SIZE = 35
def __init__(self, parent=None):
super(ProductTypeDescriptionWidget, self).__init__(parent=parent)
icon_label = QtWidgets.QLabel(self)
icon_label.setSizePolicy(
QtWidgets.QSizePolicy.Maximum,
QtWidgets.QSizePolicy.Maximum
)
# Add 4 pixel padding to avoid icon being cut off
icon_label.setFixedWidth(self.SIZE + 4)
icon_label.setFixedHeight(self.SIZE + 4)
label_layout = QtWidgets.QVBoxLayout()
label_layout.setSpacing(0)
product_type_label = QtWidgets.QLabel(self)
product_type_label.setObjectName("CreatorProductTypeLabel")
product_type_label.setAlignment(
QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft
)
help_label = QtWidgets.QLabel(self)
help_label.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft)
label_layout.addWidget(product_type_label)
label_layout.addWidget(help_label)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
layout.addWidget(icon_label)
layout.addLayout(label_layout)
self._help_label = help_label
self._product_type_label = product_type_label
self._icon_label = icon_label
def set_item(self, creator_plugin):
"""Update elements to display information of a product type item.
Args:
creator_plugin (dict): A product type item as registered with
name, help and icon.
Returns:
None
"""
if not creator_plugin:
self._icon_label.setPixmap(None)
self._product_type_label.setText("")
self._help_label.setText("")
return
# Support a font-awesome icon
icon_name = getattr(creator_plugin, "icon", None) or "info-circle"
try:
icon = qtawesome.icon("fa.{}".format(icon_name), color="white")
pixmap = icon.pixmap(self.SIZE, self.SIZE)
except Exception:
print("BUG: Couldn't load icon \"fa.{}\"".format(str(icon_name)))
# Create transparent pixmap
pixmap = QtGui.QPixmap()
pixmap.fill(QtCore.Qt.transparent)
pixmap = pixmap.scaled(self.SIZE, self.SIZE)
# Parse a clean line from the Creator's docstring
docstring = inspect.getdoc(creator_plugin)
creator_help = docstring.splitlines()[0] if docstring else ""
self._icon_label.setPixmap(pixmap)
self._product_type_label.setText(creator_plugin.product_type)
self._help_label.setText(creator_help)

View file

@ -1,508 +0,0 @@
import sys
import traceback
import re
import ayon_api
from qtpy import QtWidgets, QtCore
from ayon_core import style
from ayon_core.settings import get_current_project_settings
from ayon_core.tools.utils.lib import qt_app_context
from ayon_core.pipeline import (
get_current_project_name,
get_current_folder_path,
get_current_task_name,
)
from ayon_core.pipeline.create import (
PRODUCT_NAME_ALLOWED_SYMBOLS,
legacy_create,
CreatorError,
)
from .model import CreatorsModel
from .widgets import (
CreateErrorMessageBox,
VariantLineEdit,
ProductTypeDescriptionWidget
)
from .constants import (
ITEM_ID_ROLE,
SEPARATOR,
SEPARATORS
)
module = sys.modules[__name__]
module.window = None
class CreatorWindow(QtWidgets.QDialog):
def __init__(self, parent=None):
super(CreatorWindow, self).__init__(parent)
self.setWindowTitle("Instance Creator")
self.setFocusPolicy(QtCore.Qt.StrongFocus)
if not parent:
self.setWindowFlags(
self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint
)
creator_info = ProductTypeDescriptionWidget(self)
creators_model = CreatorsModel()
creators_proxy = QtCore.QSortFilterProxyModel()
creators_proxy.setSourceModel(creators_model)
creators_view = QtWidgets.QListView(self)
creators_view.setObjectName("CreatorsView")
creators_view.setModel(creators_proxy)
folder_path_input = QtWidgets.QLineEdit(self)
variant_input = VariantLineEdit(self)
product_name_input = QtWidgets.QLineEdit(self)
product_name_input.setEnabled(False)
variants_btn = QtWidgets.QPushButton()
variants_btn.setFixedWidth(18)
variants_menu = QtWidgets.QMenu(variants_btn)
variants_btn.setMenu(variants_menu)
name_layout = QtWidgets.QHBoxLayout()
name_layout.addWidget(variant_input)
name_layout.addWidget(variants_btn)
name_layout.setSpacing(3)
name_layout.setContentsMargins(0, 0, 0, 0)
body_layout = QtWidgets.QVBoxLayout()
body_layout.setContentsMargins(0, 0, 0, 0)
body_layout.addWidget(creator_info, 0)
body_layout.addWidget(QtWidgets.QLabel("Product type", self), 0)
body_layout.addWidget(creators_view, 1)
body_layout.addWidget(QtWidgets.QLabel("Folder path", self), 0)
body_layout.addWidget(folder_path_input, 0)
body_layout.addWidget(QtWidgets.QLabel("Product name", self), 0)
body_layout.addLayout(name_layout, 0)
body_layout.addWidget(product_name_input, 0)
useselection_chk = QtWidgets.QCheckBox("Use selection", self)
useselection_chk.setCheckState(QtCore.Qt.Checked)
create_btn = QtWidgets.QPushButton("Create", self)
# Need to store error_msg to prevent garbage collection
msg_label = QtWidgets.QLabel(self)
footer_layout = QtWidgets.QVBoxLayout()
footer_layout.addWidget(create_btn, 0)
footer_layout.addWidget(msg_label, 0)
footer_layout.setContentsMargins(0, 0, 0, 0)
layout = QtWidgets.QVBoxLayout(self)
layout.addLayout(body_layout, 1)
layout.addWidget(useselection_chk, 0, QtCore.Qt.AlignLeft)
layout.addLayout(footer_layout, 0)
msg_timer = QtCore.QTimer()
msg_timer.setSingleShot(True)
msg_timer.setInterval(5000)
validation_timer = QtCore.QTimer()
validation_timer.setSingleShot(True)
validation_timer.setInterval(300)
msg_timer.timeout.connect(self._on_msg_timer)
validation_timer.timeout.connect(self._on_validation_timer)
create_btn.clicked.connect(self._on_create)
variant_input.returnPressed.connect(self._on_create)
variant_input.textChanged.connect(self._on_data_changed)
variant_input.report.connect(self.echo)
folder_path_input.textChanged.connect(self._on_data_changed)
creators_view.selectionModel().currentChanged.connect(
self._on_selection_changed
)
# Store valid states and
self._is_valid = False
create_btn.setEnabled(self._is_valid)
self._first_show = True
# Message dialog when something goes wrong during creation
self._message_dialog = None
self._creator_info = creator_info
self._create_btn = create_btn
self._useselection_chk = useselection_chk
self._variant_input = variant_input
self._product_name_input = product_name_input
self._folder_path_input = folder_path_input
self._creators_model = creators_model
self._creators_proxy = creators_proxy
self._creators_view = creators_view
self._variants_btn = variants_btn
self._variants_menu = variants_menu
self._msg_label = msg_label
self._validation_timer = validation_timer
self._msg_timer = msg_timer
# Defaults
self.resize(300, 500)
variant_input.setFocus()
def _set_valid_state(self, valid):
if self._is_valid == valid:
return
self._is_valid = valid
self._create_btn.setEnabled(valid)
def _build_menu(self, default_names=None):
"""Create optional predefined variants.
Args:
default_names(list): all predefined names
Returns:
None
"""
if not default_names:
default_names = []
menu = self._variants_menu
button = self._variants_btn
# Get and destroy the action group
group = button.findChild(QtWidgets.QActionGroup)
if group:
group.deleteLater()
state = any(default_names)
button.setEnabled(state)
if state is False:
return
# Build new action group
group = QtWidgets.QActionGroup(button)
for name in default_names:
if name in SEPARATORS:
menu.addSeparator()
continue
action = group.addAction(name)
menu.addAction(action)
group.triggered.connect(self._on_action_clicked)
def _on_action_clicked(self, action):
self._variant_input.setText(action.text())
def _on_data_changed(self, *args):
# Set invalid state until it's reconfirmed to be valid by the
# scheduled callback so any form of creation is held back until
# valid again
self._set_valid_state(False)
self._validation_timer.start()
def _on_validation_timer(self):
index = self._creators_view.currentIndex()
item_id = index.data(ITEM_ID_ROLE)
creator_plugin = self._creators_model.get_creator_by_id(item_id)
user_input_text = self._variant_input.text()
folder_path = self._folder_path_input.text()
# Early exit if no folder path
if not folder_path:
self._build_menu()
self.echo("Folder is required ..")
self._set_valid_state(False)
return
project_name = get_current_project_name()
folder_entity = None
if creator_plugin:
# Get the folder from the database which match with the name
folder_entity = ayon_api.get_folder_by_path(
project_name, folder_path, fields={"id"}
)
# Get plugin
if not folder_entity or not creator_plugin:
self._build_menu()
if not creator_plugin:
self.echo("No registered product types ..")
else:
self.echo("Folder '{}' not found ..".format(folder_path))
self._set_valid_state(False)
return
folder_id = folder_entity["id"]
task_name = get_current_task_name()
task_entity = ayon_api.get_task_by_name(
project_name, folder_id, task_name
)
# Calculate product name with Creator plugin
product_name = creator_plugin.get_product_name(
project_name, folder_entity, task_entity, user_input_text
)
# Force replacement of prohibited symbols
# QUESTION should Creator care about this and here should be only
# validated with schema regex?
# Allow curly brackets in product name for dynamic keys
curly_left = "__cbl__"
curly_right = "__cbr__"
tmp_product_name = (
product_name
.replace("{", curly_left)
.replace("}", curly_right)
)
# Replace prohibited symbols
tmp_product_name = re.sub(
"[^{}]+".format(PRODUCT_NAME_ALLOWED_SYMBOLS),
"",
tmp_product_name
)
product_name = (
tmp_product_name
.replace(curly_left, "{")
.replace(curly_right, "}")
)
self._product_name_input.setText(product_name)
# Get all products of the current folder
product_entities = ayon_api.get_products(
project_name, folder_ids={folder_id}, fields={"name"}
)
existing_product_names = {
product_entity["name"]
for product_entity in product_entities
}
existing_product_names_low = set(
_name.lower()
for _name in existing_product_names
)
# Defaults to dropdown
defaults = []
# Check if Creator plugin has set defaults
if (
creator_plugin.defaults
and isinstance(creator_plugin.defaults, (list, tuple, set))
):
defaults = list(creator_plugin.defaults)
# Replace
compare_regex = re.compile(re.sub(
user_input_text, "(.+)", product_name, flags=re.IGNORECASE
))
variant_hints = set()
if user_input_text:
for _name in existing_product_names:
_result = compare_regex.search(_name)
if _result:
variant_hints |= set(_result.groups())
if variant_hints:
if defaults:
defaults.append(SEPARATOR)
defaults.extend(variant_hints)
self._build_menu(defaults)
# Indicate product existence
if not user_input_text:
self._variant_input.as_empty()
elif product_name.lower() in existing_product_names_low:
# validate existence of product name with lowered text
# - "renderMain" vs. "rensermain" mean same path item for
# windows
self._variant_input.as_exists()
else:
self._variant_input.as_new()
# Update the valid state
valid = product_name.strip() != ""
self._set_valid_state(valid)
def _on_selection_changed(self, old_idx, new_idx):
index = self._creators_view.currentIndex()
item_id = index.data(ITEM_ID_ROLE)
creator_plugin = self._creators_model.get_creator_by_id(item_id)
self._creator_info.set_item(creator_plugin)
if creator_plugin is None:
return
default = None
if hasattr(creator_plugin, "get_default_variant"):
default = creator_plugin.get_default_variant()
if not default:
if (
creator_plugin.defaults
and isinstance(creator_plugin.defaults, list)
):
default = creator_plugin.defaults[0]
else:
default = "Default"
self._variant_input.setText(default)
self._on_data_changed()
def keyPressEvent(self, event):
"""Custom keyPressEvent.
Override keyPressEvent to do nothing so that Maya's panels won't
take focus when pressing "SHIFT" whilst mouse is over viewport or
outliner. This way users don't accidentally perform Maya commands
whilst trying to name an instance.
"""
pass
def showEvent(self, event):
super(CreatorWindow, self).showEvent(event)
if self._first_show:
self._first_show = False
self.setStyleSheet(style.load_stylesheet())
def refresh(self):
self._folder_path_input.setText(get_current_folder_path())
self._creators_model.reset()
product_types_smart_select = (
get_current_project_settings()
["core"]
["tools"]
["creator"]
["product_types_smart_select"]
)
current_index = None
product_type = None
task_name = get_current_task_name() or None
lowered_task_name = task_name.lower()
if task_name:
for smart_item in product_types_smart_select:
_low_task_names = {
name.lower() for name in smart_item["task_names"]
}
for _task_name in _low_task_names:
if _task_name in lowered_task_name:
product_type = smart_item["name"]
break
if product_type:
break
if product_type:
indexes = self._creators_model.get_indexes_by_product_type(
product_type
)
if indexes:
index = indexes[0]
current_index = self._creators_proxy.mapFromSource(index)
if current_index is None or not current_index.isValid():
current_index = self._creators_proxy.index(0, 0)
self._creators_view.setCurrentIndex(current_index)
def _on_create(self):
# Do not allow creation in an invalid state
if not self._is_valid:
return
index = self._creators_view.currentIndex()
item_id = index.data(ITEM_ID_ROLE)
creator_plugin = self._creators_model.get_creator_by_id(item_id)
if creator_plugin is None:
return
product_name = self._product_name_input.text()
folder_path = self._folder_path_input.text()
use_selection = self._useselection_chk.isChecked()
variant = self._variant_input.text()
error_info = None
try:
legacy_create(
creator_plugin,
product_name,
folder_path,
options={"useSelection": use_selection},
data={"variant": variant}
)
except CreatorError as exc:
self.echo("Creator error: {}".format(str(exc)))
error_info = (str(exc), None)
except Exception as exc:
self.echo("Program error: %s" % str(exc))
exc_type, exc_value, exc_traceback = sys.exc_info()
formatted_traceback = "".join(traceback.format_exception(
exc_type, exc_value, exc_traceback
))
error_info = (str(exc), formatted_traceback)
if error_info:
box = CreateErrorMessageBox(
creator_plugin.product_type,
product_name,
folder_path,
*error_info,
parent=self
)
box.show()
# Store dialog so is not garbage collected before is shown
self._message_dialog = box
else:
self.echo("Created %s .." % product_name)
def _on_msg_timer(self):
self._msg_label.setText("")
def echo(self, message):
self._msg_label.setText(str(message))
self._msg_timer.start()
def show(parent=None):
"""Display product creator GUI
Arguments:
debug (bool, optional): Run loader in debug-mode,
defaults to False
parent (QtCore.QObject, optional): When provided parent the interface
to this QObject.
"""
try:
module.window.close()
del module.window
except (AttributeError, RuntimeError):
pass
with qt_app_context():
window = CreatorWindow(parent)
window.refresh()
window.show()
module.window = window
# Pull window to the front.
module.window.raise_()
module.window.activateWindow()

View file

@ -517,7 +517,12 @@ class ActionsModel:
uri = payload["uri"]
else:
uri = data["uri"]
run_detached_ayon_launcher_process(uri)
# Remove bundles from environment variables
env = os.environ.copy()
env.pop("AYON_BUNDLE_NAME", None)
env.pop("AYON_STUDIO_BUNDLE_NAME", None)
run_detached_ayon_launcher_process(uri, env=env)
elif response_type in ("query", "navigate"):
response.error_message = (

View file

@ -9,7 +9,11 @@ from ayon_core.lib.attribute_definitions import (
deserialize_attr_defs,
serialize_attr_defs,
)
from ayon_core.tools.common_models import TaskItem, TagItem
from ayon_core.tools.common_models import (
TaskItem,
TagItem,
ProductTypeIconMapping,
)
class ProductTypeItem:
@ -78,7 +82,6 @@ class ProductItem:
product_type (str): Product type.
product_name (str): Product name.
product_icon (dict[str, Any]): Product icon definition.
product_type_icon (dict[str, Any]): Product type icon definition.
product_in_scene (bool): Is product in scene (only when used in DCC).
group_name (str): Group name.
folder_id (str): Folder id.
@ -93,8 +96,6 @@ class ProductItem:
product_base_type: str,
product_name: str,
product_icon: dict[str, Any],
product_type_icon: dict[str, Any],
product_base_type_icon: dict[str, Any],
group_name: str,
folder_id: str,
folder_label: str,
@ -106,8 +107,6 @@ class ProductItem:
self.product_base_type = product_base_type
self.product_name = product_name
self.product_icon = product_icon
self.product_type_icon = product_type_icon
self.product_base_type_icon = product_base_type_icon
self.product_in_scene = product_in_scene
self.group_name = group_name
self.folder_id = folder_id
@ -121,8 +120,6 @@ class ProductItem:
"product_base_type": self.product_base_type,
"product_name": self.product_name,
"product_icon": self.product_icon,
"product_type_icon": self.product_type_icon,
"product_base_type_icon": self.product_base_type_icon,
"product_in_scene": self.product_in_scene,
"group_name": self.group_name,
"folder_id": self.folder_id,
@ -499,8 +496,8 @@ class BackendLoaderController(_BaseLoaderController):
topic (str): Event topic name.
data (Optional[dict[str, Any]]): Event data.
source (Optional[str]): Event source.
"""
"""
pass
@abstractmethod
@ -509,8 +506,20 @@ class BackendLoaderController(_BaseLoaderController):
Returns:
set[str]: Set of loaded product ids.
"""
"""
pass
@abstractmethod
def get_product_type_icons_mapping(
self, project_name: Optional[str]
) -> ProductTypeIconMapping:
"""Product type icons mapping.
Returns:
ProductTypeIconMapping: Product type icons mapping.
"""
pass

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import logging
import uuid
from typing import Optional
import ayon_api
@ -16,6 +17,7 @@ from ayon_core.tools.common_models import (
HierarchyModel,
ThumbnailsModel,
TagItem,
ProductTypeIconMapping,
)
from .abstract import (
@ -198,6 +200,13 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
project_name, sender
)
def get_product_type_icons_mapping(
self, project_name: Optional[str]
) -> ProductTypeIconMapping:
return self._projects_model.get_product_type_icons_mapping(
project_name
)
def get_folder_items(self, project_name, sender=None):
return self._hierarchy_model.get_folder_items(project_name, sender)

View file

@ -9,9 +9,9 @@ import arrow
import ayon_api
from ayon_api.operations import OperationsSession
from ayon_core.lib import NestedCacheItem
from ayon_core.style import get_default_entity_icon_color
from ayon_core.tools.common_models import ProductTypeIconMapping
from ayon_core.tools.loader.abstract import (
ProductTypeItem,
ProductBaseTypeItem,
@ -21,8 +21,11 @@ from ayon_core.tools.loader.abstract import (
)
if TYPE_CHECKING:
from ayon_api.typing import ProductBaseTypeDict, ProductDict, VersionDict
from ayon_api.typing import (
ProductBaseTypeDict,
ProductDict,
VersionDict,
)
PRODUCTS_MODEL_SENDER = "products.model"
@ -84,42 +87,18 @@ def version_item_from_entity(version):
def product_item_from_entity(
product_entity: ProductDict,
version_entities,
product_type_items_by_name: dict[str, ProductTypeItem],
product_base_type_items_by_name: dict[str, ProductBaseTypeItem],
folder_label,
icons_mapping,
product_in_scene,
):
product_attribs = product_entity["attrib"]
group = product_attribs.get("productGroup")
product_type = product_entity["productType"]
product_type_item = product_type_items_by_name.get(product_type)
# NOTE This is needed for cases when products were not created on server
# using api functions. In that case product type item may not be
# available and we need to create a default.
if product_type_item is None:
product_type_item = create_default_product_type_item(product_type)
# Cache the item for future use
product_type_items_by_name[product_type] = product_type_item
product_base_type = product_entity.get("productBaseType")
product_base_type_item = product_base_type_items_by_name.get(
product_base_type)
# Same as for product type item above. Not sure if this is still needed
# though.
if product_base_type_item is None:
product_base_type_item = create_default_product_base_type_item(
product_base_type)
# Cache the item for future use
product_base_type_items_by_name[product_base_type] = (
product_base_type_item)
product_type_icon = product_type_item.icon
product_base_type_icon = product_base_type_item.icon
product_icon = {
"type": "awesome-font",
"name": "fa.file-o",
"color": get_default_entity_icon_color(),
}
product_icon = icons_mapping.get_icon(
product_base_type, product_type
)
version_items = {
version_entity["id"]: version_item_from_entity(version_entity)
for version_entity in version_entities
@ -131,8 +110,6 @@ def product_item_from_entity(
product_base_type=product_base_type,
product_name=product_entity["name"],
product_icon=product_icon,
product_type_icon=product_type_icon,
product_base_type_icon=product_base_type_icon,
product_in_scene=product_in_scene,
group_name=group,
folder_id=product_entity["folderId"],
@ -141,22 +118,8 @@ def product_item_from_entity(
)
def product_type_item_from_data(
product_type_data: ProductDict) -> ProductTypeItem:
# TODO implement icon implementation
# icon = product_type_data["icon"]
# color = product_type_data["color"]
icon = {
"type": "awesome-font",
"name": "fa.folder",
"color": "#0091B2",
}
# TODO implement checked logic
return ProductTypeItem(product_type_data["name"], icon)
def product_base_type_item_from_data(
product_base_type_data: ProductBaseTypeDict
product_base_type_data: ProductBaseTypeDict
) -> ProductBaseTypeItem:
"""Create product base type item from data.
@ -174,34 +137,8 @@ def product_base_type_item_from_data(
}
return ProductBaseTypeItem(
name=product_base_type_data["name"],
icon=icon)
def create_default_product_type_item(product_type: str) -> ProductTypeItem:
icon = {
"type": "awesome-font",
"name": "fa.folder",
"color": "#0091B2",
}
return ProductTypeItem(product_type, icon)
def create_default_product_base_type_item(
product_base_type: str) -> ProductBaseTypeItem:
"""Create default product base type item.
Args:
product_base_type (str): Product base type name.
Returns:
ProductBaseTypeItem: Default product base type item.
"""
icon = {
"type": "awesome-font",
"name": "fa.folder",
"color": "#0091B2",
}
return ProductBaseTypeItem(product_base_type, icon)
icon=icon
)
class ProductsModel:
@ -247,7 +184,9 @@ class ProductsModel:
self._product_items_cache.reset()
self._repre_items_cache.reset()
def get_product_type_items(self, project_name):
def get_product_type_items(
self, project_name: Optional[str]
) -> list[ProductTypeItem]:
"""Product type items for project.
Args:
@ -255,25 +194,33 @@ class ProductsModel:
Returns:
list[ProductTypeItem]: Product type items.
"""
"""
if not project_name:
return []
cache = self._product_type_items_cache[project_name]
if not cache.is_valid:
icons_mapping = self._get_product_type_icons(project_name)
product_types = ayon_api.get_project_product_types(project_name)
cache.update_data([
product_type_item_from_data(product_type)
ProductTypeItem(
product_type["name"],
icons_mapping.get_icon(product_type=product_type["name"]),
)
for product_type in product_types
])
return cache.get_data()
def get_product_base_type_items(
self,
project_name: Optional[str]) -> list[ProductBaseTypeItem]:
self, project_name: Optional[str]
) -> list[ProductBaseTypeItem]:
"""Product base type items for the project.
Notes:
This will be used for filtering product types in UI when
product base types are fully implemented.
Args:
project_name (optional, str): Project name.
@ -286,6 +233,7 @@ class ProductsModel:
cache = self._product_base_type_items_cache[project_name]
if not cache.is_valid:
icons_mapping = self._get_product_type_icons(project_name)
product_base_types = []
# TODO add temp implementation here when it is actually
# implemented and available on server.
@ -294,7 +242,10 @@ class ProductsModel:
project_name
)
cache.update_data([
product_base_type_item_from_data(product_base_type)
ProductBaseTypeItem(
product_base_type["name"],
icons_mapping.get_icon(product_base_type["name"]),
)
for product_base_type in product_base_types
])
return cache.get_data()
@ -511,6 +462,11 @@ class ProductsModel:
PRODUCTS_MODEL_SENDER
)
def _get_product_type_icons(
self, project_name: Optional[str]
) -> ProductTypeIconMapping:
return self._controller.get_product_type_icons_mapping(project_name)
def _get_product_items_by_id(self, project_name, product_ids):
product_item_by_id = self._product_item_by_id[project_name]
missing_product_ids = set()
@ -524,7 +480,7 @@ class ProductsModel:
output.update(
self._query_product_items_by_ids(
project_name, missing_product_ids
project_name, product_ids=missing_product_ids
)
)
return output
@ -553,36 +509,18 @@ class ProductsModel:
products: Iterable[ProductDict],
versions: Iterable[VersionDict],
folder_items=None,
product_type_items=None,
product_base_type_items: Optional[Iterable[ProductBaseTypeItem]] = None
):
if folder_items is None:
folder_items = self._controller.get_folder_items(project_name)
if product_type_items is None:
product_type_items = self.get_product_type_items(project_name)
if product_base_type_items is None:
product_base_type_items = self.get_product_base_type_items(
project_name
)
loaded_product_ids = self._controller.get_loaded_product_ids()
versions_by_product_id = collections.defaultdict(list)
for version in versions:
versions_by_product_id[version["productId"]].append(version)
product_type_items_by_name = {
product_type_item.name: product_type_item
for product_type_item in product_type_items
}
product_base_type_items_by_name: dict[str, ProductBaseTypeItem] = {
product_base_type_item.name: product_base_type_item
for product_base_type_item in product_base_type_items
}
output: dict[str, ProductItem] = {}
icons_mapping = self._get_product_type_icons(project_name)
for product in products:
product_id = product["id"]
folder_id = product["folderId"]
@ -595,9 +533,8 @@ class ProductsModel:
product_item = product_item_from_entity(
product,
versions,
product_type_items_by_name,
product_base_type_items_by_name,
folder_item.label,
icons_mapping,
product_id in loaded_product_ids,
)
output[product_id] = product_item

View file

@ -17,7 +17,6 @@ PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6
PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8
PRODUCT_BASE_TYPE_ROLE = QtCore.Qt.UserRole + 9
PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 10
PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 11
VERSION_ID_ROLE = QtCore.Qt.UserRole + 12
VERSION_HERO_ROLE = QtCore.Qt.UserRole + 13
@ -228,10 +227,7 @@ class ProductsModel(QtGui.QStandardItemModel):
return super().data(index, role)
if role == QtCore.Qt.DecorationRole:
if col == 1:
role = PRODUCT_TYPE_ICON_ROLE
else:
return None
return None
if (
role == VERSION_NAME_EDIT_ROLE
@ -455,7 +451,6 @@ class ProductsModel(QtGui.QStandardItemModel):
model_item = QtGui.QStandardItem(product_item.product_name)
model_item.setEditable(False)
icon = get_qt_icon(product_item.product_icon)
product_type_icon = get_qt_icon(product_item.product_type_icon)
model_item.setColumnCount(self.columnCount())
model_item.setData(icon, QtCore.Qt.DecorationRole)
model_item.setData(product_id, PRODUCT_ID_ROLE)
@ -464,7 +459,6 @@ class ProductsModel(QtGui.QStandardItemModel):
product_item.product_base_type, PRODUCT_BASE_TYPE_ROLE
)
model_item.setData(product_item.product_type, PRODUCT_TYPE_ROLE)
model_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE)
model_item.setData(product_item.folder_id, FOLDER_ID_ROLE)
self._product_items_by_id[product_id] = product_item

View file

@ -13,7 +13,7 @@ from typing import (
)
from ayon_core.lib import AbstractAttrDef
from ayon_core.host import HostBase
from ayon_core.host import AbstractHost
from ayon_core.pipeline.create import (
CreateContext,
ConvertorItem,
@ -176,7 +176,7 @@ class AbstractPublisherBackend(AbstractPublisherCommon):
pass
@abstractmethod
def get_host(self) -> HostBase:
def get_host(self) -> AbstractHost:
pass
@abstractmethod

View file

@ -219,6 +219,8 @@ class InstanceItem:
is_active: bool,
is_mandatory: bool,
has_promised_context: bool,
parent_instance_id: Optional[str],
parent_flags: int,
):
self._instance_id: str = instance_id
self._creator_identifier: str = creator_identifier
@ -232,6 +234,8 @@ class InstanceItem:
self._is_active: bool = is_active
self._is_mandatory: bool = is_mandatory
self._has_promised_context: bool = has_promised_context
self._parent_instance_id: Optional[str] = parent_instance_id
self._parent_flags: int = parent_flags
@property
def id(self):
@ -261,6 +265,14 @@ class InstanceItem:
def has_promised_context(self):
return self._has_promised_context
@property
def parent_instance_id(self):
return self._parent_instance_id
@property
def parent_flags(self) -> int:
return self._parent_flags
def get_variant(self):
return self._variant
@ -312,6 +324,8 @@ class InstanceItem:
instance["active"],
instance.is_mandatory,
instance.has_promised_context,
instance.parent_instance_id,
instance.parent_flags,
)
@ -486,6 +500,9 @@ class CreateModel:
self._create_context.add_instance_requirement_change_callback(
self._cc_instance_requirement_changed
)
self._create_context.add_instance_parent_change_callback(
self._cc_instance_parent_changed
)
self._create_context.reset_finalization()
@ -566,15 +583,21 @@ class CreateModel:
def set_instances_active_state(
self, active_state_by_id: Dict[str, bool]
):
changed_ids = set()
with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE):
for instance_id, active in active_state_by_id.items():
instance = self._create_context.get_instance_by_id(instance_id)
instance["active"] = active
if instance["active"] is not active:
instance["active"] = active
changed_ids.add(instance_id)
if not changed_ids:
return
self._emit_event(
"create.model.instances.context.changed",
{
"instance_ids": set(active_state_by_id.keys())
"instance_ids": changed_ids
}
)
@ -1191,6 +1214,16 @@ class CreateModel:
{"instance_ids": instance_ids},
)
def _cc_instance_parent_changed(self, event):
instance_ids = {
instance.id
for instance in event.data["instances"]
}
self._emit_event(
"create.model.instance.parent.changed",
{"instance_ids": instance_ids},
)
def _get_allowed_creators_pattern(self) -> Union[Pattern, None]:
"""Provide regex pattern for configured creator labels in this context

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,7 @@
from __future__ import annotations
from typing import Generator
from qtpy import QtWidgets, QtCore
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@ -6,6 +10,7 @@ from .border_label_widget import BorderedLabelWidget
from .card_view_widgets import InstanceCardView
from .list_view_widgets import InstanceListView
from .widgets import (
AbstractInstanceView,
CreateInstanceBtn,
RemoveInstanceBtn,
ChangeViewBtn,
@ -43,10 +48,16 @@ class OverviewWidget(QtWidgets.QFrame):
product_view_cards = InstanceCardView(controller, product_views_widget)
product_list_view = InstanceListView(controller, product_views_widget)
product_list_view.set_parent_grouping(False)
product_list_view_grouped = InstanceListView(
controller, product_views_widget
)
product_list_view_grouped.set_parent_grouping(True)
product_views_layout = QtWidgets.QStackedLayout()
product_views_layout.addWidget(product_view_cards)
product_views_layout.addWidget(product_list_view)
product_views_layout.addWidget(product_list_view_grouped)
product_views_layout.setCurrentWidget(product_view_cards)
# Buttons at the bottom of product view
@ -118,6 +129,12 @@ class OverviewWidget(QtWidgets.QFrame):
product_list_view.double_clicked.connect(
self.publish_tab_requested
)
product_list_view_grouped.selection_changed.connect(
self._on_product_change
)
product_list_view_grouped.double_clicked.connect(
self.publish_tab_requested
)
product_view_cards.selection_changed.connect(
self._on_product_change
)
@ -159,16 +176,22 @@ class OverviewWidget(QtWidgets.QFrame):
"create.model.instance.requirement.changed",
self._on_instance_requirement_changed
)
controller.register_event_callback(
"create.model.instance.parent.changed",
self._on_instance_parent_changed
)
self._product_content_widget = product_content_widget
self._product_content_layout = product_content_layout
self._product_view_cards = product_view_cards
self._product_list_view = product_list_view
self._product_list_view_grouped = product_list_view_grouped
self._product_views_layout = product_views_layout
self._create_btn = create_btn
self._delete_btn = delete_btn
self._change_view_btn = change_view_btn
self._product_attributes_widget = product_attributes_widget
self._create_widget = create_widget
@ -246,7 +269,7 @@ class OverviewWidget(QtWidgets.QFrame):
)
def has_items(self):
view = self._product_views_layout.currentWidget()
view = self._get_current_view()
return view.has_items()
def _on_create_clicked(self):
@ -361,17 +384,18 @@ class OverviewWidget(QtWidgets.QFrame):
def _on_instance_requirement_changed(self, event):
self._refresh_instance_states(event["instance_ids"])
def _refresh_instance_states(self, instance_ids):
current_idx = self._product_views_layout.currentIndex()
for idx in range(self._product_views_layout.count()):
if idx == current_idx:
continue
widget = self._product_views_layout.widget(idx)
if widget.refreshed:
widget.set_refreshed(False)
def _on_instance_parent_changed(self, event):
self._refresh_instance_states(event["instance_ids"])
current_widget = self._product_views_layout.widget(current_idx)
current_widget.refresh_instance_states(instance_ids)
def _refresh_instance_states(self, instance_ids):
current_view = self._get_current_view()
for view in self._iter_views():
if view is current_view:
current_view = view
elif view.refreshed:
view.set_refreshed(False)
current_view.refresh_instance_states(instance_ids)
def _on_convert_requested(self):
self.convert_requested.emit()
@ -385,7 +409,7 @@ class OverviewWidget(QtWidgets.QFrame):
convertor plugins.
"""
view = self._product_views_layout.currentWidget()
view = self._get_current_view()
return view.get_selected_items()
def get_selected_legacy_convertors(self):
@ -400,12 +424,12 @@ class OverviewWidget(QtWidgets.QFrame):
return convertor_identifiers
def _change_view_type(self):
old_view = self._get_current_view()
idx = self._product_views_layout.currentIndex()
new_idx = (idx + 1) % self._product_views_layout.count()
old_view = self._product_views_layout.currentWidget()
new_view = self._product_views_layout.widget(new_idx)
new_view = self._get_view_by_idx(new_idx)
if not new_view.refreshed:
new_view.refresh()
new_view.set_refreshed(True)
@ -418,22 +442,52 @@ class OverviewWidget(QtWidgets.QFrame):
new_view.set_selected_items(
instance_ids, context_selected, convertor_identifiers
)
view_type = "list"
if new_view is self._product_list_view_grouped:
view_type = "card"
elif new_view is self._product_list_view:
view_type = "list-parent-grouping"
self._change_view_btn.set_view_type(view_type)
self._product_views_layout.setCurrentIndex(new_idx)
self._on_product_change()
def _iter_views(self) -> Generator[AbstractInstanceView, None, None]:
for idx in range(self._product_views_layout.count()):
widget = self._product_views_layout.widget(idx)
if not isinstance(widget, AbstractInstanceView):
raise TypeError(
"Current widget is not instance of 'AbstractInstanceView'"
)
yield widget
def _get_current_view(self) -> AbstractInstanceView:
widget = self._product_views_layout.currentWidget()
if isinstance(widget, AbstractInstanceView):
return widget
raise TypeError(
"Current widget is not instance of 'AbstractInstanceView'"
)
def _get_view_by_idx(self, idx: int) -> AbstractInstanceView:
widget = self._product_views_layout.widget(idx)
if isinstance(widget, AbstractInstanceView):
return widget
raise TypeError(
"Current widget is not instance of 'AbstractInstanceView'"
)
def _refresh_instances(self):
if self._refreshing_instances:
return
self._refreshing_instances = True
for idx in range(self._product_views_layout.count()):
widget = self._product_views_layout.widget(idx)
widget.set_refreshed(False)
for view in self._iter_views():
view.set_refreshed(False)
view = self._product_views_layout.currentWidget()
view = self._get_current_view()
view.refresh()
view.set_refreshed(True)
@ -444,25 +498,22 @@ class OverviewWidget(QtWidgets.QFrame):
# Give a change to process Resize Request
QtWidgets.QApplication.processEvents()
# Trigger update geometry of
widget = self._product_views_layout.currentWidget()
widget.updateGeometry()
# Trigger update geometry
view.updateGeometry()
def _on_publish_start(self):
"""Publish started."""
self._create_btn.setEnabled(False)
self._product_attributes_wrap.setEnabled(False)
for idx in range(self._product_views_layout.count()):
widget = self._product_views_layout.widget(idx)
widget.set_active_toggle_enabled(False)
for view in self._iter_views():
view.set_active_toggle_enabled(False)
def _on_controller_reset_start(self):
"""Controller reset started."""
for idx in range(self._product_views_layout.count()):
widget = self._product_views_layout.widget(idx)
widget.set_active_toggle_enabled(True)
for view in self._iter_views():
view.set_active_toggle_enabled(True)
def _on_publish_reset(self):
"""Context in controller has been reseted."""
@ -477,7 +528,19 @@ class OverviewWidget(QtWidgets.QFrame):
self._refresh_instances()
def _on_instances_added(self):
view = self._get_current_view()
is_card_view = False
count = 0
if isinstance(view, InstanceCardView):
is_card_view = True
count = view.get_current_instance_count()
self._refresh_instances()
if is_card_view and count < 10:
new_count = view.get_current_instance_count()
if new_count > count and new_count >= 10:
self._change_view_type()
def _on_instances_removed(self):
self._refresh_instances()

View file

@ -1147,6 +1147,8 @@ class LogItemMessage(QtWidgets.QTextEdit):
QtWidgets.QSizePolicy.Preferred,
QtWidgets.QSizePolicy.Maximum
)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
document = self.document()
document.documentLayout().documentSizeChanged.connect(
self._adjust_minimum_size

View file

@ -146,19 +146,19 @@ class TasksModel(QtGui.QStandardItemModel):
self._controller.get_current_project_name()
)
}
icon_name_by_task_name = {}
type_item_by_task_name = {}
for task_items in task_items_by_folder_path.values():
for task_item in task_items:
task_name = task_item.name
if (
task_name not in new_task_names
or task_name in icon_name_by_task_name
or task_name in type_item_by_task_name
):
continue
task_type_name = task_item.task_type
task_type_item = task_type_items.get(task_type_name)
if task_type_item:
icon_name_by_task_name[task_name] = task_type_item.icon
type_item_by_task_name[task_name] = task_type_item
for task_name in new_task_names:
item = self._items_by_name.get(task_name)
@ -171,13 +171,18 @@ class TasksModel(QtGui.QStandardItemModel):
if not task_name:
continue
icon_name = icon_name_by_task_name.get(task_name)
icon = None
icon = icon_name = icon_color = None
task_type_item = type_item_by_task_name.get(task_name)
if task_type_item is not None:
icon_name = task_type_item.icon
icon_color = task_type_item.color
if icon_name:
if not icon_color:
icon_color = get_default_entity_icon_color()
icon = get_qt_icon({
"type": "material-symbols",
"name": icon_name,
"color": get_default_entity_icon_color(),
"color": icon_color,
})
if icon is None:
icon = default_icon

View file

@ -10,6 +10,7 @@ from ayon_core.tools.flickcharm import FlickCharm
from ayon_core.tools.utils import (
IconButton,
PixmapLabel,
get_qt_icon,
)
from ayon_core.tools.publisher.constants import ResetKeySequence
@ -287,12 +288,32 @@ class RemoveInstanceBtn(PublishIconBtn):
self.setToolTip("Remove selected instances")
class ChangeViewBtn(PublishIconBtn):
"""Create toggle view button."""
class ChangeViewBtn(IconButton):
"""Toggle views button."""
def __init__(self, parent=None):
icon_path = get_icon_path("change_view")
super().__init__(icon_path, parent)
self.setToolTip("Swap between views")
super().__init__(parent)
self.set_view_type("list")
def set_view_type(self, view_type):
if view_type == "list":
# icon_name = "data_table"
icon_name = "dehaze"
tooltip = "Change to list view"
elif view_type == "card":
icon_name = "view_agenda"
tooltip = "Change to card view"
else:
icon_name = "segment"
tooltip = "Change to parent grouping view"
# "format_align_right"
# "segment"
icon = get_qt_icon({
"type": "material-symbols",
"name": icon_name,
})
self.setIcon(icon)
self.setToolTip(tooltip)
class AbstractInstanceView(QtWidgets.QWidget):
@ -370,6 +391,20 @@ class AbstractInstanceView(QtWidgets.QWidget):
"{} Method 'set_active_toggle_enabled' is not implemented."
).format(self.__class__.__name__))
def refresh_instance_states(self, instance_ids=None):
"""Refresh instance states.
Args:
instance_ids: Optional[Iterable[str]]: Instance ids to refresh.
If not passed then all instances are refreshed.
"""
raise NotImplementedError(
f"{self.__class__.__name__} Method 'refresh_instance_states'"
" is not implemented."
)
class ClickableLineEdit(QtWidgets.QLineEdit):
"""QLineEdit capturing left mouse click.

View file

@ -1,4 +1,5 @@
import threading
from typing import Dict
import ayon_api
@ -13,10 +14,11 @@ from .models import (
UserPublishValuesModel,
IntegrateModel,
)
from .models.integrate import ProjectPushItemProcess
class PushToContextController:
def __init__(self, project_name=None, version_id=None):
def __init__(self, project_name=None, version_ids=None):
self._event_system = self._create_event_system()
self._projects_model = ProjectsModel(self)
@ -27,18 +29,20 @@ class PushToContextController:
self._user_values = UserPublishValuesModel(self)
self._src_project_name = None
self._src_version_id = None
self._src_version_ids = []
self._src_folder_entity = None
self._src_folder_task_entities = {}
self._src_product_entity = None
self._src_version_entity = None
self._src_version_entities = []
self._src_product_entities = {}
self._src_label = None
self._submission_enabled = False
self._process_thread = None
self._process_item_id = None
self.set_source(project_name, version_id)
self._use_original_name = False
self.set_source(project_name, version_ids)
# Events system
def emit_event(self, topic, data=None, source=None):
@ -51,38 +55,47 @@ class PushToContextController:
def register_event_callback(self, topic, callback):
self._event_system.add_callback(topic, callback)
def set_source(self, project_name, version_id):
def set_source(self, project_name, version_ids):
"""Set source project and version.
There is currently assumption that tool is working on products of same
folder.
Args:
project_name (Union[str, None]): Source project name.
version_id (Union[str, None]): Source version id.
version_ids (Optional[list[str]]): Version ids.
"""
if not project_name or not version_ids:
return
if (
project_name == self._src_project_name
and version_id == self._src_version_id
and version_ids == self._src_version_ids
):
return
self._src_project_name = project_name
self._src_version_id = version_id
self._src_version_ids = version_ids
self._src_label = None
folder_entity = None
task_entities = {}
product_entity = None
version_entity = None
if project_name and version_id:
version_entity = ayon_api.get_version_by_id(
project_name, version_id
product_entities = []
version_entities = []
if project_name and self._src_version_ids:
version_entities = list(ayon_api.get_versions(
project_name, version_ids=self._src_version_ids))
if version_entities:
product_ids = [
version_entity["productId"]
for version_entity in version_entities
]
product_entities = list(ayon_api.get_products(
project_name, product_ids=product_ids)
)
if version_entity:
product_entity = ayon_api.get_product_by_id(
project_name, version_entity["productId"]
)
if product_entity:
if product_entities:
# all products for same folder
product_entity = product_entities[0]
folder_entity = ayon_api.get_folder_by_id(
project_name, product_entity["folderId"]
)
@ -97,15 +110,18 @@ class PushToContextController:
self._src_folder_entity = folder_entity
self._src_folder_task_entities = task_entities
self._src_product_entity = product_entity
self._src_version_entity = version_entity
self._src_version_entities = version_entities
self._src_product_entities = {
product["id"]: product
for product in product_entities
}
if folder_entity:
self._user_values.set_new_folder_name(folder_entity["name"])
variant = self._get_src_variant()
if variant:
self._user_values.set_variant(variant)
comment = version_entity["attrib"].get("comment")
comment = version_entities[0]["attrib"].get("comment")
if comment:
self._user_values.set_comment(comment)
@ -113,7 +129,7 @@ class PushToContextController:
"source.changed",
{
"project_name": project_name,
"version_id": version_id
"version_ids": self._src_version_ids
}
)
@ -142,6 +158,14 @@ class PushToContextController:
def get_user_values(self):
return self._user_values.get_data()
def original_names_required(self):
"""Checks if original product names must be used.
Currently simple check if multiple versions, but if multiple products
with different product_type were used, it wouldn't be necessary.
"""
return len(self._src_version_entities) > 1
def set_user_value_folder_name(self, folder_name):
self._user_values.set_new_folder_name(folder_name)
self._invalidate()
@ -165,8 +189,9 @@ class PushToContextController:
def set_selected_task(self, task_id, task_name):
self._selection_model.set_selected_task(task_id, task_name)
def get_process_item_status(self, item_id):
return self._integrate_model.get_item_status(item_id)
def get_process_items(self) -> Dict[str, ProjectPushItemProcess]:
"""Returns dict of all ProjectPushItemProcess items """
return self._integrate_model.get_items()
# Processing methods
def submit(self, wait=True):
@ -176,29 +201,33 @@ class PushToContextController:
if self._process_thread is not None:
return
item_id = self._integrate_model.create_process_item(
self._src_project_name,
self._src_version_id,
self._selection_model.get_selected_project_name(),
self._selection_model.get_selected_folder_id(),
self._selection_model.get_selected_task_name(),
self._user_values.variant,
comment=self._user_values.comment,
new_folder_name=self._user_values.new_folder_name,
dst_version=1
)
item_ids = []
for src_version_entity in self._src_version_entities:
item_id = self._integrate_model.create_process_item(
self._src_project_name,
src_version_entity["id"],
self._selection_model.get_selected_project_name(),
self._selection_model.get_selected_folder_id(),
self._selection_model.get_selected_task_name(),
self._user_values.variant,
comment=self._user_values.comment,
new_folder_name=self._user_values.new_folder_name,
dst_version=1,
use_original_name=self._use_original_name,
)
item_ids.append(item_id)
self._process_item_id = item_id
self._process_item_ids = item_ids
self._emit_event("submit.started")
if wait:
self._submit_callback()
self._process_item_id = None
self._process_item_ids = []
return item_id
thread = threading.Thread(target=self._submit_callback)
self._process_thread = thread
thread.start()
return item_id
return item_ids
def wait_for_process_thread(self):
if self._process_thread is None:
@ -207,7 +236,7 @@ class PushToContextController:
self._process_thread = None
def _prepare_source_label(self):
if not self._src_project_name or not self._src_version_id:
if not self._src_project_name or not self._src_version_ids:
return "Source is not defined"
folder_entity = self._src_folder_entity
@ -215,14 +244,21 @@ class PushToContextController:
return "Source is invalid"
folder_path = folder_entity["path"]
product_entity = self._src_product_entity
version_entity = self._src_version_entity
return "Source: {}{}/{}/v{:0>3}".format(
self._src_project_name,
folder_path,
product_entity["name"],
version_entity["version"]
)
src_labels = []
for version_entity in self._src_version_entities:
product_entity = self._src_product_entities.get(
version_entity["productId"]
)
src_labels.append(
"Source: {}{}/{}/v{:0>3}".format(
self._src_project_name,
folder_path,
product_entity["name"],
version_entity["version"],
)
)
return "\n".join(src_labels)
def _get_task_info_from_repre_entities(
self, task_entities, repre_entities
@ -256,7 +292,8 @@ class PushToContextController:
def _get_src_variant(self):
project_name = self._src_project_name
version_entity = self._src_version_entity
# parse variant only from first version
version_entity = self._src_version_entities[0]
task_entities = self._src_folder_task_entities
repre_entities = ayon_api.get_representations(
project_name, version_ids={version_entity["id"]}
@ -264,9 +301,12 @@ class PushToContextController:
task_name, task_type = self._get_task_info_from_repre_entities(
task_entities, repre_entities
)
product_entity = self._src_product_entities.get(
version_entity["productId"]
)
project_settings = get_project_settings(project_name)
product_type = self._src_product_entity["productType"]
product_type = product_entity["productType"]
template = get_product_name_template(
self._src_project_name,
product_type,
@ -300,7 +340,7 @@ class PushToContextController:
print("Failed format", exc)
return ""
product_name = self._src_product_entity["name"]
product_name = product_entity["name"]
if (
(product_s and not product_name.startswith(product_s))
or (product_e and not product_name.endswith(product_e))
@ -314,9 +354,6 @@ class PushToContextController:
return product_name
def _check_submit_validations(self):
if not self._user_values.is_valid:
return False
if not self._selection_model.get_selected_project_name():
return False
@ -325,6 +362,13 @@ class PushToContextController:
and not self._selection_model.get_selected_folder_id()
):
return False
if self._use_original_name:
return True
if not self._user_values.is_valid:
return False
return True
def _invalidate(self):
@ -338,13 +382,14 @@ class PushToContextController:
)
def _submit_callback(self):
process_item_id = self._process_item_id
if process_item_id is None:
return
self._integrate_model.integrate_item(process_item_id)
process_item_ids = self._process_item_ids
for process_item_id in process_item_ids:
self._integrate_model.integrate_item(process_item_id)
self._emit_event("submit.finished", {})
if process_item_id == self._process_item_id:
self._process_item_id = None
if process_item_ids is self._process_item_ids:
self._process_item_ids = []
def _emit_event(self, topic, data=None):
if data is None:

View file

@ -4,28 +4,28 @@ from ayon_core.tools.utils import get_ayon_qt_app
from ayon_core.tools.push_to_project.ui import PushToContextSelectWindow
def main_show(project_name, version_id):
def main_show(project_name, version_ids):
app = get_ayon_qt_app()
window = PushToContextSelectWindow()
window.show()
window.set_source(project_name, version_id)
window.set_source(project_name, version_ids)
app.exec_()
@click.command()
@click.option("--project", help="Source project name")
@click.option("--version", help="Source version id")
def main(project, version):
@click.option("--versions", help="Source version ids")
def main(project, versions):
"""Run PushToProject tool to integrate version in different project.
Args:
project (str): Source project name.
version (str): Version id.
versions (str): comma separated versions for same context
"""
main_show(project, version)
main_show(project, versions.split(","))
if __name__ == "__main__":

View file

@ -5,6 +5,7 @@ import itertools
import sys
import traceback
import uuid
from typing import Optional, Dict
import ayon_api
from ayon_api.utils import create_entity_id
@ -21,6 +22,7 @@ from ayon_core.lib import (
source_hash,
)
from ayon_core.lib.file_transaction import FileTransaction
from ayon_core.pipeline.thumbnails import get_thumbnail_path
from ayon_core.settings import get_project_settings
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.version_start import get_versioning_start
@ -88,6 +90,7 @@ class ProjectPushItem:
new_folder_name,
dst_version,
item_id=None,
use_original_name=False
):
if not item_id:
item_id = uuid.uuid4().hex
@ -102,6 +105,7 @@ class ProjectPushItem:
self.comment = comment or ""
self.item_id = item_id
self._repr_value = None
self.use_original_name = use_original_name
@property
def _repr(self):
@ -113,7 +117,8 @@ class ProjectPushItem:
str(self.dst_folder_id),
str(self.new_folder_name),
str(self.dst_task_name),
str(self.dst_version)
str(self.dst_version),
self.use_original_name
])
return self._repr_value
@ -132,6 +137,7 @@ class ProjectPushItem:
"comment": self.comment,
"new_folder_name": self.new_folder_name,
"item_id": self.item_id,
"use_original_name": self.use_original_name
}
@classmethod
@ -311,7 +317,7 @@ class ProjectPushRepreItem:
if self._src_files is not None:
return self._src_files, self._resource_files
repre_context = self._repre_entity["context"]
repre_context = self.repre_entity["context"]
if "frame" in repre_context or "udim" in repre_context:
src_files, resource_files = self._get_source_files_with_frames()
else:
@ -328,7 +334,7 @@ class ProjectPushRepreItem:
udim_placeholder = "__udim__"
src_files = []
resource_files = []
template = self._repre_entity["attrib"]["template"]
template = self.repre_entity["attrib"]["template"]
# Remove padding from 'udim' and 'frame' formatting keys
# - "{frame:0>4}" -> "{frame}"
for key in ("udim", "frame"):
@ -336,7 +342,7 @@ class ProjectPushRepreItem:
replacement = "{{{}}}".format(key)
template = re.sub(sub_part, replacement, template)
repre_context = self._repre_entity["context"]
repre_context = self.repre_entity["context"]
fill_repre_context = copy.deepcopy(repre_context)
if "frame" in fill_repre_context:
fill_repre_context["frame"] = frame_placeholder
@ -357,7 +363,7 @@ class ProjectPushRepreItem:
.replace(udim_placeholder, "(?P<udim>[0-9]+)")
)
src_basename_regex = re.compile("^{}$".format(src_basename))
for file_info in self._repre_entity["files"]:
for file_info in self.repre_entity["files"]:
filepath_template = self._clean_path(file_info["path"])
filepath = self._clean_path(
filepath_template.format(root=self._roots)
@ -371,7 +377,6 @@ class ProjectPushRepreItem:
resource_files.append(ResourceFile(filepath, relative_path))
continue
filepath = os.path.join(src_dirpath, basename)
frame = None
udim = None
for item in src_basename_regex.finditer(basename):
@ -389,8 +394,8 @@ class ProjectPushRepreItem:
def _get_source_files(self):
src_files = []
resource_files = []
template = self._repre_entity["attrib"]["template"]
repre_context = self._repre_entity["context"]
template = self.repre_entity["attrib"]["template"]
repre_context = self.repre_entity["context"]
fill_repre_context = copy.deepcopy(repre_context)
fill_roots = fill_repre_context["root"]
for root_name in tuple(fill_roots.keys()):
@ -399,7 +404,7 @@ class ProjectPushRepreItem:
fill_repre_context)
repre_path = self._clean_path(repre_path)
src_dirpath = os.path.dirname(repre_path)
for file_info in self._repre_entity["files"]:
for file_info in self.repre_entity["files"]:
filepath_template = self._clean_path(file_info["path"])
filepath = self._clean_path(
filepath_template.format(root=self._roots))
@ -492,8 +497,11 @@ class ProjectPushItemProcess:
except Exception as exc:
_exc, _value, _tb = sys.exc_info()
product_name = self._src_product_entity["name"]
self._status.set_failed(
"Unhandled error happened: {}".format(str(exc)),
"Unhandled error happened for `{}`: {}".format(
product_name, str(exc)
),
(_exc, _value, _tb)
)
@ -816,31 +824,34 @@ class ProjectPushItemProcess:
self._template_name = template_name
def _determine_product_name(self):
product_type = self._product_type
task_info = self._task_info
task_name = task_type = None
if task_info:
task_name = task_info["name"]
task_type = task_info["taskType"]
if self._item.use_original_name:
product_name = self._src_product_entity["name"]
else:
product_type = self._product_type
task_info = self._task_info
task_name = task_type = None
if task_info:
task_name = task_info["name"]
task_type = task_info["taskType"]
try:
product_name = get_product_name(
self._item.dst_project_name,
task_name,
task_type,
self.host_name,
product_type,
self._item.variant,
project_settings=self._project_settings
)
except TaskNotSetError:
self._status.set_failed(
"Target product name template requires task name. To continue"
" you have to select target task or change settings"
" <b>ayon+settings://core/tools/creator/product_name_profiles"
f"?project={self._item.dst_project_name}</b>."
)
raise PushToProjectError(self._status.fail_reason)
try:
product_name = get_product_name(
self._item.dst_project_name,
task_name,
task_type,
self.host_name,
product_type,
self._item.variant,
project_settings=self._project_settings
)
except TaskNotSetError:
self._status.set_failed(
"Target product name template requires task name. To "
"continue you have to select target task or change settings " # noqa: E501
" <b>ayon+settings://core/tools/creator/product_name_profiles" # noqa: E501
f"?project={self._item.dst_project_name}</b>."
)
raise PushToProjectError(self._status.fail_reason)
self._log_info(
f"Push will be integrating to product with name '{product_name}'"
@ -917,14 +928,19 @@ class ProjectPushItemProcess:
task_name=self._task_info["name"],
task_type=self._task_info["taskType"],
product_type=product_type,
product_name=product_entity["name"]
product_name=product_entity["name"],
)
existing_version_entity = ayon_api.get_version_by_name(
project_name, version, product_id
)
thumbnail_id = self._copy_version_thumbnail()
# Update existing version
if existing_version_entity:
updata_data = {"attrib": dst_attrib}
if thumbnail_id:
updata_data["thumbnailId"] = thumbnail_id
self._operations.update_entity(
project_name,
"version",
@ -939,6 +955,7 @@ class ProjectPushItemProcess:
version,
product_id,
attribs=dst_attrib,
thumbnail_id=thumbnail_id,
)
self._operations.create_entity(
project_name, "version", version_entity
@ -1005,10 +1022,18 @@ class ProjectPushItemProcess:
self, anatomy, template_name, formatting_data, file_template
):
processed_repre_items = []
repre_context = None
for repre_item in self._src_repre_items:
repre_entity = repre_item.repre_entity
repre_name = repre_entity["name"]
repre_format_data = copy.deepcopy(formatting_data)
if not repre_context:
repre_context = self._update_repre_context(
copy.deepcopy(repre_entity),
formatting_data
)
repre_format_data["representation"] = repre_name
for src_file in repre_item.src_files:
ext = os.path.splitext(src_file.path)[-1]
@ -1024,7 +1049,6 @@ class ProjectPushItemProcess:
"publish", template_name, "directory"
)
folder_path = template_obj.format_strict(formatting_data)
repre_context = folder_path.used_values
folder_path_rootless = folder_path.rootless
repre_filepaths = []
published_path = None
@ -1047,7 +1071,6 @@ class ProjectPushItemProcess:
)
if published_path is None or frame == repre_item.frame:
published_path = dst_filepath
repre_context.update(filename.used_values)
repre_filepaths.append((dst_filepath, dst_rootless_path))
self._file_transaction.add(src_file.path, dst_filepath)
@ -1134,7 +1157,7 @@ class ProjectPushItemProcess:
self._item.dst_project_name,
"representation",
entity_id,
changes
changes,
)
existing_repre_names = set(existing_repres_by_low_name.keys())
@ -1147,6 +1170,45 @@ class ProjectPushItemProcess:
{"active": False}
)
def _copy_version_thumbnail(self) -> Optional[str]:
thumbnail_id = self._src_version_entity["thumbnailId"]
if not thumbnail_id:
return None
path = get_thumbnail_path(
self._item.src_project_name,
"version",
self._src_version_entity["id"],
thumbnail_id
)
if not path:
return None
return ayon_api.create_thumbnail(
self._item.dst_project_name,
path
)
def _update_repre_context(self, repre_entity, formatting_data):
"""Replace old context value with new ones.
Folder might change, project definitely changes etc.
"""
repre_context = repre_entity["context"]
for context_key, context_value in repre_context.items():
if context_value and isinstance(context_value, dict):
for context_sub_key in context_value.keys():
value_to_update = formatting_data.get(context_key, {}).get(
context_sub_key)
if value_to_update:
repre_context[context_key][
context_sub_key] = value_to_update
else:
value_to_update = formatting_data.get(context_key)
if value_to_update:
repre_context[context_key] = value_to_update
if "task" not in formatting_data:
repre_context.pop("task")
return repre_context
class IntegrateModel:
def __init__(self, controller):
@ -1170,6 +1232,7 @@ class IntegrateModel:
comment,
new_folder_name,
dst_version,
use_original_name
):
"""Create new item for integration.
@ -1183,6 +1246,7 @@ class IntegrateModel:
comment (Union[str, None]): Comment.
new_folder_name (Union[str, None]): New folder name.
dst_version (int): Destination version number.
use_original_name (bool): If original product names should be used
Returns:
str: Item id. The id can be used to trigger integration or get
@ -1198,7 +1262,8 @@ class IntegrateModel:
variant,
comment=comment,
new_folder_name=new_folder_name,
dst_version=dst_version
dst_version=dst_version,
use_original_name=use_original_name
)
process_item = ProjectPushItemProcess(self, item)
self._process_items[item.item_id] = process_item
@ -1216,17 +1281,6 @@ class IntegrateModel:
return
item.integrate()
def get_item_status(self, item_id):
"""Status of an item.
Args:
item_id (str): Item id for which status should be returned.
Returns:
dict[str, Any]: Status data.
"""
item = self._process_items.get(item_id)
if item is not None:
return item.get_status_data()
return None
def get_items(self) -> Dict[str, ProjectPushItemProcess]:
"""Returns dict of all ProjectPushItemProcess items """
return self._process_items

View file

@ -85,6 +85,13 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
header_widget = QtWidgets.QWidget(main_context_widget)
library_only_label = QtWidgets.QLabel(
"Show only libraries",
header_widget
)
library_only_checkbox = NiceCheckbox(
True, parent=header_widget)
header_label = QtWidgets.QLabel(
controller.get_source_label(),
header_widget
@ -92,7 +99,9 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.addWidget(header_label)
header_layout.addWidget(header_label, 1)
header_layout.addWidget(library_only_label, 0)
header_layout.addWidget(library_only_checkbox, 0)
main_splitter = QtWidgets.QSplitter(
QtCore.Qt.Horizontal, main_context_widget
@ -124,6 +133,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
inputs_widget = QtWidgets.QWidget(main_splitter)
new_folder_checkbox = NiceCheckbox(True, parent=inputs_widget)
original_names_checkbox = NiceCheckbox(False, parent=inputs_widget)
folder_name_input = PlaceholderLineEdit(inputs_widget)
folder_name_input.setPlaceholderText("< Name of new folder >")
@ -142,6 +152,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
inputs_layout.addRow("Create new folder", new_folder_checkbox)
inputs_layout.addRow("New folder name", folder_name_input)
inputs_layout.addRow("Variant", variant_input)
inputs_layout.addRow(
"Use original product names", original_names_checkbox)
inputs_layout.addRow("Comment", comment_input)
main_splitter.addWidget(context_widget)
@ -196,6 +208,10 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
show_detail_btn.setToolTip(
"Show error detail dialog to copy full error."
)
original_names_checkbox.setToolTip(
"Required for multi copy, doesn't allow changes "
"variant values."
)
overlay_close_btn = QtWidgets.QPushButton(
"Close", overlay_btns_widget
@ -240,6 +256,9 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
folder_name_input.textChanged.connect(self._on_new_folder_change)
variant_input.textChanged.connect(self._on_variant_change)
comment_input.textChanged.connect(self._on_comment_change)
library_only_checkbox.stateChanged.connect(self._on_library_only_change)
original_names_checkbox.stateChanged.connect(
self._on_original_names_change)
publish_btn.clicked.connect(self._on_select_click)
cancel_btn.clicked.connect(self._on_close_click)
@ -288,6 +307,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._new_folder_checkbox = new_folder_checkbox
self._folder_name_input = folder_name_input
self._comment_input = comment_input
self._use_original_names_checkbox = original_names_checkbox
self._publish_btn = publish_btn
@ -316,7 +336,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._main_thread_timer = main_thread_timer
self._main_thread_timer_can_stop = True
self._last_submit_message = None
self._process_item_id = None
self._variant_is_valid = None
self._folder_is_valid = None
@ -327,17 +346,17 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
overlay_try_btn.setVisible(False)
# Support of public api function of controller
def set_source(self, project_name, version_id):
def set_source(self, project_name, version_ids):
"""Set source project and version.
Call the method on controller.
Args:
project_name (Union[str, None]): Name of project.
version_id (Union[str, None]): Version id.
version_ids (Union[str, None]): comma separated Version ids.
"""
self._controller.set_source(project_name, version_id)
self._controller.set_source(project_name, version_ids)
def showEvent(self, event):
super(PushToContextSelectWindow, self).showEvent(event)
@ -352,10 +371,12 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._folder_name_input.setText(new_folder_name or "")
self._variant_input.setText(variant or "")
self._invalidate_variant(user_values["is_variant_valid"])
self._invalidate_use_original_names(
self._use_original_names_checkbox.isChecked())
self._invalidate_new_folder_name(
new_folder_name, user_values["is_new_folder_name_valid"]
)
self._controller._invalidate()
self._projects_combobox.refresh()
def _on_first_show(self):
@ -394,6 +415,15 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._comment_input_text = text
self._user_input_changed_timer.start()
def _on_library_only_change(self, state: int) -> None:
"""Change toggle state, reset filter, recalculate dropdown"""
state = bool(state)
self._projects_combobox.set_standard_filter_enabled(state)
def _on_original_names_change(self, state: int) -> None:
use_original_name = bool(state)
self._invalidate_use_original_names(use_original_name)
def _on_user_input_timer(self):
folder_name_enabled = self._new_folder_name_enabled
folder_name = self._new_folder_name_input_text
@ -456,17 +486,27 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
state = ""
if folder_name is not None:
state = "valid" if is_valid else "invalid"
set_style_property(
self._folder_name_input, "state", state
)
set_style_property(self._folder_name_input, "state", state)
def _invalidate_variant(self, is_valid):
if self._variant_is_valid is is_valid:
return
self._variant_is_valid = is_valid
state = "valid" if is_valid else "invalid"
set_style_property(self._variant_input, "state", state)
def _invalidate_use_original_names(self, use_original_names):
"""Checks if original names must be used.
Invalidates Variant if necessary
"""
if self._controller.original_names_required():
use_original_names = True
self._variant_input.setEnabled(not use_original_names)
self._invalidate_variant(not use_original_names)
self._controller._use_original_name = use_original_names
self._use_original_names_checkbox.setChecked(use_original_names)
def _on_submission_change(self, event):
self._publish_btn.setEnabled(event["enabled"])
@ -495,31 +535,43 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._overlay_label.setText(self._last_submit_message)
self._last_submit_message = None
process_status = self._controller.get_process_item_status(
self._process_item_id
)
push_failed = process_status["failed"]
fail_traceback = process_status["full_traceback"]
failed_pushes = []
fail_tracebacks = []
for process_item in self._controller.get_process_items().values():
process_status = process_item.get_status_data()
if process_status["failed"]:
failed_pushes.append(process_status)
# push_failed = process_status["failed"]
# fail_traceback = process_status["full_traceback"]
if self._main_thread_timer_can_stop:
self._main_thread_timer.stop()
self._overlay_close_btn.setVisible(True)
if push_failed:
if failed_pushes:
self._overlay_try_btn.setVisible(True)
if fail_traceback:
fail_tracebacks = [
process_status["full_traceback"]
for process_status in failed_pushes
if process_status["full_traceback"]
]
if fail_tracebacks:
self._show_detail_btn.setVisible(True)
if push_failed:
reason = process_status["fail_reason"]
if fail_traceback:
if failed_pushes:
reasons = [
process_status["fail_reason"]
for process_status in failed_pushes
]
if fail_tracebacks:
reason = "\n".join(reasons)
message = (
"Unhandled error happened."
" Check error detail for more information."
)
self._error_detail_dialog.set_detail(
reason, fail_traceback
reason, "\n".join(fail_tracebacks)
)
else:
message = f"Push Failed:\n{reason}"
message = f"Push Failed:\n{reasons}"
self._overlay_label.setText(message)
set_style_property(self._overlay_close_btn, "state", "error")
@ -534,7 +586,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._main_thread_timer_can_stop = False
self._main_thread_timer.start()
self._main_layout.setCurrentWidget(self._overlay_widget)
self._overlay_label.setText("Submittion started")
self._overlay_label.setText("Submission started")
def _on_controller_submit_end(self):
self._main_thread_timer_can_stop = True

View file

@ -1,12 +1,18 @@
from typing import Optional
import ayon_api
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.host import HostBase
from ayon_core.host import ILoadHost
from ayon_core.pipeline import (
registered_host,
get_current_context,
)
from ayon_core.tools.common_models import HierarchyModel, ProjectsModel
from ayon_core.tools.common_models import (
HierarchyModel,
ProjectsModel,
ProductTypeIconMapping,
)
from .models import SiteSyncModel, ContainersModel
@ -35,7 +41,7 @@ class SceneInventoryController:
self._projects_model = ProjectsModel(self)
self._event_system = self._create_event_system()
def get_host(self) -> HostBase:
def get_host(self) -> ILoadHost:
return self._host
def emit_event(self, topic, data=None, source=None):
@ -93,6 +99,13 @@ class SceneInventoryController:
project_name, None
)
def get_product_type_icons_mapping(
self, project_name: Optional[str]
) -> ProductTypeIconMapping:
return self._projects_model.get_product_type_icons_mapping(
project_name
)
# Containers methods
def get_containers(self):
return self._containers_model.get_containers()

View file

@ -214,9 +214,6 @@ class InventoryModel(QtGui.QStandardItemModel):
group_icon = qtawesome.icon(
"fa.object-group", color=self._default_icon_color
)
product_type_icon = qtawesome.icon(
"fa.folder", color="#0091B2"
)
group_item_font = QtGui.QFont()
group_item_font.setBold(True)
@ -303,7 +300,7 @@ class InventoryModel(QtGui.QStandardItemModel):
remote_site_progress = "{}%".format(
max(progress["remote_site"], 0) * 100
)
product_type_icon = get_qt_icon(repre_info.product_type_icon)
group_item = QtGui.QStandardItem()
group_item.setColumnCount(root_item.columnCount())
group_item.setData(group_name, QtCore.Qt.DisplayRole)

View file

@ -126,6 +126,7 @@ class RepresentationInfo:
product_id,
product_name,
product_type,
product_type_icon,
product_group,
version_id,
representation_name,
@ -135,6 +136,7 @@ class RepresentationInfo:
self.product_id = product_id
self.product_name = product_name
self.product_type = product_type
self.product_type_icon = product_type_icon
self.product_group = product_group
self.version_id = version_id
self.representation_name = representation_name
@ -153,7 +155,17 @@ class RepresentationInfo:
@classmethod
def new_invalid(cls):
return cls(None, None, None, None, None, None, None, None)
return cls(
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
class VersionItem:
@ -229,6 +241,9 @@ class ContainersModel:
def get_representation_info_items(self, project_name, representation_ids):
output = {}
missing_repre_ids = set()
icons_mapping = self._controller.get_product_type_icons_mapping(
project_name
)
for repre_id in representation_ids:
try:
uuid.UUID(repre_id)
@ -253,6 +268,7 @@ class ContainersModel:
"product_id": None,
"product_name": None,
"product_type": None,
"product_type_icon": None,
"product_group": None,
"version_id": None,
"representation_name": None,
@ -265,10 +281,17 @@ class ContainersModel:
kwargs["folder_id"] = folder["id"]
kwargs["folder_path"] = folder["path"]
if product:
product_type = product["productType"]
product_base_type = product.get("productBaseType")
icon = icons_mapping.get_icon(
product_base_type=product_base_type,
product_type=product_type,
)
group = product["attrib"]["productGroup"]
kwargs["product_id"] = product["id"]
kwargs["product_name"] = product["name"]
kwargs["product_type"] = product["productType"]
kwargs["product_type_icon"] = icon
kwargs["product_group"] = group
if version:
kwargs["version_id"] = version["id"]

View file

@ -127,6 +127,7 @@ class SelectVersionComboBox(QtWidgets.QComboBox):
status_text_rect.setLeft(icon_rect.right() + 2)
if status_text_rect.width() <= 0:
painter.restore()
return
if status_text_rect.width() < metrics.width(status_name):
@ -144,6 +145,7 @@ class SelectVersionComboBox(QtWidgets.QComboBox):
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
status_name
)
painter.restore()
def set_current_index(self, index):
model = self._combo_view.model()

View file

@ -1,19 +0,0 @@
Subset manager
--------------
Simple UI showing list of created subset that will be published via Pyblish.
Useful for applications (Photoshop, AfterEffects, TVPaint, Harmony) which are
storing metadata about instance hidden from user.
This UI allows listing all created subset and removal of them if needed (
in case use doesn't want to publish anymore, its using workfile as a starting
file for different task and instances should be completely different etc.
)
Host is expected to implemented:
- `list_instances` - returning list of dictionaries (instances), must contain
unique uuid field
example:
```[{"uuid":"15","active":true,"subset":"imageBG","family":"image","id":"ayon.create.instance","asset":"Town"}]```
- `remove_instance(instance)` - removes instance from file's metadata
instance is a dictionary, with uuid field

View file

@ -1,9 +0,0 @@
from .window import (
show,
SubsetManagerWindow
)
__all__ = (
"show",
"SubsetManagerWindow"
)

View file

@ -1,56 +0,0 @@
import uuid
from qtpy import QtCore, QtGui
from ayon_core.pipeline import registered_host
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
class InstanceModel(QtGui.QStandardItemModel):
def __init__(self, *args, **kwargs):
super(InstanceModel, self).__init__(*args, **kwargs)
self._instances_by_item_id = {}
def get_instance_by_id(self, item_id):
return self._instances_by_item_id.get(item_id)
def refresh(self):
self.clear()
self._instances_by_item_id = {}
instances = None
host = registered_host()
list_instances = getattr(host, "list_instances", None)
if list_instances:
instances = list_instances()
if not instances:
return
items = []
for instance_data in instances:
item_id = str(uuid.uuid4())
product_name = (
instance_data.get("productName")
or instance_data.get("subset")
)
label = instance_data.get("label") or product_name
item = QtGui.QStandardItem(label)
item.setEnabled(True)
item.setEditable(False)
item.setData(item_id, ITEM_ID_ROLE)
items.append(item)
self._instances_by_item_id[item_id] = instance_data
if items:
self.invisibleRootItem().appendRows(items)
def headerData(self, section, orientation, role):
if role == QtCore.Qt.DisplayRole and section == 0:
return "Instance"
return super(InstanceModel, self).headerData(
section, orientation, role
)

View file

@ -1,110 +0,0 @@
import json
from qtpy import QtWidgets, QtCore
class InstanceDetail(QtWidgets.QWidget):
save_triggered = QtCore.Signal()
def __init__(self, parent=None):
super(InstanceDetail, self).__init__(parent)
details_widget = QtWidgets.QPlainTextEdit(self)
details_widget.setObjectName("SubsetManagerDetailsText")
save_btn = QtWidgets.QPushButton("Save", self)
self._block_changes = False
self._editable = False
self._item_id = None
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(details_widget, 1)
layout.addWidget(save_btn, 0, QtCore.Qt.AlignRight)
save_btn.clicked.connect(self._on_save_clicked)
details_widget.textChanged.connect(self._on_text_change)
self._details_widget = details_widget
self._save_btn = save_btn
self.set_editable(False)
def _on_save_clicked(self):
if self.is_valid():
self.save_triggered.emit()
def set_editable(self, enabled=True):
self._editable = enabled
self.update_state()
def update_state(self, valid=None):
editable = self._editable
if not self._item_id:
editable = False
self._save_btn.setVisible(editable)
self._details_widget.setReadOnly(not editable)
if valid is None:
valid = self.is_valid()
self._save_btn.setEnabled(valid)
self._set_invalid_detail(valid)
def _set_invalid_detail(self, valid):
state = ""
if not valid:
state = "invalid"
current_state = self._details_widget.property("state")
if current_state != state:
self._details_widget.setProperty("state", state)
self._details_widget.style().polish(self._details_widget)
def set_details(self, container, item_id):
self._item_id = item_id
text = "Nothing selected"
if item_id:
try:
text = json.dumps(container, indent=4)
except Exception:
text = str(container)
self._block_changes = True
self._details_widget.setPlainText(text)
self._block_changes = False
self.update_state()
def instance_data_from_text(self):
try:
jsoned = json.loads(self._details_widget.toPlainText())
except Exception:
jsoned = None
return jsoned
def item_id(self):
return self._item_id
def is_valid(self):
if not self._item_id:
return True
value = self._details_widget.toPlainText()
valid = False
try:
jsoned = json.loads(value)
if jsoned and isinstance(jsoned, dict):
valid = True
except Exception:
pass
return valid
def _on_text_change(self):
if self._block_changes or not self._item_id:
return
valid = self.is_valid()
self.update_state(valid)

View file

@ -1,218 +0,0 @@
import os
import sys
from qtpy import QtWidgets, QtCore
import qtawesome
from ayon_core import style
from ayon_core.pipeline import registered_host
from ayon_core.tools.utils import PlaceholderLineEdit
from ayon_core.tools.utils.lib import (
iter_model_rows,
qt_app_context
)
from ayon_core.tools.utils.models import RecursiveSortFilterProxyModel
from .model import (
InstanceModel,
ITEM_ID_ROLE
)
from .widgets import InstanceDetail
module = sys.modules[__name__]
module.window = None
class SubsetManagerWindow(QtWidgets.QDialog):
def __init__(self, parent=None):
super(SubsetManagerWindow, self).__init__(parent=parent)
self.setWindowTitle("Subset Manager 0.1")
self.setObjectName("SubsetManager")
if not parent:
self.setWindowFlags(
self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint
)
self.resize(780, 430)
# Trigger refresh on first called show
self._first_show = True
left_side_widget = QtWidgets.QWidget(self)
# Header part
header_widget = QtWidgets.QWidget(left_side_widget)
# Filter input
filter_input = PlaceholderLineEdit(header_widget)
filter_input.setPlaceholderText("Filter products..")
# Refresh button
icon = qtawesome.icon("fa.refresh", color="white")
refresh_btn = QtWidgets.QPushButton(header_widget)
refresh_btn.setIcon(icon)
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.addWidget(filter_input)
header_layout.addWidget(refresh_btn)
# Instances view
view = QtWidgets.QTreeView(left_side_widget)
view.setIndentation(0)
view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
model = InstanceModel(view)
proxy = RecursiveSortFilterProxyModel()
proxy.setSourceModel(model)
proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
view.setModel(proxy)
left_side_layout = QtWidgets.QVBoxLayout(left_side_widget)
left_side_layout.setContentsMargins(0, 0, 0, 0)
left_side_layout.addWidget(header_widget)
left_side_layout.addWidget(view)
details_widget = InstanceDetail(self)
layout = QtWidgets.QHBoxLayout(self)
layout.addWidget(left_side_widget, 0)
layout.addWidget(details_widget, 1)
filter_input.textChanged.connect(proxy.setFilterFixedString)
refresh_btn.clicked.connect(self._on_refresh_clicked)
view.clicked.connect(self._on_activated)
view.customContextMenuRequested.connect(self.on_context_menu)
details_widget.save_triggered.connect(self._on_save)
self._model = model
self._proxy = proxy
self._view = view
self._details_widget = details_widget
self._refresh_btn = refresh_btn
def _on_refresh_clicked(self):
self.refresh()
def _on_activated(self, index):
container = None
item_id = None
if index.isValid():
item_id = index.data(ITEM_ID_ROLE)
container = self._model.get_instance_by_id(item_id)
self._details_widget.set_details(container, item_id)
def _on_save(self):
host = registered_host()
if not hasattr(host, "save_instances"):
print("BUG: Host does not have \"save_instances\" method")
return
current_index = self._view.selectionModel().currentIndex()
if not current_index.isValid():
return
item_id = current_index.data(ITEM_ID_ROLE)
if item_id != self._details_widget.item_id():
return
item_data = self._details_widget.instance_data_from_text()
new_instances = []
for index in iter_model_rows(self._model, 0):
_item_id = index.data(ITEM_ID_ROLE)
if _item_id == item_id:
instance_data = item_data
else:
instance_data = self._model.get_instance_by_id(item_id)
new_instances.append(instance_data)
host.save_instances(new_instances)
def on_context_menu(self, point):
point_index = self._view.indexAt(point)
item_id = point_index.data(ITEM_ID_ROLE)
instance_data = self._model.get_instance_by_id(item_id)
if instance_data is None:
return
# Prepare menu
menu = QtWidgets.QMenu(self)
actions = []
host = registered_host()
if hasattr(host, "remove_instance"):
action = QtWidgets.QAction("Remove instance", menu)
action.setData(host.remove_instance)
actions.append(action)
if hasattr(host, "select_instance"):
action = QtWidgets.QAction("Select instance", menu)
action.setData(host.select_instance)
actions.append(action)
if not actions:
actions.append(QtWidgets.QAction("* Nothing to do", menu))
for action in actions:
menu.addAction(action)
# Show menu under mouse
global_point = self._view.mapToGlobal(point)
action = menu.exec_(global_point)
if not action or not action.data():
return
# Process action
# TODO catch exceptions
function = action.data()
function(instance_data)
# Reset modified data
self.refresh()
def refresh(self):
self._details_widget.set_details(None, None)
self._model.refresh()
host = registered_host()
dev_mode = os.environ.get("AVALON_DEVELOP_MODE") or ""
editable = False
if dev_mode.lower() in ("1", "yes", "true", "on"):
editable = hasattr(host, "save_instances")
self._details_widget.set_editable(editable)
def showEvent(self, *args, **kwargs):
super(SubsetManagerWindow, self).showEvent(*args, **kwargs)
if self._first_show:
self._first_show = False
self.setStyleSheet(style.load_stylesheet())
self.refresh()
def show(root=None, debug=False, parent=None):
"""Display Scene Inventory GUI
Arguments:
debug (bool, optional): Run in debug-mode,
defaults to False
parent (QtCore.QObject, optional): When provided parent the interface
to this QObject.
"""
try:
module.window.close()
del module.window
except (RuntimeError, AttributeError):
pass
with qt_app_context():
window = SubsetManagerWindow(parent)
window.show()
module.window = window
# Pull window to the front.
module.window.raise_()
module.window.activateWindow()

View file

@ -240,6 +240,16 @@ class TrayManager:
self.log.warning("Other tray started meanwhile. Exiting.")
self.exit()
project_bundle = os.getenv("AYON_BUNDLE_NAME")
studio_bundle = os.getenv("AYON_STUDIO_BUNDLE_NAME")
if studio_bundle and project_bundle != studio_bundle:
self.log.info(
f"Project bundle '{project_bundle}' is defined, but tray"
" cannot be running in project scope. Restarting tray to use"
" studio bundle."
)
self.restart()
def get_services_submenu(self):
return self._services_submenu
@ -270,11 +280,18 @@ class TrayManager:
elif is_staging_enabled():
additional_args.append("--use-staging")
if "--project" in additional_args:
idx = additional_args.index("--project")
additional_args.pop(idx)
additional_args.pop(idx)
args.extend(additional_args)
envs = dict(os.environ.items())
for key in {
"AYON_BUNDLE_NAME",
"AYON_STUDIO_BUNDLE_NAME",
"AYON_PROJECT_NAME",
}:
envs.pop(key, None)
@ -329,6 +346,7 @@ class TrayManager:
return json_response({
"username": self._cached_username,
"bundle": os.getenv("AYON_BUNDLE_NAME"),
"studio_bundle": os.getenv("AYON_STUDIO_BUNDLE_NAME"),
"dev_mode": is_dev_mode_enabled(),
"staging_mode": is_staging_enabled(),
"addons": {
@ -516,6 +534,8 @@ class TrayManager:
"AYON_SERVER_URL",
"AYON_API_KEY",
"AYON_BUNDLE_NAME",
"AYON_STUDIO_BUNDLE_NAME",
"AYON_PROJECT_NAME",
}:
os.environ.pop(key, None)
self.restart()
@ -549,6 +569,8 @@ class TrayManager:
envs = dict(os.environ.items())
for key in {
"AYON_BUNDLE_NAME",
"AYON_STUDIO_BUNDLE_NAME",
"AYON_PROJECT_NAME",
}:
envs.pop(key, None)

View file

@ -186,8 +186,15 @@ class StatusDelegate(QtWidgets.QStyledItemDelegate):
)
fm = QtGui.QFontMetrics(option.font)
if text_rect.width() < fm.width(text):
text = self._get_status_short_name(index)
if text_rect.width() < fm.width(text):
short_text = self._get_status_short_name(index)
if short_text:
text = short_text
text = fm.elidedText(
text, QtCore.Qt.ElideRight, text_rect.width()
)
# Allow at least one character
if len(text) < 2:
text = ""
fg_color = self._get_status_color(index)

View file

@ -31,9 +31,7 @@ class HostToolsHelper:
# Prepare attributes for all tools
self._workfiles_tool = None
self._loader_tool = None
self._creator_tool = None
self._publisher_tool = None
self._subset_manager_tool = None
self._scene_inventory_tool = None
self._experimental_tools_dialog = None
@ -96,49 +94,6 @@ class HostToolsHelper:
loader_tool.refresh()
def get_creator_tool(self, parent):
"""Create, cache and return creator tool window."""
if self._creator_tool is None:
from ayon_core.tools.creator import CreatorWindow
creator_window = CreatorWindow(parent=parent or self._parent)
self._creator_tool = creator_window
return self._creator_tool
def show_creator(self, parent=None):
"""Show tool to create new instantes for publishing."""
with qt_app_context():
creator_tool = self.get_creator_tool(parent)
creator_tool.refresh()
creator_tool.show()
# Pull window to the front.
creator_tool.raise_()
creator_tool.activateWindow()
def get_subset_manager_tool(self, parent):
"""Create, cache and return subset manager tool window."""
if self._subset_manager_tool is None:
from ayon_core.tools.subsetmanager import SubsetManagerWindow
subset_manager_window = SubsetManagerWindow(
parent=parent or self._parent
)
self._subset_manager_tool = subset_manager_window
return self._subset_manager_tool
def show_subset_manager(self, parent=None):
"""Show tool display/remove existing created instances."""
with qt_app_context():
subset_manager_tool = self.get_subset_manager_tool(parent)
subset_manager_tool.show()
# Pull window to the front.
subset_manager_tool.raise_()
subset_manager_tool.activateWindow()
def get_scene_inventory_tool(self, parent):
"""Create, cache and return scene inventory tool window."""
if self._scene_inventory_tool is None:
@ -261,35 +216,29 @@ class HostToolsHelper:
if tool_name == "workfiles":
return self.get_workfiles_tool(parent, *args, **kwargs)
elif tool_name == "loader":
if tool_name == "loader":
return self.get_loader_tool(parent, *args, **kwargs)
elif tool_name == "libraryloader":
if tool_name == "libraryloader":
return self.get_library_loader_tool(parent, *args, **kwargs)
elif tool_name == "creator":
return self.get_creator_tool(parent, *args, **kwargs)
elif tool_name == "subsetmanager":
return self.get_subset_manager_tool(parent, *args, **kwargs)
elif tool_name == "sceneinventory":
if tool_name == "sceneinventory":
return self.get_scene_inventory_tool(parent, *args, **kwargs)
elif tool_name == "publish":
self.log.info("Can't return publish tool window.")
# "new" publisher
elif tool_name == "publisher":
if tool_name == "publisher":
return self.get_publisher_tool(parent, *args, **kwargs)
elif tool_name == "experimental_tools":
if tool_name == "experimental_tools":
return self.get_experimental_tools_dialog(parent, *args, **kwargs)
else:
self.log.warning(
"Can't show unknown tool name: \"{}\"".format(tool_name)
)
if tool_name == "publish":
self.log.info("Can't return publish tool window.")
return None
self.log.warning(
"Can't show unknown tool name: \"{}\"".format(tool_name)
)
return None
def show_tool_by_name(self, tool_name, parent=None, *args, **kwargs):
"""Show tool by it's name.
@ -305,12 +254,6 @@ class HostToolsHelper:
elif tool_name == "libraryloader":
self.show_library_loader(parent, *args, **kwargs)
elif tool_name == "creator":
self.show_creator(parent, *args, **kwargs)
elif tool_name == "subsetmanager":
self.show_subset_manager(parent, *args, **kwargs)
elif tool_name == "sceneinventory":
self.show_scene_inventory(parent, *args, **kwargs)
@ -379,14 +322,6 @@ def show_library_loader(parent=None):
_SingletonPoint.show_tool_by_name("libraryloader", parent)
def show_creator(parent=None):
_SingletonPoint.show_tool_by_name("creator", parent)
def show_subset_manager(parent=None):
_SingletonPoint.show_tool_by_name("subsetmanager", parent)
def show_scene_inventory(parent=None):
_SingletonPoint.show_tool_by_name("sceneinventory", parent)

View file

@ -1,4 +1,5 @@
from math import floor, sqrt, ceil
from math import floor, ceil
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.style import get_objected_colors
@ -9,12 +10,15 @@ class NiceCheckbox(QtWidgets.QFrame):
clicked = QtCore.Signal()
_checked_bg_color = None
_checked_bg_color_disabled = None
_unchecked_bg_color = None
_unchecked_bg_color_disabled = None
_checker_color = None
_checker_color_disabled = None
_checker_hover_color = None
def __init__(self, checked=False, draw_icons=False, parent=None):
super(NiceCheckbox, self).__init__(parent)
super().__init__(parent)
self.setObjectName("NiceCheckbox")
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
@ -48,8 +52,6 @@ class NiceCheckbox(QtWidgets.QFrame):
self._pressed = False
self._under_mouse = False
self.icon_scale_factor = sqrt(2) / 2
icon_path_stroker = QtGui.QPainterPathStroker()
icon_path_stroker.setCapStyle(QtCore.Qt.RoundCap)
icon_path_stroker.setJoinStyle(QtCore.Qt.RoundJoin)
@ -61,35 +63,6 @@ class NiceCheckbox(QtWidgets.QFrame):
self._base_size = QtCore.QSize(90, 50)
self._load_colors()
@classmethod
def _load_colors(cls):
if cls._checked_bg_color is not None:
return
colors_info = get_objected_colors("nice-checkbox")
cls._checked_bg_color = colors_info["bg-checked"].get_qcolor()
cls._unchecked_bg_color = colors_info["bg-unchecked"].get_qcolor()
cls._checker_color = colors_info["bg-checker"].get_qcolor()
cls._checker_hover_color = colors_info["bg-checker-hover"].get_qcolor()
@property
def checked_bg_color(self):
return self._checked_bg_color
@property
def unchecked_bg_color(self):
return self._unchecked_bg_color
@property
def checker_color(self):
return self._checker_color
@property
def checker_hover_color(self):
return self._checker_hover_color
def setTristate(self, tristate=True):
if self._is_tristate != tristate:
self._is_tristate = tristate
@ -121,14 +94,14 @@ class NiceCheckbox(QtWidgets.QFrame):
def setFixedHeight(self, *args, **kwargs):
self._fixed_height_set = True
super(NiceCheckbox, self).setFixedHeight(*args, **kwargs)
super().setFixedHeight(*args, **kwargs)
if not self._fixed_width_set:
width = self.get_width_hint_by_height(self.height())
self.setFixedWidth(width)
def setFixedWidth(self, *args, **kwargs):
self._fixed_width_set = True
super(NiceCheckbox, self).setFixedWidth(*args, **kwargs)
super().setFixedWidth(*args, **kwargs)
if not self._fixed_height_set:
height = self.get_height_hint_by_width(self.width())
self.setFixedHeight(height)
@ -136,7 +109,7 @@ class NiceCheckbox(QtWidgets.QFrame):
def setFixedSize(self, *args, **kwargs):
self._fixed_height_set = True
self._fixed_width_set = True
super(NiceCheckbox, self).setFixedSize(*args, **kwargs)
super().setFixedSize(*args, **kwargs)
def steps(self):
return self._steps
@ -242,7 +215,7 @@ class NiceCheckbox(QtWidgets.QFrame):
if event.buttons() & QtCore.Qt.LeftButton:
self._pressed = True
self.repaint()
super(NiceCheckbox, self).mousePressEvent(event)
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
if self._pressed and not event.buttons() & QtCore.Qt.LeftButton:
@ -252,7 +225,7 @@ class NiceCheckbox(QtWidgets.QFrame):
self.clicked.emit()
event.accept()
return
super(NiceCheckbox, self).mouseReleaseEvent(event)
super().mouseReleaseEvent(event)
def mouseMoveEvent(self, event):
if self._pressed:
@ -261,19 +234,19 @@ class NiceCheckbox(QtWidgets.QFrame):
self._under_mouse = under_mouse
self.repaint()
super(NiceCheckbox, self).mouseMoveEvent(event)
super().mouseMoveEvent(event)
def enterEvent(self, event):
self._under_mouse = True
if self.isEnabled():
self.repaint()
super(NiceCheckbox, self).enterEvent(event)
super().enterEvent(event)
def leaveEvent(self, event):
self._under_mouse = False
if self.isEnabled():
self.repaint()
super(NiceCheckbox, self).leaveEvent(event)
super().leaveEvent(event)
def _on_animation_timeout(self):
if self._checkstate == QtCore.Qt.Checked:
@ -302,24 +275,13 @@ class NiceCheckbox(QtWidgets.QFrame):
@staticmethod
def steped_color(color1, color2, offset_ratio):
red_dif = (
color1.red() - color2.red()
)
green_dif = (
color1.green() - color2.green()
)
blue_dif = (
color1.blue() - color2.blue()
)
red = int(color2.red() + (
red_dif * offset_ratio
))
green = int(color2.green() + (
green_dif * offset_ratio
))
blue = int(color2.blue() + (
blue_dif * offset_ratio
))
red_dif = color1.red() - color2.red()
green_dif = color1.green() - color2.green()
blue_dif = color1.blue() - color2.blue()
red = int(color2.red() + (red_dif * offset_ratio))
green = int(color2.green() + (green_dif * offset_ratio))
blue = int(color2.blue() + (blue_dif * offset_ratio))
return QtGui.QColor(red, green, blue)
@ -334,20 +296,28 @@ class NiceCheckbox(QtWidgets.QFrame):
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
painter.setPen(QtCore.Qt.NoPen)
# Draw inner background
if self._current_step == self._steps:
bg_color = self.checked_bg_color
if not self.isEnabled():
bg_color = (
self._checked_bg_color_disabled
if self._current_step == self._steps
else self._unchecked_bg_color_disabled
)
elif self._current_step == self._steps:
bg_color = self._checked_bg_color
elif self._current_step == 0:
bg_color = self.unchecked_bg_color
bg_color = self._unchecked_bg_color
else:
offset_ratio = float(self._current_step) / self._steps
# Animation bg
bg_color = self.steped_color(
self.checked_bg_color,
self.unchecked_bg_color,
self._checked_bg_color,
self._unchecked_bg_color,
offset_ratio
)
@ -378,14 +348,20 @@ class NiceCheckbox(QtWidgets.QFrame):
-margin_size_c, -margin_size_c
)
if checkbox_rect.width() > checkbox_rect.height():
radius = floor(checkbox_rect.height() * 0.5)
else:
radius = floor(checkbox_rect.width() * 0.5)
slider_rect = QtCore.QRect(checkbox_rect)
slider_offset = int(
ceil(min(slider_rect.width(), slider_rect.height())) * 0.08
)
if slider_offset < 1:
slider_offset = 1
slider_rect.adjust(
slider_offset, slider_offset,
-slider_offset, -slider_offset
)
radius = floor(min(slider_rect.width(), slider_rect.height()) * 0.5)
painter.setPen(QtCore.Qt.NoPen)
painter.setBrush(bg_color)
painter.drawRoundedRect(checkbox_rect, radius, radius)
painter.drawRoundedRect(slider_rect, radius, radius)
# Draw checker
checker_size = size_without_margins - (margin_size_c * 2)
@ -394,9 +370,8 @@ class NiceCheckbox(QtWidgets.QFrame):
- (margin_size_c * 2)
- checker_size
)
if self._current_step == 0:
x_offset = 0
else:
x_offset = 0
if self._current_step != 0:
x_offset = (float(area_width) / self._steps) * self._current_step
pos_x = checkbox_rect.x() + x_offset + margin_size_c
@ -404,55 +379,80 @@ class NiceCheckbox(QtWidgets.QFrame):
checker_rect = QtCore.QRect(pos_x, pos_y, checker_size, checker_size)
under_mouse = self.isEnabled() and self._under_mouse
if under_mouse:
checker_color = self.checker_hover_color
else:
checker_color = self.checker_color
checker_color = self._checker_color
if not self.isEnabled():
checker_color = self._checker_color_disabled
elif self._under_mouse:
checker_color = self._checker_hover_color
painter.setBrush(checker_color)
painter.drawEllipse(checker_rect)
if self._draw_icons:
painter.setBrush(bg_color)
icon_path = self._get_icon_path(painter, checker_rect)
icon_path = self._get_icon_path(checker_rect)
painter.drawPath(icon_path)
# Draw shadow overlay
if not self.isEnabled():
level = 33
alpha = 127
painter.setPen(QtCore.Qt.transparent)
painter.setBrush(QtGui.QColor(level, level, level, alpha))
painter.drawRoundedRect(checkbox_rect, radius, radius)
painter.end()
def _get_icon_path(self, painter, checker_rect):
@classmethod
def _load_colors(cls):
if cls._checked_bg_color is not None:
return
colors_info = get_objected_colors("nice-checkbox")
disabled_color = QtGui.QColor(33, 33, 33, 127)
cls._checked_bg_color = colors_info["bg-checked"].get_qcolor()
cls._checked_bg_color_disabled = cls._merge_colors(
cls._checked_bg_color, disabled_color
)
cls._unchecked_bg_color = colors_info["bg-unchecked"].get_qcolor()
cls._unchecked_bg_color_disabled = cls._merge_colors(
cls._unchecked_bg_color, disabled_color
)
cls._checker_color = colors_info["bg-checker"].get_qcolor()
cls._checker_color_disabled = cls._merge_colors(
cls._checker_color, disabled_color
)
cls._checker_hover_color = colors_info["bg-checker-hover"].get_qcolor()
@staticmethod
def _merge_colors(color_1, color_2):
a = color_2.alphaF()
return QtGui.QColor(
floor((color_1.red() + (color_2.red() * a)) * 0.5),
floor((color_1.green() + (color_2.green() * a)) * 0.5),
floor((color_1.blue() + (color_2.blue() * a)) * 0.5),
color_1.alpha()
)
def _get_icon_path(self, checker_rect):
self.icon_path_stroker.setWidth(checker_rect.height() / 5)
if self._current_step == self._steps:
return self._get_enabled_icon_path(painter, checker_rect)
return self._get_enabled_icon_path(checker_rect)
if self._current_step == 0:
return self._get_disabled_icon_path(painter, checker_rect)
return self._get_disabled_icon_path(checker_rect)
if self._current_step == self._middle_step:
return self._get_middle_circle_path(painter, checker_rect)
return self._get_middle_circle_path(checker_rect)
disabled_step = self._steps - self._current_step
enabled_step = self._steps - disabled_step
half_steps = self._steps + 1 - ((self._steps + 1) % 2)
if enabled_step > disabled_step:
return self._get_enabled_icon_path(
painter, checker_rect, enabled_step, half_steps
)
else:
return self._get_disabled_icon_path(
painter, checker_rect, disabled_step, half_steps
checker_rect, enabled_step, half_steps
)
return self._get_disabled_icon_path(
checker_rect, disabled_step, half_steps
)
def _get_middle_circle_path(self, painter, checker_rect):
def _get_middle_circle_path(self, checker_rect):
width = self.icon_path_stroker.width()
path = QtGui.QPainterPath()
path.addEllipse(checker_rect.center(), width, width)
@ -460,7 +460,7 @@ class NiceCheckbox(QtWidgets.QFrame):
return path
def _get_enabled_icon_path(
self, painter, checker_rect, step=None, half_steps=None
self, checker_rect, step=None, half_steps=None
):
fifteenth = float(checker_rect.height()) / 15
# Left point
@ -509,7 +509,7 @@ class NiceCheckbox(QtWidgets.QFrame):
return self.icon_path_stroker.createStroke(path)
def _get_disabled_icon_path(
self, painter, checker_rect, step=None, half_steps=None
self, checker_rect, step=None, half_steps=None
):
center_point = QtCore.QPointF(
float(checker_rect.width()) / 2,

View file

@ -234,10 +234,11 @@ class TasksQtModel(QtGui.QStandardItemModel):
)
icon = None
if task_type_item is not None:
color = task_type_item.color or get_default_entity_icon_color()
icon = get_qt_icon({
"type": "material-symbols",
"name": task_type_item.icon,
"color": get_default_entity_icon_color()
"color": color,
})
if icon is None:

View file

@ -418,7 +418,7 @@ class ExpandingTextEdit(QtWidgets.QTextEdit):
"""QTextEdit which does not have sroll area but expands height."""
def __init__(self, parent=None):
super(ExpandingTextEdit, self).__init__(parent)
super().__init__(parent)
size_policy = self.sizePolicy()
size_policy.setHeightForWidth(True)
@ -441,14 +441,18 @@ class ExpandingTextEdit(QtWidgets.QTextEdit):
margins = self.contentsMargins()
document_width = 0
if width >= margins.left() + margins.right():
document_width = width - margins.left() - margins.right()
margins_size = margins.left() + margins.right()
if width >= margins_size:
document_width = width - margins_size
document = self.document().clone()
document.setTextWidth(document_width)
return math.ceil(
margins.top() + document.size().height() + margins.bottom()
margins.top()
+ document.size().height()
+ margins.bottom()
+ 2
)
def sizeHint(self):

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

@ -14,7 +14,6 @@ from ayon_core.lib import (
Logger,
)
from ayon_core.host import (
HostBase,
IWorkfileHost,
WorkfileInfo,
PublishedWorkfileInfo,
@ -49,19 +48,15 @@ if typing.TYPE_CHECKING:
_NOT_SET = object()
class HostType(HostBase, IWorkfileHost):
pass
class WorkfilesModel:
"""Workfiles model."""
def __init__(
self,
host: HostType,
host: IWorkfileHost,
controller: AbstractWorkfilesBackend
):
self._host: HostType = host
self._host: IWorkfileHost = host
self._controller: AbstractWorkfilesBackend = controller
self._log = Logger.get_logger("WorkfilesModel")

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.6.0+dev"

View file

@ -15,7 +15,8 @@ qtawesome = "0.7.3"
[ayon.runtimeDependencies]
aiohttp-middlewares = "^2.0.0"
Click = "^8"
OpenTimelineIO = "0.16.0"
OpenTimelineIO = "0.17.0"
otio-burnins-adapter = "1.0.0"
opencolorio = "^2.3.2,<2.4.0"
Pillow = "9.5.0"
websocket-client = ">=0.40.0,<2"

View file

@ -1,11 +1,13 @@
name = "core"
title = "Core"
version = "1.5.0+dev"
version = "1.6.0+dev"
client_dir = "ayon_core"
plugin_for = ["ayon_server"]
project_can_override_addon_version = True
ayon_server_version = ">=1.8.4,<2.0.0"
ayon_launcher_version = ">=1.0.2"
ayon_required_addons = {}

View file

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