Merge branch 'develop' into bugfix/ocio-v2-aces1.3-display-resolving-error

This commit is contained in:
Jakub Jezek 2025-08-19 14:02:27 +02:00
commit c121f419cc
No known key found for this signature in database
GPG key ID: 06DBD609ADF27FD9
99 changed files with 7149 additions and 4788 deletions

View file

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

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

View file

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

View file

@ -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

@ -33,6 +33,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
"cinema4d",
"silhouette",
"gaffer",
"loki",
}
launch_types = {LaunchTypes.local}

View file

@ -24,6 +24,7 @@ class OCIOEnvHook(PreLaunchHook):
"cinema4d",
"silhouette",
"gaffer",
"loki",
}
launch_types = set()

View file

@ -1,9 +1,14 @@
from .constants import ContextChangeReason
from .abstract import AbstractHost
from .host import (
HostBase,
ContextChangeData,
)
from .interfaces import (
IWorkfileHost,
WorkfileInfo,
PublishedWorkfileInfo,
ILoadHost,
IPublishHost,
INewPublisher,
@ -13,9 +18,16 @@ from .dirmap import HostDirmap
__all__ = (
"ContextChangeReason",
"AbstractHost",
"HostBase",
"ContextChangeData",
"IWorkfileHost",
"WorkfileInfo",
"PublishedWorkfileInfo",
"ILoadHost",
"IPublishHost",
"INewPublisher",

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

@ -0,0 +1,15 @@
from enum import Enum
class StrEnum(str, Enum):
"""A string-based Enum class that allows for string comparison."""
def __str__(self) -> str:
return self.value
class ContextChangeReason(StrEnum):
"""Reasons for context change in the host."""
undefined = "undefined"
workfile_open = "workfile.opened"
workfile_save = "workfile.saved"

View file

@ -1,13 +1,35 @@
from __future__ import annotations
import os
import logging
import contextlib
from abc import ABC, abstractproperty
import typing
from typing import Optional, Any
from dataclasses import dataclass
# NOTE can't import 'typing' because of issues in Maya 2020
# - shiboken crashes on 'typing' module import
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 HostContextData
class HostBase(ABC):
@dataclass
class ContextChangeData:
project_entity: dict[str, Any]
folder_entity: dict[str, Any]
task_entity: dict[str, Any]
reason: ContextChangeReason
anatomy: Anatomy
class HostBase(AbstractHost):
"""Base of host implementation class.
Host is pipeline implementation of DCC application. This class should help
@ -82,47 +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
@abstractproperty
def name(self):
"""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["AYON_PROJECT_NAME"]
return os.environ.get("AYON_PROJECT_NAME")
def get_current_folder_path(self):
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):
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):
def get_current_context(self) -> HostContextData:
"""Get current context information.
This method should be used to get current context of host. Usage of
@ -131,16 +147,85 @@ 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(),
"task_name": self.get_current_task_name()
}
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:
"""Set current context information.
This method should be used to set current context of host. Usage of
this method can be crucial for host implementations in DCCs where
can be opened multiple workfiles at one moment and change of context
can't be caught properly.
Notes:
This method should not care about change of workdir and expect any
of the arguments.
Args:
folder_entity (Optional[dict[str, Any]]): Folder entity.
task_entity (Optional[dict[str, Any]]): Task entity.
reason (ContextChangeReason): Reason for context change.
project_entity (Optional[dict[str, Any]]): Project entity data.
anatomy (Optional[Anatomy]): Anatomy instance for the project.
Returns:
dict[str, Optional[str]]: Context information with project name,
folder path and task name.
"""
from ayon_core.pipeline import Anatomy
folder_path = folder_entity["path"]
task_name = task_entity["name"]
context = self.get_current_context()
# Don't do anything if context did not change
if (
context["folder_path"] == folder_path
and context["task_name"] == task_name
):
return context
project_name = self.get_current_project_name()
if project_entity is None:
project_entity = ayon_api.get_project(project_name)
if anatomy is None:
anatomy = Anatomy(project_name, project_entity=project_entity)
context_change_data = ContextChangeData(
project_entity,
folder_entity,
task_entity,
reason,
anatomy,
)
self._before_context_change(context_change_data)
self._set_current_context(context_change_data)
self._after_context_change(context_change_data)
return self._emit_context_change_event(
project_name,
folder_path,
task_name,
)
def get_context_title(self):
"""Context title shown for UI purposes.
@ -187,3 +272,91 @@ class HostBase(ABC):
yield
finally:
pass
def _emit_context_change_event(
self,
project_name: str,
folder_path: Optional[str],
task_name: Optional[str],
) -> HostContextData:
"""Emit context change event.
Args:
project_name (str): Name of the project.
folder_path (Optional[str]): Path of the folder.
task_name (Optional[str]): Name of the task.
Returns:
HostContextData: Data send to context change event.
"""
data: HostContextData = {
"project_name": project_name,
"folder_path": folder_path,
"task_name": task_name,
}
emit_event("taskChanged", data)
return data
def _set_current_context(
self, context_change_data: ContextChangeData
) -> None:
"""Method that changes the context in host.
Can be overriden for hosts that do need different handling of context
than using environment variables.
Args:
context_change_data (ContextChangeData): Context change related
data.
"""
project_name = self.get_current_project_name()
folder_path = None
task_name = None
if context_change_data.folder_entity:
folder_path = context_change_data.folder_entity["path"]
if context_change_data.task_entity:
task_name = context_change_data.task_entity["name"]
envs = {
"AYON_PROJECT_NAME": project_name,
"AYON_FOLDER_PATH": folder_path,
"AYON_TASK_NAME": task_name,
}
# Update the Session and environments. Pop from environments all
# keys with value set to None.
for key, value in envs.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def _before_context_change(self, context_change_data: ContextChangeData):
"""Before context is changed.
This method is called before the context is changed in the host.
Can be overridden to implement host specific logic.
Args:
context_change_data (ContextChangeData): Object with information
about context change.
"""
pass
def _after_context_change(self, context_change_data: ContextChangeData):
"""After context is changed.
This method is called after the context is changed in the host.
Can be overridden to implement host specific logic.
Args:
context_change_data (ContextChangeData): Object with information
about context change.
"""
pass

View file

@ -1,384 +0,0 @@
from abc import ABC, abstractmethod
class MissingMethodsError(ValueError):
"""Exception when host miss some required methods for specific workflow.
Args:
host (HostBase): Host implementation where are missing methods.
missing_methods (list[str]): List of missing methods.
"""
def __init__(self, host, missing_methods):
joined_missing = ", ".join(
['"{}"'.format(item) for item in missing_methods]
)
host_name = getattr(host, "name", None)
if not host_name:
try:
host_name = host.__file__.replace("\\", "/").split("/")[-3]
except Exception:
host_name = str(host)
message = (
"Host \"{}\" miss methods {}".format(host_name, joined_missing)
)
super(MissingMethodsError, self).__init__(message)
class ILoadHost:
"""Implementation requirements to be able use reference of representations.
The load plugins can do referencing even without implementation of methods
here, but switch and removement of containers would not be possible.
Questions:
- Is list container dependency of host or load plugins?
- Should this be directly in HostBase?
- how to find out if referencing is available?
- do we need to know that?
"""
@staticmethod
def get_missing_load_methods(host):
"""Look for missing methods on "old type" host implementation.
Method is used for validation of implemented functions related to
loading. Checks only existence of methods.
Args:
Union[ModuleType, HostBase]: Object of host where to look for
required methods.
Returns:
list[str]: Missing method implementations for loading workflow.
"""
if isinstance(host, ILoadHost):
return []
required = ["ls"]
missing = []
for name in required:
if not hasattr(host, name):
missing.append(name)
return missing
@staticmethod
def validate_load_methods(host):
"""Validate implemented methods of "old type" host for load workflow.
Args:
Union[ModuleType, HostBase]: Object of host to validate.
Raises:
MissingMethodsError: If there are missing methods on host
implementation.
"""
missing = ILoadHost.get_missing_load_methods(host)
if missing:
raise MissingMethodsError(host, missing)
@abstractmethod
def get_containers(self):
"""Retrieve referenced containers from scene.
This can be implemented in hosts where referencing can be used.
Todo:
Rename function to something more self explanatory.
Suggestion: 'get_containers'
Returns:
list[dict]: Information about loaded containers.
"""
pass
# --- Deprecated method names ---
def ls(self):
"""Deprecated variant of 'get_containers'.
Todo:
Remove when all usages are replaced.
"""
return self.get_containers()
class IWorkfileHost(ABC):
"""Implementation requirements to be able use workfile utils and tool."""
@staticmethod
def get_missing_workfile_methods(host):
"""Look for missing methods on "old type" host implementation.
Method is used for validation of implemented functions related to
workfiles. Checks only existence of methods.
Args:
Union[ModuleType, HostBase]: Object of host where to look for
required methods.
Returns:
list[str]: Missing method implementations for workfiles workflow.
"""
if isinstance(host, IWorkfileHost):
return []
required = [
"open_file",
"save_file",
"current_file",
"has_unsaved_changes",
"file_extensions",
"work_root",
]
missing = []
for name in required:
if not hasattr(host, name):
missing.append(name)
return missing
@staticmethod
def validate_workfile_methods(host):
"""Validate methods of "old type" host for workfiles workflow.
Args:
Union[ModuleType, HostBase]: Object of host to validate.
Raises:
MissingMethodsError: If there are missing methods on host
implementation.
"""
missing = IWorkfileHost.get_missing_workfile_methods(host)
if missing:
raise MissingMethodsError(host, missing)
@abstractmethod
def get_workfile_extensions(self):
"""Extensions that can be used as save.
Questions:
This could potentially use 'HostDefinition'.
"""
return []
@abstractmethod
def save_workfile(self, dst_path=None):
"""Save currently opened scene.
Args:
dst_path (str): Where the current scene should be saved. Or use
current path if 'None' is passed.
"""
pass
@abstractmethod
def open_workfile(self, filepath):
"""Open passed filepath in the host.
Args:
filepath (str): Path to workfile.
"""
pass
@abstractmethod
def get_current_workfile(self):
"""Retrieve path to current opened file.
Returns:
str: Path to file which is currently opened.
None: If nothing is opened.
"""
return None
def workfile_has_unsaved_changes(self):
"""Currently opened scene is saved.
Not all hosts can know if current scene is saved because the API of
DCC does not support it.
Returns:
bool: True if scene is saved and False if has unsaved
modifications.
None: Can't tell if workfiles has modifications.
"""
return None
def work_root(self, session):
"""Modify workdir per host.
Default implementation keeps workdir untouched.
Warnings:
We must handle this modification with more sophisticated way
because this can't be called out of DCC so opening of last workfile
(calculated before DCC is launched) is complicated. Also breaking
defined work template is not a good idea.
Only place where it's really used and can make sense is Maya. There
workspace.mel can modify subfolders where to look for maya files.
Args:
session (dict): Session context data.
Returns:
str: Path to new workdir.
"""
return session["AYON_WORKDIR"]
# --- Deprecated method names ---
def file_extensions(self):
"""Deprecated variant of 'get_workfile_extensions'.
Todo:
Remove when all usages are replaced.
"""
return self.get_workfile_extensions()
def save_file(self, dst_path=None):
"""Deprecated variant of 'save_workfile'.
Todo:
Remove when all usages are replaced.
"""
self.save_workfile(dst_path)
def open_file(self, filepath):
"""Deprecated variant of 'open_workfile'.
Todo:
Remove when all usages are replaced.
"""
return self.open_workfile(filepath)
def current_file(self):
"""Deprecated variant of 'get_current_workfile'.
Todo:
Remove when all usages are replaced.
"""
return self.get_current_workfile()
def has_unsaved_changes(self):
"""Deprecated variant of 'workfile_has_unsaved_changes'.
Todo:
Remove when all usages are replaced.
"""
return self.workfile_has_unsaved_changes()
class IPublishHost:
"""Functions related to new creation system in new publisher.
New publisher is not storing information only about each created instance
but also some global data. At this moment are data related only to context
publish plugins but that can extend in future.
"""
@staticmethod
def get_missing_publish_methods(host):
"""Look for missing methods on "old type" host implementation.
Method is used for validation of implemented functions related to
new publish creation. Checks only existence of methods.
Args:
Union[ModuleType, HostBase]: Host module where to look for
required methods.
Returns:
list[str]: Missing method implementations for new publisher
workflow.
"""
if isinstance(host, IPublishHost):
return []
required = [
"get_context_data",
"update_context_data",
"get_context_title",
"get_current_context",
]
missing = []
for name in required:
if not hasattr(host, name):
missing.append(name)
return missing
@staticmethod
def validate_publish_methods(host):
"""Validate implemented methods of "old type" host.
Args:
Union[ModuleType, HostBase]: Host module to validate.
Raises:
MissingMethodsError: If there are missing methods on host
implementation.
"""
missing = IPublishHost.get_missing_publish_methods(host)
if missing:
raise MissingMethodsError(host, missing)
@abstractmethod
def get_context_data(self):
"""Get global data related to creation-publishing from workfile.
These data are not related to any created instance but to whole
publishing context. Not saving/returning them will cause that each
reset of publishing resets all values to default ones.
Context data can contain information about enabled/disabled publish
plugins or other values that can be filled by artist.
Returns:
dict: Context data stored using 'update_context_data'.
"""
pass
@abstractmethod
def update_context_data(self, data, changes):
"""Store global context data to workfile.
Called when some values in context data has changed.
Without storing the values in a way that 'get_context_data' would
return them will each reset of publishing cause loose of filled values
by artist. Best practice is to store values into workfile, if possible.
Args:
data (dict): New data as are.
changes (dict): Only data that has been changed. Each value has
tuple with '(<old>, <new>)' value.
"""
pass
class INewPublisher(IPublishHost):
"""Legacy interface replaced by 'IPublishHost'.
Deprecated:
'INewPublisher' is replaced by 'IPublishHost' please change your
imports.
There is no "reasonable" way hot mark these classes as deprecated
to show warning of wrong import. Deprecated since 3.14.* will be
removed in 3.15.*
"""
pass

View file

@ -0,0 +1,66 @@
from .exceptions import MissingMethodsError
from .workfiles import (
IWorkfileHost,
WorkfileInfo,
PublishedWorkfileInfo,
OpenWorkfileOptionalData,
ListWorkfilesOptionalData,
ListPublishedWorkfilesOptionalData,
SaveWorkfileOptionalData,
CopyWorkfileOptionalData,
CopyPublishedWorkfileOptionalData,
get_open_workfile_context,
get_list_workfiles_context,
get_list_published_workfiles_context,
get_save_workfile_context,
get_copy_workfile_context,
get_copy_repre_workfile_context,
OpenWorkfileContext,
ListWorkfilesContext,
ListPublishedWorkfilesContext,
SaveWorkfileContext,
CopyWorkfileContext,
CopyPublishedWorkfileContext,
)
from .interfaces import (
IPublishHost,
INewPublisher,
ILoadHost,
)
__all__ = (
"MissingMethodsError",
"IWorkfileHost",
"WorkfileInfo",
"PublishedWorkfileInfo",
"OpenWorkfileOptionalData",
"ListWorkfilesOptionalData",
"ListPublishedWorkfilesOptionalData",
"SaveWorkfileOptionalData",
"CopyWorkfileOptionalData",
"CopyPublishedWorkfileOptionalData",
"get_open_workfile_context",
"get_list_workfiles_context",
"get_list_published_workfiles_context",
"get_save_workfile_context",
"get_copy_workfile_context",
"get_copy_repre_workfile_context",
"OpenWorkfileContext",
"ListWorkfilesContext",
"ListPublishedWorkfilesContext",
"SaveWorkfileContext",
"CopyWorkfileContext",
"CopyPublishedWorkfileContext",
"IPublishHost",
"INewPublisher",
"ILoadHost",
)

View file

@ -0,0 +1,15 @@
class MissingMethodsError(ValueError):
"""Exception when host miss some required methods for a specific workflow.
Args:
host (HostBase): Host implementation where are missing methods.
missing_methods (list[str]): List of missing methods.
"""
def __init__(self, host, missing_methods):
joined_missing = ", ".join(
['"{}"'.format(item) for item in missing_methods]
)
super().__init__(
f"Host \"{host.name}\" miss methods {joined_missing}"
)

View file

@ -0,0 +1,189 @@
from abc import abstractmethod
from ayon_core.host.abstract import AbstractHost
from .exceptions import MissingMethodsError
class ILoadHost(AbstractHost):
"""Implementation requirements to be able use reference of representations.
The load plugins can do referencing even without implementation of methods
here, but switch and removement of containers would not be possible.
Questions:
- Is list container dependency of host or load plugins?
- Should this be directly in HostBase?
- how to find out if referencing is available?
- do we need to know that?
"""
@staticmethod
def get_missing_load_methods(host):
"""Look for missing methods on "old type" host implementation.
Method is used for validation of implemented functions related to
loading. Checks only existence of methods.
Args:
Union[ModuleType, AbstractHost]: Object of host where to look for
required methods.
Returns:
list[str]: Missing method implementations for loading workflow.
"""
if isinstance(host, ILoadHost):
return []
required = ["ls"]
missing = []
for name in required:
if not hasattr(host, name):
missing.append(name)
return missing
@staticmethod
def validate_load_methods(host):
"""Validate implemented methods of "old type" host for load workflow.
Args:
Union[ModuleType, AbstractHost]: Object of host to validate.
Raises:
MissingMethodsError: If there are missing methods on host
implementation.
"""
missing = ILoadHost.get_missing_load_methods(host)
if missing:
raise MissingMethodsError(host, missing)
@abstractmethod
def get_containers(self):
"""Retrieve referenced containers from scene.
This can be implemented in hosts where referencing can be used.
Todo:
Rename function to something more self explanatory.
Suggestion: 'get_containers'
Returns:
list[dict]: Information about loaded containers.
"""
pass
# --- Deprecated method names ---
def ls(self):
"""Deprecated variant of 'get_containers'.
Todo:
Remove when all usages are replaced.
"""
return self.get_containers()
class IPublishHost(AbstractHost):
"""Functions related to new creation system in new publisher.
New publisher is not storing information only about each created instance
but also some global data. At this moment are data related only to context
publish plugins but that can extend in future.
"""
@staticmethod
def get_missing_publish_methods(host):
"""Look for missing methods on "old type" host implementation.
Method is used for validation of implemented functions related to
new publish creation. Checks only existence of methods.
Args:
Union[ModuleType, AbstractHost]: Host module where to look for
required methods.
Returns:
list[str]: Missing method implementations for new publisher
workflow.
"""
if isinstance(host, IPublishHost):
return []
required = [
"get_context_data",
"update_context_data",
"get_context_title",
"get_current_context",
]
missing = []
for name in required:
if not hasattr(host, name):
missing.append(name)
return missing
@staticmethod
def validate_publish_methods(host):
"""Validate implemented methods of "old type" host.
Args:
Union[ModuleType, AbstractHost]: Host module to validate.
Raises:
MissingMethodsError: If there are missing methods on host
implementation.
"""
missing = IPublishHost.get_missing_publish_methods(host)
if missing:
raise MissingMethodsError(host, missing)
@abstractmethod
def get_context_data(self):
"""Get global data related to creation-publishing from workfile.
These data are not related to any created instance but to whole
publishing context. Not saving/returning them will cause that each
reset of publishing resets all values to default ones.
Context data can contain information about enabled/disabled publish
plugins or other values that can be filled by artist.
Returns:
dict: Context data stored using 'update_context_data'.
"""
pass
@abstractmethod
def update_context_data(self, data, changes):
"""Store global context data to workfile.
Called when some values in context data has changed.
Without storing the values in a way that 'get_context_data' would
return them will each reset of publishing cause loose of filled values
by artist. Best practice is to store values into workfile, if possible.
Args:
data (dict): New data as are.
changes (dict): Only data that has been changed. Each value has
tuple with '(<old>, <new>)' value.
"""
pass
class INewPublisher(IPublishHost):
"""Legacy interface replaced by 'IPublishHost'.
Deprecated:
'INewPublisher' is replaced by 'IPublishHost' please change your
imports.
There is no "reasonable" way hot mark these classes as deprecated
to show warning of wrong import. Deprecated since 3.14.* will be
removed in 3.15.*
"""
pass

File diff suppressed because it is too large Load diff

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,22 +16,31 @@ import ayon_api
_PLACEHOLDER = object()
def _get_ayon_appdirs(*args):
# 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: 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.
@ -48,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.
@ -73,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.
@ -97,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
@ -107,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.
@ -120,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.
@ -130,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
@ -148,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:
@ -168,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
@ -191,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`_
@ -223,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:
@ -273,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:
@ -297,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`.
@ -314,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.
@ -350,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
@ -368,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
@ -384,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.
@ -406,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()
@ -414,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:
@ -431,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()
@ -440,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
@ -457,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`
@ -490,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`
@ -524,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():
@ -591,10 +572,26 @@ def get_local_site_id():
def get_ayon_username():
"""AYON username used for templates and publishing.
Uses curet ayon api username.
Uses current ayon api username.
Returns:
str: Username.
"""
return ayon_api.get_user()["name"]
# Look for username in the connection stack
# - this is used when service is working as other user
# (e.g. in background sync)
# TODO @iLLiCiTiT - do not use private attribute of 'ServerAPI', rather
# use public method to get username from connection stack.
con = ayon_api.get_server_api_connection()
user_stack = getattr(con, "_as_user_stack", None)
if user_stack is not None:
username = user_stack.username
if username is not None:
return username
# Cache the username to avoid multiple API calls
# - it is not expected that user would change
if _Cache.username is None:
_Cache.username = ayon_api.get_user()["name"]
return _Cache.username

View file

@ -3,6 +3,7 @@ import re
import copy
import numbers
import warnings
import platform
from string import Formatter
import typing
from typing import List, Dict, Any, Set
@ -12,6 +13,7 @@ if typing.TYPE_CHECKING:
SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)")
OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?")
_IS_WINDOWS = platform.system().lower() == "windows"
class TemplateUnsolved(Exception):
@ -277,8 +279,11 @@ class TemplateResult(str):
"""Convert to normalized path."""
cls = self.__class__
path = str(self)
if _IS_WINDOWS:
path = path.replace("\\", "/")
return cls(
os.path.normpath(self.replace("\\", "/")),
os.path.normpath(path),
self.template,
self.solved,
self.used_values,

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

@ -6,6 +6,7 @@ from .exceptions import (
AnatomyTemplateUnsolved,
)
from .anatomy import Anatomy
from .templates import AnatomyTemplateResult, AnatomyStringTemplate
__all__ = (
@ -16,4 +17,7 @@ __all__ = (
"AnatomyTemplateUnsolved",
"Anatomy",
"AnatomyTemplateResult",
"AnatomyStringTemplate",
)

View file

@ -1,6 +1,7 @@
import os
import re
import copy
import platform
import collections
import numbers
@ -15,6 +16,7 @@ from .exceptions import (
AnatomyTemplateUnsolved,
)
_IS_WINDOWS = platform.system().lower() == "windows"
_PLACEHOLDER = object()
@ -526,6 +528,14 @@ class AnatomyTemplates:
root_key = "{" + root_key + "}"
output = output.replace(str(used_value), root_key)
# Make sure rootless path is with forward slashes
if _IS_WINDOWS:
output.replace("\\", "/")
# Make sure there are no double slashes
while "//" in output:
output = output.replace("//", "/")
return output
def format(self, data, strict=True):

View file

@ -1,21 +1,22 @@
"""Core pipeline functionality"""
from __future__ import annotations
import os
import logging
import platform
import uuid
import warnings
from typing import Optional, Any
import ayon_api
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,
emit_event,
version_up
)
from ayon_core.addon import load_addons, AddonsManager
from ayon_core.settings import get_project_settings
@ -23,13 +24,7 @@ from ayon_core.settings import get_project_settings
from .publish.lib import filter_pyblish_plugins
from .anatomy import Anatomy
from .template_data import get_template_data_with_names
from .workfile import (
get_workdir,
get_custom_workfile_template_by_string_context,
get_workfile_template_key_from_context,
get_last_workfile,
MissingWorkdirError,
)
from .workfile import get_custom_workfile_template_by_string_context
from . import (
register_loader_plugin_path,
register_inventory_action_path,
@ -75,7 +70,7 @@ def _get_addons_manager():
def register_root(path):
"""Register currently active root"""
"""DEPRECATED Register currently active root."""
log.info("Registering root: %s" % path)
_registered_root["_"] = path
@ -94,18 +89,29 @@ def registered_root():
Returns:
dict[str, str]: Root paths.
"""
"""
warnings.warn(
"Used deprecated function 'registered_root'. Please use 'Anatomy'"
" to get roots.",
DeprecationWarning,
stacklevel=2,
)
return _registered_root["_"]
def install_host(host):
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, AbstractHost):
log.error(
f"Host must be a subclass of 'AbstractHost', got '{type(host)}'."
)
global _is_installed
_is_installed = True
@ -183,7 +189,7 @@ def install_ayon_plugins(project_name=None, host_name=None):
register_inventory_action_path(INVENTORY_PATH)
if host_name is None:
host_name = os.environ.get("AYON_HOST_NAME")
host_name = get_current_host_name()
addons_manager = _get_addons_manager()
publish_plugin_dirs = addons_manager.collect_publish_plugin_paths(
@ -304,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")
@ -340,32 +346,50 @@ 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"]
def get_current_project_settings() -> dict[str, Any]:
"""Project settings for the current context project.
Returns:
dict[str, Any]: Project settings for the current context project.
Raises:
ValueError: If current project is not set.
"""
project_name = get_current_project_name()
if not project_name:
raise ValueError(
"Current project is not set. Can't get project settings."
)
return get_project_settings(project_name)
def get_current_project_entity(fields=None):
"""Helper function to get project document based on global Session.
@ -505,66 +529,64 @@ def get_current_context_custom_workfile_template(project_settings=None):
)
def change_current_context(folder_entity, task_entity, template_key=None):
_PLACEHOLDER = object()
def change_current_context(
folder_entity: dict[str, Any],
task_entity: dict[str, Any],
*,
template_key: Optional[str] = _PLACEHOLDER,
reason: Optional[str] = None,
project_entity: Optional[dict[str, Any]] = None,
anatomy: Optional[Anatomy] = None,
) -> dict[str, str]:
"""Update active Session to a new task work area.
This updates the live Session to a different task under folder.
This updates the live Session to a different task under a folder.
Notes:
* This function does a lot of things related to workfiles which
extends arguments options a lot.
* We might want to implement 'set_current_context' on host integration
instead. But `AYON_WORKDIR`, which is related to 'IWorkfileHost',
would not be available in that case which might break some
logic.
Args:
folder_entity (Dict[str, Any]): Folder entity to set.
task_entity (Dict[str, Any]): Task entity to set.
template_key (Union[str, None]): Prepared template key to be used for
workfile template in Anatomy.
template_key (Optional[str]): DEPRECATED: Prepared template key to
be used for workfile template in Anatomy.
reason (Optional[str]): Reason for changing context.
anatomy (Optional[Anatomy]): Anatomy object used for workdir
calculation.
project_entity (Optional[dict[str, Any]]): Project entity used for
workdir calculation.
Returns:
Dict[str, str]: The changed key, values in the current Session.
"""
dict[str, str]: New context data.
project_name = get_current_project_name()
workdir = None
folder_path = None
task_name = None
if folder_entity:
folder_path = folder_entity["path"]
if task_entity:
task_name = task_entity["name"]
project_entity = ayon_api.get_project(project_name)
host_name = get_current_host_name()
workdir = get_workdir(
project_entity,
folder_entity,
task_entity,
host_name,
template_key=template_key
"""
if template_key is not _PLACEHOLDER:
warnings.warn(
(
"Used deprecated argument 'template_key' in"
" 'change_current_context'."
" It is not necessary to pass it in anymore."
),
DeprecationWarning,
stacklevel=2,
)
envs = {
"AYON_PROJECT_NAME": project_name,
"AYON_FOLDER_PATH": folder_path,
"AYON_TASK_NAME": task_name,
"AYON_WORKDIR": workdir,
}
# Update the Session and environments. Pop from environments all keys with
# value set to None.
for key, value in envs.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
data = envs.copy()
# Convert env keys to human readable keys
data["project_name"] = project_name
data["folder_path"] = folder_path
data["task_name"] = task_name
data["workdir_path"] = workdir
# Emit session change
emit_event("taskChanged", data)
return data
host = registered_host()
return host.set_current_context(
folder_entity,
task_entity,
reason=reason,
project_entity=project_entity,
anatomy=anatomy,
)
def get_process_id():
@ -583,53 +605,16 @@ def get_process_id():
def version_up_current_workfile():
"""Function to increment and save workfile
"""DEPRECATED Function to increment and save workfile.
Please use 'save_next_version' from 'ayon_core.pipeline.workfile' instead.
"""
host = registered_host()
project_name = get_current_project_name()
folder_path = get_current_folder_path()
task_name = get_current_task_name()
host_name = get_current_host_name()
template_key = get_workfile_template_key_from_context(
project_name,
folder_path,
task_name,
host_name,
warnings.warn(
"Used deprecated 'version_up_current_workfile' please use"
" 'save_next_version' from 'ayon_core.pipeline.workfile' instead.",
DeprecationWarning,
stacklevel=2,
)
anatomy = Anatomy(project_name)
data = get_template_data_with_names(
project_name, folder_path, task_name, host_name
)
data["root"] = anatomy.roots
work_template = anatomy.get_template_item("work", template_key)
# Define saving file extension
extensions = host.get_workfile_extensions()
current_file = host.get_current_workfile()
if current_file:
extensions = [os.path.splitext(current_file)[-1]]
work_root = work_template["directory"].format_strict(data)
file_template = work_template["file"].template
last_workfile_path = get_last_workfile(
work_root, file_template, data, extensions, True
)
# `get_last_workfile` will return the first expected file version
# if no files exist yet. In that case, if they do not exist we will
# want to save v001
new_workfile_path = last_workfile_path
if os.path.exists(new_workfile_path):
new_workfile_path = version_up(new_workfile_path)
# Raise an error if the parent folder doesn't exist as `host.save_workfile`
# is not supposed/able to create missing folders.
parent_folder = os.path.dirname(new_workfile_path)
if not os.path.exists(parent_folder):
raise MissingWorkdirError(
f"Work area directory '{parent_folder}' does not exist.")
host.save_workfile(new_workfile_path)
from ayon_core.pipeline.workfile import save_next_version
save_next_version()

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

@ -7,6 +7,10 @@ import opentimelineio as otio
from opentimelineio import opentime as _ot
# https://github.com/AcademySoftwareFoundation/OpenTimelineIO/issues/1822
OTIO_EPSILON = 1e-9
def otio_range_to_frame_range(otio_range):
start = _ot.to_frames(
otio_range.start_time, otio_range.start_time.rate)

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

@ -720,11 +720,13 @@ def get_representation_path(representation, root=None):
str: fullpath of the representation
"""
if root is None:
from ayon_core.pipeline import registered_root
from ayon_core.pipeline import get_current_project_name, Anatomy
root = registered_root()
anatomy = Anatomy(get_current_project_name())
return get_representation_path_with_anatomy(
representation, anatomy
)
def path_from_representation():
try:
@ -772,7 +774,7 @@ def get_representation_path(representation, root=None):
dir_path, file_name = os.path.split(path)
if not os.path.exists(dir_path):
return
return None
base_name, ext = os.path.splitext(file_name)
file_name_items = None
@ -782,7 +784,7 @@ def get_representation_path(representation, root=None):
file_name_items = base_name.split("%")
if not file_name_items:
return
return None
filename_start = file_name_items[0]

View file

@ -51,7 +51,7 @@ class DiscoverResult:
"*** Discovered {} plugins".format(len(self.plugins))
)
for cls in self.plugins:
lines.append("- {}".format(cls.__class__.__name__))
lines.append("- {}".format(cls.__name__))
# Plugin that were defined to be ignored
if self.ignored_plugins or full_report:

View file

@ -5,6 +5,7 @@ import sys
import inspect
import copy
import warnings
import hashlib
import xml.etree.ElementTree
from typing import TYPE_CHECKING, Optional, Union, List
@ -243,32 +244,38 @@ def publish_plugins_discover(
for path in paths:
path = os.path.normpath(path)
if not os.path.isdir(path):
continue
filenames = []
if os.path.isdir(path):
filenames.extend(
name
for name in os.listdir(path)
if (
os.path.isfile(os.path.join(path, name))
and not name.startswith("_")
)
)
else:
filenames.append(os.path.basename(path))
path = os.path.dirname(path)
for fname in os.listdir(path):
if fname.startswith("_"):
continue
abspath = os.path.join(path, fname)
if not os.path.isfile(abspath):
continue
mod_name, mod_ext = os.path.splitext(fname)
if mod_ext != ".py":
dirpath_hash = hashlib.md5(path.encode("utf-8")).hexdigest()
for filename in filenames:
basename, ext = os.path.splitext(filename)
if ext.lower() != ".py":
continue
filepath = os.path.join(path, filename)
module_name = f"{dirpath_hash}.{basename}"
try:
module = import_filepath(
abspath, mod_name, sys_module_name=mod_name)
filepath, module_name, sys_module_name=module_name
)
except Exception as err: # noqa: BLE001
# we need broad exception to catch all possible errors.
result.crashed_file_paths[abspath] = sys.exc_info()
result.crashed_file_paths[filepath] = sys.exc_info()
log.debug('Skipped: "%s" (%s)', mod_name, err)
log.debug('Skipped: "%s" (%s)', filepath, err)
continue
for plugin in pyblish.plugin.plugins_from_module(module):
@ -354,12 +361,18 @@ def get_plugin_settings(plugin, project_settings, log, category=None):
# Use project settings based on a category name
if category:
try:
return (
output = (
project_settings
[category]
["publish"]
[plugin.__name__]
)
warnings.warn(
"Please fill 'settings_category'"
f" for plugin '{plugin.__name__}'.",
DeprecationWarning
)
return output
except KeyError:
pass
@ -384,12 +397,18 @@ def get_plugin_settings(plugin, project_settings, log, category=None):
category_from_file = "core"
try:
return (
output = (
project_settings
[category_from_file]
[plugin_kind]
[plugin.__name__]
)
warnings.warn(
"Please fill 'settings_category'"
f" for plugin '{plugin.__name__}'.",
DeprecationWarning
)
return output
except KeyError:
pass
return {}
@ -1048,7 +1067,7 @@ def main_cli_publish(
discover_result = publish_plugins_discover()
publish_plugins = discover_result.plugins
print("\n".join(discover_result.get_report(only_errors=False)))
print(discover_result.get_report(only_errors=False))
# Error exit as soon as any error occurs.
error_format = ("Failed {plugin.__name__}: "

View file

@ -4,6 +4,8 @@ from .path_resolving import (
get_workdir_with_workdir_data,
get_workdir,
get_last_workfile_with_version_from_paths,
get_last_workfile_from_paths,
get_last_workfile_with_version,
get_last_workfile,
@ -11,12 +13,21 @@ from .path_resolving import (
get_custom_workfile_template_by_string_context,
create_workdir_extra_folders,
get_comments_from_workfile_paths,
)
from .utils import (
should_use_last_workfile_on_launch,
should_open_workfiles_tool_on_launch,
MissingWorkdirError,
save_workfile_info,
save_current_workfile_to,
save_workfile_with_current_context,
save_next_version,
copy_workfile_to_context,
find_workfile_rootless_path,
)
from .build_workfile import BuildWorkfile
@ -37,18 +48,29 @@ __all__ = (
"get_workdir_with_workdir_data",
"get_workdir",
"get_last_workfile_with_version_from_paths",
"get_last_workfile_from_paths",
"get_last_workfile_with_version",
"get_last_workfile",
"find_workfile_rootless_path",
"get_custom_workfile_template",
"get_custom_workfile_template_by_string_context",
"create_workdir_extra_folders",
"get_comments_from_workfile_paths",
"should_use_last_workfile_on_launch",
"should_open_workfiles_tool_on_launch",
"MissingWorkdirError",
"save_workfile_info",
"save_current_workfile_to",
"save_workfile_with_current_context",
"save_next_version",
"copy_workfile_to_context",
"BuildWorkfile",
"discover_workfile_build_plugins",

View file

@ -1,8 +1,12 @@
from __future__ import annotations
import os
import re
import copy
import platform
import warnings
import typing
from typing import Optional, Dict, Any
from dataclasses import dataclass
import ayon_api
@ -15,6 +19,9 @@ from ayon_core.lib import (
from ayon_core.pipeline import version_start, Anatomy
from ayon_core.pipeline.template_data import get_template_data
if typing.TYPE_CHECKING:
from ayon_core.pipeline.anatomy import AnatomyTemplateResult
def get_workfile_template_key_from_context(
project_name: str,
@ -111,7 +118,7 @@ def get_workdir_with_workdir_data(
anatomy=None,
template_key=None,
project_settings=None
):
) -> "AnatomyTemplateResult":
"""Fill workdir path from entered data and project's anatomy.
It is possible to pass only project's name instead of project's anatomy but
@ -130,9 +137,9 @@ def get_workdir_with_workdir_data(
if 'template_key' is not passed.
Returns:
TemplateResult: Workdir path.
"""
AnatomyTemplateResult: Workdir path.
"""
if not anatomy:
anatomy = Anatomy(project_name)
@ -147,7 +154,7 @@ def get_workdir_with_workdir_data(
template_obj = anatomy.get_template_item(
"work", template_key, "directory"
)
# Output is TemplateResult object which contain useful data
# Output is AnatomyTemplateResult object which contain useful data
output = template_obj.format_strict(workdir_data)
if output:
return output.normalized()
@ -155,14 +162,14 @@ def get_workdir_with_workdir_data(
def get_workdir(
project_entity,
folder_entity,
task_entity,
host_name,
project_entity: dict[str, Any],
folder_entity: dict[str, Any],
task_entity: dict[str, Any],
host_name: str,
anatomy=None,
template_key=None,
project_settings=None
):
) -> "AnatomyTemplateResult":
"""Fill workdir path from entered data and project's anatomy.
Args:
@ -174,8 +181,8 @@ def get_workdir(
is stored under `AYON_HOST_NAME` key.
anatomy (Anatomy): Optional argument. Anatomy object is created using
project name from `project_entity`. It is preferred to pass this
argument as initialization of a new Anatomy object may be time
consuming.
argument as initialization of a new Anatomy object may be
time-consuming.
template_key (str): Key of work templates in anatomy templates. Default
value is defined in `get_workdir_with_workdir_data`.
project_settings(Dict[str, Any]): Prepared project settings for
@ -183,9 +190,9 @@ def get_workdir(
if 'template_key' is not passed.
Returns:
TemplateResult: Workdir path.
"""
AnatomyTemplateResult: Workdir path.
"""
if not anatomy:
anatomy = Anatomy(
project_entity["name"], project_entity=project_entity
@ -197,7 +204,7 @@ def get_workdir(
task_entity,
host_name,
)
# Output is TemplateResult object which contain useful data
# Output is AnatomyTemplateResult object which contain useful data
return get_workdir_with_workdir_data(
workdir_data,
anatomy.project_name,
@ -207,12 +214,141 @@ def get_workdir(
)
def get_last_workfile_with_version(
workdir, file_template, fill_data, extensions
):
@dataclass
class WorkfileParsedData:
version: Optional[int] = None
comment: Optional[str] = None
ext: Optional[str] = None
class WorkfileDataParser:
"""Parse dynamic data from existing filenames based on template.
Args:
file_template (str): Workfile file template.
data (dict[str, Any]): Data to fill the template with.
"""
def __init__(
self,
file_template: str,
data: dict[str, Any],
):
data = copy.deepcopy(data)
file_template = str(file_template)
# Use placeholders that will never be in the filename
ext_replacement = "CIextID"
version_replacement = "CIversionID"
comment_replacement = "CIcommentID"
data["version"] = version_replacement
data["comment"] = comment_replacement
for pattern, replacement in (
# Replace `.{ext}` with `{ext}` so we are sure dot is not
# at the end
(r"\.?{ext}", ext_replacement),
):
file_template = re.sub(pattern, replacement, file_template)
file_template = StringTemplate(file_template)
# Prepare template that does contain 'comment'
comment_template = re.escape(str(file_template.format_strict(data)))
# Prepare template that does not contain 'comment'
# - comment is usually marked as optional and in that case the regex
# to find the comment is different based on the filename
# - if filename contains comment then 'comment_template' will match
# - if filename does not contain comment then 'file_template' will
# match
data.pop("comment")
file_template = re.escape(str(file_template.format_strict(data)))
for src, replacement in (
(ext_replacement, r"(?P<ext>\..*)"),
(version_replacement, r"(?P<version>[0-9]+)"),
(comment_replacement, r"(?P<comment>.+?)"),
):
comment_template = comment_template.replace(src, replacement)
file_template = file_template.replace(src, replacement)
kwargs = {}
if platform.system().lower() == "windows":
kwargs["flags"] = re.IGNORECASE
# Match from beginning to end of string to be safe
self._comment_template = re.compile(f"^{comment_template}$", **kwargs)
self._file_template = re.compile(f"^{file_template}$", **kwargs)
def parse_data(self, filename: str) -> WorkfileParsedData:
"""Parse the dynamic data from a filename."""
match = self._comment_template.match(filename)
if not match:
match = self._file_template.match(filename)
if not match:
return WorkfileParsedData()
kwargs = match.groupdict()
version = kwargs.get("version")
if version is not None:
kwargs["version"] = int(version)
return WorkfileParsedData(**kwargs)
def parse_dynamic_data_from_workfile(
filename: str,
file_template: str,
template_data: dict[str, Any],
) -> WorkfileParsedData:
"""Parse dynamic data from a workfile filename.
Dynamic data are 'version', 'comment' and 'ext'.
Args:
filename (str): Workfile filename.
file_template (str): Workfile file template.
template_data (dict[str, Any]): Data to fill the template with.
Returns:
WorkfileParsedData: Dynamic data parsed from the filename.
"""
parser = WorkfileDataParser(file_template, template_data)
return parser.parse_data(filename)
def parse_dynamic_data_from_workfiles(
filenames: list[str],
file_template: str,
template_data: dict[str, Any],
) -> dict[str, WorkfileParsedData]:
"""Parse dynamic data from a workfiles filenames.
Dynamic data are 'version', 'comment' and 'ext'.
Args:
filenames (list[str]): Workfiles filenames.
file_template (str): Workfile file template.
template_data (dict[str, Any]): Data to fill the template with.
Returns:
dict[str, WorkfileParsedData]: Dynamic data parsed from the filenames
by filename.
"""
parser = WorkfileDataParser(file_template, template_data)
return {
filename: parser.parse_data(filename)
for filename in filenames
}
def get_last_workfile_with_version_from_paths(
filepaths: list[str],
file_template: str,
template_data: dict[str, Any],
extensions: set[str],
) -> tuple[Optional[str], Optional[int]]:
"""Return last workfile version.
Usign workfile template and it's filling data find most possible last
Using the workfile template and its template data find most possible last
version of workfile which was created for the context.
Functionality is fully based on knowing which keys are optional or what
@ -222,50 +358,43 @@ def get_last_workfile_with_version(
last workfile.
Args:
workdir (str): Path to dir where workfiles are stored.
filepaths (list[str]): Workfile paths.
file_template (str): Template of file name.
fill_data (Dict[str, Any]): Data for filling template.
extensions (Iterable[str]): All allowed file extensions of workfile.
template_data (Dict[str, Any]): Data for filling template.
extensions (set[str]): All allowed file extensions of workfile.
Returns:
Tuple[Union[str, None], Union[int, None]]: Last workfile with version
tuple[Optional[str], Optional[int]]: Last workfile with version
if there is any workfile otherwise None for both.
"""
if not os.path.exists(workdir):
"""
if not filepaths:
return None, None
dotted_extensions = set()
for ext in extensions:
if not ext.startswith("."):
ext = ".{}".format(ext)
dotted_extensions.add(ext)
# Fast match on extension
filenames = [
filename
for filename in os.listdir(workdir)
if os.path.splitext(filename)[-1] in dotted_extensions
]
ext = f".{ext}"
dotted_extensions.add(re.escape(ext))
# Build template without optionals, version to digits only regex
# and comment to any definable value.
# Escape extensions dot for regex
regex_exts = [
"\\" + ext
for ext in dotted_extensions
]
ext_expression = "(?:" + "|".join(regex_exts) + ")"
ext_expression = "(?:" + "|".join(dotted_extensions) + ")"
for pattern, replacement in (
# Replace `.{ext}` with `{ext}` so we are sure dot is not at the end
(r"\.?{ext}", ext_expression),
# Replace optional keys with optional content regex
(r"<.*?>", r".*?"),
# Replace `{version}` with group regex
(r"{version.*?}", r"([0-9]+)"),
(r"{comment.*?}", r".+?"),
):
file_template = re.sub(pattern, replacement, file_template)
# Replace `.{ext}` with `{ext}` so we are sure there is not dot at the end
file_template = re.sub(r"\.?{ext}", ext_expression, file_template)
# Replace optional keys with optional content regex
file_template = re.sub(r"<.*?>", r".*?", file_template)
# Replace `{version}` with group regex
file_template = re.sub(r"{version.*?}", r"([0-9]+)", file_template)
file_template = re.sub(r"{comment.*?}", r".+?", file_template)
file_template = StringTemplate.format_strict_template(
file_template, fill_data
file_template, template_data
)
# Match with ignore case on Windows due to the Windows
@ -278,64 +407,189 @@ def get_last_workfile_with_version(
# Get highest version among existing matching files
version = None
output_filenames = []
for filename in sorted(filenames):
output_filepaths = []
for filepath in sorted(filepaths):
filename = os.path.basename(filepath)
match = re.match(file_template, filename, **kwargs)
if not match:
continue
if not match.groups():
output_filenames.append(filename)
output_filepaths.append(filename)
continue
file_version = int(match.group(1))
if version is None or file_version > version:
output_filenames[:] = []
output_filepaths.clear()
version = file_version
if file_version == version:
output_filenames.append(filename)
output_filepaths.append(filepath)
output_filename = None
if output_filenames:
if len(output_filenames) == 1:
output_filename = output_filenames[0]
else:
last_time = None
for _output_filename in output_filenames:
full_path = os.path.join(workdir, _output_filename)
mod_time = os.path.getmtime(full_path)
if last_time is None or last_time < mod_time:
output_filename = _output_filename
last_time = mod_time
# Use file modification time to use most recent file if there are
# multiple workfiles with the same version
output_filepath = None
last_time = None
for _output_filepath in output_filepaths:
mod_time = None
if os.path.exists(_output_filepath):
mod_time = os.path.getmtime(_output_filepath)
if (
last_time is None
or (mod_time is not None and last_time < mod_time)
):
output_filepath = _output_filepath
last_time = mod_time
return output_filename, version
return output_filepath, version
def get_last_workfile(
workdir, file_template, fill_data, extensions, full_path=False
):
"""Return last workfile filename.
def get_last_workfile_from_paths(
filepaths: list[str],
file_template: str,
template_data: dict[str, Any],
extensions: set[str],
) -> Optional[str]:
"""Return the last workfile filename.
Returns file with version 1 if there is not workfile yet.
Returns the file with version 1 if there is not workfile yet.
Args:
filepaths (list[str]): Paths to workfiles.
file_template (str): Template of file name.
template_data (dict[str, Any]): Data for filling template.
extensions (set[str]): All allowed file extensions of workfile.
Returns:
Optional[str]: Last workfile path.
"""
filepath, _version = get_last_workfile_with_version_from_paths(
filepaths, file_template, template_data, extensions
)
return filepath
def _filter_dir_files_by_ext(
dirpath: str,
extensions: set[str],
) -> tuple[list[str], set[str]]:
"""Filter files by extensions.
Args:
dirpath (str): List of file paths.
extensions (set[str]): Set of file extensions.
Returns:
tuple[list[str], set[str]]: Filtered list of file paths.
"""
dotted_extensions = set()
for ext in extensions:
if not ext.startswith("."):
ext = f".{ext}"
dotted_extensions.add(ext)
if not os.path.exists(dirpath):
return [], dotted_extensions
filtered_paths = [
os.path.join(dirpath, filename)
for filename in os.listdir(dirpath)
if os.path.splitext(filename)[-1] in dotted_extensions
]
return filtered_paths, dotted_extensions
def get_last_workfile_with_version(
workdir: str,
file_template: str,
template_data: dict[str, Any],
extensions: set[str],
) -> tuple[Optional[str], Optional[int]]:
"""Return last workfile version.
Using the workfile template and its filling data to find the most possible
last version of workfile which was created for the context.
Functionality is fully based on knowing which keys are optional or what
values are expected as value.
The last modified file is used if more files can be considered as
last workfile.
Args:
workdir (str): Path to dir where workfiles are stored.
file_template (str): Template of file name.
fill_data (Dict[str, Any]): Data for filling template.
extensions (Iterable[str]): All allowed file extensions of workfile.
full_path (Optional[bool]): Full path to file is returned if
set to True.
template_data (dict[str, Any]): Data for filling template.
extensions (set[str]): All allowed file extensions of workfile.
Returns:
str: Last or first workfile as filename of full path to filename.
tuple[Optional[str], Optional[int]]: Last workfile with version
if there is any workfile otherwise None for both.
"""
filename, _version = get_last_workfile_with_version(
workdir, file_template, fill_data, extensions
if not os.path.exists(workdir):
return None, None
filepaths, dotted_extensions = _filter_dir_files_by_ext(
workdir, extensions
)
if filename is None:
data = copy.deepcopy(fill_data)
return get_last_workfile_with_version_from_paths(
filepaths,
file_template,
template_data,
dotted_extensions,
)
def get_last_workfile(
workdir: str,
file_template: str,
template_data: dict[str, Any],
extensions: set[str],
full_path: bool = False,
) -> str:
"""Return last the workfile filename.
Returns first file name/path if there are not workfiles yet.
Args:
workdir (str): Path to dir where workfiles are stored.
file_template (str): Template of file name.
template_data (Dict[str, Any]): Data for filling template.
extensions (Iterable[str]): All allowed file extensions of workfile.
full_path (bool): Return full path to the file or only filename.
Returns:
str: Last or first workfile file name or path based on
'full_path' value.
"""
# TODO (iLLiCiTiT): Remove the argument 'full_path' and return only full
# path. As far as I can tell it is always called with 'full_path' set
# to 'True'.
# - it has to be 2 step operation, first warn about having it 'False', and
# then warn about having it filled.
if full_path is False:
warnings.warn(
"Argument 'full_path' will be removed and will return"
" only full path in future.",
DeprecationWarning,
)
filepaths, dotted_extensions = _filter_dir_files_by_ext(
workdir, extensions
)
filepath = get_last_workfile_from_paths(
filepaths,
file_template,
template_data,
dotted_extensions
)
if filepath is None:
data = copy.deepcopy(template_data)
data["version"] = version_start.get_versioning_start(
data["project"]["name"],
data["app"],
@ -344,15 +598,15 @@ def get_last_workfile(
product_type="workfile"
)
data.pop("comment", None)
if not data.get("ext"):
data["ext"] = extensions[0]
if data.get("ext") is None:
data["ext"] = next(iter(extensions), "")
data["ext"] = data["ext"].lstrip(".")
filename = StringTemplate.format_strict_template(file_template, data)
filepath = os.path.join(workdir, filename)
if full_path:
return os.path.normpath(os.path.join(workdir, filename))
return filename
return os.path.normpath(filepath)
return os.path.basename(filepath)
def get_custom_workfile_template(
@ -389,11 +643,10 @@ def get_custom_workfile_template(
project_settings(Dict[str, Any]): Preloaded project settings.
Returns:
str: Path to template or None if none of profiles match current
context. Existence of formatted path is not validated.
None: If no profile is matching context.
"""
Optional[str]: Path to template or None if none of profiles match
current context. Existence of formatted path is not validated.
"""
log = Logger.get_logger("CustomWorkfileResolve")
project_name = project_entity["name"]
@ -562,3 +815,112 @@ def create_workdir_extra_folders(
fullpath = os.path.join(workdir, subfolder)
if not os.path.exists(fullpath):
os.makedirs(fullpath)
class CommentMatcher:
"""Use anatomy and work file data to parse comments from filenames.
Args:
extensions (set[str]): Set of extensions.
file_template (StringTemplate): Workfile file template.
data (dict[str, Any]): Data to fill the template with.
"""
def __init__(
self,
extensions: set[str],
file_template: StringTemplate,
data: dict[str, Any]
):
warnings.warn(
"Class 'CommentMatcher' is deprecated. Please"
" use 'parse_dynamic_data_from_workfiles' instead.",
DeprecationWarning,
stacklevel=2,
)
self._fname_regex = None
if "{comment}" not in file_template:
# Don't look for comment if template doesn't allow it
return
# Create a regex group for extensions
any_extension = "(?:{})".format(
"|".join(re.escape(ext.lstrip(".")) for ext in extensions)
)
# Use placeholders that will never be in the filename
temp_data = copy.deepcopy(data)
temp_data["comment"] = "<<comment>>"
temp_data["version"] = "<<version>>"
temp_data["ext"] = "<<ext>>"
fname_pattern = re.escape(
file_template.format_strict(temp_data)
)
# Replace comment and version with something we can match with regex
replacements = (
("<<comment>>", r"(?P<comment>.+)"),
("<<version>>", r"[0-9]+"),
("<<ext>>", any_extension),
)
for src, dest in replacements:
fname_pattern = fname_pattern.replace(re.escape(src), dest)
# Match from beginning to end of string to be safe
self._fname_regex = re.compile(f"^{fname_pattern}$")
def parse_comment(self, filename: str) -> Optional[str]:
"""Parse the {comment} part from a filename."""
if self._fname_regex:
match = self._fname_regex.match(filename)
if match:
return match.group("comment")
return None
def get_comments_from_workfile_paths(
filepaths: list[str],
extensions: set[str],
file_template: StringTemplate,
template_data: dict[str, Any],
current_filename: Optional[str] = None,
) -> tuple[list[str], str]:
"""DEPRECATED Collect comments from workfile filenames.
Based on 'current_filename' is also returned "current comment".
Args:
filepaths (list[str]): List of filepaths to parse.
extensions (set[str]): Set of file extensions.
file_template (StringTemplate): Workfile file template.
template_data (dict[str, Any]): Data to fill the template with.
current_filename (str): Filename to check for the current comment.
Returns:
tuple[list[str], str]: List of comments and the current comment.
"""
warnings.warn(
"Function 'get_comments_from_workfile_paths' is deprecated. Please"
" use 'parse_dynamic_data_from_workfiles' instead.",
DeprecationWarning,
stacklevel=2,
)
current_comment = ""
if not filepaths:
return [], current_comment
matcher = CommentMatcher(extensions, file_template, template_data)
comment_hints = set()
for filepath in filepaths:
filename = os.path.basename(filepath)
comment = matcher.parse_comment(filename)
if comment:
comment_hints.add(comment)
if filename == current_filename:
current_comment = comment
return list(comment_hints), current_comment

View file

@ -1,5 +1,30 @@
from ayon_core.lib import filter_profiles
from __future__ import annotations
import os
import platform
import uuid
import typing
from typing import Optional, Any
import ayon_api
from ayon_api.operations import OperationsSession
from ayon_core.lib import filter_profiles, get_ayon_username
from ayon_core.settings import get_project_settings
from ayon_core.host.interfaces import (
SaveWorkfileOptionalData,
ListWorkfilesOptionalData,
CopyWorkfileOptionalData,
)
from ayon_core.pipeline.version_start import get_versioning_start
from ayon_core.pipeline.template_data import get_template_data
from .path_resolving import (
get_workdir,
get_workfile_template_key,
)
if typing.TYPE_CHECKING:
from ayon_core.pipeline import Anatomy
class MissingWorkdirError(Exception):
@ -7,14 +32,61 @@ class MissingWorkdirError(Exception):
pass
def get_workfiles_info(
workfile_path: str,
project_name: str,
task_id: str,
*,
anatomy: Optional["Anatomy"] = None,
workfile_entities: Optional[list[dict[str, Any]]] = None,
) -> Optional[dict[str, Any]]:
"""Find workfile info entity for a workfile path.
Args:
workfile_path (str): Workfile path.
project_name (str): The name of the project.
task_id (str): Task id under which is workfile created.
anatomy (Optional[Anatomy]): Project anatomy used to get roots.
workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched
workfile entities related to the task.
Returns:
Optional[dict[str, Any]]: Workfile info entity if found, otherwise
`None`.
"""
if anatomy is None:
anatomy = Anatomy(project_name)
if workfile_entities is None:
workfile_entities = list(ayon_api.get_workfiles_info(
project_name,
task_ids=[task_id],
))
if platform.system().lower() == "windows":
workfile_path = workfile_path.replace("\\", "/")
workfile_path = workfile_path.lower()
for workfile_entity in workfile_entities:
path = workfile_entity["path"]
filled_path = anatomy.fill_root(path)
if platform.system().lower() == "windows":
filled_path = filled_path.replace("\\", "/")
filled_path = filled_path.lower()
if filled_path == workfile_path:
return workfile_entity
return None
def should_use_last_workfile_on_launch(
project_name,
host_name,
task_name,
task_type,
default_output=False,
project_settings=None,
):
project_name: str,
host_name: str,
task_name: str,
task_type: str,
default_output: bool = False,
project_settings: Optional[dict[str, Any]] = None,
) -> bool:
"""Define if host should start last version workfile if possible.
Default output is `False`. Can be overridden with environment variable
@ -124,3 +196,608 @@ def should_open_workfiles_tool_on_launch(
if output is None:
return default_output
return output
def save_workfile_info(
project_name: str,
task_id: str,
rootless_path: str,
host_name: str,
version: Optional[int] = None,
comment: Optional[str] = None,
description: Optional[str] = None,
username: Optional[str] = None,
workfile_entities: Optional[list[dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Save workfile info entity for a workfile path.
Args:
project_name (str): The name of the project.
task_id (str): Task id under which is workfile created.
rootless_path (str): Rootless path of the workfile.
host_name (str): Name of host which is saving the workfile.
version (Optional[int]): Workfile version.
comment (Optional[str]): Workfile comment.
description (Optional[str]): Workfile description.
username (Optional[str]): Username of user who saves the workfile.
If not provided, current user is used.
workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched
workfile entities related to task.
Returns:
dict[str, Any]: Workfile info entity.
"""
if workfile_entities is None:
workfile_entities = list(ayon_api.get_workfiles_info(
project_name,
task_ids=[task_id],
))
workfile_entity = next(
(
_ent
for _ent in workfile_entities
if _ent["path"] == rootless_path
),
None
)
if username is None:
username = get_ayon_username()
if not workfile_entity:
return _create_workfile_info_entity(
project_name,
task_id,
host_name,
rootless_path,
username,
version,
comment,
description,
)
data = {
key: value
for key, value in (
("host_name", host_name),
("version", version),
("comment", comment),
)
if value is not None
}
old_data = workfile_entity["data"]
changed_data = {}
for key, value in data.items():
if key not in old_data or old_data[key] != value:
changed_data[key] = value
update_data = {}
if changed_data:
update_data["data"] = changed_data
old_description = workfile_entity["attrib"].get("description")
if description is not None and old_description != description:
update_data["attrib"] = {"description": description}
workfile_entity["attrib"]["description"] = description
# Automatically fix 'createdBy' and 'updatedBy' fields
# NOTE both fields were not automatically filled by server
# until 1.1.3 release.
if workfile_entity.get("createdBy") is None:
update_data["createdBy"] = username
workfile_entity["createdBy"] = username
if workfile_entity.get("updatedBy") != username:
update_data["updatedBy"] = username
workfile_entity["updatedBy"] = username
if not update_data:
return workfile_entity
session = OperationsSession()
session.update_entity(
project_name,
"workfile",
workfile_entity["id"],
update_data,
)
session.commit()
return workfile_entity
def save_current_workfile_to(
workfile_path: str,
folder_path: str,
task_name: str,
*,
version: Optional[int] = None,
comment: Optional[str] = None,
description: Optional[str] = None,
prepared_data: Optional[SaveWorkfileOptionalData] = None,
) -> None:
"""Save current workfile to new location or context.
Args:
workfile_path (str): Destination workfile path.
folder_path (str): Target folder path.
task_name (str): Target task name.
version (Optional[int]): Workfile version.
comment (optional[str]): Workfile comment.
description (Optional[str]): Workfile description.
prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data
for speed enhancements.
"""
from ayon_core.pipeline.context_tools import registered_host
host = registered_host()
context = host.get_current_context()
project_name = context["project_name"]
folder_entity = ayon_api.get_folder_by_path(
project_name, folder_path
)
task_entity = ayon_api.get_task_by_name(
project_name, folder_entity["id"], task_name
)
host.save_workfile_with_context(
workfile_path,
folder_entity,
task_entity,
version=version,
comment=comment,
description=description,
prepared_data=prepared_data,
)
def save_workfile_with_current_context(
workfile_path: str,
*,
version: Optional[int] = None,
comment: Optional[str] = None,
description: Optional[str] = None,
prepared_data: Optional[SaveWorkfileOptionalData] = None,
) -> None:
"""Save current workfile to new location using current context.
Helper function to save workfile using current context. Calls
'save_current_workfile_to' at the end.
Args:
workfile_path (str): Destination workfile path.
version (Optional[int]): Workfile version.
comment (optional[str]): Workfile comment.
description (Optional[str]): Workfile description.
prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data
for speed enhancements.
"""
from ayon_core.pipeline.context_tools import registered_host
host = registered_host()
context = host.get_current_context()
project_name = context["project_name"]
folder_path = context["folder_path"]
task_name = context["task_name"]
folder_entity = task_entity = None
if folder_path:
folder_entity = ayon_api.get_folder_by_path(project_name, folder_path)
if folder_entity and task_name:
task_entity = ayon_api.get_task_by_name(
project_name, folder_entity["id"], task_name
)
host.save_workfile_with_context(
workfile_path,
folder_entity,
task_entity,
version=version,
comment=comment,
description=description,
prepared_data=prepared_data,
)
def save_next_version(
version: Optional[int] = None,
comment: Optional[str] = None,
description: Optional[str] = None,
*,
prepared_data: Optional[SaveWorkfileOptionalData] = None,
) -> None:
"""Save workfile using current context, version and comment.
Helper function to save a workfile using the current context. Last
workfile version + 1 is used if is not passed in.
Args:
version (Optional[int]): Workfile version that will be used. Last
version + 1 is used if is not passed in.
comment (optional[str]): Workfile comment. Pass '""' to clear comment.
The current workfile comment is used if it is not passed.
description (Optional[str]): Workfile description.
prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data
for speed enhancements.
"""
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.context_tools import registered_host
host = registered_host()
current_path = host.get_current_workfile()
if not current_path:
current_path = None
else:
current_path = os.path.normpath(current_path)
context = host.get_current_context()
project_name = context["project_name"]
folder_path = context["folder_path"]
task_name = context["task_name"]
if prepared_data is None:
prepared_data = SaveWorkfileOptionalData()
project_entity = prepared_data.project_entity
anatomy = prepared_data.anatomy
project_settings = prepared_data.project_settings
if project_entity is None:
project_entity = ayon_api.get_project(project_name)
prepared_data.project_entity = project_entity
if project_settings is None:
project_settings = get_project_settings(project_name)
prepared_data.project_settings = project_settings
if anatomy is None:
anatomy = Anatomy(project_name, project_entity=project_entity)
prepared_data.anatomy = anatomy
folder_entity = ayon_api.get_folder_by_path(project_name, folder_path)
task_entity = ayon_api.get_task_by_name(
project_name, folder_entity["id"], task_name
)
template_key = get_workfile_template_key(
project_name,
task_entity["taskType"],
host.name,
project_settings=project_settings
)
file_template = anatomy.get_template_item("work", template_key, "file")
template_data = get_template_data(
project_entity,
folder_entity,
task_entity,
host.name,
project_settings,
)
workdir = get_workdir(
project_entity,
folder_entity,
task_entity,
host.name,
anatomy=anatomy,
template_key=template_key,
project_settings=project_settings,
)
rootless_dir = workdir.rootless
last_workfile = None
current_workfile = None
if version is None or comment is None:
workfiles = host.list_workfiles(
project_name, folder_entity, task_entity,
prepared_data=ListWorkfilesOptionalData(
project_entity=project_entity,
anatomy=anatomy,
project_settings=project_settings,
template_key=template_key,
)
)
for workfile in workfiles:
if current_workfile is None and workfile.filepath == current_path:
current_workfile = workfile
if workfile.version is None:
continue
if (
last_workfile is None
or last_workfile.version < workfile.version
):
last_workfile = workfile
if version is None and last_workfile is not None:
version = last_workfile.version + 1
if version is None:
version = get_versioning_start(
project_name,
host.name,
task_name=task_entity["name"],
task_type=task_entity["taskType"],
product_type="workfile"
)
# Re-use comment from the current workfile if is not passed in
if comment is None and current_workfile is not None:
comment = current_workfile.comment
template_data["version"] = version
if comment:
template_data["comment"] = comment
# Resolve extension
# - Don't fill any if the host does not have defined any -> e.g. if host
# uses directory instead of a file.
# 1. Use the current file extension.
# 2. Use the last known workfile extension.
# 3. Use the first extensions from 'get_workfile_extensions'.
ext = None
workfile_extensions = host.get_workfile_extensions()
if workfile_extensions:
if current_path:
ext = os.path.splitext(current_path)[1]
elif last_workfile is not None:
ext = os.path.splitext(last_workfile.filepath)[1]
else:
ext = next(iter(workfile_extensions))
ext = ext.lstrip(".")
if ext:
template_data["ext"] = ext
filename = file_template.format_strict(template_data)
workfile_path = os.path.join(workdir, filename)
rootless_path = f"{rootless_dir}/{filename}"
if platform.system().lower() == "windows":
rootless_path = rootless_path.replace("\\", "/")
prepared_data.rootless_path = rootless_path
host.save_workfile_with_context(
workfile_path,
folder_entity,
task_entity,
version=version,
comment=comment,
description=description,
prepared_data=prepared_data,
)
def copy_workfile_to_context(
src_workfile_path: str,
folder_entity: dict[str, Any],
task_entity: dict[str, Any],
*,
version: Optional[int] = None,
comment: Optional[str] = None,
description: Optional[str] = None,
open_workfile: bool = True,
prepared_data: Optional[CopyWorkfileOptionalData] = None,
) -> None:
"""Copy workfile to a context.
Copy workfile to a specified folder and task. Destination path is
calculated based on passed information.
Args:
src_workfile_path (str): Source workfile path.
folder_entity (dict[str, Any]): Target folder entity.
task_entity (dict[str, Any]): Target task entity.
version (Optional[int]): Workfile version. Use next version if not
passed.
comment (optional[str]): Workfile comment.
description (Optional[str]): Workfile description.
prepared_data (Optional[CopyWorkfileOptionalData]): Prepared data
for speed enhancements. Rootless path is calculated in this
function.
"""
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.context_tools import registered_host
host = registered_host()
project_name = host.get_current_project_name()
anatomy = prepared_data.anatomy
if anatomy is None:
if prepared_data.project_entity is None:
prepared_data.project_entity = ayon_api.get_project(
project_name
)
anatomy = Anatomy(
project_name, project_entity=prepared_data.project_entity
)
prepared_data.anatomy = anatomy
project_settings = prepared_data.project_settings
if project_settings is None:
project_settings = get_project_settings(project_name)
prepared_data.project_settings = project_settings
if version is None:
list_prepared_data = None
if prepared_data is not None:
list_prepared_data = ListWorkfilesOptionalData(
project_entity=prepared_data.project_entity,
anatomy=prepared_data.anatomy,
project_settings=prepared_data.project_settings,
workfile_entities=prepared_data.workfile_entities,
)
workfiles = host.list_workfiles(
project_name,
folder_entity,
task_entity,
prepared_data=list_prepared_data
)
if workfiles:
version = max(
workfile.version
for workfile in workfiles
) + 1
else:
version = get_versioning_start(
project_name,
host.name,
task_name=task_entity["name"],
task_type=task_entity["taskType"],
product_type="workfile"
)
task_type = task_entity["taskType"]
template_key = get_workfile_template_key(
project_name,
task_type,
host.name,
project_settings=prepared_data.project_settings
)
template_data = get_template_data(
prepared_data.project_entity,
folder_entity,
task_entity,
host.name,
prepared_data.project_settings,
)
template_data["version"] = version
if comment:
template_data["comment"] = comment
workfile_extensions = host.get_workfile_extensions()
if workfile_extensions:
ext = os.path.splitext(src_workfile_path)[1].lstrip(".")
template_data["ext"] = ext
workfile_template = anatomy.get_template_item(
"work", template_key, "path"
)
workfile_path = workfile_template.format_strict(template_data)
prepared_data.rootless_path = workfile_path.rootless
host.copy_workfile(
src_workfile_path,
workfile_path,
folder_entity,
task_entity,
version=version,
comment=comment,
description=description,
open_workfile=open_workfile,
prepared_data=prepared_data,
)
def find_workfile_rootless_path(
workfile_path: str,
project_name: str,
folder_entity: dict[str, Any],
task_entity: dict[str, Any],
host_name: str,
*,
project_entity: Optional[dict[str, Any]] = None,
project_settings: Optional[dict[str, Any]] = None,
anatomy: Optional["Anatomy"] = None,
) -> str:
"""Find rootless workfile path."""
if anatomy is None:
from ayon_core.pipeline import Anatomy
anatomy = Anatomy(project_name, project_entity=project_entity)
task_type = task_entity["taskType"]
template_key = get_workfile_template_key(
project_name,
task_type,
host_name,
project_settings=project_settings
)
dir_template = anatomy.get_template_item(
"work", template_key, "directory"
)
result = dir_template.format({"root": anatomy.roots})
used_root = result.used_values.get("root")
rootless_path = str(workfile_path)
if platform.system().lower() == "windows":
rootless_path = rootless_path.replace("\\", "/")
root_key = root_value = None
if used_root is not None:
root_key, root_value = next(iter(used_root.items()))
if platform.system().lower() == "windows":
root_value = root_value.replace("\\", "/")
if root_value and rootless_path.startswith(root_value):
rootless_path = rootless_path[len(root_value):].lstrip("/")
rootless_path = f"{{root[{root_key}]}}/{rootless_path}"
else:
success, result = anatomy.find_root_template_from_path(rootless_path)
if success:
rootless_path = result
return rootless_path
def _create_workfile_info_entity(
project_name: str,
task_id: str,
host_name: str,
rootless_path: str,
username: str,
version: Optional[int],
comment: Optional[str],
description: Optional[str],
) -> dict[str, Any]:
"""Create workfile entity data.
Args:
project_name (str): Project name.
task_id (str): Task id.
host_name (str): Host name.
rootless_path (str): Rootless workfile path.
username (str): Username.
version (Optional[int]): Workfile version.
comment (Optional[str]): Workfile comment.
description (Optional[str]): Workfile description.
Returns:
dict[str, Any]: Created workfile entity data.
"""
extension = os.path.splitext(rootless_path)[1]
attrib = {}
for key, value in (
("extension", extension),
("description", description),
):
if value is not None:
attrib[key] = value
data = {
"host_name": host_name,
"version": version,
"comment": comment,
}
workfile_info = {
"id": uuid.uuid4().hex,
"path": rootless_path,
"taskId": task_id,
"attrib": attrib,
"data": data,
# TODO remove 'createdBy' and 'updatedBy' fields when server is
# or above 1.1.3 .
"createdBy": username,
"updatedBy": username,
}
session = OperationsSession()
session.create_entity(
project_name, "workfile", workfile_info
)
session.commit()
return workfile_info

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
@ -631,7 +611,7 @@ class AbstractTemplateBuilder(ABC):
"""Open template file with registered host."""
template_preset = self.get_template_preset()
template_path = template_preset["path"]
self.host.open_file(template_path)
self.host.open_workfile(template_path)
@abstractmethod
def import_template(self, template_path):
@ -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

@ -38,6 +38,8 @@ class CleanUp(pyblish.api.InstancePlugin):
"webpublisher",
"shell"
]
settings_category = "core"
exclude_families = ["clip"]
optional = True
active = True

View file

@ -13,6 +13,8 @@ class CleanUpFarm(pyblish.api.ContextPlugin):
order = pyblish.api.IntegratorOrder + 11
label = "Clean Up Farm"
settings_category = "core"
enabled = True
# Keep "filesequence" for backwards compatibility of older jobs

View file

@ -46,6 +46,8 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
order = pyblish.api.CollectorOrder + 0.49
label = "Collect Anatomy Instance data"
settings_category = "core"
follow_workfile_version = False
def process(self, context):

View file

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

View file

@ -23,6 +23,7 @@ class CollectFramesFixDef(
targets = ["local"]
hosts = ["nuke"]
families = ["render", "prerender"]
settings_category = "core"
rewrite_version_enable = False

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

@ -1,7 +1,9 @@
import ayon_api
import ayon_api.utils
from ayon_core.host import ILoadHost
from ayon_core.pipeline import registered_host
import pyblish.api
@ -27,16 +29,23 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin):
def process(self, context):
host = registered_host()
if host is None:
self.log.warn("No registered host.")
self.log.warning("No registered host.")
return
if not hasattr(host, "ls"):
host_name = host.__name__
self.log.warn("Host %r doesn't have ls() implemented." % host_name)
if not isinstance(host, ILoadHost):
host_name = host.name
self.log.warning(
f"Host {host_name} does not implement ILoadHost. "
"Skipping querying of loaded versions in scene."
)
return
containers = list(host.get_containers())
if not containers:
# Opt out early if there are no containers
self.log.debug("No loaded containers found in scene.")
return
loaded_versions = []
containers = list(host.ls())
repre_ids = {
container["representation"]
for container in containers
@ -61,6 +70,7 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin):
# QUESTION should we add same representation id when loaded multiple
# times?
loaded_versions = []
for con in containers:
repre_id = con["representation"]
repre_entity = repre_entities_by_id.get(repre_id)
@ -80,4 +90,5 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin):
}
loaded_versions.append(version)
self.log.debug(f"Collected {len(loaded_versions)} loaded versions.")
context.data["loadedVersions"] = loaded_versions

View file

@ -12,9 +12,10 @@ class CollectSceneVersion(pyblish.api.ContextPlugin):
"""
order = pyblish.api.CollectorOrder
label = 'Collect Scene Version'
label = "Collect Scene Version"
# configurable in Settings
hosts = ["*"]
settings_category = "core"
# in some cases of headless publishing (for example webpublisher using PS)
# you want to ignore version from name and let integrate use next version

View file

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

View file

@ -55,6 +55,8 @@ class ExtractOIIOTranscode(publish.Extractor):
label = "Transcode color spaces"
order = pyblish.api.ExtractorOrder + 0.019
settings_category = "core"
optional = True
# Supported extensions

View file

@ -158,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
@ -172,6 +173,14 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
clip_start = otio_clip.source_range.start_time
fps = clip_start.rate
conformed_av_start = media_av_start.rescaled_to(fps)
# Avoid rounding issue on media available range.
if clip_start.almost_equal(
conformed_av_start,
OTIO_EPSILON
):
conformed_av_start = clip_start
# ffmpeg ignores embedded tc
start = clip_start - conformed_av_start
duration = otio_clip.source_range.duration

View file

@ -23,7 +23,10 @@ from ayon_core.lib import (
get_ffmpeg_tool_args,
run_subprocess,
)
from ayon_core.pipeline import publish
from ayon_core.pipeline import (
KnownPublishError,
publish,
)
class ExtractOTIOReview(
@ -97,8 +100,11 @@ class ExtractOTIOReview(
# skip instance if no reviewable data available
if (
not isinstance(otio_review_clips[0], otio.schema.Clip)
and len(otio_review_clips) == 1
len(otio_review_clips) == 1
and (
not isinstance(otio_review_clips[0], otio.schema.Clip)
or otio_review_clips[0].media_reference.is_missing_reference
)
):
self.log.warning(
"Instance `{}` has nothing to process".format(instance))
@ -248,7 +254,7 @@ class ExtractOTIOReview(
# Single video way.
# Extraction via FFmpeg.
else:
elif hasattr(media_ref, "target_url"):
path = media_ref.target_url
# Set extract range from 0 (FFmpeg ignores
# embedded timecode).
@ -352,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):
@ -370,6 +377,13 @@ class ExtractOTIOReview(
avl_start = avl_range.start_time
# Avoid rounding issue on media available range.
if start.almost_equal(
avl_start,
OTIO_EPSILON
):
avl_start = start
# An additional gap is required before the available
# range to conform source start point and head handles.
if start < avl_start:
@ -388,6 +402,14 @@ class ExtractOTIOReview(
# (media duration is shorter then clip requirement).
end_point = start + duration
avl_end_point = avl_range.end_time_exclusive()
# Avoid rounding issue on media available range.
if end_point.almost_equal(
avl_end_point,
OTIO_EPSILON
):
avl_end_point = end_point
if end_point > avl_end_point:
gap_duration = end_point - avl_end_point
duration -= gap_duration
@ -444,7 +466,7 @@ class ExtractOTIOReview(
command = get_ffmpeg_tool_args("ffmpeg")
input_extension = None
if sequence:
if sequence is not None:
input_dir, collection, sequence_fps = sequence
in_frame_start = min(collection.indexes)
@ -478,7 +500,7 @@ class ExtractOTIOReview(
"-i", input_path
])
elif video:
elif video is not None:
video_path, otio_range = video
frame_start = otio_range.start_time.value
input_fps = otio_range.start_time.rate
@ -496,7 +518,7 @@ class ExtractOTIOReview(
"-i", video_path
])
elif gap:
elif gap is not None:
sec_duration = frames_to_seconds(gap, self.actual_fps)
# form command for rendering gap files
@ -510,6 +532,9 @@ class ExtractOTIOReview(
"-tune", "stillimage"
])
else:
raise KnownPublishError("Sequence, video or gap is required.")
if video or sequence:
command.extend([
"-vf", f"scale={self.to_width}:{self.to_height}:flags=lanczos",

View file

@ -161,9 +161,11 @@ class ExtractReview(pyblish.api.InstancePlugin):
"aftereffects",
"flame",
"unreal",
"circuit",
"batchdelivery",
"photoshop"
]
settings_category = "core"
# Supported extensions
image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"}
video_exts = {"mov", "mp4"}
@ -202,15 +204,21 @@ class ExtractReview(pyblish.api.InstancePlugin):
def _get_outputs_for_instance(self, instance):
host_name = instance.context.data["hostName"]
product_type = instance.data["productType"]
task_type = None
task_entity = instance.data.get("taskEntity")
if task_entity:
task_type = task_entity["taskType"]
self.log.debug("Host: \"{}\"".format(host_name))
self.log.debug("Product type: \"{}\"".format(product_type))
self.log.debug("Task type: \"{}\"".format(task_type))
profile = filter_profiles(
self.profiles,
{
"hosts": host_name,
"product_types": product_type,
"task_types": task_type
},
logger=self.log)
if not profile:

View file

@ -38,10 +38,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
"substancedesigner",
"nuke",
"aftereffects",
"photoshop",
"unreal",
"houdini",
"circuit",
"batchdelivery",
]
settings_category = "core"
enabled = False
integrate_thumbnail = False

View file

@ -256,6 +256,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
label = "Collect USD Layer Contributions (Asset/Shot)"
families = ["usd"]
enabled = True
settings_category = "core"
# A contribution defines a contribution into a (department) layer which
# will get layered into the target product, usually the asset or shot.
@ -633,6 +634,8 @@ class ExtractUSDLayerContribution(publish.Extractor):
label = "Extract USD Layer Contributions (Asset/Shot)"
order = pyblish.api.ExtractorOrder + 0.45
settings_category = "core"
use_ayon_entity_uri = False
def process(self, instance):
@ -795,6 +798,8 @@ class ExtractUSDAssetContribution(publish.Extractor):
label = "Extract USD Asset/Shot Contributions"
order = ExtractUSDLayerContribution.order + 0.01
settings_category = "core"
use_ayon_entity_uri = False
def process(self, instance):

View file

@ -61,6 +61,8 @@ class IntegrateHeroVersion(
# Must happen after IntegrateNew
order = pyblish.api.IntegratorOrder + 0.1
settings_category = "core"
optional = True
active = True

View file

@ -105,7 +105,7 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin):
created links by its type
"""
if workfile_instance is None:
self.log.warn("No workfile in this publish session.")
self.log.warning("No workfile in this publish session.")
return
workfile_version_id = workfile_instance.data["versionEntity"]["id"]

View file

@ -24,6 +24,8 @@ class IntegrateProductGroup(pyblish.api.InstancePlugin):
order = pyblish.api.IntegratorOrder - 0.1
label = "Product Group"
settings_category = "core"
# Attributes set by settings
product_grouping_profiles = None

View file

@ -22,6 +22,8 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin):
label = "Override Integrate Thumbnail Representations"
order = pyblish.api.IntegratorOrder - 0.1
settings_category = "core"
integrate_profiles = []
def process(self, instance):

View file

@ -31,6 +31,7 @@ class ValidateOutdatedContainers(
label = "Validate Outdated Containers"
order = pyblish.api.ValidatorOrder
settings_category = "core"
optional = True
actions = [ShowInventory]

View file

@ -37,7 +37,7 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin):
label = "Validate File Saved"
order = pyblish.api.ValidatorOrder - 0.1
hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter",
"cinema4d", "silhouette", "gaffer", "blender"]
"cinema4d", "silhouette", "gaffer", "blender", "loki"]
actions = [SaveByVersionUpAction, ShowWorkfilesAction]
def process(self, context):

View file

@ -14,6 +14,8 @@ class ValidateIntent(pyblish.api.ContextPlugin):
order = pyblish.api.ValidatorOrder
label = "Validate Intent"
settings_category = "core"
enabled = False
# Can be modified by settings

View file

@ -34,7 +34,11 @@ class ValidateProductUniqueness(pyblish.api.ContextPlugin):
for instance in context:
# Ignore disabled instances
if not instance.data.get('publish', True):
if not instance.data.get("publish", True):
continue
# Ignore instances not marked to integrate
if not instance.data.get("integrate", True):
continue
# Ignore instance without folder data

View file

@ -17,6 +17,7 @@ class ValidateVersion(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin):
order = pyblish.api.ValidatorOrder
label = "Validate Version"
settings_category = "core"
optional = False
active = True

View file

@ -4,6 +4,8 @@ import logging
import collections
import copy
import time
import warnings
from urllib.parse import urlencode
import ayon_api
@ -35,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
@ -67,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()
@ -175,17 +225,22 @@ def get_project_environments(project_name, project_settings=None):
def get_current_project_settings():
"""Project settings for current context project.
"""DEPRECATE Project settings for current context project.
Function requires access to pipeline context which is in
'ayon_core.pipeline'.
Returns:
dict[str, Any]: Project settings for current context project.
Project name should be stored in environment variable `AYON_PROJECT_NAME`.
This function should be used only in host context where environment
variable must be set and should not happen that any part of process will
change the value of the environment variable.
"""
project_name = os.environ.get("AYON_PROJECT_NAME")
if not project_name:
raise ValueError(
"Missing context project in environment"
" variable `AYON_PROJECT_NAME`."
)
return get_project_settings(project_name)
warnings.warn(
"Used deprecated function 'get_current_project_settings' in"
" 'ayon_core.settings'. The function was moved to"
" 'ayon_core.pipeline.context_tools'.",
DeprecationWarning,
stacklevel=2
)
from ayon_core.pipeline.context_tools import get_current_project_settings
return get_current_project_settings()

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

@ -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

@ -399,7 +399,11 @@ class ActionsModel:
return cache.get_data()
try:
response = ayon_api.post("actions/list", **request_data)
# 'variant' query is supported since AYON backend 1.10.4
query = urlencode({"variant": self._variant})
response = ayon_api.post(
f"actions/list?{query}", **request_data
)
response.raise_for_status()
except Exception:
self.log.warning("Failed to collect webactions.", exc_info=True)
@ -513,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

@ -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

@ -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,7 +1,7 @@
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,
@ -35,7 +35,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):

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

@ -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

@ -4,76 +4,6 @@ from abc import ABC, abstractmethod
from ayon_core.style import get_default_entity_icon_color
class WorkfileInfo:
"""Information about workarea file with possible additional from database.
Args:
folder_id (str): Folder id.
task_id (str): Task id.
filepath (str): Filepath.
filesize (int): File size.
creation_time (float): Creation time (timestamp).
modification_time (float): Modification time (timestamp).
created_by (Union[str, none]): User who created the file.
updated_by (Union[str, none]): User who last updated the file.
note (str): Note.
"""
def __init__(
self,
folder_id,
task_id,
filepath,
filesize,
creation_time,
modification_time,
created_by,
updated_by,
note,
):
self.folder_id = folder_id
self.task_id = task_id
self.filepath = filepath
self.filesize = filesize
self.creation_time = creation_time
self.modification_time = modification_time
self.created_by = created_by
self.updated_by = updated_by
self.note = note
def to_data(self):
"""Converts WorkfileInfo item to data.
Returns:
dict[str, Any]: Folder item data.
"""
return {
"folder_id": self.folder_id,
"task_id": self.task_id,
"filepath": self.filepath,
"filesize": self.filesize,
"creation_time": self.creation_time,
"modification_time": self.modification_time,
"created_by": self.created_by,
"updated_by": self.updated_by,
"note": self.note,
}
@classmethod
def from_data(cls, data):
"""Re-creates WorkfileInfo item from data.
Args:
data (dict[str, Any]): Workfile info item data.
Returns:
WorkfileInfo: Workfile info item.
"""
return cls(**data)
class FolderItem:
"""Item representing folder entity on a server.
@ -87,8 +17,8 @@ class FolderItem:
label (str): Folder label.
icon_name (str): Name of icon from font awesome.
icon_color (str): Hex color string that will be used for icon.
"""
"""
def __init__(
self, entity_id, parent_id, name, label, icon_name, icon_color
):
@ -104,8 +34,8 @@ class FolderItem:
Returns:
dict[str, Any]: Folder item data.
"""
"""
return {
"entity_id": self.entity_id,
"parent_id": self.parent_id,
@ -124,8 +54,8 @@ class FolderItem:
Returns:
FolderItem: Folder item.
"""
"""
return cls(**data)
@ -144,8 +74,8 @@ class TaskItem:
parent_id (str): Parent folder id.
icon_name (str): Name of icon from font awesome.
icon_color (str): Hex color string that will be used for icon.
"""
"""
def __init__(
self, task_id, name, task_type, parent_id, icon_name, icon_color
):
@ -163,8 +93,8 @@ class TaskItem:
Returns:
str: Task id.
"""
"""
return self.task_id
@property
@ -173,8 +103,8 @@ class TaskItem:
Returns:
str: Label of task item.
"""
"""
if self._label is None:
self._label = "{} ({})".format(self.name, self.task_type)
return self._label
@ -184,8 +114,8 @@ class TaskItem:
Returns:
dict[str, Any]: Task item data.
"""
"""
return {
"task_id": self.task_id,
"name": self.name,
@ -204,116 +134,11 @@ class TaskItem:
Returns:
TaskItem: Task item.
"""
"""
return cls(**data)
class FileItem:
"""File item that represents a file.
Can be used for both Workarea and Published workfile. Workarea file
will always exist on disk which is not the case for Published workfile.
Args:
dirpath (str): Directory path of file.
filename (str): Filename.
modified (float): Modified timestamp.
created_by (Optional[str]): Username.
representation_id (Optional[str]): Representation id of published
workfile.
filepath (Optional[str]): Prepared filepath.
exists (Optional[bool]): If file exists on disk.
"""
def __init__(
self,
dirpath,
filename,
modified,
created_by=None,
updated_by=None,
representation_id=None,
filepath=None,
exists=None
):
self.filename = filename
self.dirpath = dirpath
self.modified = modified
self.created_by = created_by
self.updated_by = updated_by
self.representation_id = representation_id
self._filepath = filepath
self._exists = exists
@property
def filepath(self):
"""Filepath of file.
Returns:
str: Full path to a file.
"""
if self._filepath is None:
self._filepath = os.path.join(self.dirpath, self.filename)
return self._filepath
@property
def exists(self):
"""File is available.
Returns:
bool: If file exists on disk.
"""
if self._exists is None:
self._exists = os.path.exists(self.filepath)
return self._exists
def to_data(self):
"""Converts file item to data.
Returns:
dict[str, Any]: File item data.
"""
return {
"filename": self.filename,
"dirpath": self.dirpath,
"modified": self.modified,
"created_by": self.created_by,
"representation_id": self.representation_id,
"filepath": self.filepath,
"exists": self.exists,
}
@classmethod
def from_data(cls, data):
"""Re-creates file item from data.
Args:
data (dict[str, Any]): File item data.
Returns:
FileItem: File item.
"""
required_keys = {
"filename",
"dirpath",
"modified",
"representation_id"
}
missing_keys = required_keys - set(data.keys())
if missing_keys:
raise KeyError("Missing keys: {}".format(missing_keys))
return cls(**{
key: data[key]
for key in required_keys
})
class WorkareaFilepathResult:
"""Result of workarea file formatting.
@ -323,8 +148,8 @@ class WorkareaFilepathResult:
exists (bool): True if file exists.
filepath (str): Filepath. If not provided it will be constructed
from root and filename.
"""
"""
def __init__(self, root, filename, exists, filepath=None):
if not filepath and root and filename:
filepath = os.path.join(root, filename)
@ -341,8 +166,8 @@ class AbstractWorkfilesCommon(ABC):
Returns:
bool: True if host is valid.
"""
"""
pass
@abstractmethod
@ -353,8 +178,8 @@ class AbstractWorkfilesCommon(ABC):
Returns:
Iterable[str]: List of extensions.
"""
"""
pass
@abstractmethod
@ -363,8 +188,8 @@ class AbstractWorkfilesCommon(ABC):
Returns:
bool: True if save is enabled.
"""
"""
pass
@abstractmethod
@ -373,8 +198,8 @@ class AbstractWorkfilesCommon(ABC):
Args:
enabled (bool): Enable save workfile when True.
"""
"""
pass
@ -386,6 +211,7 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
Returns:
str: Name of host.
"""
pass
@ -395,8 +221,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
Returns:
str: Name of project.
"""
"""
pass
@abstractmethod
@ -406,8 +232,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
Returns:
Union[str, None]: Folder id or None if host does not have
any context.
"""
"""
pass
@abstractmethod
@ -417,8 +243,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
Returns:
Union[str, None]: Task name or None if host does not have
any context.
"""
"""
pass
@abstractmethod
@ -428,8 +254,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
Returns:
Union[str, None]: Path to workfile or None if host does
not have opened specific file.
"""
"""
pass
@property
@ -439,8 +265,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
Returns:
Anatomy: Project anatomy.
"""
"""
pass
@property
@ -450,8 +276,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
Returns:
dict[str, Any]: Project settings.
"""
"""
pass
@abstractmethod
@ -463,8 +289,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
Returns:
dict[str, Any]: Project entity data.
"""
"""
pass
@abstractmethod
@ -477,8 +303,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
Returns:
dict[str, Any]: Folder entity data.
"""
"""
pass
@abstractmethod
@ -491,10 +317,24 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
Returns:
dict[str, Any]: Task entity data.
"""
"""
pass
@abstractmethod
def get_workfile_entities(self, task_id: str):
"""Workfile entities for given task.
Args:
task_id (str): Task id.
Returns:
list[dict[str, Any]]: List of workfile entities.
"""
pass
@abstractmethod
def emit_event(self, topic, data=None, source=None):
"""Emit event.
@ -502,8 +342,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon):
topic (str): Event topic used for callbacks filtering.
data (Optional[dict[str, Any]]): Event data.
source (Optional[str]): Event source.
"""
"""
pass
@ -530,8 +370,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
topic (str): Name of topic.
callback (Callable): Callback that will be called when event
is triggered.
"""
"""
pass
@abstractmethod
@ -592,8 +432,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Returns:
List[str]: File extensions that can be used as workfile for
current host.
"""
"""
pass
# Selection information
@ -603,8 +443,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Returns:
Union[str, None]: Folder id or None if no folder is selected.
"""
"""
pass
@abstractmethod
@ -616,8 +456,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Args:
folder_id (Union[str, None]): Folder id or None if no folder
is selected.
"""
"""
pass
@abstractmethod
@ -626,8 +466,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Returns:
Union[str, None]: Task id or None if no folder is selected.
"""
"""
pass
@abstractmethod
@ -649,8 +489,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
is selected.
task_name (Union[str, None]): Task name or None if no task
is selected.
"""
"""
pass
@abstractmethod
@ -659,18 +499,22 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Returns:
Union[str, None]: Selected workfile path.
"""
"""
pass
@abstractmethod
def set_selected_workfile_path(self, path):
def set_selected_workfile_path(
self, rootless_path, path, workfile_entity_id
):
"""Change selected workfile path.
Args:
rootless_path (Union[str, None]): Selected workfile rootless path.
path (Union[str, None]): Selected workfile path.
"""
workfile_entity_id (Union[str, None]): Workfile entity id.
"""
pass
@abstractmethod
@ -680,8 +524,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Returns:
Union[str, None]: Representation id or None if no representation
is selected.
"""
"""
pass
@abstractmethod
@ -691,8 +535,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Args:
representation_id (Union[str, None]): Selected workfile
representation id.
"""
"""
pass
def get_selected_context(self):
@ -700,8 +544,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Returns:
dict[str, Union[str, None]]: Selected context.
"""
"""
return {
"folder_id": self.get_selected_folder_id(),
"task_id": self.get_selected_task_id(),
@ -737,8 +581,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
files UI element.
representation_id (Optional[str]): Representation id. Used for
published filed UI element.
"""
"""
pass
@abstractmethod
@ -750,8 +594,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Returns:
dict[str, Any]: Expected selection data.
"""
"""
pass
@abstractmethod
@ -760,8 +604,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Args:
folder_id (str): Folder id which was selected.
"""
"""
pass
@abstractmethod
@ -771,8 +615,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Args:
folder_id (str): Folder id under which task is.
task_name (str): Task name which was selected.
"""
"""
pass
@abstractmethod
@ -785,8 +629,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
folder_id (str): Folder id under which representation is.
task_name (str): Task name under which representation is.
representation_id (str): Representation id which was selected.
"""
"""
pass
@abstractmethod
@ -797,8 +641,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
folder_id (str): Folder id under which workfile is.
task_name (str): Task name under which workfile is.
workfile_name (str): Workfile filename which was selected.
"""
"""
pass
@abstractmethod
@ -823,8 +667,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Returns:
list[FolderItem]: Minimum possible information needed
for visualisation of folder hierarchy.
"""
"""
pass
@abstractmethod
@ -843,8 +687,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Returns:
list[TaskItem]: Minimum possible information needed
for visualisation of tasks.
"""
"""
pass
@abstractmethod
@ -853,8 +697,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Returns:
bool: Has unsaved changes.
"""
"""
pass
@abstractmethod
@ -867,8 +711,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Returns:
str: Workarea directory.
"""
"""
pass
@abstractmethod
@ -881,9 +725,9 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
sender (Optional[str]): Who requested workarea file items.
Returns:
list[FileItem]: List of workarea file items.
"""
list[WorkfileInfo]: List of workarea file items.
"""
pass
@abstractmethod
@ -899,8 +743,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Returns:
dict[str, Any]: Data for Save As operation.
"""
"""
pass
@abstractmethod
@ -925,12 +769,12 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Returns:
WorkareaFilepathResult: Result of the operation.
"""
"""
pass
@abstractmethod
def get_published_file_items(self, folder_id, task_id):
def get_published_file_items(self, folder_id: str, task_id: str):
"""Get published file items.
Args:
@ -938,44 +782,52 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
task_id (Union[str, None]): Task id.
Returns:
list[FileItem]: List of published file items.
"""
list[PublishedWorkfileInfo]: List of published file items.
"""
pass
@abstractmethod
def get_workfile_info(self, folder_id, task_name, filepath):
def get_workfile_info(self, folder_id, task_id, rootless_path):
"""Workfile info from database.
Args:
folder_id (str): Folder id.
task_name (str): Task id.
filepath (str): Workfile path.
task_id (str): Task id.
rootless_path (str): Workfile path.
Returns:
Union[WorkfileInfo, None]: Workfile info or None if was passed
Optional[WorkfileInfo]: Workfile info or None if was passed
invalid context.
"""
"""
pass
@abstractmethod
def save_workfile_info(self, folder_id, task_name, filepath, note):
def save_workfile_info(
self,
task_id,
rootless_path,
version=None,
comment=None,
description=None,
):
"""Save workfile info to database.
At this moment the only information which can be saved about
workfile is 'note'.
workfile is 'description'.
When 'note' is 'None' it is only validated if workfile info exists,
and if not then creates one with empty note.
If value of 'version', 'comment' or 'description' is 'None' it is not
added/updated to entity.
Args:
folder_id (str): Folder id.
task_name (str): Task id.
filepath (str): Workfile path.
note (Union[str, None]): Note.
"""
task_id (str): Task id.
rootless_path (str): Rootless workfile path.
version (Optional[int]): Version of workfile.
comment (Optional[str]): User's comment (subversion).
description (Optional[str]): Workfile description.
"""
pass
# General commands
@ -985,8 +837,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
Triggers 'controller.reset.started' event at the beginning and
'controller.reset.finished' at the end.
"""
"""
pass
# Controller actions
@ -998,8 +850,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
folder_id (str): Folder id.
task_id (str): Task id.
filepath (str): Workfile path.
"""
"""
pass
@abstractmethod
@ -1013,22 +865,27 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
self,
folder_id,
task_id,
rootless_workdir,
workdir,
filename,
template_key,
artist_note,
version,
comment,
description,
):
"""Save current state of workfile to workarea.
Args:
folder_id (str): Folder id.
task_id (str): Task id.
workdir (str): Workarea directory.
rootless_workdir (str): Workarea directory.
filename (str): Workarea filename.
template_key (str): Template key used to get the workdir
and filename.
"""
version (Optional[int]): Version of workfile.
comment (Optional[str]): User's comment (subversion).
description (Optional[str]): Workfile description.
"""
pass
@abstractmethod
@ -1040,8 +897,10 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
task_id,
workdir,
filename,
template_key,
artist_note,
rootless_workdir,
version,
comment,
description,
):
"""Action to copy published workfile representation to workarea.
@ -1055,23 +914,40 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
task_id (str): Task id.
workdir (str): Workarea directory.
filename (str): Workarea filename.
template_key (str): Template key.
artist_note (str): Artist note.
"""
rootless_workdir (str): Rootless workdir.
version (int): Workfile version.
comment (str): User's comment (subversion).
description (str): Description note.
"""
pass
@abstractmethod
def duplicate_workfile(self, src_filepath, workdir, filename, artist_note):
def duplicate_workfile(
self,
folder_id,
task_id,
src_filepath,
rootless_workdir,
workdir,
filename,
description,
version,
comment
):
"""Duplicate workfile.
Workfiles is not opened when done.
Args:
folder_id (str): Folder id.
task_id (str): Task id.
src_filepath (str): Source workfile path.
rootless_workdir (str): Rootless workdir.
workdir (str): Destination workdir.
filename (str): Destination filename.
artist_note (str): Artist note.
version (int): Workfile version.
comment (str): User's comment (subversion).
description (str): Workfile description.
"""
pass

View file

@ -1,33 +1,28 @@
import os
import shutil
import ayon_api
from ayon_core.host import IWorkfileHost
from ayon_core.lib import Logger, emit_event
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 (
change_current_context,
get_current_host_name,
get_global_context,
)
from ayon_core.pipeline.workfile import create_workdir_extra_folders
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):
@ -140,12 +135,7 @@ class BaseWorkfileController(
if host is None:
host = registered_host()
host_is_valid = False
if host is not None:
missing_methods = (
IWorkfileHost.get_missing_workfile_methods(host)
)
host_is_valid = len(missing_methods) == 0
host_is_valid = isinstance(host, IWorkfileHost)
self._host = host
self._host_is_valid = host_is_valid
@ -154,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
@ -182,7 +173,7 @@ class BaseWorkfileController(
return UsersModel(self)
def _create_workfiles_model(self):
return WorkfilesModel(self)
return WorkfilesModel(self._host, self)
def _create_expected_selection_obj(self):
return WorkfilesToolExpectedSelection(self)
@ -293,28 +284,14 @@ class BaseWorkfileController(
# Host information
def get_workfile_extensions(self):
host = self._host
if isinstance(host, IWorkfileHost):
return host.get_workfile_extensions()
return host.file_extensions()
return self._host.get_workfile_extensions()
def has_unsaved_changes(self):
host = self._host
if isinstance(host, IWorkfileHost):
return host.workfile_has_unsaved_changes()
return host.has_unsaved_changes()
return self._host.workfile_has_unsaved_changes()
# Current context
def get_host_name(self):
host = self._host
if isinstance(host, IWorkfileHost):
return host.name
return get_current_host_name()
def _get_host_current_context(self):
if hasattr(self._host, "get_current_context"):
return self._host.get_current_context()
return get_global_context()
return self._host.name
def get_current_project_name(self):
return self._current_project_name
@ -326,10 +303,7 @@ class BaseWorkfileController(
return self._current_task_name
def get_current_workfile(self):
host = self._host
if isinstance(host, IWorkfileHost):
return host.get_current_workfile()
return host.current_file()
return self._workfiles_model.get_current_workfile()
# Selection information
def get_selected_folder_id(self):
@ -350,8 +324,12 @@ class BaseWorkfileController(
def get_selected_workfile_path(self):
return self._selection_model.get_selected_workfile_path()
def set_selected_workfile_path(self, path):
self._selection_model.set_selected_workfile_path(path)
def set_selected_workfile_path(
self, rootless_path, path, workfile_entity_id
):
self._selection_model.set_selected_workfile_path(
rootless_path, path, workfile_entity_id
)
def get_selected_representation_id(self):
return self._selection_model.get_selected_representation_id()
@ -424,7 +402,7 @@ class BaseWorkfileController(
def get_workarea_file_items(self, folder_id, task_name, sender=None):
task_id = self._get_task_id(folder_id, task_name)
return self._workfiles_model.get_workarea_file_items(
folder_id, task_id, task_name
folder_id, task_id
)
def get_workarea_save_as_data(self, folder_id, task_id):
@ -450,28 +428,34 @@ class BaseWorkfileController(
)
def get_published_file_items(self, folder_id, task_id):
task_name = None
if task_id:
task = self.get_task_entity(
self.get_current_project_name(), task_id
)
task_name = task.get("name")
return self._workfiles_model.get_published_file_items(
folder_id, task_name)
folder_id, task_id
)
def get_workfile_info(self, folder_id, task_name, filepath):
task_id = self._get_task_id(folder_id, task_name)
def get_workfile_info(self, folder_id, task_id, rootless_path):
return self._workfiles_model.get_workfile_info(
folder_id, task_id, filepath
folder_id, task_id, rootless_path
)
def save_workfile_info(self, folder_id, task_name, filepath, note):
task_id = self._get_task_id(folder_id, task_name)
def save_workfile_info(
self,
task_id,
rootless_path,
version=None,
comment=None,
description=None,
):
self._workfiles_model.save_workfile_info(
folder_id, task_id, filepath, note
task_id,
rootless_path,
version,
comment,
description,
)
def get_workfile_entities(self, task_id):
return self._workfiles_model.get_workfile_entities(task_id)
def reset(self):
if not self._host_is_valid:
self._emit_event("controller.reset.started")
@ -509,6 +493,7 @@ class BaseWorkfileController(
self._projects_model.reset()
self._hierarchy_model.reset()
self._workfiles_model.reset()
if not expected_folder_id:
expected_folder_id = folder_id
@ -528,53 +513,31 @@ class BaseWorkfileController(
# Controller actions
def open_workfile(self, folder_id, task_id, filepath):
self._emit_event("open_workfile.started")
failed = False
try:
self._open_workfile(folder_id, task_id, filepath)
except Exception:
failed = True
self.log.warning("Open of workfile failed", exc_info=True)
self._emit_event(
"open_workfile.finished",
{"failed": failed},
)
self._workfiles_model.open_workfile(folder_id, task_id, filepath)
def save_current_workfile(self):
current_file = self.get_current_workfile()
self._host_save_workfile(current_file)
self._workfiles_model.save_current_workfile()
def save_as_workfile(
self,
folder_id,
task_id,
rootless_workdir,
workdir,
filename,
template_key,
artist_note,
version,
comment,
description,
):
self._emit_event("save_as.started")
failed = False
try:
self._save_as_workfile(
folder_id,
task_id,
workdir,
filename,
template_key,
artist_note=artist_note,
)
except Exception:
failed = True
self.log.warning("Save as failed", exc_info=True)
self._emit_event(
"save_as.finished",
{"failed": failed},
self._workfiles_model.save_as_workfile(
folder_id,
task_id,
rootless_workdir,
workdir,
filename,
version,
comment,
description,
)
def copy_workfile_representation(
@ -585,63 +548,61 @@ class BaseWorkfileController(
task_id,
workdir,
filename,
template_key,
artist_note,
rootless_workdir,
version,
comment,
description,
):
self._emit_event("copy_representation.started")
failed = False
try:
self._save_as_workfile(
folder_id,
task_id,
workdir,
filename,
template_key,
artist_note,
src_filepath=representation_filepath
)
except Exception:
failed = True
self.log.warning(
"Copy of workfile representation failed", exc_info=True
)
self._emit_event(
"copy_representation.finished",
{"failed": failed},
self._workfiles_model.copy_workfile_representation(
representation_id,
representation_filepath,
folder_id,
task_id,
workdir,
filename,
rootless_workdir,
version,
comment,
description,
)
def duplicate_workfile(self, src_filepath, workdir, filename, artist_note):
self._emit_event("workfile_duplicate.started")
failed = False
try:
dst_filepath = os.path.join(workdir, filename)
shutil.copy(src_filepath, dst_filepath)
except Exception:
failed = True
self.log.warning("Duplication of workfile failed", exc_info=True)
self._emit_event(
"workfile_duplicate.finished",
{"failed": failed},
def duplicate_workfile(
self,
folder_id,
task_id,
src_filepath,
rootless_workdir,
workdir,
filename,
version,
comment,
description
):
self._workfiles_model.duplicate_workfile(
folder_id,
task_id,
src_filepath,
rootless_workdir,
workdir,
filename,
version,
comment,
description,
)
# Helper host methods that resolve 'IWorkfileHost' interface
def _host_open_workfile(self, filepath):
host = self._host
if isinstance(host, IWorkfileHost):
host.open_workfile(filepath)
else:
host.open_file(filepath)
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 _host_save_workfile(self, filepath):
host = self._host
if isinstance(host, IWorkfileHost):
host.save_workfile(filepath)
else:
host.save_file(filepath)
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")
@ -657,6 +618,11 @@ class BaseWorkfileController(
return None
return task_item.id
def _get_host_current_context(self):
if hasattr(self._host, "get_current_context"):
return self._host.get_current_context()
return get_global_context()
# Expected selection
# - expected selection is used to restore selection after refresh
# or when current context should be used
@ -665,123 +631,3 @@ class BaseWorkfileController(
"expected_selection_changed",
self._expected_selection.get_expected_selection_data(),
)
def _get_event_context_data(
self, project_name, folder_id, task_id, folder=None, task=None
):
if folder is None:
folder = self.get_folder_entity(project_name, folder_id)
if task is None:
task = self.get_task_entity(project_name, task_id)
return {
"project_name": project_name,
"folder_id": folder_id,
"folder_path": folder["path"],
"task_id": task_id,
"task_name": task["name"],
"host_name": self.get_host_name(),
}
def _open_workfile(self, folder_id, task_id, filepath):
project_name = self.get_current_project_name()
event_data = self._get_event_context_data(
project_name, folder_id, task_id
)
event_data["filepath"] = filepath
emit_event("workfile.open.before", event_data, source="workfiles.tool")
# Change context
task_name = event_data["task_name"]
if (
folder_id != self.get_current_folder_id()
or task_name != self.get_current_task_name()
):
self._change_current_context(project_name, folder_id, task_id)
self._host_open_workfile(filepath)
emit_event("workfile.open.after", event_data, source="workfiles.tool")
def _save_as_workfile(
self,
folder_id: str,
task_id: str,
workdir: str,
filename: str,
template_key: str,
artist_note: str,
src_filepath=None,
):
# Trigger before save event
project_name = self.get_current_project_name()
folder = self.get_folder_entity(project_name, folder_id)
task = self.get_task_entity(project_name, task_id)
task_name = task["name"]
# QUESTION should the data be different for 'before' and 'after'?
event_data = self._get_event_context_data(
project_name, folder_id, task_id, folder, task
)
event_data.update({
"filename": filename,
"workdir_path": workdir,
})
emit_event("workfile.save.before", event_data, source="workfiles.tool")
# Create workfiles root folder
if not os.path.exists(workdir):
self.log.debug("Initializing work directory: %s", workdir)
os.makedirs(workdir)
# Change context
if (
folder_id != self.get_current_folder_id()
or task_name != self.get_current_task_name()
):
self._change_current_context(
project_name, folder_id, task_id, template_key
)
# Save workfile
dst_filepath = os.path.join(workdir, filename)
if src_filepath:
shutil.copyfile(src_filepath, dst_filepath)
self._host_open_workfile(dst_filepath)
else:
self._host_save_workfile(dst_filepath)
# Make sure workfile info exists
if not artist_note:
artist_note = None
self.save_workfile_info(
folder_id, task_name, dst_filepath, note=artist_note
)
# Create extra folders
create_workdir_extra_folders(
workdir,
self.get_host_name(),
task["taskType"],
task_name,
project_name
)
# Trigger after save events
emit_event("workfile.save.after", event_data, source="workfiles.tool")
def _change_current_context(
self, project_name, folder_id, task_id, template_key=None
):
# Change current context
folder_entity = self.get_folder_entity(project_name, folder_id)
task_entity = self.get_task_entity(project_name, task_id)
change_current_context(
folder_entity,
task_entity,
template_key=template_key
)
self._current_folder_id = folder_entity["id"]
self._current_folder_path = folder_entity["path"]
self._current_task_name = task_entity["name"]

View file

@ -62,7 +62,9 @@ class SelectionModel(object):
def get_selected_workfile_path(self):
return self._workfile_path
def set_selected_workfile_path(self, path):
def set_selected_workfile_path(
self, rootless_path, path, workfile_entity_id
):
if path == self._workfile_path:
return
@ -72,9 +74,11 @@ class SelectionModel(object):
{
"project_name": self._controller.get_current_project_name(),
"path": path,
"rootless_path": rootless_path,
"folder_id": self._folder_id,
"task_name": self._task_name,
"task_id": self._task_id,
"workfile_entity_id": workfile_entity_id,
},
self.event_source
)

File diff suppressed because it is too large Load diff

View file

@ -200,6 +200,9 @@ class FilesWidget(QtWidgets.QWidget):
self._open_workfile(folder_id, task_id, path)
def _on_current_open_requests(self):
# TODO validate if item under mouse is enabled
# - this uses selected item, but that does not have to be the one
# under mouse
self._on_workarea_open_clicked()
def _on_duplicate_request(self):
@ -210,11 +213,18 @@ class FilesWidget(QtWidgets.QWidget):
result = self._exec_save_as_dialog()
if result is None:
return
folder_id = self._selected_folder_id
task_id = self._selected_task_id
self._controller.duplicate_workfile(
folder_id,
task_id,
filepath,
result["rootless_workdir"],
result["workdir"],
result["filename"],
artist_note=result["artist_note"]
version=result["version"],
comment=result["comment"],
description=result["description"]
)
def _on_workarea_browse_clicked(self):
@ -259,10 +269,12 @@ class FilesWidget(QtWidgets.QWidget):
self._controller.save_as_workfile(
result["folder_id"],
result["task_id"],
result["rootless_workdir"],
result["workdir"],
result["filename"],
result["template_key"],
artist_note=result["artist_note"]
version=result["version"],
comment=result["comment"],
description=result["description"]
)
def _on_workarea_path_changed(self, event):
@ -275,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):
@ -314,12 +327,16 @@ class FilesWidget(QtWidgets.QWidget):
result["task_id"],
result["workdir"],
result["filename"],
result["template_key"],
artist_note=result["artist_note"]
result["rootless_workdir"],
version=result["version"],
comment=result["comment"],
description=result["description"],
)
def _on_save_as_request(self):
self._on_published_save_clicked()
# Make sure the save is enabled
if self._is_save_enabled and self._valid_selected_context:
self._on_published_save_clicked()
def _set_select_contex_mode(self, enabled):
if self._select_context_mode is enabled:

View file

@ -1,3 +1,5 @@
import os
import qtawesome
from qtpy import QtWidgets, QtCore, QtGui
@ -205,24 +207,25 @@ class PublishedFilesModel(QtGui.QStandardItemModel):
new_items.append(item)
item.setColumnCount(self.columnCount())
item.setData(self._file_icon, QtCore.Qt.DecorationRole)
item.setData(file_item.filename, QtCore.Qt.DisplayRole)
item.setData(repre_id, REPRE_ID_ROLE)
if file_item.exists:
if file_item.available:
flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
else:
flags = QtCore.Qt.NoItemFlags
author = file_item.created_by
author = file_item.author
user_item = user_items_by_name.get(author)
if user_item is not None and user_item.full_name:
author = user_item.full_name
item.setFlags(flags)
filename = os.path.basename(file_item.filepath)
item.setFlags(flags)
item.setData(filename, QtCore.Qt.DisplayRole)
item.setData(file_item.filepath, FILEPATH_ROLE)
item.setData(author, AUTHOR_ROLE)
item.setData(file_item.modified, DATE_MODIFIED_ROLE)
item.setData(file_item.file_modified, DATE_MODIFIED_ROLE)
self._items_by_id[repre_id] = item

View file

@ -1,3 +1,5 @@
import os
import qtawesome
from qtpy import QtWidgets, QtCore, QtGui
@ -10,8 +12,10 @@ from ayon_core.tools.utils.delegates import PrettyTimeDelegate
FILENAME_ROLE = QtCore.Qt.UserRole + 1
FILEPATH_ROLE = QtCore.Qt.UserRole + 2
AUTHOR_ROLE = QtCore.Qt.UserRole + 3
DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 4
ROOTLESS_PATH_ROLE = QtCore.Qt.UserRole + 3
AUTHOR_ROLE = QtCore.Qt.UserRole + 4
DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 5
WORKFILE_ENTITY_ID_ROLE = QtCore.Qt.UserRole + 6
class WorkAreaFilesModel(QtGui.QStandardItemModel):
@ -198,7 +202,7 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel):
items_to_remove = set(self._items_by_filename.keys())
new_items = []
for file_item in file_items:
filename = file_item.filename
filename = os.path.basename(file_item.filepath)
if filename in self._items_by_filename:
items_to_remove.discard(filename)
item = self._items_by_filename[filename]
@ -206,23 +210,28 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel):
item = QtGui.QStandardItem()
new_items.append(item)
item.setColumnCount(self.columnCount())
item.setFlags(
QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
)
item.setData(self._file_icon, QtCore.Qt.DecorationRole)
item.setData(file_item.filename, QtCore.Qt.DisplayRole)
item.setData(file_item.filename, FILENAME_ROLE)
item.setData(filename, QtCore.Qt.DisplayRole)
item.setData(filename, FILENAME_ROLE)
flags = QtCore.Qt.ItemIsSelectable
if file_item.available:
flags |= QtCore.Qt.ItemIsEnabled
item.setFlags(flags)
updated_by = file_item.updated_by
user_item = user_items_by_name.get(updated_by)
if user_item is not None and user_item.full_name:
updated_by = user_item.full_name
item.setData(
file_item.workfile_entity_id, WORKFILE_ENTITY_ID_ROLE
)
item.setData(file_item.filepath, FILEPATH_ROLE)
item.setData(file_item.rootless_path, ROOTLESS_PATH_ROLE)
item.setData(file_item.file_modified, DATE_MODIFIED_ROLE)
item.setData(updated_by, AUTHOR_ROLE)
item.setData(file_item.modified, DATE_MODIFIED_ROLE)
self._items_by_filename[file_item.filename] = item
self._items_by_filename[filename] = item
if new_items:
root_item.appendRows(new_items)
@ -354,14 +363,18 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
def _get_selected_info(self):
selection_model = self._view.selectionModel()
filepath = None
filename = None
workfile_entity_id = filename = rootless_path = filepath = None
for index in selection_model.selectedIndexes():
filepath = index.data(FILEPATH_ROLE)
rootless_path = index.data(ROOTLESS_PATH_ROLE)
filename = index.data(FILENAME_ROLE)
workfile_entity_id = index.data(WORKFILE_ENTITY_ID_ROLE)
return {
"filepath": filepath,
"rootless_path": rootless_path,
"filename": filename,
"workfile_entity_id": workfile_entity_id,
}
def get_selected_path(self):
@ -374,8 +387,12 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
return self._get_selected_info()["filepath"]
def _on_selection_change(self):
filepath = self.get_selected_path()
self._controller.set_selected_workfile_path(filepath)
info = self._get_selected_info()
self._controller.set_selected_workfile_path(
info["rootless_path"],
info["filepath"],
info["workfile_entity_id"],
)
def _on_mouse_double_click(self, event):
if event.button() == QtCore.Qt.LeftButton:
@ -430,19 +447,25 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
)
def _on_model_refresh(self):
if (
not self._change_selection_on_refresh
or self._proxy_model.rowCount() < 1
):
if not self._change_selection_on_refresh:
return
# Find the row with latest date modified
indexes = [
self._proxy_model.index(idx, 0)
for idx in range(self._proxy_model.rowCount())
]
filtered_indexes = [
index
for index in indexes
if self._proxy_model.flags(index) & QtCore.Qt.ItemIsEnabled
]
if not filtered_indexes:
return
latest_index = max(
(
self._proxy_model.index(idx, 0)
for idx in range(self._proxy_model.rowCount())
),
key=lambda model_index: model_index.data(DATE_MODIFIED_ROLE)
filtered_indexes,
key=lambda model_index: model_index.data(DATE_MODIFIED_ROLE) or 0
)
# Select row of latest modified

View file

@ -108,6 +108,7 @@ class SaveAsDialog(QtWidgets.QDialog):
self._ext_value = None
self._filename = None
self._workdir = None
self._rootless_workdir = None
self._result = None
@ -144,8 +145,8 @@ class SaveAsDialog(QtWidgets.QDialog):
version_layout.addWidget(last_version_check)
# Artist note widget
artist_note_input = PlaceholderPlainTextEdit(inputs_widget)
artist_note_input.setPlaceholderText(
description_input = PlaceholderPlainTextEdit(inputs_widget)
description_input.setPlaceholderText(
"Provide a note about this workfile.")
# Preview widget
@ -166,7 +167,7 @@ class SaveAsDialog(QtWidgets.QDialog):
subversion_label = QtWidgets.QLabel("Subversion:", inputs_widget)
extension_label = QtWidgets.QLabel("Extension:", inputs_widget)
preview_label = QtWidgets.QLabel("Preview:", inputs_widget)
artist_note_label = QtWidgets.QLabel("Artist Note:", inputs_widget)
description_label = QtWidgets.QLabel("Artist Note:", inputs_widget)
# Build inputs
inputs_layout = QtWidgets.QGridLayout(inputs_widget)
@ -178,8 +179,8 @@ class SaveAsDialog(QtWidgets.QDialog):
inputs_layout.addWidget(extension_combobox, 2, 1)
inputs_layout.addWidget(preview_label, 3, 0)
inputs_layout.addWidget(preview_widget, 3, 1)
inputs_layout.addWidget(artist_note_label, 4, 0, 1, 2)
inputs_layout.addWidget(artist_note_input, 5, 0, 1, 2)
inputs_layout.addWidget(description_label, 4, 0, 1, 2)
inputs_layout.addWidget(description_input, 5, 0, 1, 2)
# Build layout
main_layout = QtWidgets.QVBoxLayout(self)
@ -214,13 +215,13 @@ class SaveAsDialog(QtWidgets.QDialog):
self._extension_combobox = extension_combobox
self._subversion_input = subversion_input
self._preview_widget = preview_widget
self._artist_note_input = artist_note_input
self._description_input = description_input
self._version_label = version_label
self._subversion_label = subversion_label
self._extension_label = extension_label
self._preview_label = preview_label
self._artist_note_label = artist_note_label
self._description_label = description_label
# Post init setup
@ -255,6 +256,7 @@ class SaveAsDialog(QtWidgets.QDialog):
self._folder_id = folder_id
self._task_id = task_id
self._workdir = data["workdir"]
self._rootless_workdir = data["rootless_workdir"]
self._comment_value = data["comment"]
self._ext_value = data["ext"]
self._template_key = data["template_key"]
@ -329,10 +331,13 @@ class SaveAsDialog(QtWidgets.QDialog):
self._result = {
"filename": self._filename,
"workdir": self._workdir,
"rootless_workdir": self._rootless_workdir,
"folder_id": self._folder_id,
"task_id": self._task_id,
"template_key": self._template_key,
"artist_note": self._artist_note_input.toPlainText(),
"version": self._version_value,
"comment": self._comment_value,
"description": self._description_input.toPlainText(),
}
self.close()

View file

@ -4,6 +4,8 @@ from qtpy import QtWidgets, QtCore
def file_size_to_string(file_size):
if not file_size:
return "N/A"
size = 0
size_ending_mapping = {
"KB": 1024 ** 1,
@ -43,44 +45,47 @@ class SidePanelWidget(QtWidgets.QWidget):
details_input = QtWidgets.QPlainTextEdit(self)
details_input.setReadOnly(True)
artist_note_widget = QtWidgets.QWidget(self)
note_label = QtWidgets.QLabel("Artist note", artist_note_widget)
note_input = QtWidgets.QPlainTextEdit(artist_note_widget)
btn_note_save = QtWidgets.QPushButton("Save note", artist_note_widget)
description_widget = QtWidgets.QWidget(self)
description_label = QtWidgets.QLabel("Artist note", description_widget)
description_input = QtWidgets.QPlainTextEdit(description_widget)
btn_description_save = QtWidgets.QPushButton(
"Save note", description_widget
)
artist_note_layout = QtWidgets.QVBoxLayout(artist_note_widget)
artist_note_layout.setContentsMargins(0, 0, 0, 0)
artist_note_layout.addWidget(note_label, 0)
artist_note_layout.addWidget(note_input, 1)
artist_note_layout.addWidget(
btn_note_save, 0, alignment=QtCore.Qt.AlignRight
description_layout = QtWidgets.QVBoxLayout(description_widget)
description_layout.setContentsMargins(0, 0, 0, 0)
description_layout.addWidget(description_label, 0)
description_layout.addWidget(description_input, 1)
description_layout.addWidget(
btn_description_save, 0, alignment=QtCore.Qt.AlignRight
)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(details_label, 0)
main_layout.addWidget(details_input, 1)
main_layout.addWidget(artist_note_widget, 1)
main_layout.addWidget(description_widget, 1)
note_input.textChanged.connect(self._on_note_change)
btn_note_save.clicked.connect(self._on_save_click)
description_input.textChanged.connect(self._on_description_change)
btn_description_save.clicked.connect(self._on_save_click)
controller.register_event_callback(
"selection.workarea.changed", self._on_selection_change
)
self._details_input = details_input
self._artist_note_widget = artist_note_widget
self._note_input = note_input
self._btn_note_save = btn_note_save
self._description_widget = description_widget
self._description_input = description_input
self._btn_description_save = btn_description_save
self._folder_id = None
self._task_name = None
self._task_id = None
self._filepath = None
self._orig_note = ""
self._rootless_path = None
self._orig_description = ""
self._controller = controller
self._set_context(None, None, None)
self._set_context(None, None, None, None)
def set_published_mode(self, published_mode):
"""Change published mode.
@ -89,64 +94,69 @@ class SidePanelWidget(QtWidgets.QWidget):
published_mode (bool): Published mode enabled.
"""
self._artist_note_widget.setVisible(not published_mode)
self._description_widget.setVisible(not published_mode)
def _on_selection_change(self, event):
folder_id = event["folder_id"]
task_name = event["task_name"]
task_id = event["task_id"]
filepath = event["path"]
rootless_path = event["rootless_path"]
self._set_context(folder_id, task_name, filepath)
self._set_context(folder_id, task_id, rootless_path, filepath)
def _on_note_change(self):
text = self._note_input.toPlainText()
self._btn_note_save.setEnabled(self._orig_note != text)
def _on_description_change(self):
text = self._description_input.toPlainText()
self._btn_description_save.setEnabled(self._orig_description != text)
def _on_save_click(self):
note = self._note_input.toPlainText()
description = self._description_input.toPlainText()
self._controller.save_workfile_info(
self._folder_id,
self._task_name,
self._filepath,
note
self._task_id,
self._rootless_path,
description=description,
)
self._orig_note = note
self._btn_note_save.setEnabled(False)
self._orig_description = description
self._btn_description_save.setEnabled(False)
def _set_context(self, folder_id, task_name, filepath):
def _set_context(self, folder_id, task_id, rootless_path, filepath):
workfile_info = None
# Check if folder, task and file are selected
if bool(folder_id) and bool(task_name) and bool(filepath):
if folder_id and task_id and rootless_path:
workfile_info = self._controller.get_workfile_info(
folder_id, task_name, filepath
folder_id, task_id, rootless_path
)
enabled = workfile_info is not None
self._details_input.setEnabled(enabled)
self._note_input.setEnabled(enabled)
self._btn_note_save.setEnabled(enabled)
self._description_input.setEnabled(enabled)
self._btn_description_save.setEnabled(enabled)
self._folder_id = folder_id
self._task_name = task_name
self._task_id = task_id
self._filepath = filepath
self._rootless_path = rootless_path
# Disable inputs and remove texts if any required arguments are
# missing
if not enabled:
self._orig_note = ""
self._orig_description = ""
self._details_input.setPlainText("")
self._note_input.setPlainText("")
self._description_input.setPlainText("")
return
note = workfile_info.note
size_value = file_size_to_string(workfile_info.filesize)
description = workfile_info.description
size_value = file_size_to_string(workfile_info.file_size)
# Append html string
datetime_format = "%b %d %Y %H:%M:%S"
creation_time = datetime.datetime.fromtimestamp(
workfile_info.creation_time)
modification_time = datetime.datetime.fromtimestamp(
workfile_info.modification_time)
file_created = workfile_info.file_created
modification_time = workfile_info.file_modified
if file_created:
file_created = datetime.datetime.fromtimestamp(file_created)
if modification_time:
modification_time = datetime.datetime.fromtimestamp(
modification_time)
user_items_by_name = self._controller.get_user_items_by_name()
@ -156,33 +166,38 @@ class SidePanelWidget(QtWidgets.QWidget):
return user_item.full_name
return username
created_lines = [
creation_time.strftime(datetime_format)
]
created_lines = []
if workfile_info.created_by:
created_lines.insert(
0, convert_username(workfile_info.created_by)
created_lines.append(
convert_username(workfile_info.created_by)
)
if file_created:
created_lines.append(file_created.strftime(datetime_format))
modified_lines = [
modification_time.strftime(datetime_format)
]
if created_lines:
created_lines.insert(0, "<b>Created:</b>")
modified_lines = []
if workfile_info.updated_by:
modified_lines.insert(
0, convert_username(workfile_info.updated_by)
modified_lines.append(
convert_username(workfile_info.updated_by)
)
if modification_time:
modified_lines.append(
modification_time.strftime(datetime_format)
)
if modified_lines:
modified_lines.insert(0, "<b>Modified:</b>")
lines = (
"<b>Size:</b>",
size_value,
"<b>Created:</b>",
"<br/>".join(created_lines),
"<b>Modified:</b>",
"<br/>".join(modified_lines),
)
self._orig_note = note
self._note_input.setPlainText(note)
self._orig_description = description
self._description_input.setPlainText(description)
# Set as empty string
self._details_input.setPlainText("")
self._details_input.appendHtml("<br>".join(lines))
self._details_input.appendHtml("<br/>".join(lines))

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.4.1+dev"
__version__ = "1.5.3+dev"

View file

@ -1,11 +1,13 @@
name = "core"
title = "Core"
version = "1.4.1+dev"
version = "1.5.3+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.4.1+dev"
version = "1.5.3+dev"
description = ""
authors = ["Ynput Team <team@ynput.io>"]
readme = "README.md"
@ -19,6 +19,7 @@ python = ">=3.9.1,<3.10"
pytest = "^8.0"
pytest-print = "^1.0"
ayon-python-api = "^1.0"
arrow = "0.17.0"
# linting dependencies
ruff = "^0.11.7"
pre-commit = "^4"

View file

@ -747,6 +747,11 @@ class ExtractReviewProfileModel(BaseSettingsModel):
hosts: list[str] = SettingsField(
default_factory=list, title="Host names"
)
task_types: list[str] = SettingsField(
default_factory=list,
title="Task Types",
enum_resolver=task_types_enum,
)
outputs: list[ExtractReviewOutputDefModel] = SettingsField(
default_factory=list, title="Output Definitions"
)
@ -1348,6 +1353,7 @@ DEFAULT_PUBLISH_VALUES = {
{
"product_types": [],
"hosts": [],
"task_types": [],
"outputs": [
{
"name": "png",