mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 12:54:40 +01:00
Merge branch 'develop' into bugfix/ocio-v2-aces1.3-display-resolving-error
This commit is contained in:
commit
c121f419cc
99 changed files with 7149 additions and 4788 deletions
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
|
|||
"cinema4d",
|
||||
"silhouette",
|
||||
"gaffer",
|
||||
"loki",
|
||||
}
|
||||
launch_types = {LaunchTypes.local}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class OCIOEnvHook(PreLaunchHook):
|
|||
"cinema4d",
|
||||
"silhouette",
|
||||
"gaffer",
|
||||
"loki",
|
||||
}
|
||||
launch_types = set()
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
96
client/ayon_core/host/abstract.py
Normal file
96
client/ayon_core/host/abstract.py
Normal 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
|
||||
15
client/ayon_core/host/constants.py
Normal file
15
client/ayon_core/host/constants.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
66
client/ayon_core/host/interfaces/__init__.py
Normal file
66
client/ayon_core/host/interfaces/__init__.py
Normal 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",
|
||||
)
|
||||
15
client/ayon_core/host/interfaces/exceptions.py
Normal file
15
client/ayon_core/host/interfaces/exceptions.py
Normal 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}"
|
||||
)
|
||||
189
client/ayon_core/host/interfaces/interfaces.py
Normal file
189
client/ayon_core/host/interfaces/interfaces.py
Normal 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
|
||||
1798
client/ayon_core/host/interfaces/workfiles.py
Normal file
1798
client/ayon_core/host/interfaces/workfiles.py
Normal file
File diff suppressed because it is too large
Load diff
7
client/ayon_core/host/typing.py
Normal file
7
client/ayon_core/host/typing.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from typing import Optional, TypedDict
|
||||
|
||||
|
||||
class HostContextData(TypedDict):
|
||||
project_name: str
|
||||
folder_path: Optional[str]
|
||||
task_name: Optional[str]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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__}: "
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ class CleanUp(pyblish.api.InstancePlugin):
|
|||
"webpublisher",
|
||||
"shell"
|
||||
]
|
||||
settings_category = "core"
|
||||
|
||||
exclude_families = ["clip"]
|
||||
optional = True
|
||||
active = True
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -39,8 +39,9 @@ class CollectAudio(pyblish.api.ContextPlugin):
|
|||
"blender",
|
||||
"houdini",
|
||||
"max",
|
||||
"circuit",
|
||||
"batchdelivery",
|
||||
]
|
||||
settings_category = "core"
|
||||
|
||||
audio_product_name = "audioMain"
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class CollectFramesFixDef(
|
|||
targets = ["local"]
|
||||
hosts = ["nuke"]
|
||||
families = ["render", "prerender"]
|
||||
settings_category = "core"
|
||||
|
||||
rewrite_version_enable = False
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -55,8 +55,9 @@ class ExtractBurnin(publish.Extractor):
|
|||
"max",
|
||||
"blender",
|
||||
"unreal",
|
||||
"circuit",
|
||||
"batchdelivery",
|
||||
]
|
||||
settings_category = "core"
|
||||
|
||||
optional = True
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ class IntegrateHeroVersion(
|
|||
# Must happen after IntegrateNew
|
||||
order = pyblish.api.IntegratorOrder + 0.1
|
||||
|
||||
settings_category = "core"
|
||||
|
||||
optional = True
|
||||
active = True
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class ValidateOutdatedContainers(
|
|||
|
||||
label = "Validate Outdated Containers"
|
||||
order = pyblish.api.ValidatorOrder
|
||||
settings_category = "core"
|
||||
|
||||
optional = True
|
||||
actions = [ShowInventory]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class ValidateVersion(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin):
|
|||
order = pyblish.api.ValidatorOrder
|
||||
|
||||
label = "Validate Version"
|
||||
settings_category = "core"
|
||||
|
||||
optional = False
|
||||
active = True
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
},
|
||||
"publisher": {
|
||||
"error": "#AA5050",
|
||||
"disabled": "#5b6779",
|
||||
"crash": "#FF6432",
|
||||
"success": "#458056",
|
||||
"warning": "#ffc671",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
from .window import (
|
||||
show,
|
||||
CreatorWindow
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"show",
|
||||
"CreatorWindow"
|
||||
)
|
||||
|
|
@ -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---"}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
from .window import (
|
||||
show,
|
||||
SubsetManagerWindow
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"show",
|
||||
"SubsetManagerWindow"
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Package declaring AYON addon 'core' version."""
|
||||
__version__ = "1.4.1+dev"
|
||||
__version__ = "1.5.3+dev"
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue