merge develop

This commit is contained in:
Ondrej Samohel 2024-12-22 01:13:40 +01:00
commit 5d31e66472
No known key found for this signature in database
GPG key ID: 02376E18990A97C6
105 changed files with 3886 additions and 2212 deletions

View file

@ -0,0 +1,48 @@
name: 🔸Auto assign pr
on:
workflow_dispatch:
inputs:
pr_number:
type: string
description: "Run workflow for this PR number"
required: true
project_id:
type: string
description: "Github Project Number"
required: true
default: "16"
pull_request:
types:
- opened
env:
GH_TOKEN: ${{ github.token }}
jobs:
get-pr-repo:
runs-on: ubuntu-latest
outputs:
pr_repo_name: ${{ steps.get-repo-name.outputs.repo_name || github.event.pull_request.head.repo.full_name }}
# INFO `github.event.pull_request.head.repo.full_name` is not available on manual triggered (dispatched) runs
steps:
- name: Get PR repo name
if: ${{ github.event_name == 'workflow_dispatch' }}
id: get-repo-name
run: |
repo_name=$(gh pr view ${{ inputs.pr_number }} --json headRepository,headRepositoryOwner --repo ${{ github.repository }} | jq -r '.headRepositoryOwner.login + "/" + .headRepository.name')
echo "repo_name=$repo_name" >> $GITHUB_OUTPUT
auto-assign-pr:
needs:
- get-pr-repo
if: ${{ needs.get-pr-repo.outputs.pr_repo_name == github.repository }}
uses: ynput/ops-repo-automation/.github/workflows/pr_to_project.yml@main
with:
repo: "${{ github.repository }}"
project_id: ${{ inputs.project_id != '' && fromJSON(inputs.project_id) || 16 }}
pull_request_number: ${{ github.event.pull_request.number || fromJSON(inputs.pr_number) }}
secrets:
# INFO fallback to default `github.token` is required for PRs from forks
# INFO organization secrets won't be available to forks
token: ${{ secrets.YNPUT_BOT_TOKEN || github.token}}

View file

@ -0,0 +1,18 @@
name: 🔎 Validate PR Labels
on:
pull_request:
types:
- opened
- edited
- labeled
- unlabeled
jobs:
validate-type-label:
uses: ynput/ops-repo-automation/.github/workflows/validate_pr_labels.yml@main
with:
repo: "${{ github.repository }}"
pull_request_number: ${{ github.event.pull_request.number }}
query_prefix: "type: "
secrets:
token: ${{ secrets.YNPUT_BOT_TOKEN }}

View file

@ -370,67 +370,11 @@ def _load_ayon_addons(log):
return all_addon_modules
def _load_addons_in_core(log):
# Add current directory at first place
# - has small differences in import logic
addon_modules = []
modules_dir = os.path.join(AYON_CORE_ROOT, "modules")
if not os.path.exists(modules_dir):
log.warning(
f"Could not find path when loading AYON addons \"{modules_dir}\""
)
return addon_modules
ignored_filenames = IGNORED_FILENAMES | IGNORED_DEFAULT_FILENAMES
for filename in os.listdir(modules_dir):
# Ignore filenames
if filename in ignored_filenames:
continue
fullpath = os.path.join(modules_dir, filename)
basename, ext = os.path.splitext(filename)
# Validations
if os.path.isdir(fullpath):
# Check existence of init file
init_path = os.path.join(fullpath, "__init__.py")
if not os.path.exists(init_path):
log.debug((
"Addon directory does not contain __init__.py"
f" file {fullpath}"
))
continue
elif ext != ".py":
continue
# TODO add more logic how to define if folder is addon or not
# - check manifest and content of manifest
try:
# Don't import dynamically current directory modules
import_str = f"ayon_core.modules.{basename}"
default_module = __import__(import_str, fromlist=("", ))
addon_modules.append(default_module)
except Exception:
log.error(
f"Failed to import in-core addon '{basename}'.",
exc_info=True
)
return addon_modules
def _load_addons():
log = Logger.get_logger("AddonsLoader")
addon_modules = _load_ayon_addons(log)
# All addon in 'modules' folder are tray actions and should be moved
# to tray tool.
# TODO remove
addon_modules.extend(_load_addons_in_core(log))
# Store modules to local cache
_LoadCache.addon_modules = addon_modules
_LoadCache.addon_modules = _load_ayon_addons(log)
class AYONAddon(ABC):
@ -535,8 +479,8 @@ class AYONAddon(ABC):
Implementation of this method is optional.
Note:
The logic can be similar to logic in tray, but tray does not require
to be logged in.
The logic can be similar to logic in tray, but tray does not
require to be logged in.
Args:
process_context (ProcessContext): Context of child
@ -950,6 +894,21 @@ class AddonsManager:
output.extend(paths)
return output
def collect_launcher_action_paths(self):
"""Helper to collect launcher action paths from addons.
Returns:
list: List of paths to launcher actions.
"""
output = self._collect_plugin_paths(
"get_launcher_action_paths"
)
# Add default core actions
actions_dir = os.path.join(AYON_CORE_ROOT, "plugins", "actions")
output.insert(0, actions_dir)
return output
def collect_create_plugin_paths(self, host_name):
"""Helper to collect creator plugin paths from addons.

View file

@ -84,6 +84,13 @@ class IPluginPaths(AYONInterface):
paths = [paths]
return paths
def get_launcher_action_paths(self):
"""Receive launcher actions paths.
Give addons ability to add launcher actions paths.
"""
return self._get_plugin_paths_by_type("actions")
def get_create_plugin_paths(self, host_name: str) -> list[str]:
"""Receive create plugin paths.
@ -154,8 +161,9 @@ class ITrayAddon(AYONInterface):
"""
tray_initialized = False
manager: AddonsManager = None
_tray_manager: TrayManager = None
_tray_manager = None
_admin_submenu = None
@abstractmethod
def tray_init(self) -> None:
@ -236,6 +244,27 @@ class ITrayAddon(AYONInterface):
if hasattr(self.manager, "add_doubleclick_callback"):
self.manager.add_doubleclick_callback(self, callback)
@staticmethod
def admin_submenu(tray_menu):
if ITrayAddon._admin_submenu is None:
from qtpy import QtWidgets
admin_submenu = QtWidgets.QMenu("Admin", tray_menu)
admin_submenu.menuAction().setVisible(False)
ITrayAddon._admin_submenu = admin_submenu
return ITrayAddon._admin_submenu
@staticmethod
def add_action_to_admin_submenu(label, tray_menu):
from qtpy import QtWidgets
menu = ITrayAddon.admin_submenu(tray_menu)
action = QtWidgets.QAction(label, menu)
menu.addAction(action)
if not menu.menuAction().isVisible():
menu.menuAction().setVisible(True)
return action
class ITrayAction(ITrayAddon):
"""Implementation of Tray action.
@ -249,7 +278,6 @@ class ITrayAction(ITrayAddon):
"""
admin_action = False
_admin_submenu = None
_action_item = None
@property
@ -268,12 +296,7 @@ class ITrayAction(ITrayAddon):
from qtpy import QtWidgets
if self.admin_action:
menu = self.admin_submenu(tray_menu)
action = QtWidgets.QAction(self.label, menu)
menu.addAction(action)
if not menu.menuAction().isVisible():
menu.menuAction().setVisible(True)
action = self.add_action_to_admin_submenu(self.label, tray_menu)
else:
action = QtWidgets.QAction(self.label, tray_menu)
tray_menu.addAction(action)
@ -289,17 +312,6 @@ class ITrayAction(ITrayAddon):
"""Cleanup method which is executed on tray shutdown."""
return
@staticmethod
def admin_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu:
"""Get or create admin submenu."""
if ITrayAction._admin_submenu is None:
from qtpy import QtWidgets
admin_submenu = QtWidgets.QMenu("Admin", tray_menu)
admin_submenu.menuAction().setVisible(False)
ITrayAction._admin_submenu = admin_submenu
return ITrayAction._admin_submenu
class ITrayService(ITrayAddon):
"""Tray service Interface."""

View file

@ -146,7 +146,8 @@ def publish_report_viewer():
@main_cli.command()
@click.argument("output_path")
@click.option("--project", help="Define project context")
@click.option("--folder", help="Define folder in project (project must be set)")
@click.option(
"--folder", help="Define folder in project (project must be set)")
@click.option(
"--strict",
is_flag=True,

View file

@ -6,82 +6,58 @@ import json
import copy
import warnings
from abc import ABCMeta, abstractmethod
from typing import Any, Optional
import typing
from typing import (
Any,
Optional,
List,
Set,
Dict,
Iterable,
TypeVar,
)
import clique
if typing.TYPE_CHECKING:
from typing import Self, Tuple, Union, TypedDict, Pattern
class EnumItemDict(TypedDict):
label: str
value: Any
EnumItemsInputType = Union[
Dict[Any, str],
List[Tuple[Any, str]],
List[Any],
List[EnumItemDict]
]
class FileDefItemDict(TypedDict):
directory: str
filenames: List[str]
frames: Optional[List[int]]
template: Optional[str]
is_sequence: Optional[bool]
# Global variable which store attribute definitions by type
# - default types are registered on import
_attr_defs_by_type = {}
def register_attr_def_class(cls):
"""Register attribute definition.
Currently registered definitions are used to deserialize data to objects.
Attrs:
cls (AbstractAttrDef): Non-abstract class to be registered with unique
'type' attribute.
Raises:
KeyError: When type was already registered.
"""
if cls.type in _attr_defs_by_type:
raise KeyError("Type \"{}\" was already registered".format(cls.type))
_attr_defs_by_type[cls.type] = cls
def get_attributes_keys(attribute_definitions):
"""Collect keys from list of attribute definitions.
Args:
attribute_definitions (List[AbstractAttrDef]): Objects of attribute
definitions.
Returns:
Set[str]: Keys that will be created using passed attribute definitions.
"""
keys = set()
if not attribute_definitions:
return keys
for attribute_def in attribute_definitions:
if not isinstance(attribute_def, UIDef):
keys.add(attribute_def.key)
return keys
def get_default_values(attribute_definitions):
"""Receive default values for attribute definitions.
Args:
attribute_definitions (List[AbstractAttrDef]): Attribute definitions
for which default values should be collected.
Returns:
Dict[str, Any]: Default values for passed attribute definitions.
"""
output = {}
if not attribute_definitions:
return output
for attr_def in attribute_definitions:
# Skip UI definitions
if not isinstance(attr_def, UIDef):
output[attr_def.key] = attr_def.default
return output
# Type hint helpers
IntFloatType = "Union[int, float]"
class AbstractAttrDefMeta(ABCMeta):
"""Metaclass to validate the existence of 'key' attribute.
Each object of `AbstractAttrDef` must have defined 'key' attribute.
"""
"""
def __call__(cls, *args, **kwargs):
obj = super(AbstractAttrDefMeta, cls).__call__(*args, **kwargs)
init_class = getattr(obj, "__init__class__", None)
@ -93,8 +69,12 @@ class AbstractAttrDefMeta(ABCMeta):
def _convert_reversed_attr(
main_value, depr_value, main_label, depr_label, default
):
main_value: Any,
depr_value: Any,
main_label: str,
depr_label: str,
default: Any,
) -> Any:
if main_value is not None and depr_value is not None:
if main_value == depr_value:
print(
@ -140,8 +120,8 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
enabled (Optional[bool]): Item is enabled (for UI purposes).
hidden (Optional[bool]): DEPRECATED: Use 'visible' instead.
disabled (Optional[bool]): DEPRECATED: Use 'enabled' instead.
"""
"""
type_attributes = []
is_value_def = True
@ -183,7 +163,7 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
def id(self) -> str:
return self._id
def clone(self):
def clone(self) -> "Self":
data = self.serialize()
data.pop("type")
return self.deserialize(data)
@ -251,28 +231,28 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
Returns:
str: Type of attribute definition.
"""
"""
pass
@abstractmethod
def convert_value(self, value):
def convert_value(self, value: Any) -> Any:
"""Convert value to a valid one.
Convert passed value to a valid type. Use default if value can't be
converted.
"""
"""
pass
def serialize(self):
def serialize(self) -> Dict[str, Any]:
"""Serialize object to data so it's possible to recreate it.
Returns:
Dict[str, Any]: Serialized object that can be passed to
'deserialize' method.
"""
"""
data = {
"type": self.type,
"key": self.key,
@ -288,7 +268,7 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
return data
@classmethod
def deserialize(cls, data):
def deserialize(cls, data: Dict[str, Any]) -> "Self":
"""Recreate object from data.
Data can be received using 'serialize' method.
@ -299,10 +279,12 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
return cls(**data)
def _def_type_compare(self, other: "AbstractAttrDef") -> bool:
def _def_type_compare(self, other: "Self") -> bool:
return True
AttrDefType = TypeVar("AttrDefType", bound=AbstractAttrDef)
# -----------------------------------------
# UI attribute definitions won't hold value
# -----------------------------------------
@ -310,13 +292,19 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
class UIDef(AbstractAttrDef):
is_value_def = False
def __init__(self, key=None, default=None, *args, **kwargs):
def __init__(
self,
key: Optional[str] = None,
default: Optional[Any] = None,
*args,
**kwargs
):
super().__init__(key, default, *args, **kwargs)
def is_value_valid(self, value: Any) -> bool:
return True
def convert_value(self, value):
def convert_value(self, value: Any) -> Any:
return value
@ -343,18 +331,18 @@ class UnknownDef(AbstractAttrDef):
This attribute can be used to keep existing data unchanged but does not
have known definition of type.
"""
"""
type = "unknown"
def __init__(self, key, default=None, **kwargs):
def __init__(self, key: str, default: Optional[Any] = None, **kwargs):
kwargs["default"] = default
super().__init__(key, **kwargs)
def is_value_valid(self, value: Any) -> bool:
return True
def convert_value(self, value):
def convert_value(self, value: Any) -> Any:
return value
@ -365,11 +353,11 @@ class HiddenDef(AbstractAttrDef):
to other attributes (e.g. in multi-page UIs).
Keep in mind the value should be possible to parse by json parser.
"""
"""
type = "hidden"
def __init__(self, key, default=None, **kwargs):
def __init__(self, key: str, default: Optional[Any] = None, **kwargs):
kwargs["default"] = default
kwargs["visible"] = False
super().__init__(key, **kwargs)
@ -377,7 +365,7 @@ class HiddenDef(AbstractAttrDef):
def is_value_valid(self, value: Any) -> bool:
return True
def convert_value(self, value):
def convert_value(self, value: Any) -> Any:
return value
@ -392,8 +380,8 @@ class NumberDef(AbstractAttrDef):
maximum(int, float): Maximum possible value.
decimals(int): Maximum decimal points of value.
default(int, float): Default value for conversion.
"""
"""
type = "number"
type_attributes = [
"minimum",
@ -402,7 +390,12 @@ class NumberDef(AbstractAttrDef):
]
def __init__(
self, key, minimum=None, maximum=None, decimals=None, default=None,
self,
key: str,
minimum: Optional[IntFloatType] = None,
maximum: Optional[IntFloatType] = None,
decimals: Optional[int] = None,
default: Optional[IntFloatType] = None,
**kwargs
):
minimum = 0 if minimum is None else minimum
@ -428,9 +421,9 @@ class NumberDef(AbstractAttrDef):
super().__init__(key, default=default, **kwargs)
self.minimum = minimum
self.maximum = maximum
self.decimals = 0 if decimals is None else decimals
self.minimum: IntFloatType = minimum
self.maximum: IntFloatType = maximum
self.decimals: int = 0 if decimals is None else decimals
def is_value_valid(self, value: Any) -> bool:
if self.decimals == 0:
@ -442,7 +435,7 @@ class NumberDef(AbstractAttrDef):
return False
return True
def convert_value(self, value):
def convert_value(self, value: Any) -> IntFloatType:
if isinstance(value, str):
try:
value = float(value)
@ -477,8 +470,8 @@ class TextDef(AbstractAttrDef):
regex(str, re.Pattern): Regex validation.
placeholder(str): UI placeholder for attribute.
default(str, None): Default value. Empty string used when not defined.
"""
"""
type = "text"
type_attributes = [
"multiline",
@ -486,7 +479,12 @@ class TextDef(AbstractAttrDef):
]
def __init__(
self, key, multiline=None, regex=None, placeholder=None, default=None,
self,
key: str,
multiline: Optional[bool] = None,
regex: Optional[str] = None,
placeholder: Optional[str] = None,
default: Optional[str] = None,
**kwargs
):
if default is None:
@ -505,9 +503,9 @@ class TextDef(AbstractAttrDef):
if isinstance(regex, str):
regex = re.compile(regex)
self.multiline = multiline
self.placeholder = placeholder
self.regex = regex
self.multiline: bool = multiline
self.placeholder: Optional[str] = placeholder
self.regex: Optional["Pattern"] = regex
def is_value_valid(self, value: Any) -> bool:
if not isinstance(value, str):
@ -516,12 +514,12 @@ class TextDef(AbstractAttrDef):
return False
return True
def convert_value(self, value):
def convert_value(self, value: Any) -> str:
if isinstance(value, str):
return value
return self.default
def serialize(self):
def serialize(self) -> Dict[str, Any]:
data = super().serialize()
regex = None
if self.regex is not None:
@ -545,18 +543,24 @@ class EnumDef(AbstractAttrDef):
is enabled.
Args:
items (Union[list[str], list[dict[str, Any]]): Items definition that
can be converted using 'prepare_enum_items'.
key (str): Key under which value is stored.
items (EnumItemsInputType): Items definition that can be converted
using 'prepare_enum_items'.
default (Optional[Any]): Default value. Must be one key(value) from
passed items or list of values for multiselection.
multiselection (Optional[bool]): If True, multiselection is allowed.
Output is list of selected items.
"""
"""
type = "enum"
def __init__(
self, key, items, default=None, multiselection=False, **kwargs
self,
key: str,
items: "EnumItemsInputType",
default: "Union[str, List[Any]]" = None,
multiselection: Optional[bool] = False,
**kwargs
):
if not items:
raise ValueError((
@ -567,6 +571,9 @@ class EnumDef(AbstractAttrDef):
items = self.prepare_enum_items(items)
item_values = [item["value"] for item in items]
item_values_set = set(item_values)
if multiselection is None:
multiselection = False
if multiselection:
if default is None:
default = []
@ -577,9 +584,9 @@ class EnumDef(AbstractAttrDef):
super().__init__(key, default=default, **kwargs)
self.items = items
self._item_values = item_values_set
self.multiselection = multiselection
self.items: List["EnumItemDict"] = items
self._item_values: Set[Any] = item_values_set
self.multiselection: bool = multiselection
def convert_value(self, value):
if not self.multiselection:
@ -609,7 +616,9 @@ class EnumDef(AbstractAttrDef):
return data
@staticmethod
def prepare_enum_items(items):
def prepare_enum_items(
items: "EnumItemsInputType"
) -> List["EnumItemDict"]:
"""Convert items to unified structure.
Output is a list where each item is dictionary with 'value'
@ -625,13 +634,12 @@ class EnumDef(AbstractAttrDef):
```
Args:
items (Union[Dict[str, Any], List[Any], List[Dict[str, Any]]): The
items to convert.
items (EnumItemsInputType): The items to convert.
Returns:
List[Dict[str, Any]]: Unified structure of items.
"""
List[EnumItemDict]: Unified structure of items.
"""
output = []
if isinstance(items, dict):
for value, label in items.items():
@ -682,11 +690,11 @@ class BoolDef(AbstractAttrDef):
Args:
default(bool): Default value. Set to `False` if not defined.
"""
"""
type = "bool"
def __init__(self, key, default=None, **kwargs):
def __init__(self, key: str, default: Optional[bool] = None, **kwargs):
if default is None:
default = False
super().__init__(key, default=default, **kwargs)
@ -694,7 +702,7 @@ class BoolDef(AbstractAttrDef):
def is_value_valid(self, value: Any) -> bool:
return isinstance(value, bool)
def convert_value(self, value):
def convert_value(self, value: Any) -> bool:
if isinstance(value, bool):
return value
return self.default
@ -702,7 +710,11 @@ class BoolDef(AbstractAttrDef):
class FileDefItem:
def __init__(
self, directory, filenames, frames=None, template=None
self,
directory: str,
filenames: List[str],
frames: Optional[List[int]] = None,
template: Optional[str] = None,
):
self.directory = directory
@ -731,7 +743,7 @@ class FileDefItem:
)
@property
def label(self):
def label(self) -> Optional[str]:
if self.is_empty:
return None
@ -774,7 +786,7 @@ class FileDefItem:
filename_template, ",".join(ranges)
)
def split_sequence(self):
def split_sequence(self) -> List["Self"]:
if not self.is_sequence:
raise ValueError("Cannot split single file item")
@ -785,7 +797,7 @@ class FileDefItem:
return self.from_paths(paths, False)
@property
def ext(self):
def ext(self) -> Optional[str]:
if self.is_empty:
return None
_, ext = os.path.splitext(self.filenames[0])
@ -794,14 +806,14 @@ class FileDefItem:
return None
@property
def lower_ext(self):
def lower_ext(self) -> Optional[str]:
ext = self.ext
if ext is not None:
return ext.lower()
return ext
@property
def is_dir(self):
def is_dir(self) -> bool:
if self.is_empty:
return False
@ -810,10 +822,15 @@ class FileDefItem:
return False
return True
def set_directory(self, directory):
def set_directory(self, directory: str):
self.directory = directory
def set_filenames(self, filenames, frames=None, template=None):
def set_filenames(
self,
filenames: List[str],
frames: Optional[List[int]] = None,
template: Optional[str] = None,
):
if frames is None:
frames = []
is_sequence = False
@ -830,17 +847,21 @@ class FileDefItem:
self.is_sequence = is_sequence
@classmethod
def create_empty_item(cls):
def create_empty_item(cls) -> "Self":
return cls("", "")
@classmethod
def from_value(cls, value, allow_sequences):
def from_value(
cls,
value: "Union[List[FileDefItemDict], FileDefItemDict]",
allow_sequences: bool,
) -> List["Self"]:
"""Convert passed value to FileDefItem objects.
Returns:
list: Created FileDefItem objects.
"""
"""
# Convert single item to iterable
if not isinstance(value, (list, tuple, set)):
value = [value]
@ -872,7 +893,7 @@ class FileDefItem:
return output
@classmethod
def from_dict(cls, data):
def from_dict(cls, data: "FileDefItemDict") -> "Self":
return cls(
data["directory"],
data["filenames"],
@ -881,7 +902,11 @@ class FileDefItem:
)
@classmethod
def from_paths(cls, paths, allow_sequences):
def from_paths(
cls,
paths: List[str],
allow_sequences: bool,
) -> List["Self"]:
filenames_by_dir = collections.defaultdict(list)
for path in paths:
normalized = os.path.normpath(path)
@ -910,7 +935,7 @@ class FileDefItem:
return output
def to_dict(self):
def to_dict(self) -> "FileDefItemDict":
output = {
"is_sequence": self.is_sequence,
"directory": self.directory,
@ -948,8 +973,15 @@ class FileDef(AbstractAttrDef):
]
def __init__(
self, key, single_item=True, folders=None, extensions=None,
allow_sequences=True, extensions_label=None, default=None, **kwargs
self,
key: str,
single_item: Optional[bool] = True,
folders: Optional[bool] = None,
extensions: Optional[Iterable[str]] = None,
allow_sequences: Optional[bool] = True,
extensions_label: Optional[str] = None,
default: Optional["Union[FileDefItemDict, List[str]]"] = None,
**kwargs
):
if folders is None and extensions is None:
folders = True
@ -966,7 +998,9 @@ class FileDef(AbstractAttrDef):
FileDefItem.from_dict(default)
elif isinstance(default, str):
default = FileDefItem.from_paths([default.strip()])[0]
default = FileDefItem.from_paths(
[default.strip()], allow_sequences
)[0]
else:
raise TypeError((
@ -985,14 +1019,14 @@ class FileDef(AbstractAttrDef):
if is_label_horizontal is None:
kwargs["is_label_horizontal"] = False
self.single_item = single_item
self.folders = folders
self.extensions = set(extensions)
self.allow_sequences = allow_sequences
self.extensions_label = extensions_label
self.single_item: bool = single_item
self.folders: bool = folders
self.extensions: Set[str] = set(extensions)
self.allow_sequences: bool = allow_sequences
self.extensions_label: Optional[str] = extensions_label
super().__init__(key, default=default, **kwargs)
def __eq__(self, other):
def __eq__(self, other: Any) -> bool:
if not super().__eq__(other):
return False
@ -1026,7 +1060,9 @@ class FileDef(AbstractAttrDef):
return False
return True
def convert_value(self, value):
def convert_value(
self, value: Any
) -> "Union[FileDefItemDict, List[FileDefItemDict]]":
if isinstance(value, (str, dict)):
value = [value]
@ -1044,7 +1080,9 @@ class FileDef(AbstractAttrDef):
pass
if string_paths:
file_items = FileDefItem.from_paths(string_paths)
file_items = FileDefItem.from_paths(
string_paths, self.allow_sequences
)
dict_items.extend([
file_item.to_dict()
for file_item in file_items
@ -1062,55 +1100,124 @@ class FileDef(AbstractAttrDef):
return []
def serialize_attr_def(attr_def):
def register_attr_def_class(cls: AttrDefType):
"""Register attribute definition.
Currently registered definitions are used to deserialize data to objects.
Attrs:
cls (AttrDefType): Non-abstract class to be registered with unique
'type' attribute.
Raises:
KeyError: When type was already registered.
"""
if cls.type in _attr_defs_by_type:
raise KeyError("Type \"{}\" was already registered".format(cls.type))
_attr_defs_by_type[cls.type] = cls
def get_attributes_keys(
attribute_definitions: List[AttrDefType]
) -> Set[str]:
"""Collect keys from list of attribute definitions.
Args:
attribute_definitions (List[AttrDefType]): Objects of attribute
definitions.
Returns:
Set[str]: Keys that will be created using passed attribute definitions.
"""
keys = set()
if not attribute_definitions:
return keys
for attribute_def in attribute_definitions:
if not isinstance(attribute_def, UIDef):
keys.add(attribute_def.key)
return keys
def get_default_values(
attribute_definitions: List[AttrDefType]
) -> Dict[str, Any]:
"""Receive default values for attribute definitions.
Args:
attribute_definitions (List[AttrDefType]): Attribute definitions
for which default values should be collected.
Returns:
Dict[str, Any]: Default values for passed attribute definitions.
"""
output = {}
if not attribute_definitions:
return output
for attr_def in attribute_definitions:
# Skip UI definitions
if not isinstance(attr_def, UIDef):
output[attr_def.key] = attr_def.default
return output
def serialize_attr_def(attr_def: AttrDefType) -> Dict[str, Any]:
"""Serialize attribute definition to data.
Args:
attr_def (AbstractAttrDef): Attribute definition to serialize.
attr_def (AttrDefType): Attribute definition to serialize.
Returns:
Dict[str, Any]: Serialized data.
"""
"""
return attr_def.serialize()
def serialize_attr_defs(attr_defs):
def serialize_attr_defs(
attr_defs: List[AttrDefType]
) -> List[Dict[str, Any]]:
"""Serialize attribute definitions to data.
Args:
attr_defs (List[AbstractAttrDef]): Attribute definitions to serialize.
attr_defs (List[AttrDefType]): Attribute definitions to serialize.
Returns:
List[Dict[str, Any]]: Serialized data.
"""
"""
return [
serialize_attr_def(attr_def)
for attr_def in attr_defs
]
def deserialize_attr_def(attr_def_data):
def deserialize_attr_def(attr_def_data: Dict[str, Any]) -> AttrDefType:
"""Deserialize attribute definition from data.
Args:
attr_def_data (Dict[str, Any]): Attribute definition data to
deserialize.
"""
"""
attr_type = attr_def_data.pop("type")
cls = _attr_defs_by_type[attr_type]
return cls.deserialize(attr_def_data)
def deserialize_attr_defs(attr_defs_data):
def deserialize_attr_defs(
attr_defs_data: List[Dict[str, Any]]
) -> List[AttrDefType]:
"""Deserialize attribute definitions.
Args:
List[Dict[str, Any]]: List of attribute definitions.
"""
"""
return [
deserialize_attr_def(attr_def_data)
for attr_def_data in attr_defs_data

View file

@ -276,12 +276,7 @@ class ASettingRegistry(ABC):
@abstractmethod
def _delete_item(self, name):
# type: (str) -> None
"""Delete item from settings.
Note:
see :meth:`ayon_core.lib.user_settings.ARegistrySettings.delete_item`
"""
"""Delete item from settings."""
pass
def __delitem__(self, name):
@ -433,12 +428,7 @@ class IniSettingRegistry(ASettingRegistry):
config.write(cfg)
def _delete_item(self, name):
"""Delete item from default section.
Note:
See :meth:`~ayon_core.lib.IniSettingsRegistry.delete_item_from_section`
"""
"""Delete item from default section."""
self.delete_item_from_section("MAIN", name)

View file

@ -1,9 +1,15 @@
import os
import re
import copy
import numbers
import warnings
from string import Formatter
import typing
from typing import List, Dict, Any, Set
if typing.TYPE_CHECKING:
from typing import Union
KEY_PATTERN = re.compile(r"(\{.*?[^{0]*\})")
KEY_PADDING_PATTERN = re.compile(r"([^:]+)\S+[><]\S+")
SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)")
OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?")
@ -18,9 +24,7 @@ class TemplateUnsolved(Exception):
def __init__(self, template, missing_keys, invalid_types):
invalid_type_items = []
for _key, _type in invalid_types.items():
invalid_type_items.append(
"\"{0}\" {1}".format(_key, str(_type))
)
invalid_type_items.append(f"\"{_key}\" {str(_type)}")
invalid_types_msg = ""
if invalid_type_items:
@ -33,31 +37,32 @@ class TemplateUnsolved(Exception):
missing_keys_msg = self.missing_keys_msg.format(
", ".join(missing_keys)
)
super(TemplateUnsolved, self).__init__(
super().__init__(
self.msg.format(template, missing_keys_msg, invalid_types_msg)
)
class StringTemplate:
"""String that can be formatted."""
def __init__(self, template):
def __init__(self, template: str):
if not isinstance(template, str):
raise TypeError("<{}> argument must be a string, not {}.".format(
self.__class__.__name__, str(type(template))
))
raise TypeError(
f"<{self.__class__.__name__}> argument must be a string,"
f" not {str(type(template))}."
)
self._template = template
self._template: str = template
parts = []
last_end_idx = 0
for item in KEY_PATTERN.finditer(template):
start, end = item.span()
if start > last_end_idx:
parts.append(template[last_end_idx:start])
parts.append(FormattingPart(template[start:end]))
last_end_idx = end
formatter = Formatter()
if last_end_idx < len(template):
parts.append(template[last_end_idx:len(template)])
for item in formatter.parse(template):
literal_text, field_name, format_spec, conversion = item
if literal_text:
parts.append(literal_text)
if field_name:
parts.append(
FormattingPart(field_name, format_spec, conversion)
)
new_parts = []
for part in parts:
@ -77,15 +82,17 @@ class StringTemplate:
if substr:
new_parts.append(substr)
self._parts = self.find_optional_parts(new_parts)
self._parts: List["Union[str, OptionalPart, FormattingPart]"] = (
self.find_optional_parts(new_parts)
)
def __str__(self):
def __str__(self) -> str:
return self.template
def __repr__(self):
return "<{}> {}".format(self.__class__.__name__, self.template)
def __repr__(self) -> str:
return f"<{self.__class__.__name__}> {self.template}"
def __contains__(self, other):
def __contains__(self, other: str) -> bool:
return other in self.template
def replace(self, *args, **kwargs):
@ -93,10 +100,10 @@ class StringTemplate:
return self
@property
def template(self):
def template(self) -> str:
return self._template
def format(self, data):
def format(self, data: Dict[str, Any]) -> "TemplateResult":
""" Figure out with whole formatting.
Separate advanced keys (*Like '{project[name]}') from string which must
@ -108,6 +115,7 @@ class StringTemplate:
Returns:
TemplateResult: Filled or partially filled template containing all
data needed or missing for filling template.
"""
result = TemplatePartResult()
for part in self._parts:
@ -135,23 +143,29 @@ class StringTemplate:
invalid_types
)
def format_strict(self, *args, **kwargs):
result = self.format(*args, **kwargs)
def format_strict(self, data: Dict[str, Any]) -> "TemplateResult":
result = self.format(data)
result.validate()
return result
@classmethod
def format_template(cls, template, data):
def format_template(
cls, template: str, data: Dict[str, Any]
) -> "TemplateResult":
objected_template = cls(template)
return objected_template.format(data)
@classmethod
def format_strict_template(cls, template, data):
def format_strict_template(
cls, template: str, data: Dict[str, Any]
) -> "TemplateResult":
objected_template = cls(template)
return objected_template.format_strict(data)
@staticmethod
def find_optional_parts(parts):
def find_optional_parts(
parts: List["Union[str, FormattingPart]"]
) -> List["Union[str, OptionalPart, FormattingPart]"]:
new_parts = []
tmp_parts = {}
counted_symb = -1
@ -216,11 +230,11 @@ class TemplateResult(str):
of number.
"""
used_values = None
solved = None
template = None
missing_keys = None
invalid_types = None
used_values: Dict[str, Any] = None
solved: bool = None
template: str = None
missing_keys: List[str] = None
invalid_types: Dict[str, Any] = None
def __new__(
cls, filled_template, template, solved,
@ -248,7 +262,7 @@ class TemplateResult(str):
self.invalid_types
)
def copy(self):
def copy(self) -> "TemplateResult":
cls = self.__class__
return cls(
str(self),
@ -259,7 +273,7 @@ class TemplateResult(str):
self.invalid_types
)
def normalized(self):
def normalized(self) -> "TemplateResult":
"""Convert to normalized path."""
cls = self.__class__
@ -275,27 +289,28 @@ class TemplateResult(str):
class TemplatePartResult:
"""Result to store result of template parts."""
def __init__(self, optional=False):
def __init__(self, optional: bool = False):
# Missing keys or invalid value types of required keys
self._missing_keys = set()
self._invalid_types = {}
self._missing_keys: Set[str] = set()
self._invalid_types: Dict[str, Any] = {}
# Missing keys or invalid value types of optional keys
self._missing_optional_keys = set()
self._invalid_optional_types = {}
self._missing_optional_keys: Set[str] = set()
self._invalid_optional_types: Dict[str, Any] = {}
# Used values stored by key with origin type
# - key without any padding or key modifiers
# - value from filling data
# Example: {"version": 1}
self._used_values = {}
self._used_values: Dict[str, Any] = {}
# Used values stored by key with all modifirs
# - value is already formatted string
# Example: {"version:0>3": "001"}
self._realy_used_values = {}
self._really_used_values: Dict[str, Any] = {}
# Concatenated string output after formatting
self._output = ""
self._output: str = ""
# Is this result from optional part
self._optional = True
# TODO find out why we don't use 'optional' from args
self._optional: bool = True
def add_output(self, other):
if isinstance(other, str):
@ -313,7 +328,7 @@ class TemplatePartResult:
if other.optional and not other.solved:
return
self._used_values.update(other.used_values)
self._realy_used_values.update(other.realy_used_values)
self._really_used_values.update(other.really_used_values)
else:
raise TypeError("Cannot add data from \"{}\" to \"{}\"".format(
@ -321,7 +336,7 @@ class TemplatePartResult:
)
@property
def solved(self):
def solved(self) -> bool:
if self.optional:
if (
len(self.missing_optional_keys) > 0
@ -334,45 +349,53 @@ class TemplatePartResult:
)
@property
def optional(self):
def optional(self) -> bool:
return self._optional
@property
def output(self):
def output(self) -> str:
return self._output
@property
def missing_keys(self):
def missing_keys(self) -> Set[str]:
return self._missing_keys
@property
def missing_optional_keys(self):
def missing_optional_keys(self) -> Set[str]:
return self._missing_optional_keys
@property
def invalid_types(self):
def invalid_types(self) -> Dict[str, Any]:
return self._invalid_types
@property
def invalid_optional_types(self):
def invalid_optional_types(self) -> Dict[str, Any]:
return self._invalid_optional_types
@property
def realy_used_values(self):
return self._realy_used_values
def really_used_values(self) -> Dict[str, Any]:
return self._really_used_values
@property
def used_values(self):
def realy_used_values(self) -> Dict[str, Any]:
warnings.warn(
"Property 'realy_used_values' is deprecated."
" Use 'really_used_values' instead.",
DeprecationWarning
)
return self._really_used_values
@property
def used_values(self) -> Dict[str, Any]:
return self._used_values
@staticmethod
def split_keys_to_subdicts(values):
def split_keys_to_subdicts(values: Dict[str, Any]) -> Dict[str, Any]:
output = {}
formatter = Formatter()
for key, value in values.items():
key_padding = list(KEY_PADDING_PATTERN.findall(key))
if key_padding:
key = key_padding[0]
key_subdict = list(SUB_DICT_PATTERN.findall(key))
_, field_name, _, _ = next(formatter.parse(f"{{{key}}}"))
key_subdict = list(SUB_DICT_PATTERN.findall(field_name))
data = output
last_key = key_subdict.pop(-1)
for subkey in key_subdict:
@ -382,7 +405,7 @@ class TemplatePartResult:
data[last_key] = value
return output
def get_clean_used_values(self):
def get_clean_used_values(self) -> Dict[str, Any]:
new_used_values = {}
for key, value in self.used_values.items():
if isinstance(value, FormatObject):
@ -391,19 +414,27 @@ class TemplatePartResult:
return self.split_keys_to_subdicts(new_used_values)
def add_realy_used_value(self, key, value):
self._realy_used_values[key] = value
def add_really_used_value(self, key: str, value: Any):
self._really_used_values[key] = value
def add_used_value(self, key, value):
def add_realy_used_value(self, key: str, value: Any):
warnings.warn(
"Method 'add_realy_used_value' is deprecated."
" Use 'add_really_used_value' instead.",
DeprecationWarning
)
self.add_really_used_value(key, value)
def add_used_value(self, key: str, value: Any):
self._used_values[key] = value
def add_missing_key(self, key):
def add_missing_key(self, key: str):
if self._optional:
self._missing_optional_keys.add(key)
else:
self._missing_keys.add(key)
def add_invalid_type(self, key, value):
def add_invalid_type(self, key: str, value: Any):
if self._optional:
self._invalid_optional_types[key] = type(value)
else:
@ -421,10 +452,10 @@ class FormatObject:
def __format__(self, *args, **kwargs):
return self.value.__format__(*args, **kwargs)
def __str__(self):
def __str__(self) -> str:
return str(self.value)
def __repr__(self):
def __repr__(self) -> str:
return self.__str__()
@ -434,23 +465,44 @@ class FormattingPart:
Containt only single key to format e.g. "{project[name]}".
Args:
template(str): String containing the formatting key.
field_name (str): Name of key.
format_spec (str): Format specification.
conversion (Union[str, None]): Conversion type.
"""
def __init__(self, template):
self._template = template
def __init__(
self,
field_name: str,
format_spec: str,
conversion: "Union[str, None]",
):
format_spec_v = ""
if format_spec:
format_spec_v = f":{format_spec}"
conversion_v = ""
if conversion:
conversion_v = f"!{conversion}"
self._field_name: str = field_name
self._format_spec: str = format_spec_v
self._conversion: str = conversion_v
template_base = f"{field_name}{format_spec_v}{conversion_v}"
self._template_base: str = template_base
self._template: str = f"{{{template_base}}}"
@property
def template(self):
def template(self) -> str:
return self._template
def __repr__(self):
def __repr__(self) -> str:
return "<Format:{}>".format(self._template)
def __str__(self):
def __str__(self) -> str:
return self._template
@staticmethod
def validate_value_type(value):
def validate_value_type(value: Any) -> bool:
"""Check if value can be used for formatting of single key."""
if isinstance(value, (numbers.Number, FormatObject)):
return True
@ -461,7 +513,7 @@ class FormattingPart:
return False
@staticmethod
def validate_key_is_matched(key):
def validate_key_is_matched(key: str) -> bool:
"""Validate that opening has closing at correct place.
Future-proof, only square brackets are currently used in keys.
@ -488,16 +540,29 @@ class FormattingPart:
return False
return not queue
def format(self, data, result):
@staticmethod
def keys_to_template_base(keys: List[str]):
if not keys:
return None
# Create copy of keys
keys = list(keys)
template_base = keys.pop(0)
joined_keys = "".join([f"[{key}]" for key in keys])
return f"{template_base}{joined_keys}"
def format(
self, data: Dict[str, Any], result: TemplatePartResult
) -> TemplatePartResult:
"""Format the formattings string.
Args:
data(dict): Data that should be used for formatting.
result(TemplatePartResult): Object where result is stored.
"""
key = self.template[1:-1]
if key in result.realy_used_values:
result.add_output(result.realy_used_values[key])
key = self._template_base
if key in result.really_used_values:
result.add_output(result.really_used_values[key])
return result
# ensure key is properly formed [({})] properly closed.
@ -507,17 +572,38 @@ class FormattingPart:
return result
# check if key expects subdictionary keys (e.g. project[name])
existence_check = key
key_padding = list(KEY_PADDING_PATTERN.findall(existence_check))
if key_padding:
existence_check = key_padding[0]
key_subdict = list(SUB_DICT_PATTERN.findall(existence_check))
key_subdict = list(SUB_DICT_PATTERN.findall(self._field_name))
value = data
missing_key = False
invalid_type = False
used_keys = []
keys_to_value = None
used_value = None
for sub_key in key_subdict:
if isinstance(value, list):
if not sub_key.lstrip("-").isdigit():
invalid_type = True
break
sub_key = int(sub_key)
if sub_key < 0:
sub_key = len(value) + sub_key
invalid = 0 > sub_key < len(data)
if invalid:
used_keys.append(sub_key)
missing_key = True
break
used_keys.append(sub_key)
if keys_to_value is None:
keys_to_value = list(used_keys)
keys_to_value.pop(-1)
used_value = copy.deepcopy(value)
value = value[sub_key]
continue
if (
value is None
or (hasattr(value, "items") and sub_key not in value)
@ -533,45 +619,57 @@ class FormattingPart:
used_keys.append(sub_key)
value = value.get(sub_key)
if missing_key or invalid_type:
if len(used_keys) == 0:
invalid_key = key_subdict[0]
else:
invalid_key = used_keys[0]
for idx, sub_key in enumerate(used_keys):
if idx == 0:
continue
invalid_key += "[{0}]".format(sub_key)
field_name = key_subdict[0]
if used_keys:
field_name = self.keys_to_template_base(used_keys)
if missing_key or invalid_type:
if missing_key:
result.add_missing_key(invalid_key)
result.add_missing_key(field_name)
elif invalid_type:
result.add_invalid_type(invalid_key, value)
result.add_invalid_type(field_name, value)
result.add_output(self.template)
return result
if self.validate_value_type(value):
fill_data = {}
first_value = True
for used_key in reversed(used_keys):
if first_value:
first_value = False
fill_data[used_key] = value
else:
_fill_data = {used_key: fill_data}
fill_data = _fill_data
formatted_value = self.template.format(**fill_data)
result.add_realy_used_value(key, formatted_value)
result.add_used_value(existence_check, formatted_value)
result.add_output(formatted_value)
if not self.validate_value_type(value):
result.add_invalid_type(key, value)
result.add_output(self.template)
return result
result.add_invalid_type(key, value)
result.add_output(self.template)
fill_data = root_fill_data = {}
parent_fill_data = None
parent_key = None
fill_value = data
value_filled = False
for used_key in used_keys:
if isinstance(fill_value, list):
parent_fill_data[parent_key] = fill_value
value_filled = True
break
fill_value = fill_value[used_key]
parent_fill_data = fill_data
fill_data = parent_fill_data.setdefault(used_key, {})
parent_key = used_key
if not value_filled:
parent_fill_data[used_keys[-1]] = value
template = f"{{{field_name}{self._format_spec}{self._conversion}}}"
formatted_value = template.format(**root_fill_data)
used_key = key
if keys_to_value is not None:
used_key = self.keys_to_template_base(keys_to_value)
if used_value is None:
if isinstance(value, numbers.Number):
used_value = value
else:
used_value = formatted_value
result.add_really_used_value(self._field_name, used_value)
result.add_used_value(used_key, used_value)
result.add_output(formatted_value)
return result
@ -585,20 +683,27 @@ class OptionalPart:
'FormattingPart'.
"""
def __init__(self, parts):
self._parts = parts
def __init__(
self,
parts: List["Union[str, OptionalPart, FormattingPart]"]
):
self._parts: List["Union[str, OptionalPart, FormattingPart]"] = parts
@property
def parts(self):
def parts(self) -> List["Union[str, OptionalPart, FormattingPart]"]:
return self._parts
def __str__(self):
def __str__(self) -> str:
return "<{}>".format("".join([str(p) for p in self._parts]))
def __repr__(self):
def __repr__(self) -> str:
return "<Optional:{}>".format("".join([str(p) for p in self._parts]))
def format(self, data, result):
def format(
self,
data: Dict[str, Any],
result: TemplatePartResult,
) -> TemplatePartResult:
new_result = TemplatePartResult(True)
for part in self._parts:
if isinstance(part, str):

View file

@ -1,60 +0,0 @@
import os
from ayon_core import AYON_CORE_ROOT
from ayon_core.addon import AYONAddon, ITrayAction
class LauncherAction(AYONAddon, ITrayAction):
label = "Launcher"
name = "launcher_tool"
version = "1.0.0"
def initialize(self, settings):
# Tray attributes
self._window = None
def tray_init(self):
self._create_window()
self.add_doubleclick_callback(self._show_launcher)
def tray_start(self):
return
def connect_with_addons(self, enabled_modules):
# Register actions
if not self.tray_initialized:
return
from ayon_core.pipeline.actions import register_launcher_action_path
actions_dir = os.path.join(AYON_CORE_ROOT, "plugins", "actions")
if os.path.exists(actions_dir):
register_launcher_action_path(actions_dir)
actions_paths = self.manager.collect_plugin_paths()["actions"]
for path in actions_paths:
if path and os.path.exists(path):
register_launcher_action_path(path)
def on_action_trigger(self):
"""Implementation for ITrayAction interface.
Show launcher tool on action trigger.
"""
self._show_launcher()
def _create_window(self):
if self._window:
return
from ayon_core.tools.launcher.ui import LauncherWindow
self._window = LauncherWindow()
def _show_launcher(self):
if self._window is None:
return
self._window.show()
self._window.raise_()
self._window.activateWindow()

View file

@ -1,68 +0,0 @@
from ayon_core.addon import AYONAddon, ITrayAddon
class LoaderAddon(AYONAddon, ITrayAddon):
name = "loader_tool"
version = "1.0.0"
def initialize(self, settings):
# Tray attributes
self._loader_imported = None
self._loader_window = None
def tray_init(self):
# Add library tool
self._loader_imported = False
try:
from ayon_core.tools.loader.ui import LoaderWindow # noqa F401
self._loader_imported = True
except Exception:
self.log.warning(
"Couldn't load Loader tool for tray.",
exc_info=True
)
# Definition of Tray menu
def tray_menu(self, tray_menu):
if not self._loader_imported:
return
from qtpy import QtWidgets
# Actions
action_loader = QtWidgets.QAction(
"Loader", tray_menu
)
action_loader.triggered.connect(self.show_loader)
tray_menu.addAction(action_loader)
def tray_start(self, *_a, **_kw):
return
def tray_exit(self, *_a, **_kw):
return
def show_loader(self):
if self._loader_window is None:
from ayon_core.pipeline import install_ayon_plugins
self._init_loader()
install_ayon_plugins()
self._loader_window.show()
# Raise and activate the window
# for MacOS
self._loader_window.raise_()
# for Windows
self._loader_window.activateWindow()
def _init_loader(self):
from ayon_core.tools.loader.ui import LoaderWindow
libraryloader = LoaderWindow()
self._loader_window = libraryloader

View file

@ -1,8 +0,0 @@
from .addon import (
PythonInterpreterAction
)
__all__ = (
"PythonInterpreterAction",
)

View file

@ -1,42 +0,0 @@
from ayon_core.addon import AYONAddon, ITrayAction
class PythonInterpreterAction(AYONAddon, ITrayAction):
label = "Console"
name = "python_interpreter"
version = "1.0.0"
admin_action = True
def initialize(self, settings):
self._interpreter_window = None
def tray_init(self):
self.create_interpreter_window()
def tray_exit(self):
if self._interpreter_window is not None:
self._interpreter_window.save_registry()
def create_interpreter_window(self):
"""Initializa Settings Qt window."""
if self._interpreter_window:
return
from ayon_core.modules.python_console_interpreter.window import (
PythonInterpreterWidget
)
self._interpreter_window = PythonInterpreterWidget()
def on_action_trigger(self):
self.show_interpreter_window()
def show_interpreter_window(self):
self.create_interpreter_window()
if self._interpreter_window.isVisible():
self._interpreter_window.activateWindow()
self._interpreter_window.raise_()
return
self._interpreter_window.show()

View file

@ -1,8 +0,0 @@
from .widgets import (
PythonInterpreterWidget
)
__all__ = (
"PythonInterpreterWidget",
)

View file

@ -1,660 +0,0 @@
import os
import re
import sys
import collections
from code import InteractiveInterpreter
import appdirs
from qtpy import QtCore, QtWidgets, QtGui
from ayon_core import resources
from ayon_core.style import load_stylesheet
from ayon_core.lib import JSONSettingRegistry
ayon_art = r"""
· · - =[ by YNPUT ]:[ http://ayon.ynput.io ]= - · ·
"""
class PythonInterpreterRegistry(JSONSettingRegistry):
"""Class handling OpenPype general settings registry.
Attributes:
vendor (str): Name used for path construction.
product (str): Additional name used for path construction.
"""
def __init__(self):
self.vendor = "Ynput"
self.product = "AYON"
name = "python_interpreter_tool"
path = appdirs.user_data_dir(self.product, self.vendor)
super(PythonInterpreterRegistry, self).__init__(name, path)
class StdOEWrap:
def __init__(self):
self._origin_stdout_write = None
self._origin_stderr_write = None
self._listening = False
self.lines = collections.deque()
if not sys.stdout:
sys.stdout = open(os.devnull, "w")
if not sys.stderr:
sys.stderr = open(os.devnull, "w")
if self._origin_stdout_write is None:
self._origin_stdout_write = sys.stdout.write
if self._origin_stderr_write is None:
self._origin_stderr_write = sys.stderr.write
self._listening = True
sys.stdout.write = self._stdout_listener
sys.stderr.write = self._stderr_listener
def stop_listen(self):
self._listening = False
def _stdout_listener(self, text):
if self._listening:
self.lines.append(text)
if self._origin_stdout_write is not None:
self._origin_stdout_write(text)
def _stderr_listener(self, text):
if self._listening:
self.lines.append(text)
if self._origin_stderr_write is not None:
self._origin_stderr_write(text)
class PythonCodeEditor(QtWidgets.QPlainTextEdit):
execute_requested = QtCore.Signal()
def __init__(self, parent):
super(PythonCodeEditor, self).__init__(parent)
self.setObjectName("PythonCodeEditor")
self._indent = 4
def _tab_shift_right(self):
cursor = self.textCursor()
selected_text = cursor.selectedText()
if not selected_text:
cursor.insertText(" " * self._indent)
return
sel_start = cursor.selectionStart()
sel_end = cursor.selectionEnd()
cursor.setPosition(sel_end)
end_line = cursor.blockNumber()
cursor.setPosition(sel_start)
while True:
cursor.movePosition(QtGui.QTextCursor.StartOfLine)
text = cursor.block().text()
spaces = len(text) - len(text.lstrip(" "))
new_spaces = spaces % self._indent
if not new_spaces:
new_spaces = self._indent
cursor.insertText(" " * new_spaces)
if cursor.blockNumber() == end_line:
break
cursor.movePosition(QtGui.QTextCursor.NextBlock)
def _tab_shift_left(self):
tmp_cursor = self.textCursor()
sel_start = tmp_cursor.selectionStart()
sel_end = tmp_cursor.selectionEnd()
cursor = QtGui.QTextCursor(self.document())
cursor.setPosition(sel_end)
end_line = cursor.blockNumber()
cursor.setPosition(sel_start)
while True:
cursor.movePosition(QtGui.QTextCursor.StartOfLine)
text = cursor.block().text()
spaces = len(text) - len(text.lstrip(" "))
if spaces:
spaces_to_remove = (spaces % self._indent) or self._indent
if spaces_to_remove > spaces:
spaces_to_remove = spaces
cursor.setPosition(
cursor.position() + spaces_to_remove,
QtGui.QTextCursor.KeepAnchor
)
cursor.removeSelectedText()
if cursor.blockNumber() == end_line:
break
cursor.movePosition(QtGui.QTextCursor.NextBlock)
def keyPressEvent(self, event):
if event.key() == QtCore.Qt.Key_Backtab:
self._tab_shift_left()
event.accept()
return
if event.key() == QtCore.Qt.Key_Tab:
if event.modifiers() == QtCore.Qt.NoModifier:
self._tab_shift_right()
event.accept()
return
if (
event.key() == QtCore.Qt.Key_Return
and event.modifiers() == QtCore.Qt.ControlModifier
):
self.execute_requested.emit()
event.accept()
return
super(PythonCodeEditor, self).keyPressEvent(event)
class PythonTabWidget(QtWidgets.QWidget):
add_tab_requested = QtCore.Signal()
before_execute = QtCore.Signal(str)
def __init__(self, parent):
super(PythonTabWidget, self).__init__(parent)
code_input = PythonCodeEditor(self)
self.setFocusProxy(code_input)
add_tab_btn = QtWidgets.QPushButton("Add tab...", self)
add_tab_btn.setToolTip("Add new tab")
execute_btn = QtWidgets.QPushButton("Execute", self)
execute_btn.setToolTip("Execute command (Ctrl + Enter)")
btns_layout = QtWidgets.QHBoxLayout()
btns_layout.setContentsMargins(0, 0, 0, 0)
btns_layout.addWidget(add_tab_btn)
btns_layout.addStretch(1)
btns_layout.addWidget(execute_btn)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(code_input, 1)
layout.addLayout(btns_layout, 0)
add_tab_btn.clicked.connect(self._on_add_tab_clicked)
execute_btn.clicked.connect(self._on_execute_clicked)
code_input.execute_requested.connect(self.execute)
self._code_input = code_input
self._interpreter = InteractiveInterpreter()
def _on_add_tab_clicked(self):
self.add_tab_requested.emit()
def _on_execute_clicked(self):
self.execute()
def get_code(self):
return self._code_input.toPlainText()
def set_code(self, code_text):
self._code_input.setPlainText(code_text)
def execute(self):
code_text = self._code_input.toPlainText()
self.before_execute.emit(code_text)
self._interpreter.runcode(code_text)
class TabNameDialog(QtWidgets.QDialog):
default_width = 330
default_height = 85
def __init__(self, parent):
super(TabNameDialog, self).__init__(parent)
self.setWindowTitle("Enter tab name")
name_label = QtWidgets.QLabel("Tab name:", self)
name_input = QtWidgets.QLineEdit(self)
inputs_layout = QtWidgets.QHBoxLayout()
inputs_layout.addWidget(name_label)
inputs_layout.addWidget(name_input)
ok_btn = QtWidgets.QPushButton("Ok", self)
cancel_btn = QtWidgets.QPushButton("Cancel", self)
btns_layout = QtWidgets.QHBoxLayout()
btns_layout.addStretch(1)
btns_layout.addWidget(ok_btn)
btns_layout.addWidget(cancel_btn)
layout = QtWidgets.QVBoxLayout(self)
layout.addLayout(inputs_layout)
layout.addStretch(1)
layout.addLayout(btns_layout)
ok_btn.clicked.connect(self._on_ok_clicked)
cancel_btn.clicked.connect(self._on_cancel_clicked)
self._name_input = name_input
self._ok_btn = ok_btn
self._cancel_btn = cancel_btn
self._result = None
self.resize(self.default_width, self.default_height)
def set_tab_name(self, name):
self._name_input.setText(name)
def result(self):
return self._result
def showEvent(self, event):
super(TabNameDialog, self).showEvent(event)
btns_width = max(
self._ok_btn.width(),
self._cancel_btn.width()
)
self._ok_btn.setMinimumWidth(btns_width)
self._cancel_btn.setMinimumWidth(btns_width)
def _on_ok_clicked(self):
self._result = self._name_input.text()
self.accept()
def _on_cancel_clicked(self):
self._result = None
self.reject()
class OutputTextWidget(QtWidgets.QTextEdit):
v_max_offset = 4
def vertical_scroll_at_max(self):
v_scroll = self.verticalScrollBar()
return v_scroll.value() > v_scroll.maximum() - self.v_max_offset
def scroll_to_bottom(self):
v_scroll = self.verticalScrollBar()
return v_scroll.setValue(v_scroll.maximum())
class EnhancedTabBar(QtWidgets.QTabBar):
double_clicked = QtCore.Signal(QtCore.QPoint)
right_clicked = QtCore.Signal(QtCore.QPoint)
mid_clicked = QtCore.Signal(QtCore.QPoint)
def __init__(self, parent):
super(EnhancedTabBar, self).__init__(parent)
self.setDrawBase(False)
def mouseDoubleClickEvent(self, event):
self.double_clicked.emit(event.globalPos())
event.accept()
def mouseReleaseEvent(self, event):
if event.button() == QtCore.Qt.RightButton:
self.right_clicked.emit(event.globalPos())
event.accept()
return
elif event.button() == QtCore.Qt.MidButton:
self.mid_clicked.emit(event.globalPos())
event.accept()
else:
super(EnhancedTabBar, self).mouseReleaseEvent(event)
class PythonInterpreterWidget(QtWidgets.QWidget):
default_width = 1000
default_height = 600
def __init__(self, allow_save_registry=True, parent=None):
super(PythonInterpreterWidget, self).__init__(parent)
self.setWindowTitle("AYON Console")
self.setWindowIcon(QtGui.QIcon(resources.get_ayon_icon_filepath()))
self.ansi_escape = re.compile(
r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]"
)
self._tabs = []
self._stdout_err_wrapper = StdOEWrap()
output_widget = OutputTextWidget(self)
output_widget.setObjectName("PythonInterpreterOutput")
output_widget.setLineWrapMode(QtWidgets.QTextEdit.NoWrap)
output_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
tab_widget = QtWidgets.QTabWidget(self)
tab_bar = EnhancedTabBar(tab_widget)
tab_widget.setTabBar(tab_bar)
tab_widget.setTabsClosable(False)
tab_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
widgets_splitter = QtWidgets.QSplitter(self)
widgets_splitter.setOrientation(QtCore.Qt.Vertical)
widgets_splitter.addWidget(output_widget)
widgets_splitter.addWidget(tab_widget)
widgets_splitter.setStretchFactor(0, 1)
widgets_splitter.setStretchFactor(1, 1)
height = int(self.default_height / 2)
widgets_splitter.setSizes([height, self.default_height - height])
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(widgets_splitter)
line_check_timer = QtCore.QTimer()
line_check_timer.setInterval(200)
line_check_timer.timeout.connect(self._on_timer_timeout)
tab_bar.right_clicked.connect(self._on_tab_right_click)
tab_bar.double_clicked.connect(self._on_tab_double_click)
tab_bar.mid_clicked.connect(self._on_tab_mid_click)
tab_widget.tabCloseRequested.connect(self._on_tab_close_req)
self._widgets_splitter = widgets_splitter
self._output_widget = output_widget
self._tab_widget = tab_widget
self._line_check_timer = line_check_timer
self._append_lines([ayon_art])
self._first_show = True
self._splitter_size_ratio = None
self._allow_save_registry = allow_save_registry
self._registry_saved = True
self._init_from_registry()
if self._tab_widget.count() < 1:
self.add_tab("Python")
def _init_from_registry(self):
setting_registry = PythonInterpreterRegistry()
width = None
height = None
try:
width = setting_registry.get_item("width")
height = setting_registry.get_item("height")
except ValueError:
pass
if width is None or width < 200:
width = self.default_width
if height is None or height < 200:
height = self.default_height
self.resize(width, height)
try:
self._splitter_size_ratio = (
setting_registry.get_item("splitter_sizes")
)
except ValueError:
pass
try:
tab_defs = setting_registry.get_item("tabs") or []
for tab_def in tab_defs:
widget = self.add_tab(tab_def["name"])
widget.set_code(tab_def["code"])
except ValueError:
pass
def save_registry(self):
# Window was not showed
if not self._allow_save_registry or self._registry_saved:
return
self._registry_saved = True
setting_registry = PythonInterpreterRegistry()
setting_registry.set_item("width", self.width())
setting_registry.set_item("height", self.height())
setting_registry.set_item(
"splitter_sizes", self._widgets_splitter.sizes()
)
tabs = []
for tab_idx in range(self._tab_widget.count()):
widget = self._tab_widget.widget(tab_idx)
tab_code = widget.get_code()
tab_name = self._tab_widget.tabText(tab_idx)
tabs.append({
"name": tab_name,
"code": tab_code
})
setting_registry.set_item("tabs", tabs)
def _on_tab_right_click(self, global_point):
point = self._tab_widget.mapFromGlobal(global_point)
tab_bar = self._tab_widget.tabBar()
tab_idx = tab_bar.tabAt(point)
last_index = tab_bar.count() - 1
if tab_idx < 0 or tab_idx > last_index:
return
menu = QtWidgets.QMenu(self._tab_widget)
add_tab_action = QtWidgets.QAction("Add tab...", menu)
add_tab_action.setToolTip("Add new tab")
rename_tab_action = QtWidgets.QAction("Rename...", menu)
rename_tab_action.setToolTip("Rename tab")
duplicate_tab_action = QtWidgets.QAction("Duplicate...", menu)
duplicate_tab_action.setToolTip("Duplicate code to new tab")
close_tab_action = QtWidgets.QAction("Close", menu)
close_tab_action.setToolTip("Close tab and lose content")
close_tab_action.setEnabled(self._tab_widget.tabsClosable())
menu.addAction(add_tab_action)
menu.addAction(rename_tab_action)
menu.addAction(duplicate_tab_action)
menu.addAction(close_tab_action)
result = menu.exec_(global_point)
if result is None:
return
if result is rename_tab_action:
self._rename_tab_req(tab_idx)
elif result is add_tab_action:
self._on_add_requested()
elif result is duplicate_tab_action:
self._duplicate_requested(tab_idx)
elif result is close_tab_action:
self._on_tab_close_req(tab_idx)
def _rename_tab_req(self, tab_idx):
dialog = TabNameDialog(self)
dialog.set_tab_name(self._tab_widget.tabText(tab_idx))
dialog.exec_()
tab_name = dialog.result()
if tab_name:
self._tab_widget.setTabText(tab_idx, tab_name)
def _duplicate_requested(self, tab_idx=None):
if tab_idx is None:
tab_idx = self._tab_widget.currentIndex()
src_widget = self._tab_widget.widget(tab_idx)
dst_widget = self._add_tab()
if dst_widget is None:
return
dst_widget.set_code(src_widget.get_code())
def _on_tab_mid_click(self, global_point):
point = self._tab_widget.mapFromGlobal(global_point)
tab_bar = self._tab_widget.tabBar()
tab_idx = tab_bar.tabAt(point)
last_index = tab_bar.count() - 1
if tab_idx < 0 or tab_idx > last_index:
return
self._on_tab_close_req(tab_idx)
def _on_tab_double_click(self, global_point):
point = self._tab_widget.mapFromGlobal(global_point)
tab_bar = self._tab_widget.tabBar()
tab_idx = tab_bar.tabAt(point)
last_index = tab_bar.count() - 1
if tab_idx < 0 or tab_idx > last_index:
return
self._rename_tab_req(tab_idx)
def _on_tab_close_req(self, tab_index):
if self._tab_widget.count() == 1:
return
widget = self._tab_widget.widget(tab_index)
if widget in self._tabs:
self._tabs.remove(widget)
self._tab_widget.removeTab(tab_index)
if self._tab_widget.count() == 1:
self._tab_widget.setTabsClosable(False)
def _append_lines(self, lines):
at_max = self._output_widget.vertical_scroll_at_max()
tmp_cursor = QtGui.QTextCursor(self._output_widget.document())
tmp_cursor.movePosition(QtGui.QTextCursor.End)
for line in lines:
tmp_cursor.insertText(line)
if at_max:
self._output_widget.scroll_to_bottom()
def _on_timer_timeout(self):
if self._stdout_err_wrapper.lines:
lines = []
while self._stdout_err_wrapper.lines:
line = self._stdout_err_wrapper.lines.popleft()
lines.append(self.ansi_escape.sub("", line))
self._append_lines(lines)
def _on_add_requested(self):
self._add_tab()
def _add_tab(self):
dialog = TabNameDialog(self)
dialog.exec_()
tab_name = dialog.result()
if tab_name:
return self.add_tab(tab_name)
return None
def _on_before_execute(self, code_text):
at_max = self._output_widget.vertical_scroll_at_max()
document = self._output_widget.document()
tmp_cursor = QtGui.QTextCursor(document)
tmp_cursor.movePosition(QtGui.QTextCursor.End)
tmp_cursor.insertText("{}\nExecuting command:\n".format(20 * "-"))
code_block_format = QtGui.QTextFrameFormat()
code_block_format.setBackground(QtGui.QColor(27, 27, 27))
code_block_format.setPadding(4)
tmp_cursor.insertFrame(code_block_format)
char_format = tmp_cursor.charFormat()
char_format.setForeground(
QtGui.QBrush(QtGui.QColor(114, 224, 198))
)
tmp_cursor.setCharFormat(char_format)
tmp_cursor.insertText(code_text)
# Create new cursor
tmp_cursor = QtGui.QTextCursor(document)
tmp_cursor.movePosition(QtGui.QTextCursor.End)
tmp_cursor.insertText("{}\n".format(20 * "-"))
if at_max:
self._output_widget.scroll_to_bottom()
def add_tab(self, tab_name, index=None):
widget = PythonTabWidget(self)
widget.before_execute.connect(self._on_before_execute)
widget.add_tab_requested.connect(self._on_add_requested)
if index is None:
if self._tab_widget.count() > 0:
index = self._tab_widget.currentIndex() + 1
else:
index = 0
self._tabs.append(widget)
self._tab_widget.insertTab(index, widget, tab_name)
self._tab_widget.setCurrentIndex(index)
if self._tab_widget.count() > 1:
self._tab_widget.setTabsClosable(True)
widget.setFocus()
return widget
def showEvent(self, event):
self._line_check_timer.start()
self._registry_saved = False
super(PythonInterpreterWidget, self).showEvent(event)
# First show setup
if self._first_show:
self._first_show = False
self._on_first_show()
self._output_widget.scroll_to_bottom()
def _on_first_show(self):
# Change stylesheet
self.setStyleSheet(load_stylesheet())
# Check if splitter size ratio is set
# - first store value to local variable and then unset it
splitter_size_ratio = self._splitter_size_ratio
self._splitter_size_ratio = None
# Skip if is not set
if not splitter_size_ratio:
return
# Skip if number of size items does not match to splitter
splitters_count = len(self._widgets_splitter.sizes())
if len(splitter_size_ratio) == splitters_count:
self._widgets_splitter.setSizes(splitter_size_ratio)
def closeEvent(self, event):
self.save_registry()
super(PythonInterpreterWidget, self).closeEvent(event)
self._line_check_timer.stop()

View file

@ -7,6 +7,10 @@ from .constants import (
from .anatomy import Anatomy
from .tempdir import get_temp_dir
from .staging_dir import get_staging_dir_info
from .create import (
BaseCreator,
Creator,
@ -117,6 +121,12 @@ __all__ = (
# --- Anatomy ---
"Anatomy",
# --- Temp dir ---
"get_temp_dir",
# --- Staging dir ---
"get_staging_dir_info",
# --- Create ---
"BaseCreator",
"Creator",

View file

@ -585,9 +585,6 @@ def version_up_current_workfile():
"""Function to increment and save workfile
"""
host = registered_host()
if not host.has_unsaved_changes():
print("No unsaved changes, skipping file save..")
return
project_name = get_current_project_name()
folder_path = get_current_folder_path()

View file

@ -1283,12 +1283,16 @@ class CreateContext:
@contextmanager
def bulk_pre_create_attr_defs_change(self, sender=None):
with self._bulk_context("pre_create_attrs_change", sender) as bulk_info:
with self._bulk_context(
"pre_create_attrs_change", sender
) as bulk_info:
yield bulk_info
@contextmanager
def bulk_create_attr_defs_change(self, sender=None):
with self._bulk_context("create_attrs_change", sender) as bulk_info:
with self._bulk_context(
"create_attrs_change", sender
) as bulk_info:
yield bulk_info
@contextmanager
@ -1946,9 +1950,9 @@ class CreateContext:
creator are just removed from context.
Args:
instances (List[CreatedInstance]): Instances that should be removed.
Remove logic is done using creator, which may require to
do other cleanup than just remove instance from context.
instances (List[CreatedInstance]): Instances that should be
removed. Remove logic is done using creator, which may require
to do other cleanup than just remove instance from context.
sender (Optional[str]): Sender of the event.
"""

View file

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
import os
import copy
import collections
from typing import TYPE_CHECKING, Optional, Dict, Any
@ -6,7 +7,7 @@ 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
from ayon_core.lib import Logger, get_version_from_path
from ayon_core.pipeline.plugin_discover import (
discover,
register_plugin,
@ -14,6 +15,7 @@ from ayon_core.pipeline.plugin_discover import (
deregister_plugin,
deregister_plugin_path
)
from ayon_core.pipeline import get_staging_dir_info
from .constants import DEFAULT_VARIANT_VALUE
from .product_name import get_product_name
@ -831,6 +833,95 @@ class Creator(BaseCreator):
"""
return self.pre_create_attr_defs
def get_staging_dir(self, instance):
"""Return the staging dir and persistence from instance.
Args:
instance (CreatedInstance): Instance for which should be staging
dir gathered.
Returns:
Optional[namedtuple]: Staging dir path and persistence or None
"""
create_ctx = self.create_context
product_name = instance.get("productName")
product_type = instance.get("productType")
folder_path = instance.get("folderPath")
# this can only work if product name and folder path are available
if not product_name or not folder_path:
return None
publish_settings = self.project_settings["core"]["publish"]
follow_workfile_version = (
publish_settings
["CollectAnatomyInstanceData"]
["follow_workfile_version"]
)
# Gather version number provided from the instance.
version = instance.get("version")
# If follow workfile, gather version from workfile path.
if version is None and follow_workfile_version:
current_workfile = self.create_context.get_current_workfile_path()
workfile_version = get_version_from_path(current_workfile)
version = int(workfile_version)
# Fill-up version with next version available.
elif version is None:
versions = self.get_next_versions_for_instances(
[instance]
)
version, = tuple(versions.values())
template_data = {"version": version}
staging_dir_info = get_staging_dir_info(
create_ctx.get_current_project_entity(),
create_ctx.get_folder_entity(folder_path),
create_ctx.get_task_entity(folder_path, instance.get("task")),
product_type,
product_name,
create_ctx.host_name,
anatomy=create_ctx.get_current_project_anatomy(),
project_settings=create_ctx.get_current_project_settings(),
always_return_path=False,
logger=self.log,
template_data=template_data,
)
return staging_dir_info or None
def apply_staging_dir(self, instance):
"""Apply staging dir with persistence to instance's transient data.
Method is called on instance creation and on instance update.
Args:
instance (CreatedInstance): Instance for which should be staging
dir applied.
Returns:
Optional[str]: Staging dir path or None if not applied.
"""
staging_dir_info = self.get_staging_dir(instance)
if staging_dir_info is None:
return None
# path might be already created by get_staging_dir_info
staging_dir_path = staging_dir_info.directory
os.makedirs(staging_dir_path, exist_ok=True)
instance.transient_data.update({
"stagingDir": staging_dir_path,
"stagingDir_persistent": staging_dir_info.persistent,
})
self.log.info(f"Applied staging dir to instance: {staging_dir_path}")
return staging_dir_path
def _pre_create_attr_defs_changed(self):
"""Called when pre-create attribute definitions change.

View file

@ -1,5 +1,9 @@
import ayon_api
from ayon_core.lib import StringTemplate, filter_profiles, prepare_template_data
from ayon_core.lib import (
StringTemplate,
filter_profiles,
prepare_template_data,
)
from ayon_core.settings import get_project_settings
from .constants import DEFAULT_PRODUCT_TEMPLATE

View file

@ -429,11 +429,18 @@ class CreatedInstance:
__immutable_keys = (
"id",
"instance_id",
"product_type",
"productType",
"creator_identifier",
"creator_attributes",
"publish_attributes"
)
# Keys that can be changed, but should not be removed from instance
__required_keys = {
"folderPath": None,
"task": None,
"productName": None,
"active": True,
}
def __init__(
self,
@ -515,6 +522,9 @@ class CreatedInstance:
if data:
self._data.update(data)
for key, default in self.__required_keys.items():
self._data.setdefault(key, default)
if not self._data.get("instance_id"):
self._data["instance_id"] = str(uuid4())
@ -567,6 +577,8 @@ class CreatedInstance:
has_key = key in self._data
output = self._data.pop(key, *args, **kwargs)
if has_key:
if key in self.__required_keys:
self._data[key] = self.__required_keys[key]
self._create_context.instance_values_changed(
self.id, {key: None}
)

View file

@ -387,7 +387,7 @@ def get_representations_delivery_template_data(
# convert representation entity. Fixed in 'ayon_api' 1.0.10.
if isinstance(template_data, str):
con = ayon_api.get_server_api_connection()
repre_entity = con._representation_conversion(repre_entity)
con._representation_conversion(repre_entity)
template_data = repre_entity["context"]
template_data.update(copy.deepcopy(general_template_data))

View file

@ -222,6 +222,9 @@ def remap_range_on_file_sequence(otio_clip, in_out_range):
source_range = otio_clip.source_range
available_range_rate = available_range.start_time.rate
media_in = available_range.start_time.value
available_range_start_frame = (
available_range.start_time.to_frames()
)
# Temporary.
# Some AYON custom OTIO exporter were implemented with relative
@ -230,7 +233,7 @@ def remap_range_on_file_sequence(otio_clip, in_out_range):
# while we are updating those.
if (
is_clip_from_media_sequence(otio_clip)
and otio_clip.available_range().start_time.to_frames() == media_ref.start_frame
and available_range_start_frame == media_ref.start_frame
and source_range.start_time.to_frames() < media_ref.start_frame
):
media_in = 0
@ -303,8 +306,12 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
rounded_av_rate = round(available_range_rate, 2)
rounded_src_rate = round(source_range.start_time.rate, 2)
if rounded_av_rate != rounded_src_rate:
conformed_src_in = source_range.start_time.rescaled_to(available_range_rate)
conformed_src_duration = source_range.duration.rescaled_to(available_range_rate)
conformed_src_in = source_range.start_time.rescaled_to(
available_range_rate
)
conformed_src_duration = source_range.duration.rescaled_to(
available_range_rate
)
conformed_source_range = otio.opentime.TimeRange(
start_time=conformed_src_in,
duration=conformed_src_duration

View file

@ -18,13 +18,13 @@ def parse_ayon_entity_uri(uri: str) -> Optional[dict]:
Example:
>>> parse_ayon_entity_uri(
>>> "ayon://test/char/villain?product=modelMain&version=2&representation=usd" # noqa: E501
>>> "ayon://test/char/villain?product=modelMain&version=2&representation=usd"
>>> )
{'project': 'test', 'folderPath': '/char/villain',
'product': 'modelMain', 'version': 1,
'representation': 'usd'}
>>> parse_ayon_entity_uri(
>>> "ayon+entity://project/folder?product=renderMain&version=3&representation=exr" # noqa: E501
>>> "ayon+entity://project/folder?product=renderMain&version=3&representation=exr"
>>> )
{'project': 'project', 'folderPath': '/folder',
'product': 'renderMain', 'version': 3,
@ -34,7 +34,7 @@ def parse_ayon_entity_uri(uri: str) -> Optional[dict]:
dict[str, Union[str, int]]: The individual key with their values as
found in the ayon entity URI.
"""
""" # noqa: E501
if not (uri.startswith("ayon+entity://") or uri.startswith("ayon://")):
return {}

View file

@ -8,7 +8,10 @@ import attr
import ayon_api
import clique
from ayon_core.lib import Logger
from ayon_core.pipeline import get_current_project_name, get_representation_path
from ayon_core.pipeline import (
get_current_project_name,
get_representation_path,
)
from ayon_core.pipeline.create import get_product_name
from ayon_core.pipeline.farm.patterning import match_aov_pattern
from ayon_core.pipeline.publish import KnownPublishError
@ -295,11 +298,17 @@ def _add_review_families(families):
return families
def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter,
skip_integration_repre_list,
do_not_add_review,
context,
color_managed_plugin):
def prepare_representations(
skeleton_data,
exp_files,
anatomy,
aov_filter,
skip_integration_repre_list,
do_not_add_review,
context,
color_managed_plugin,
frames_to_render=None
):
"""Create representations for file sequences.
This will return representations of expected files if they are not
@ -315,6 +324,8 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter,
skip_integration_repre_list (list): exclude specific extensions,
do_not_add_review (bool): explicitly skip review
color_managed_plugin (publish.ColormanagedPyblishPluginMixin)
frames_to_render (str): implicit or explicit range of frames to render
this value is sent to Deadline in JobInfo.Frames
Returns:
list of representations
@ -325,6 +336,14 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter,
log = Logger.get_logger("farm_publishing")
if frames_to_render is not None:
frames_to_render = _get_real_frames_to_render(frames_to_render)
else:
# Backwards compatibility for older logic
frame_start = int(skeleton_data.get("frameStartHandle"))
frame_end = int(skeleton_data.get("frameEndHandle"))
frames_to_render = list(range(frame_start, frame_end + 1))
# create representation for every collected sequence
for collection in collections:
ext = collection.tail.lstrip(".")
@ -361,18 +380,21 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter,
" This may cause issues on farm."
).format(staging))
frame_start = int(skeleton_data.get("frameStartHandle"))
frame_start = frames_to_render[0]
frame_end = frames_to_render[-1]
if skeleton_data.get("slate"):
frame_start -= 1
frames_to_render.insert(0, frame_start)
files = _get_real_files_to_render(collection, frames_to_render)
# explicitly disable review by user
preview = preview and not do_not_add_review
rep = {
"name": ext,
"ext": ext,
"files": [os.path.basename(f) for f in list(collection)],
"files": files,
"frameStart": frame_start,
"frameEnd": int(skeleton_data.get("frameEndHandle")),
"frameEnd": frame_end,
# If expectedFile are absolute, we need only filenames
"stagingDir": staging,
"fps": skeleton_data.get("fps"),
@ -453,6 +475,61 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter,
return representations
def _get_real_frames_to_render(frames):
"""Returns list of frames that should be rendered.
Artists could want to selectively render only particular frames
"""
frames_to_render = []
for frame in frames.split(","):
if "-" in frame:
splitted = frame.split("-")
frames_to_render.extend(
range(int(splitted[0]), int(splitted[1])+1))
else:
frames_to_render.append(int(frame))
frames_to_render.sort()
return frames_to_render
def _get_real_files_to_render(collection, frames_to_render):
"""Filter files with frames that should be really rendered.
'expected_files' are collected from DCC based on timeline setting. This is
being calculated differently in each DCC. Filtering here is on single place
But artists might explicitly set frames they want to render in Publisher UI
This range would override and filter previously prepared expected files
from DCC.
Args:
collection (clique.Collection): absolute paths
frames_to_render (list[int]): of int 1001
Returns:
(list[str])
Example:
--------
expectedFiles = [
"foo_v01.0001.exr",
"foo_v01.0002.exr",
]
frames_to_render = 1
>>
["foo_v01.0001.exr"] - only explicitly requested frame returned
"""
included_frames = set(collection.indexes).intersection(frames_to_render)
real_collection = clique.Collection(
collection.head,
collection.tail,
collection.padding,
indexes=included_frames
)
real_full_paths = list(real_collection)
return [os.path.basename(file_url) for file_url in real_full_paths]
def create_instances_for_aov(instance, skeleton, aov_filter,
skip_integration_repre_list,
do_not_add_review):
@ -702,9 +779,14 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
project_settings = instance.context.data.get("project_settings")
use_legacy_product_name = True
try:
use_legacy_product_name = project_settings["core"]["tools"]["creator"]["use_legacy_product_names_for_renders"] # noqa: E501
use_legacy_product_name = (
project_settings
["core"]
["tools"]
["creator"]
["use_legacy_product_names_for_renders"]
)
except KeyError:
warnings.warn(
("use_legacy_for_renders not found in project settings. "
@ -720,7 +802,9 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
dynamic_data=dynamic_data)
else:
product_name, group_name = get_product_name_and_group_from_template(
(
product_name, group_name
) = get_product_name_and_group_from_template(
task_entity=instance.data["taskEntity"],
project_name=instance.context.data["projectName"],
host_name=instance.context.data["hostName"],
@ -863,7 +947,7 @@ def _collect_expected_files_for_aov(files):
# but we really expect only one collection.
# Nothing else make sense.
if len(cols) != 1:
raise ValueError("Only one image sequence type is expected.") # noqa: E501
raise ValueError("Only one image sequence type is expected.")
return list(cols[0])

View file

@ -465,7 +465,9 @@ def update_container(container, version=-1):
from ayon_core.pipeline import get_current_project_name
# Compute the different version from 'representation'
project_name = get_current_project_name()
project_name = container.get("project_name")
if project_name is None:
project_name = get_current_project_name()
repre_id = container["representation"]
if not _is_valid_representation_id(repre_id):
raise ValueError(
@ -542,9 +544,6 @@ def update_container(container, version=-1):
)
)
path = get_representation_path(new_representation)
if not path or not os.path.exists(path):
raise ValueError("Path {} doesn't exist".format(path))
project_entity = ayon_api.get_project(project_name)
context = {
"project": project_entity,
@ -553,6 +552,9 @@ def update_container(container, version=-1):
"version": new_version,
"representation": new_representation,
}
path = get_representation_path_from_context(context)
if not path or not os.path.exists(path):
raise ValueError("Path {} doesn't exist".format(path))
return Loader().update(container, context)
@ -588,7 +590,9 @@ def switch_container(container, representation, loader_plugin=None):
)
# Get the new representation to switch to
project_name = get_current_project_name()
project_name = container.get("project_name")
if project_name is None:
project_name = get_current_project_name()
context = get_representation_context(
project_name, representation["id"]

View file

@ -3,6 +3,7 @@ from .constants import (
ValidateContentsOrder,
ValidateSceneOrder,
ValidateMeshOrder,
FARM_JOB_ENV_DATA_KEY,
)
from .publish_plugins import (
@ -59,6 +60,7 @@ __all__ = (
"ValidateContentsOrder",
"ValidateSceneOrder",
"ValidateMeshOrder",
"FARM_JOB_ENV_DATA_KEY",
"AbstractMetaInstancePlugin",
"AbstractMetaContextPlugin",

View file

@ -8,4 +8,5 @@ ValidateMeshOrder = pyblish.api.ValidatorOrder + 0.3
DEFAULT_PUBLISH_TEMPLATE = "default"
DEFAULT_HERO_PUBLISH_TEMPLATE = "default"
TRANSIENT_DIR_TEMPLATE = "default"
FARM_JOB_ENV_DATA_KEY: str = "farmJobEnv"

View file

@ -2,7 +2,7 @@ import os
import sys
import inspect
import copy
import tempfile
import warnings
import xml.etree.ElementTree
from typing import Optional, Union, List
@ -18,15 +18,11 @@ from ayon_core.lib import (
)
from ayon_core.settings import get_project_settings
from ayon_core.addon import AddonsManager
from ayon_core.pipeline import (
tempdir,
Anatomy
)
from ayon_core.pipeline import get_staging_dir_info
from ayon_core.pipeline.plugin_discover import DiscoverResult
from .constants import (
DEFAULT_PUBLISH_TEMPLATE,
DEFAULT_HERO_PUBLISH_TEMPLATE,
TRANSIENT_DIR_TEMPLATE
)
@ -581,58 +577,6 @@ def context_plugin_should_run(plugin, context):
return False
def get_instance_staging_dir(instance):
"""Unified way how staging dir is stored and created on instances.
First check if 'stagingDir' is already set in instance data.
In case there already is new tempdir will not be created.
It also supports `AYON_TMPDIR`, so studio can define own temp
shared repository per project or even per more granular context.
Template formatting is supported also with optional keys. Folder is
created in case it doesn't exists.
Available anatomy formatting keys:
- root[work | <root name key>]
- project[name | code]
Note:
Staging dir does not have to be necessarily in tempdir so be careful
about its usage.
Args:
instance (pyblish.lib.Instance): Instance for which we want to get
staging dir.
Returns:
str: Path to staging dir of instance.
"""
staging_dir = instance.data.get('stagingDir')
if staging_dir:
return staging_dir
anatomy = instance.context.data.get("anatomy")
# get customized tempdir path from `AYON_TMPDIR` env var
custom_temp_dir = tempdir.create_custom_tempdir(
anatomy.project_name, anatomy)
if custom_temp_dir:
staging_dir = os.path.normpath(
tempfile.mkdtemp(
prefix="pyblish_tmp_",
dir=custom_temp_dir
)
)
else:
staging_dir = os.path.normpath(
tempfile.mkdtemp(prefix="pyblish_tmp_")
)
instance.data['stagingDir'] = staging_dir
return staging_dir
def get_publish_repre_path(instance, repre, only_published=False):
"""Get representation path that can be used for integration.
@ -685,6 +629,8 @@ def get_publish_repre_path(instance, repre, only_published=False):
return None
# deprecated: backward compatibility only (2024-09-12)
# TODO: remove in the future
def get_custom_staging_dir_info(
project_name,
host_name,
@ -694,67 +640,87 @@ def get_custom_staging_dir_info(
product_name,
project_settings=None,
anatomy=None,
log=None
log=None,
):
"""Checks profiles if context should use special custom dir as staging.
from ayon_core.pipeline.staging_dir import get_staging_dir_config
warnings.warn(
(
"Function 'get_custom_staging_dir_info' in"
" 'ayon_core.pipeline.publish' is deprecated. Please use"
" 'get_custom_staging_dir_info'"
" in 'ayon_core.pipeline.stagingdir'."
),
DeprecationWarning,
)
tr_data = get_staging_dir_config(
project_name,
task_type,
task_name,
product_type,
product_name,
host_name,
project_settings=project_settings,
anatomy=anatomy,
log=log,
)
Args:
project_name (str)
host_name (str)
product_type (str)
task_name (str)
task_type (str)
product_name (str)
project_settings(Dict[str, Any]): Prepared project settings.
anatomy (Dict[str, Any])
log (Logger) (optional)
if not tr_data:
return None, None
return tr_data["template"], tr_data["persistence"]
def get_instance_staging_dir(instance):
"""Unified way how staging dir is stored and created on instances.
First check if 'stagingDir' is already set in instance data.
In case there already is new tempdir will not be created.
Returns:
(tuple)
Raises:
ValueError - if misconfigured template should be used
str: Path to staging dir
"""
settings = project_settings or get_project_settings(project_name)
custom_staging_dir_profiles = (settings["core"]
["tools"]
["publish"]
["custom_staging_dir_profiles"])
if not custom_staging_dir_profiles:
return None, None
staging_dir = instance.data.get("stagingDir")
if not log:
log = Logger.get_logger("get_custom_staging_dir_info")
if staging_dir:
return staging_dir
filtering_criteria = {
"hosts": host_name,
"families": product_type,
"task_names": task_name,
"task_types": task_type,
"subsets": product_name
}
profile = filter_profiles(custom_staging_dir_profiles,
filtering_criteria,
logger=log)
anatomy_data = instance.data["anatomyData"]
template_data = copy.deepcopy(anatomy_data)
if not profile or not profile["active"]:
return None, None
# context data based variables
context = instance.context
if not anatomy:
anatomy = Anatomy(project_name)
# add current file as workfile name into formatting data
current_file = context.data.get("currentFile")
if current_file:
workfile = os.path.basename(current_file)
workfile_name, _ = os.path.splitext(workfile)
template_data["workfile_name"] = workfile_name
template_name = profile["template_name"] or TRANSIENT_DIR_TEMPLATE
custom_staging_dir = anatomy.get_template_item(
"staging", template_name, "directory", default=None
staging_dir_info = get_staging_dir_info(
context.data["projectEntity"],
instance.data.get("folderEntity"),
instance.data.get("taskEntity"),
instance.data["productType"],
instance.data["productName"],
context.data["hostName"],
anatomy=context.data["anatomy"],
project_settings=context.data["project_settings"],
template_data=template_data,
always_return_path=True,
)
if custom_staging_dir is None:
raise ValueError((
"Anatomy of project \"{}\" does not have set"
" \"{}\" template key!"
).format(project_name, template_name))
is_persistent = profile["custom_staging_dir_persistent"]
return custom_staging_dir.template, is_persistent
staging_dir_path = staging_dir_info.directory
# path might be already created by get_staging_dir_info
os.makedirs(staging_dir_path, exist_ok=True)
instance.data.update({
"stagingDir": staging_dir_path,
"stagingDir_persistent": staging_dir_info.persistent,
"stagingDir_custom": staging_dir_info.custom
})
return staging_dir_path
def get_published_workfile_instance(context):
@ -799,7 +765,7 @@ def replace_with_published_scene_path(instance, replace_in_path=True):
return
# determine published path from Anatomy.
template_data = workfile_instance.data.get("anatomyData")
template_data = copy.deepcopy(workfile_instance.data["anatomyData"])
rep = workfile_instance.data["representations"][0]
template_data["representation"] = rep.get("name")
template_data["ext"] = rep.get("ext")

View file

@ -205,9 +205,9 @@ class AYONPyblishPluginMixin:
if not cls.__instanceEnabled__:
return False
for _ in pyblish.logic.plugins_by_families(
[cls], [instance.product_type]
):
families = [instance.product_type]
families.extend(instance.get("families", []))
for _ in pyblish.logic.plugins_by_families([cls], families):
return True
return False

View file

@ -0,0 +1,225 @@
from dataclasses import dataclass
from ayon_core.lib import Logger, filter_profiles
from ayon_core.settings import get_project_settings
from .template_data import get_template_data
from .anatomy import Anatomy
from .tempdir import get_temp_dir
@dataclass
class StagingDir:
directory: str
persistent: bool
custom: bool # Whether the staging dir is a custom staging dir
def get_staging_dir_config(
project_name,
task_type,
task_name,
product_type,
product_name,
host_name,
project_settings=None,
anatomy=None,
log=None,
):
"""Get matching staging dir profile.
Args:
host_name (str): Name of host.
project_name (str): Name of project.
task_type (Optional[str]): Type of task.
task_name (Optional[str]): Name of task.
product_type (str): Type of product.
product_name (str): Name of product.
project_settings(Dict[str, Any]): Prepared project settings.
anatomy (Dict[str, Any])
log (Optional[logging.Logger])
Returns:
Dict or None: Data with directory template and is_persistent or None
Raises:
KeyError - if misconfigured template should be used
"""
settings = project_settings or get_project_settings(project_name)
staging_dir_profiles = settings["core"]["tools"]["publish"][
"custom_staging_dir_profiles"
]
if not staging_dir_profiles:
return None
if not log:
log = Logger.get_logger("get_staging_dir_config")
filtering_criteria = {
"hosts": host_name,
"task_types": task_type,
"task_names": task_name,
"product_types": product_type,
"product_names": product_name,
}
profile = filter_profiles(
staging_dir_profiles, filtering_criteria, logger=log)
if not profile or not profile["active"]:
return None
if not anatomy:
anatomy = Anatomy(project_name)
# get template from template name
template_name = profile["template_name"]
_validate_template_name(project_name, template_name, anatomy)
template = anatomy.get_template_item("staging", template_name)
if not template:
# template should always be found either from anatomy or from profile
raise KeyError(
f"Staging template '{template_name}' was not found."
"Check project anatomy or settings at: "
"'ayon+settings://core/tools/publish/custom_staging_dir_profiles'"
)
data_persistence = profile["custom_staging_dir_persistent"]
return {"template": template, "persistence": data_persistence}
def _validate_template_name(project_name, template_name, anatomy):
"""Check that staging dir section with appropriate template exist.
Raises:
ValueError - if misconfigured template
"""
if template_name not in anatomy.templates["staging"]:
raise ValueError(
f'Anatomy of project "{project_name}" does not have set'
f' "{template_name}" template key at Staging Dir category!'
)
def get_staging_dir_info(
project_entity,
folder_entity,
task_entity,
product_type,
product_name,
host_name,
anatomy=None,
project_settings=None,
template_data=None,
always_return_path=True,
force_tmp_dir=False,
logger=None,
prefix=None,
suffix=None,
):
"""Get staging dir info data.
If `force_temp` is set, staging dir will be created as tempdir.
If `always_get_some_dir` is set, staging dir will be created as tempdir if
no staging dir profile is found.
If `prefix` or `suffix` is not set, default values will be used.
Arguments:
project_entity (Dict[str, Any]): Project entity.
folder_entity (Optional[Dict[str, Any]]): Folder entity.
task_entity (Optional[Dict[str, Any]]): Task entity.
product_type (str): Type of product.
product_name (str): Name of product.
host_name (str): Name of host.
anatomy (Optional[Anatomy]): Anatomy object.
project_settings (Optional[Dict[str, Any]]): Prepared project settings.
template_data (Optional[Dict[str, Any]]): Additional data for
formatting staging dir template.
always_return_path (Optional[bool]): If True, staging dir will be
created as tempdir if no staging dir profile is found. Input value
False will return None if no staging dir profile is found.
force_tmp_dir (Optional[bool]): If True, staging dir will be created as
tempdir.
logger (Optional[logging.Logger]): Logger instance.
prefix (Optional[str]) Optional prefix for staging dir name.
suffix (Optional[str]): Optional suffix for staging dir name.
Returns:
Optional[StagingDir]: Staging dir info data
"""
log = logger or Logger.get_logger("get_staging_dir_info")
if anatomy is None:
anatomy = Anatomy(
project_entity["name"], project_entity=project_entity
)
if force_tmp_dir:
return get_temp_dir(
project_name=project_entity["name"],
anatomy=anatomy,
prefix=prefix,
suffix=suffix,
)
# making few queries to database
ctx_data = get_template_data(
project_entity, folder_entity, task_entity, host_name
)
# add additional data
ctx_data["product"] = {
"type": product_type,
"name": product_name
}
# add additional template formatting data
if template_data:
ctx_data.update(template_data)
task_name = task_type = None
if task_entity:
task_name = task_entity["name"]
task_type = task_entity["taskType"]
# get staging dir config
staging_dir_config = get_staging_dir_config(
project_entity["name"],
task_type,
task_name ,
product_type,
product_name,
host_name,
project_settings=project_settings,
anatomy=anatomy,
log=log,
)
if staging_dir_config:
dir_template = staging_dir_config["template"]["directory"]
return StagingDir(
dir_template.format_strict(ctx_data),
persistent=staging_dir_config["persistence"],
custom=True
)
# no config found but force an output
if always_return_path:
return StagingDir(
get_temp_dir(
project_name=project_entity["name"],
anatomy=anatomy,
prefix=prefix,
suffix=suffix,
),
persistent=False,
custom=False
)
return None

View file

@ -3,11 +3,90 @@ Temporary folder operations
"""
import os
import tempfile
from pathlib import Path
import warnings
from ayon_core.lib import StringTemplate
from ayon_core.pipeline import Anatomy
def get_temp_dir(
project_name, anatomy=None, prefix=None, suffix=None, use_local_temp=False
):
"""Get temporary dir path.
If `use_local_temp` is set, tempdir will be created in local tempdir.
If `anatomy` is not set, default anatomy will be used.
If `prefix` or `suffix` is not set, default values will be used.
It also supports `AYON_TMPDIR`, so studio can define own temp
shared repository per project or even per more granular context.
Template formatting is supported also with optional keys. Folder is
created in case it doesn't exists.
Args:
project_name (str): Name of project.
anatomy (Optional[Anatomy]): Project Anatomy object.
suffix (Optional[str]): Suffix for tempdir.
prefix (Optional[str]): Prefix for tempdir.
use_local_temp (Optional[bool]): If True, temp dir will be created in
local tempdir.
Returns:
str: Path to staging dir of instance.
"""
if prefix is None:
prefix = "ay_tmp_"
suffix = suffix or ""
if use_local_temp:
return _create_local_staging_dir(prefix, suffix)
# make sure anatomy is set
if not anatomy:
anatomy = Anatomy(project_name)
# get customized tempdir path from `OPENPYPE_TMPDIR` env var
custom_temp_dir = _create_custom_tempdir(anatomy.project_name, anatomy)
return _create_local_staging_dir(prefix, suffix, dirpath=custom_temp_dir)
def _create_local_staging_dir(prefix, suffix, dirpath=None):
"""Create local staging dir
Args:
prefix (str): prefix for tempdir
suffix (str): suffix for tempdir
dirpath (Optional[str]): path to tempdir
Returns:
str: path to tempdir
"""
# use pathlib for creating tempdir
return tempfile.mkdtemp(
prefix=prefix, suffix=suffix, dir=dirpath
)
def create_custom_tempdir(project_name, anatomy=None):
"""Backward compatibility deprecated since 2024/12/09.
"""
warnings.warn(
"Used deprecated 'create_custom_tempdir' "
"use 'ayon_core.pipeline.tempdir.get_temp_dir' instead.",
DeprecationWarning,
)
if anatomy is None:
anatomy = Anatomy(project_name)
return _create_custom_tempdir(project_name, anatomy)
def _create_custom_tempdir(project_name, anatomy):
""" Create custom tempdir
Template path formatting is supporting:
@ -18,42 +97,35 @@ def create_custom_tempdir(project_name, anatomy=None):
Args:
project_name (str): project name
anatomy (ayon_core.pipeline.Anatomy)[optional]: Anatomy object
anatomy (ayon_core.pipeline.Anatomy): Anatomy object
Returns:
str | None: formatted path or None
"""
env_tmpdir = os.getenv("AYON_TMPDIR")
if not env_tmpdir:
return
return None
custom_tempdir = None
if "{" in env_tmpdir:
if anatomy is None:
anatomy = Anatomy(project_name)
# create base formate data
data = {
template_data = {
"root": anatomy.roots,
"project": {
"name": anatomy.project_name,
"code": anatomy.project_code,
}
},
}
# path is anatomy template
custom_tempdir = StringTemplate.format_template(
env_tmpdir, data).normalized()
env_tmpdir, template_data)
custom_tempdir_path = Path(custom_tempdir)
else:
# path is absolute
custom_tempdir = env_tmpdir
custom_tempdir_path = Path(env_tmpdir)
# create the dir path if it doesn't exists
if not os.path.exists(custom_tempdir):
try:
# create it if it doesn't exists
os.makedirs(custom_tempdir)
except IOError as error:
raise IOError(
"Path couldn't be created: {}".format(error))
custom_tempdir_path.mkdir(parents=True, exist_ok=True)
return custom_tempdir
return custom_tempdir_path.as_posix()

View file

@ -87,14 +87,13 @@ def get_folder_template_data(folder_entity, project_name):
"""
path = folder_entity["path"]
hierarchy_parts = path.split("/")
# Remove empty string from the beginning
hierarchy_parts.pop(0)
# Remove empty string from the beginning and split by '/'
parents = path.lstrip("/").split("/")
# Remove last part which is folder name
folder_name = hierarchy_parts.pop(-1)
hierarchy = "/".join(hierarchy_parts)
if hierarchy_parts:
parent_name = hierarchy_parts[-1]
folder_name = parents.pop(-1)
hierarchy = "/".join(parents)
if parents:
parent_name = parents[-1]
else:
parent_name = project_name
@ -103,6 +102,7 @@ def get_folder_template_data(folder_entity, project_name):
"name": folder_name,
"type": folder_entity["folderType"],
"path": path,
"parents": parents,
},
"asset": folder_name,
"hierarchy": hierarchy,

View file

@ -2,6 +2,7 @@ import os
import re
import copy
import platform
from typing import Optional, Dict, Any
import ayon_api
@ -16,12 +17,12 @@ from ayon_core.pipeline.template_data import get_template_data
def get_workfile_template_key_from_context(
project_name,
folder_path,
task_name,
host_name,
project_settings=None
):
project_name: str,
folder_path: str,
task_name: str,
host_name: str,
project_settings: Optional[Dict[str, Any]] = None,
) -> str:
"""Helper function to get template key for workfile template.
Do the same as `get_workfile_template_key` but returns value for "session
@ -34,15 +35,23 @@ def get_workfile_template_key_from_context(
host_name (str): Host name.
project_settings (Dict[str, Any]): Project settings for passed
'project_name'. Not required at all but makes function faster.
"""
Returns:
str: Workfile template name.
"""
folder_entity = ayon_api.get_folder_by_path(
project_name, folder_path, fields={"id"}
project_name,
folder_path,
fields={"id"},
)
task_entity = ayon_api.get_task_by_name(
project_name, folder_entity["id"], task_name
project_name,
folder_entity["id"],
task_name,
fields={"taskType"},
)
task_type = task_entity.get("type")
task_type = task_entity.get("taskType")
return get_workfile_template_key(
project_name, task_type, host_name, project_settings

View file

@ -99,7 +99,7 @@ class OpenTaskPath(LauncherAction):
if platform_name == "windows":
args = ["start", path]
elif platform_name == "darwin":
args = ["open", "-na", path]
args = ["open", "-R", path]
elif platform_name == "linux":
args = ["xdg-open", path]
else:

View file

@ -413,14 +413,16 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
# Backwards compatible (Deprecated since 24/06/06)
or instance.data.get("newAssetPublishing")
):
hierarchy = instance.data["hierarchy"]
anatomy_data["hierarchy"] = hierarchy
folder_path = instance.data["folderPath"]
parents = folder_path.lstrip("/").split("/")
folder_name = parents.pop(-1)
parent_name = project_entity["name"]
if hierarchy:
parent_name = hierarchy.split("/")[-1]
hierarchy = ""
if parents:
parent_name = parents[-1]
hierarchy = "/".join(parents)
folder_name = instance.data["folderPath"].split("/")[-1]
anatomy_data.update({
"asset": folder_name,
"hierarchy": hierarchy,
@ -432,6 +434,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
# Using 'Shot' is current default behavior of editorial
# (or 'newHierarchyIntegration') publishing.
"type": "Shot",
"parents": parents,
},
})

View file

@ -1,76 +0,0 @@
"""
Requires:
anatomy
Provides:
instance.data -> stagingDir (folder path)
-> stagingDir_persistent (bool)
"""
import copy
import os.path
import pyblish.api
from ayon_core.pipeline.publish.lib import get_custom_staging_dir_info
class CollectCustomStagingDir(pyblish.api.InstancePlugin):
"""Looks through profiles if stagingDir should be persistent and in special
location.
Transient staging dir could be useful in specific use cases where is
desirable to have temporary renders in specific, persistent folders, could
be on disks optimized for speed for example.
It is studio responsibility to clean up obsolete folders with data.
Location of the folder is configured in `project_anatomy/templates/others`.
('transient' key is expected, with 'folder' key)
Which family/task type/product is applicable is configured in:
`project_settings/global/tools/publish/custom_staging_dir_profiles`
"""
label = "Collect Custom Staging Directory"
order = pyblish.api.CollectorOrder + 0.4990
template_key = "transient"
def process(self, instance):
product_type = instance.data["productType"]
product_name = instance.data["productName"]
host_name = instance.context.data["hostName"]
project_name = instance.context.data["projectName"]
project_settings = instance.context.data["project_settings"]
anatomy = instance.context.data["anatomy"]
task = instance.data["anatomyData"].get("task", {})
transient_tml, is_persistent = get_custom_staging_dir_info(
project_name,
host_name,
product_type,
product_name,
task.get("name"),
task.get("type"),
project_settings=project_settings,
anatomy=anatomy,
log=self.log)
if transient_tml:
anatomy_data = copy.deepcopy(instance.data["anatomyData"])
anatomy_data["root"] = anatomy.roots
scene_name = instance.context.data.get("currentFile")
if scene_name:
anatomy_data["scene_name"] = os.path.basename(scene_name)
transient_dir = transient_tml.format(**anatomy_data)
instance.data["stagingDir"] = transient_dir
instance.data["stagingDir_persistent"] = is_persistent
result_str = "Adding '{}' as".format(transient_dir)
else:
result_str = "Not adding"
self.log.debug("{} custom staging dir for instance with '{}'".format(
result_str, product_type
))

View file

@ -0,0 +1,43 @@
import os
import pyblish.api
from ayon_core.lib import get_ayon_username
from ayon_core.pipeline.publish import FARM_JOB_ENV_DATA_KEY
class CollectCoreJobEnvVars(pyblish.api.ContextPlugin):
"""Collect set of environment variables to submit with deadline jobs"""
order = pyblish.api.CollectorOrder - 0.45
label = "AYON core Farm Environment Variables"
targets = ["local"]
def process(self, context):
env = context.data.setdefault(FARM_JOB_ENV_DATA_KEY, {})
# Disable colored logs on farm
for key, value in (
("AYON_LOG_NO_COLORS", "1"),
("AYON_PROJECT_NAME", context.data["projectName"]),
("AYON_FOLDER_PATH", context.data.get("folderPath")),
("AYON_TASK_NAME", context.data.get("task")),
# NOTE we should use 'context.data["user"]' but that has higher
# order.
("AYON_USERNAME", get_ayon_username()),
):
if value:
self.log.debug(f"Setting job env: {key}: {value}")
env[key] = value
for key in [
"AYON_BUNDLE_NAME",
"AYON_DEFAULT_SETTINGS_VARIANT",
"AYON_IN_TESTS",
# NOTE Not sure why workdir is needed?
"AYON_WORKDIR",
]:
value = os.getenv(key)
if value:
self.log.debug(f"Setting job env: {key}: {value}")
env[key] = value

View file

@ -13,8 +13,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin):
label = "Collect Hierarchy"
order = pyblish.api.CollectorOrder - 0.076
families = ["shot"]
hosts = ["resolve", "hiero", "flame"]
hosts = ["resolve", "hiero", "flame", "traypublisher"]
def process(self, context):
project_name = context.data["projectName"]
@ -32,36 +31,50 @@ class CollectHierarchy(pyblish.api.ContextPlugin):
product_type = instance.data["productType"]
families = instance.data["families"]
# exclude other families then self.families with intersection
if not set(self.families).intersection(
set(families + [product_type])
):
# exclude other families then "shot" with intersection
if "shot" not in (families + [product_type]):
self.log.debug("Skipping not a shot: {}".format(families))
continue
# exclude if not masterLayer True
# Skip if is not a hero track
if not instance.data.get("heroTrack"):
self.log.debug("Skipping not a shot from hero track")
continue
shot_data = {
"entity_type": "folder",
# WARNING Default folder type is hardcoded
# suppose that all instances are Shots
"folder_type": "Shot",
# WARNING unless overwritten, default folder type is hardcoded
# to shot
"folder_type": instance.data.get("folder_type") or "Shot",
"tasks": instance.data.get("tasks") or {},
"comments": instance.data.get("comments", []),
"attributes": {
"handleStart": instance.data["handleStart"],
"handleEnd": instance.data["handleEnd"],
"frameStart": instance.data["frameStart"],
"frameEnd": instance.data["frameEnd"],
"clipIn": instance.data["clipIn"],
"clipOut": instance.data["clipOut"],
"fps": instance.data["fps"],
"resolutionWidth": instance.data["resolutionWidth"],
"resolutionHeight": instance.data["resolutionHeight"],
"pixelAspect": instance.data["pixelAspect"],
},
}
shot_data["attributes"] = {}
SHOT_ATTRS = (
"handleStart",
"handleEnd",
"frameStart",
"frameEnd",
"clipIn",
"clipOut",
"fps",
"resolutionWidth",
"resolutionHeight",
"pixelAspect",
)
for shot_attr in SHOT_ATTRS:
attr_value = instance.data.get(shot_attr)
if attr_value is None:
# Shot attribute might not be defined (e.g. CSV ingest)
self.log.debug(
"%s shot attribute is not defined for instance.",
shot_attr
)
continue
shot_data["attributes"][shot_attr] = attr_value
# Split by '/' for AYON where asset is a path
name = instance.data["folderPath"].split("/")[-1]
actual = {name: shot_data}

View file

@ -0,0 +1,47 @@
"""
Requires:
anatomy
Provides:
instance.data -> stagingDir (folder path)
-> stagingDir_persistent (bool)
"""
import pyblish.api
from ayon_core.pipeline.publish import get_instance_staging_dir
class CollectManagedStagingDir(pyblish.api.InstancePlugin):
"""Apply matching Staging Dir profile to a instance.
Apply Staging dir via profiles could be useful in specific use cases
where is desirable to have temporary renders in specific,
persistent folders, could be on disks optimized for speed for example.
It is studio's responsibility to clean up obsolete folders with data.
Location of the folder is configured in:
`ayon+anatomy://_/templates/staging`.
Which family/task type/subset is applicable is configured in:
`ayon+settings://core/tools/publish/custom_staging_dir_profiles`
"""
label = "Collect Managed Staging Directory"
order = pyblish.api.CollectorOrder + 0.4990
def process(self, instance):
""" Collect the staging data and stores it to the instance.
Args:
instance (object): The instance to inspect.
"""
staging_dir_path = get_instance_staging_dir(instance)
persistance = instance.data.get("stagingDir_persistent", False)
self.log.info((
f"Instance staging dir was set to `{staging_dir_path}` "
f"and persistence is set to `{persistance}`"
))

View file

@ -29,6 +29,10 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin):
otio_range_with_handles
)
if not instance.data.get("otioClip"):
self.log.debug("Skipping collect OTIO frame range.")
return
# get basic variables
otio_clip = instance.data["otioClip"]
workfile_start = instance.data["workfileFrameStart"]

View file

@ -95,9 +95,42 @@ class CollectOtioReview(pyblish.api.InstancePlugin):
instance.data["label"] = label + " (review)"
instance.data["families"] += ["review", "ftrack"]
instance.data["otioReviewClips"] = otio_review_clips
self.log.info(
"Creating review track: {}".format(otio_review_clips))
# get colorspace from metadata if available
# get metadata from first clip with media reference
r_otio_cl = next(
(
clip
for clip in otio_review_clips
if (
isinstance(clip, otio.schema.Clip)
and clip.media_reference
)
),
None
)
if r_otio_cl is not None:
media_ref = r_otio_cl.media_reference
media_metadata = media_ref.metadata
# TODO: we might need some alternative method since
# native OTIO exports do not support ayon metadata
review_colorspace = media_metadata.get(
"ayon.source.colorspace"
)
if review_colorspace is None:
# Backwards compatibility for older scenes
review_colorspace = media_metadata.get(
"openpype.source.colourtransform"
)
if review_colorspace:
instance.data["reviewColorspace"] = review_colorspace
self.log.info(
"Review colorspace: {}".format(review_colorspace))
self.log.debug(
"_ instance.data: {}".format(pformat(instance.data)))
self.log.debug(

View file

@ -10,12 +10,16 @@ import os
import clique
import pyblish.api
from ayon_core.pipeline import publish
from ayon_core.pipeline.publish import (
get_publish_template_name
)
class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
class CollectOtioSubsetResources(
pyblish.api.InstancePlugin,
publish.ColormanagedPyblishPluginMixin
):
"""Get Resources for a product version"""
label = "Collect OTIO Subset Resources"
@ -145,6 +149,7 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
self.log.info(
"frame_start-frame_end: {}-{}".format(frame_start, frame_end))
review_repre = None
if is_sequence:
# file sequence way
@ -173,6 +178,11 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
repre = self._create_representation(
frame_start, frame_end, collection=collection)
if "review" in instance.data["families"]:
review_repre = self._create_representation(
frame_start, frame_end, collection=collection,
delete=True, review=True)
else:
_trim = False
dirname, filename = os.path.split(media_ref.target_url)
@ -187,12 +197,25 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
repre = self._create_representation(
frame_start, frame_end, file=filename, trim=_trim)
if "review" in instance.data["families"]:
review_repre = self._create_representation(
frame_start, frame_end,
file=filename, delete=True, review=True)
instance.data["originalDirname"] = self.staging_dir
# add representation to instance data
if repre:
# add representation to instance data
colorspace = instance.data.get("colorspace")
# add colorspace data to representation
self.set_representation_colorspace(
repre, instance.context, colorspace)
instance.data["representations"].append(repre)
self.log.debug(">>>>>>>> {}".format(repre))
# add review representation to instance data
if review_repre:
instance.data["representations"].append(review_repre)
self.log.debug(instance.data)
@ -213,7 +236,8 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
representation_data = {
"frameStart": start,
"frameEnd": end,
"stagingDir": self.staging_dir
"stagingDir": self.staging_dir,
"tags": [],
}
if kwargs.get("collection"):
@ -239,8 +263,10 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
"frameEnd": end,
})
if kwargs.get("trim") is True:
representation_data["tags"] = ["trim"]
for tag_name in ("trim", "delete", "review"):
if kwargs.get(tag_name) is True:
representation_data["tags"].append(tag_name)
return representation_data
def get_template_name(self, instance):

View file

@ -9,11 +9,13 @@ import clique
import pyblish.api
from ayon_core import resources, AYON_CORE_ROOT
from ayon_core.pipeline import publish
from ayon_core.pipeline import (
publish,
get_temp_dir
)
from ayon_core.lib import (
run_ayon_launcher_process,
get_transcode_temp_directory,
convert_input_paths_for_ffmpeg,
should_convert_for_ffmpeg
)
@ -250,7 +252,10 @@ class ExtractBurnin(publish.Extractor):
# - change staging dir of source representation
# - must be set back after output definitions processing
if do_convert:
new_staging_dir = get_transcode_temp_directory()
new_staging_dir = get_temp_dir(
project_name=instance.context.data["projectName"],
use_local_temp=True,
)
repre["stagingDir"] = new_staging_dir
convert_input_paths_for_ffmpeg(

View file

@ -3,15 +3,15 @@ import copy
import clique
import pyblish.api
from ayon_core.pipeline import publish
from ayon_core.pipeline import (
publish,
get_temp_dir
)
from ayon_core.lib import (
is_oiio_supported,
)
from ayon_core.lib.transcoding import (
convert_colorspace,
get_transcode_temp_directory,
)
from ayon_core.lib.profiles_filtering import filter_profiles
@ -104,7 +104,10 @@ class ExtractOIIOTranscode(publish.Extractor):
new_repre = copy.deepcopy(repre)
original_staging_dir = new_repre["stagingDir"]
new_staging_dir = get_transcode_temp_directory()
new_staging_dir = get_temp_dir(
project_name=instance.context.data["projectName"],
use_local_temp=True,
)
new_repre["stagingDir"] = new_staging_dir
if isinstance(new_repre["files"], list):
@ -154,12 +157,15 @@ class ExtractOIIOTranscode(publish.Extractor):
files_to_convert = self._translate_to_sequence(
files_to_convert)
self.log.debug("Files to convert: {}".format(files_to_convert))
for file_name in files_to_convert:
self.log.debug("Transcoding file: `{}`".format(file_name))
input_path = os.path.join(original_staging_dir,
file_name)
output_path = self._get_output_file_path(input_path,
new_staging_dir,
output_extension)
convert_colorspace(
input_path,
output_path,
@ -263,7 +269,7 @@ class ExtractOIIOTranscode(publish.Extractor):
(list) of [file.1001-1010#.exr] or [fileA.exr, fileB.exr]
"""
pattern = [clique.PATTERNS["frames"]]
collections, remainder = clique.assemble(
collections, _ = clique.assemble(
files_to_convert, patterns=pattern,
assume_padded_when_ambiguous=True)

View file

@ -37,6 +37,9 @@ class ExtractColorspaceData(publish.Extractor,
# get colorspace settings
context = instance.context
# colorspace name could be kept in instance.data
colorspace = instance.data.get("colorspace")
# loop representations
for representation in representations:
# skip if colorspaceData is already at representation
@ -44,5 +47,4 @@ class ExtractColorspaceData(publish.Extractor,
continue
self.set_representation_colorspace(
representation, context
)
representation, context, colorspace)

View file

@ -22,7 +22,6 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin):
order = pyblish.api.ExtractorOrder - 0.01
label = "Extract Hierarchy To AYON"
families = ["clip", "shot"]
def process(self, context):
if not context.data.get("hierarchyContext"):

View file

@ -71,20 +71,18 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
name = inst.data["folderPath"]
recycling_file = [f for f in created_files if name in f]
# frameranges
timeline_in_h = inst.data["clipInH"]
timeline_out_h = inst.data["clipOutH"]
fps = inst.data["fps"]
# create duration
duration = (timeline_out_h - timeline_in_h) + 1
audio_clip = inst.data["otioClip"]
audio_range = audio_clip.range_in_parent()
duration = audio_range.duration.to_frames()
# ffmpeg generate new file only if doesn't exists already
if not recycling_file:
# convert to seconds
start_sec = float(timeline_in_h / fps)
duration_sec = float(duration / fps)
parent_track = audio_clip.parent()
parent_track_start = parent_track.range_in_parent().start_time
relative_start_time = (
audio_range.start_time - parent_track_start)
start_sec = relative_start_time.to_seconds()
duration_sec = audio_range.duration.to_seconds()
# temp audio file
audio_fpath = self.create_temp_file(name)
@ -163,34 +161,36 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
output = []
# go trough all audio tracks
for otio_track in otio_timeline.tracks:
if "Audio" not in otio_track.kind:
continue
for otio_track in otio_timeline.audio_tracks():
self.log.debug("_" * 50)
playhead = 0
for otio_clip in otio_track:
self.log.debug(otio_clip)
if isinstance(otio_clip, otio.schema.Gap):
playhead += otio_clip.source_range.duration.value
elif isinstance(otio_clip, otio.schema.Clip):
start = otio_clip.source_range.start_time.value
duration = otio_clip.source_range.duration.value
fps = otio_clip.source_range.start_time.rate
if (isinstance(otio_clip, otio.schema.Clip) and
not otio_clip.media_reference.is_missing_reference):
media_av_start = otio_clip.available_range().start_time
clip_start = otio_clip.source_range.start_time
fps = clip_start.rate
conformed_av_start = media_av_start.rescaled_to(fps)
# ffmpeg ignores embedded tc
start = clip_start - conformed_av_start
duration = otio_clip.source_range.duration
media_path = otio_clip.media_reference.target_url
input = {
"mediaPath": media_path,
"delayFrame": playhead,
"startFrame": start,
"durationFrame": duration,
"startFrame": start.to_frames(),
"durationFrame": duration.to_frames(),
"delayMilSec": int(float(playhead / fps) * 1000),
"startSec": float(start / fps),
"durationSec": float(duration / fps),
"fps": fps
"startSec": start.to_seconds(),
"durationSec": duration.to_seconds(),
"fps": float(fps)
}
if input not in output:
output.append(input)
self.log.debug("__ input: {}".format(input))
playhead += otio_clip.source_range.duration.value
playhead += otio_clip.source_range.duration.value
return output

View file

@ -26,7 +26,10 @@ from ayon_core.lib import (
from ayon_core.pipeline import publish
class ExtractOTIOReview(publish.Extractor):
class ExtractOTIOReview(
publish.Extractor,
publish.ColormanagedPyblishPluginMixin
):
"""
Extract OTIO timeline into one concuted image sequence file.
@ -68,17 +71,24 @@ class ExtractOTIOReview(publish.Extractor):
# TODO: convert resulting image sequence to mp4
# get otio clip and other time info from instance clip
otio_review_clips = instance.data.get("otioReviewClips")
if otio_review_clips is None:
self.log.info(f"Instance `{instance}` has no otioReviewClips")
return
# TODO: what if handles are different in `versionData`?
handle_start = instance.data["handleStart"]
handle_end = instance.data["handleEnd"]
otio_review_clips = instance.data["otioReviewClips"]
# add plugin wide attributes
self.representation_files = []
self.used_frames = []
self.workfile_start = int(instance.data.get(
"workfileFrameStart", 1001)) - handle_start
self.padding = len(str(self.workfile_start))
# NOTE: padding has to be converted from
# end frame since start could be lower then 1000
self.padding = len(str(instance.data.get("frameEnd", 1001)))
self.used_frames.append(self.workfile_start)
self.to_width = instance.data.get(
"resolutionWidth") or self.to_width
@ -86,8 +96,10 @@ class ExtractOTIOReview(publish.Extractor):
"resolutionHeight") or self.to_height
# skip instance if no reviewable data available
if (not isinstance(otio_review_clips[0], otio.schema.Clip)) \
and (len(otio_review_clips) == 1):
if (
not isinstance(otio_review_clips[0], otio.schema.Clip)
and len(otio_review_clips) == 1
):
self.log.warning(
"Instance `{}` has nothing to process".format(instance))
return
@ -119,26 +131,33 @@ class ExtractOTIOReview(publish.Extractor):
res_data[key] = value
break
self.to_width, self.to_height = res_data["width"], res_data["height"]
self.log.debug("> self.to_width x self.to_height: {} x {}".format(
self.to_width, self.to_height
))
self.to_width, self.to_height = (
res_data["width"], res_data["height"]
)
self.log.debug(
"> self.to_width x self.to_height:"
f" {self.to_width} x {self.to_height}"
)
available_range = r_otio_cl.available_range()
available_range_start_frame = (
available_range.start_time.to_frames()
)
processing_range = None
self.actual_fps = available_range.duration.rate
start = src_range.start_time.rescaled_to(self.actual_fps)
duration = src_range.duration.rescaled_to(self.actual_fps)
src_frame_start = src_range.start_time.to_frames()
# Temporary.
# Some AYON custom OTIO exporter were implemented with relative
# source range for image sequence. Following code maintain
# backward-compatibility by adjusting available range
# Some AYON custom OTIO exporter were implemented with
# relative source range for image sequence. Following code
# maintain backward-compatibility by adjusting available range
# while we are updating those.
if (
is_clip_from_media_sequence(r_otio_cl)
and available_range.start_time.to_frames() == media_ref.start_frame
and src_range.start_time.to_frames() < media_ref.start_frame
and available_range_start_frame == media_ref.start_frame
and src_frame_start < media_ref.start_frame
):
available_range = otio.opentime.TimeRange(
otio.opentime.RationalTime(0, rate=self.actual_fps),
@ -168,7 +187,7 @@ class ExtractOTIOReview(publish.Extractor):
start -= clip_handle_start
duration += clip_handle_start
elif len(otio_review_clips) > 1 \
and (index == len(otio_review_clips) - 1):
and (index == len(otio_review_clips) - 1):
# more clips | last clip reframing with handle
duration += clip_handle_end
elif len(otio_review_clips) == 1:
@ -236,7 +255,8 @@ class ExtractOTIOReview(publish.Extractor):
# Extraction via FFmpeg.
else:
path = media_ref.target_url
# Set extract range from 0 (FFmpeg ignores embedded timecode).
# Set extract range from 0 (FFmpeg ignores
# embedded timecode).
extract_range = otio.opentime.TimeRange(
otio.opentime.RationalTime(
(
@ -263,6 +283,13 @@ class ExtractOTIOReview(publish.Extractor):
# creating and registering representation
representation = self._create_representation(start, duration)
# add colorspace data to representation
if colorspace := instance.data.get("reviewColorspace"):
self.set_representation_colorspace(
representation, instance.context, colorspace
)
instance.data["representations"].append(representation)
self.log.info("Adding representation: {}".format(representation))
@ -397,7 +424,8 @@ class ExtractOTIOReview(publish.Extractor):
to defined image sequence format.
Args:
sequence (list): input dir path string, collection object, fps in list
sequence (list): input dir path string, collection object,
fps in list.
video (list)[optional]: video_path string, otio_range in list
gap (int)[optional]: gap duration
end_offset (int)[optional]: offset gap frame start in frames

View file

@ -22,8 +22,8 @@ from ayon_core.lib.transcoding import (
should_convert_for_ffmpeg,
get_review_layer_name,
convert_input_paths_for_ffmpeg,
get_transcode_temp_directory,
)
from ayon_core.pipeline import get_temp_dir
from ayon_core.pipeline.publish import (
KnownPublishError,
get_publish_instance_label,
@ -310,7 +310,10 @@ class ExtractReview(pyblish.api.InstancePlugin):
# - change staging dir of source representation
# - must be set back after output definitions processing
if do_convert:
new_staging_dir = get_transcode_temp_directory()
new_staging_dir = get_temp_dir(
project_name=instance.context.data["projectName"],
use_local_temp=True,
)
repre["stagingDir"] = new_staging_dir
convert_input_paths_for_ffmpeg(

View file

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

View file

@ -11,8 +11,8 @@ class ValidateProductUniqueness(pyblish.api.ContextPlugin):
"""Validate all product names are unique.
This only validates whether the instances currently set to publish from
the workfile overlap one another for the folder + product they are publishing
to.
the workfile overlap one another for the folder + product they are
publishing to.
This does not perform any check against existing publishes in the database
since it is allowed to publish into existing products resulting in
@ -72,8 +72,10 @@ class ValidateProductUniqueness(pyblish.api.ContextPlugin):
# All is ok
return
msg = ("Instance product names {} are not unique. ".format(non_unique) +
"Please remove or rename duplicates.")
msg = (
f"Instance product names {non_unique} are not unique."
" Please remove or rename duplicates."
)
formatting_data = {
"non_unique": ",".join(non_unique)
}

View file

@ -79,7 +79,8 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins):
- Datatypes explanation:
<color> string format must be supported by FFmpeg.
Examples: "#000000", "0x000000", "black"
<font> must be accesible by ffmpeg = name of registered Font in system or path to font file.
<font> must be accesible by ffmpeg = name of registered Font in system
or path to font file.
Examples: "Arial", "C:/Windows/Fonts/arial.ttf"
- Possible keys:
@ -87,17 +88,21 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins):
"bg_opacity" - Opacity of background (box around text) - <float, Range:0-1>
"bg_color" - Background color - <color>
"bg_padding" - Background padding in pixels - <int>
"x_offset" - offsets burnin vertically by entered pixels from border - <int>
"y_offset" - offsets burnin horizontally by entered pixels from border - <int>
"x_offset" - offsets burnin vertically by entered pixels
from border - <int>
"y_offset" - offsets burnin horizontally by entered pixels
from border - <int>
- x_offset & y_offset should be set at least to same value as bg_padding!!
"font" - Font Family for text - <font>
"font_size" - Font size in pixels - <int>
"font_color" - Color of text - <color>
"frame_offset" - Default start frame - <int>
- required IF start frame is not set when using frames or timecode burnins
- required IF start frame is not set when using frames
or timecode burnins
On initializing class can be set General options through "options_init" arg.
General can be overridden when adding burnin
On initializing class can be set General options through
"options_init" arg.
General options can be overridden when adding burnin.
'''
TOP_CENTERED = ffmpeg_burnins.TOP_CENTERED

View file

@ -190,6 +190,7 @@ def get_current_project_settings():
project_name = os.environ.get("AYON_PROJECT_NAME")
if not project_name:
raise ValueError(
"Missing context project in environemt variable `AYON_PROJECT_NAME`."
"Missing context project in environment"
" variable `AYON_PROJECT_NAME`."
)
return get_project_settings(project_name)

View file

@ -60,7 +60,11 @@
"icon-alert-tools": "#AA5050",
"icon-entity-default": "#bfccd6",
"icon-entity-disabled": "#808080",
"font-entity-deprecated": "#666666",
"font-overridden": "#91CDFC",
"overlay-messages": {
"close-btn": "#D3D8DE",
"bg-success": "#458056",

View file

@ -1585,6 +1585,10 @@ CreateNextPageOverlay {
}
/* Attribute Definition widgets */
AttributeDefinitionsLabel[overridden="1"] {
color: {color:font-overridden};
}
AttributeDefinitionsWidget QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit {
padding: 1px;
}

View file

@ -1,6 +1,7 @@
from .widgets import (
create_widget_for_attr_def,
AttributeDefinitionsWidget,
AttributeDefinitionsLabel,
)
from .dialog import (
@ -11,6 +12,7 @@ from .dialog import (
__all__ = (
"create_widget_for_attr_def",
"AttributeDefinitionsWidget",
"AttributeDefinitionsLabel",
"AttributeDefinitionsDialog",
)

View file

@ -0,0 +1 @@
REVERT_TO_DEFAULT_LABEL = "Revert to default"

View file

@ -17,6 +17,8 @@ from ayon_core.tools.utils import (
PixmapLabel
)
from ._constants import REVERT_TO_DEFAULT_LABEL
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
ITEM_LABEL_ROLE = QtCore.Qt.UserRole + 2
ITEM_ICON_ROLE = QtCore.Qt.UserRole + 3
@ -252,7 +254,7 @@ class FilesModel(QtGui.QStandardItemModel):
"""Make sure that removed items are removed from items mapping.
Connected with '_on_insert'. When user drag item and drop it to same
view the item is actually removed and creted again but it happens in
view the item is actually removed and created again but it happens in
inner calls of Qt.
"""
@ -598,7 +600,7 @@ class FilesView(QtWidgets.QListView):
"""View showing instances and their groups."""
remove_requested = QtCore.Signal()
context_menu_requested = QtCore.Signal(QtCore.QPoint)
context_menu_requested = QtCore.Signal(QtCore.QPoint, bool)
def __init__(self, *args, **kwargs):
super(FilesView, self).__init__(*args, **kwargs)
@ -690,9 +692,8 @@ class FilesView(QtWidgets.QListView):
def _on_context_menu_request(self, pos):
index = self.indexAt(pos)
if index.isValid():
point = self.viewport().mapToGlobal(pos)
self.context_menu_requested.emit(point)
point = self.viewport().mapToGlobal(pos)
self.context_menu_requested.emit(point, index.isValid())
def _on_selection_change(self):
self._remove_btn.setEnabled(self.has_selected_item_ids())
@ -721,27 +722,34 @@ class FilesView(QtWidgets.QListView):
class FilesWidget(QtWidgets.QFrame):
value_changed = QtCore.Signal()
revert_requested = QtCore.Signal()
def __init__(self, single_item, allow_sequences, extensions_label, parent):
super(FilesWidget, self).__init__(parent)
super().__init__(parent)
self.setAcceptDrops(True)
wrapper_widget = QtWidgets.QWidget(self)
empty_widget = DropEmpty(
single_item, allow_sequences, extensions_label, self
single_item, allow_sequences, extensions_label, wrapper_widget
)
files_model = FilesModel(single_item, allow_sequences)
files_proxy_model = FilesProxyModel()
files_proxy_model.setSourceModel(files_model)
files_view = FilesView(self)
files_view = FilesView(wrapper_widget)
files_view.setModel(files_proxy_model)
layout = QtWidgets.QStackedLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
layout.addWidget(empty_widget)
layout.addWidget(files_view)
layout.setCurrentWidget(empty_widget)
wrapper_layout = QtWidgets.QStackedLayout(wrapper_widget)
wrapper_layout.setContentsMargins(0, 0, 0, 0)
wrapper_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
wrapper_layout.addWidget(empty_widget)
wrapper_layout.addWidget(files_view)
wrapper_layout.setCurrentWidget(empty_widget)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(wrapper_widget, 1)
files_proxy_model.rowsInserted.connect(self._on_rows_inserted)
files_proxy_model.rowsRemoved.connect(self._on_rows_removed)
@ -761,7 +769,11 @@ class FilesWidget(QtWidgets.QFrame):
self._widgets_by_id = {}
self._layout = layout
self._wrapper_widget = wrapper_widget
self._wrapper_layout = wrapper_layout
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self._on_context_menu)
def _set_multivalue(self, multivalue):
if self._multivalue is multivalue:
@ -770,7 +782,7 @@ class FilesWidget(QtWidgets.QFrame):
self._files_view.set_multivalue(multivalue)
self._files_model.set_multivalue(multivalue)
self._files_proxy_model.set_multivalue(multivalue)
self.setEnabled(not multivalue)
self._wrapper_widget.setEnabled(not multivalue)
def set_value(self, value, multivalue):
self._in_set_value = True
@ -829,7 +841,7 @@ class FilesWidget(QtWidgets.QFrame):
self._multivalue
)
widget.context_menu_requested.connect(
self._on_context_menu_requested
self._on_item_context_menu_request
)
self._files_view.setIndexWidget(index, widget)
self._files_proxy_model.setData(
@ -847,7 +859,7 @@ class FilesWidget(QtWidgets.QFrame):
for row in range(self._files_proxy_model.rowCount()):
index = self._files_proxy_model.index(row, 0)
item_id = index.data(ITEM_ID_ROLE)
available_item_ids.add(index.data(ITEM_ID_ROLE))
available_item_ids.add(item_id)
widget_ids = set(self._widgets_by_id.keys())
for item_id in available_item_ids:
@ -888,22 +900,31 @@ class FilesWidget(QtWidgets.QFrame):
if items_to_delete:
self._remove_item_by_ids(items_to_delete)
def _on_context_menu_requested(self, pos):
if self._multivalue:
return
def _on_context_menu(self, pos):
self._on_context_menu_requested(pos, False)
def _on_context_menu_requested(self, pos, valid_index):
menu = QtWidgets.QMenu(self._files_view)
if valid_index and not self._multivalue:
if self._files_view.has_selected_sequence():
split_action = QtWidgets.QAction("Split sequence", menu)
split_action.triggered.connect(self._on_split_request)
menu.addAction(split_action)
if self._files_view.has_selected_sequence():
split_action = QtWidgets.QAction("Split sequence", menu)
split_action.triggered.connect(self._on_split_request)
menu.addAction(split_action)
remove_action = QtWidgets.QAction("Remove", menu)
remove_action.triggered.connect(self._on_remove_requested)
menu.addAction(remove_action)
remove_action = QtWidgets.QAction("Remove", menu)
remove_action.triggered.connect(self._on_remove_requested)
menu.addAction(remove_action)
if not valid_index:
revert_action = QtWidgets.QAction(REVERT_TO_DEFAULT_LABEL, menu)
revert_action.triggered.connect(self.revert_requested)
menu.addAction(revert_action)
menu.popup(pos)
if menu.actions():
menu.popup(pos)
def _on_item_context_menu_request(self, pos):
self._on_context_menu_requested(pos, True)
def dragEnterEvent(self, event):
if self._multivalue:
@ -1011,5 +1032,5 @@ class FilesWidget(QtWidgets.QFrame):
current_widget = self._files_view
else:
current_widget = self._empty_widget
self._layout.setCurrentWidget(current_widget)
self._wrapper_layout.setCurrentWidget(current_widget)
self._files_view.update_remove_btn_visibility()

View file

@ -1,4 +1,6 @@
import copy
import typing
from typing import Optional
from qtpy import QtWidgets, QtCore
@ -20,14 +22,25 @@ from ayon_core.tools.utils import (
FocusSpinBox,
FocusDoubleSpinBox,
MultiSelectionComboBox,
set_style_property,
)
from ayon_core.tools.utils import NiceCheckbox
from ._constants import REVERT_TO_DEFAULT_LABEL
from .files_widget import FilesWidget
if typing.TYPE_CHECKING:
from typing import Union
def create_widget_for_attr_def(attr_def, parent=None):
widget = _create_widget_for_attr_def(attr_def, parent)
def create_widget_for_attr_def(
attr_def: AbstractAttrDef,
parent: Optional[QtWidgets.QWidget] = None,
handle_revert_to_default: Optional[bool] = True,
):
widget = _create_widget_for_attr_def(
attr_def, parent, handle_revert_to_default
)
if not attr_def.visible:
widget.setVisible(False)
@ -36,42 +49,96 @@ def create_widget_for_attr_def(attr_def, parent=None):
return widget
def _create_widget_for_attr_def(attr_def, parent=None):
def _create_widget_for_attr_def(
attr_def: AbstractAttrDef,
parent: "Union[QtWidgets.QWidget, None]",
handle_revert_to_default: bool,
):
if not isinstance(attr_def, AbstractAttrDef):
raise TypeError("Unexpected type \"{}\" expected \"{}\"".format(
str(type(attr_def)), AbstractAttrDef
))
cls = None
if isinstance(attr_def, NumberDef):
return NumberAttrWidget(attr_def, parent)
cls = NumberAttrWidget
if isinstance(attr_def, TextDef):
return TextAttrWidget(attr_def, parent)
elif isinstance(attr_def, TextDef):
cls = TextAttrWidget
if isinstance(attr_def, EnumDef):
return EnumAttrWidget(attr_def, parent)
elif isinstance(attr_def, EnumDef):
cls = EnumAttrWidget
if isinstance(attr_def, BoolDef):
return BoolAttrWidget(attr_def, parent)
elif isinstance(attr_def, BoolDef):
cls = BoolAttrWidget
if isinstance(attr_def, UnknownDef):
return UnknownAttrWidget(attr_def, parent)
elif isinstance(attr_def, UnknownDef):
cls = UnknownAttrWidget
if isinstance(attr_def, HiddenDef):
return HiddenAttrWidget(attr_def, parent)
elif isinstance(attr_def, HiddenDef):
cls = HiddenAttrWidget
if isinstance(attr_def, FileDef):
return FileAttrWidget(attr_def, parent)
elif isinstance(attr_def, FileDef):
cls = FileAttrWidget
if isinstance(attr_def, UISeparatorDef):
return SeparatorAttrWidget(attr_def, parent)
elif isinstance(attr_def, UISeparatorDef):
cls = SeparatorAttrWidget
if isinstance(attr_def, UILabelDef):
return LabelAttrWidget(attr_def, parent)
elif isinstance(attr_def, UILabelDef):
cls = LabelAttrWidget
raise ValueError("Unknown attribute definition \"{}\"".format(
str(type(attr_def))
))
if cls is None:
raise ValueError("Unknown attribute definition \"{}\"".format(
str(type(attr_def))
))
return cls(attr_def, parent, handle_revert_to_default)
class AttributeDefinitionsLabel(QtWidgets.QLabel):
"""Label related to value attribute definition.
Label is used to show attribute definition label and to show if value
is overridden.
Label can be right-clicked to revert value to default.
"""
revert_to_default_requested = QtCore.Signal(str)
def __init__(
self,
attr_id: str,
label: str,
parent: QtWidgets.QWidget,
):
super().__init__(label, parent)
self._attr_id = attr_id
self._overridden = False
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self._on_context_menu)
def set_overridden(self, overridden: bool):
if self._overridden == overridden:
return
self._overridden = overridden
set_style_property(
self,
"overridden",
"1" if overridden else ""
)
def _on_context_menu(self, point: QtCore.QPoint):
menu = QtWidgets.QMenu(self)
action = QtWidgets.QAction(menu)
action.setText(REVERT_TO_DEFAULT_LABEL)
action.triggered.connect(self._request_revert_to_default)
menu.addAction(action)
menu.exec_(self.mapToGlobal(point))
def _request_revert_to_default(self):
self.revert_to_default_requested.emit(self._attr_id)
class AttributeDefinitionsWidget(QtWidgets.QWidget):
@ -83,16 +150,18 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
"""
def __init__(self, attr_defs=None, parent=None):
super(AttributeDefinitionsWidget, self).__init__(parent)
super().__init__(parent)
self._widgets = []
self._widgets_by_id = {}
self._labels_by_id = {}
self._current_keys = set()
self.set_attr_defs(attr_defs)
def clear_attr_defs(self):
"""Remove all existing widgets and reset layout if needed."""
self._widgets = []
self._widgets_by_id = {}
self._labels_by_id = {}
self._current_keys = set()
layout = self.layout()
@ -133,7 +202,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
self._current_keys.add(attr_def.key)
widget = create_widget_for_attr_def(attr_def, self)
self._widgets.append(widget)
self._widgets_by_id[attr_def.id] = widget
if not attr_def.visible:
continue
@ -145,7 +214,13 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
col_num = 2 - expand_cols
if attr_def.is_value_def and attr_def.label:
label_widget = QtWidgets.QLabel(attr_def.label, self)
label_widget = AttributeDefinitionsLabel(
attr_def.id, attr_def.label, self
)
label_widget.revert_to_default_requested.connect(
self._on_revert_request
)
self._labels_by_id[attr_def.id] = label_widget
tooltip = attr_def.tooltip
if tooltip:
label_widget.setToolTip(tooltip)
@ -160,6 +235,9 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
if not attr_def.is_label_horizontal:
row += 1
if attr_def.is_value_def:
widget.value_changed.connect(self._on_value_change)
layout.addWidget(
widget, row, col_num, 1, expand_cols
)
@ -168,7 +246,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
def set_value(self, value):
new_value = copy.deepcopy(value)
unused_keys = set(new_value.keys())
for widget in self._widgets:
for widget in self._widgets_by_id.values():
attr_def = widget.attr_def
if attr_def.key not in new_value:
continue
@ -181,22 +259,42 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
def current_value(self):
output = {}
for widget in self._widgets:
for widget in self._widgets_by_id.values():
attr_def = widget.attr_def
if not isinstance(attr_def, UIDef):
output[attr_def.key] = widget.current_value()
return output
def _on_revert_request(self, attr_id):
widget = self._widgets_by_id.get(attr_id)
if widget is not None:
widget.set_value(widget.attr_def.default)
def _on_value_change(self, value, attr_id):
widget = self._widgets_by_id.get(attr_id)
if widget is None:
return
label = self._labels_by_id.get(attr_id)
if label is not None:
label.set_overridden(value != widget.attr_def.default)
class _BaseAttrDefWidget(QtWidgets.QWidget):
# Type 'object' may not work with older PySide versions
value_changed = QtCore.Signal(object, str)
revert_to_default_requested = QtCore.Signal(str)
def __init__(self, attr_def, parent):
super(_BaseAttrDefWidget, self).__init__(parent)
def __init__(
self,
attr_def: AbstractAttrDef,
parent: "Union[QtWidgets.QWidget, None]",
handle_revert_to_default: Optional[bool] = True,
):
super().__init__(parent)
self.attr_def = attr_def
self.attr_def: AbstractAttrDef = attr_def
self._handle_revert_to_default: bool = handle_revert_to_default
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
@ -205,6 +303,15 @@ class _BaseAttrDefWidget(QtWidgets.QWidget):
self._ui_init()
def revert_to_default_value(self):
if not self.attr_def.is_value_def:
return
if self._handle_revert_to_default:
self.set_value(self.attr_def.default)
else:
self.revert_to_default_requested.emit(self.attr_def.id)
def _ui_init(self):
raise NotImplementedError(
"Method '_ui_init' is not implemented. {}".format(
@ -255,7 +362,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit):
clicked = QtCore.Signal()
def __init__(self, text, parent):
super(ClickableLineEdit, self).__init__(parent)
super().__init__(parent)
self.setText(text)
self.setReadOnly(True)
@ -264,7 +371,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit):
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self._mouse_pressed = True
super(ClickableLineEdit, self).mousePressEvent(event)
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
if self._mouse_pressed:
@ -272,7 +379,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit):
if self.rect().contains(event.pos()):
self.clicked.emit()
super(ClickableLineEdit, self).mouseReleaseEvent(event)
super().mouseReleaseEvent(event)
class NumberAttrWidget(_BaseAttrDefWidget):
@ -284,6 +391,9 @@ class NumberAttrWidget(_BaseAttrDefWidget):
else:
input_widget = FocusSpinBox(self)
# Override context menu event to add revert to default action
input_widget.contextMenuEvent = self._input_widget_context_event
if self.attr_def.tooltip:
input_widget.setToolTip(self.attr_def.tooltip)
@ -321,6 +431,16 @@ class NumberAttrWidget(_BaseAttrDefWidget):
self._set_multiselection_visible(True)
return False
def _input_widget_context_event(self, event):
line_edit = self._input_widget.lineEdit()
menu = line_edit.createStandardContextMenu()
menu.setAttribute(QtCore.Qt.WA_DeleteOnClose)
action = QtWidgets.QAction(menu)
action.setText(REVERT_TO_DEFAULT_LABEL)
action.triggered.connect(self.revert_to_default_value)
menu.addAction(action)
menu.popup(event.globalPos())
def current_value(self):
return self._input_widget.value()
@ -386,6 +506,9 @@ class TextAttrWidget(_BaseAttrDefWidget):
else:
input_widget = QtWidgets.QLineEdit(self)
# Override context menu event to add revert to default action
input_widget.contextMenuEvent = self._input_widget_context_event
if (
self.attr_def.placeholder
and hasattr(input_widget, "setPlaceholderText")
@ -407,6 +530,15 @@ class TextAttrWidget(_BaseAttrDefWidget):
self.main_layout.addWidget(input_widget, 0)
def _input_widget_context_event(self, event):
menu = self._input_widget.createStandardContextMenu()
menu.setAttribute(QtCore.Qt.WA_DeleteOnClose)
action = QtWidgets.QAction(menu)
action.setText(REVERT_TO_DEFAULT_LABEL)
action.triggered.connect(self.revert_to_default_value)
menu.addAction(action)
menu.popup(event.globalPos())
def _on_value_change(self):
if self.multiline:
new_value = self._input_widget.toPlainText()
@ -459,6 +591,20 @@ class BoolAttrWidget(_BaseAttrDefWidget):
self.main_layout.addWidget(input_widget, 0)
self.main_layout.addStretch(1)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self._on_context_menu)
def _on_context_menu(self, pos):
self._menu = QtWidgets.QMenu(self)
action = QtWidgets.QAction(self._menu)
action.setText(REVERT_TO_DEFAULT_LABEL)
action.triggered.connect(self.revert_to_default_value)
self._menu.addAction(action)
global_pos = self.mapToGlobal(pos)
self._menu.exec_(global_pos)
def _on_value_change(self):
new_value = self._input_widget.isChecked()
self.value_changed.emit(new_value, self.attr_def.id)
@ -487,7 +633,7 @@ class BoolAttrWidget(_BaseAttrDefWidget):
class EnumAttrWidget(_BaseAttrDefWidget):
def __init__(self, *args, **kwargs):
self._multivalue = False
super(EnumAttrWidget, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
@property
def multiselection(self):
@ -522,6 +668,20 @@ class EnumAttrWidget(_BaseAttrDefWidget):
self.main_layout.addWidget(input_widget, 0)
input_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
input_widget.customContextMenuRequested.connect(self._on_context_menu)
def _on_context_menu(self, pos):
menu = QtWidgets.QMenu(self)
action = QtWidgets.QAction(menu)
action.setText(REVERT_TO_DEFAULT_LABEL)
action.triggered.connect(self.revert_to_default_value)
menu.addAction(action)
global_pos = self.mapToGlobal(pos)
menu.exec_(global_pos)
def _on_value_change(self):
new_value = self.current_value()
if self._multivalue:
@ -614,7 +774,7 @@ class HiddenAttrWidget(_BaseAttrDefWidget):
def setVisible(self, visible):
if visible:
visible = False
super(HiddenAttrWidget, self).setVisible(visible)
super().setVisible(visible)
def current_value(self):
if self._multivalue:
@ -650,10 +810,25 @@ class FileAttrWidget(_BaseAttrDefWidget):
self.main_layout.addWidget(input_widget, 0)
input_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
input_widget.customContextMenuRequested.connect(self._on_context_menu)
input_widget.revert_requested.connect(self.revert_to_default_value)
def _on_value_change(self):
new_value = self.current_value()
self.value_changed.emit(new_value, self.attr_def.id)
def _on_context_menu(self, pos):
menu = QtWidgets.QMenu(self)
action = QtWidgets.QAction(menu)
action.setText(REVERT_TO_DEFAULT_LABEL)
action.triggered.connect(self.revert_to_default_value)
menu.addAction(action)
global_pos = self.mapToGlobal(pos)
menu.exec_(global_pos)
def current_value(self):
return self._input_widget.current_value()

View file

@ -0,0 +1,8 @@
from .abstract import AbstractInterpreterController
from .control import InterpreterController
__all__ = (
"AbstractInterpreterController",
"InterpreterController",
)

View file

@ -0,0 +1,33 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Dict, Optional
@dataclass
class TabItem:
name: str
code: str
@dataclass
class InterpreterConfig:
width: Optional[int]
height: Optional[int]
splitter_sizes: List[int] = field(default_factory=list)
tabs: List[TabItem] = field(default_factory=list)
class AbstractInterpreterController(ABC):
@abstractmethod
def get_config(self) -> InterpreterConfig:
pass
@abstractmethod
def save_config(
self,
width: int,
height: int,
splitter_sizes: List[int],
tabs: List[Dict[str, str]],
):
pass

View file

@ -0,0 +1,63 @@
from typing import List, Dict
from ayon_core.lib import JSONSettingRegistry
from ayon_core.lib.local_settings import get_launcher_local_dir
from .abstract import (
AbstractInterpreterController,
TabItem,
InterpreterConfig,
)
class InterpreterController(AbstractInterpreterController):
def __init__(self):
self._registry = JSONSettingRegistry(
"python_interpreter_tool",
get_launcher_local_dir(),
)
def get_config(self):
width = None
height = None
splitter_sizes = []
tabs = []
try:
width = self._registry.get_item("width")
height = self._registry.get_item("height")
except (ValueError, KeyError):
pass
try:
splitter_sizes = self._registry.get_item("splitter_sizes")
except (ValueError, KeyError):
pass
try:
tab_defs = self._registry.get_item("tabs") or []
for tab_def in tab_defs:
tab_name = tab_def.get("name")
if not tab_name:
continue
code = tab_def.get("code") or ""
tabs.append(TabItem(tab_name, code))
except (ValueError, KeyError):
pass
return InterpreterConfig(
width, height, splitter_sizes, tabs
)
def save_config(
self,
width: int,
height: int,
splitter_sizes: List[int],
tabs: List[Dict[str, str]],
):
self._registry.set_item("width", width)
self._registry.set_item("height", height)
self._registry.set_item("splitter_sizes", splitter_sizes)
self._registry.set_item("tabs", tabs)

View file

@ -0,0 +1,8 @@
from .window import (
ConsoleInterpreterWindow
)
__all__ = (
"ConsoleInterpreterWindow",
)

View file

@ -0,0 +1,42 @@
import os
import sys
import collections
class StdOEWrap:
def __init__(self):
self._origin_stdout_write = None
self._origin_stderr_write = None
self._listening = False
self.lines = collections.deque()
if not sys.stdout:
sys.stdout = open(os.devnull, "w")
if not sys.stderr:
sys.stderr = open(os.devnull, "w")
if self._origin_stdout_write is None:
self._origin_stdout_write = sys.stdout.write
if self._origin_stderr_write is None:
self._origin_stderr_write = sys.stderr.write
self._listening = True
sys.stdout.write = self._stdout_listener
sys.stderr.write = self._stderr_listener
def stop_listen(self):
self._listening = False
def _stdout_listener(self, text):
if self._listening:
self.lines.append(text)
if self._origin_stdout_write is not None:
self._origin_stdout_write(text)
def _stderr_listener(self, text):
if self._listening:
self.lines.append(text)
if self._origin_stderr_write is not None:
self._origin_stderr_write(text)

View file

@ -0,0 +1,251 @@
from code import InteractiveInterpreter
from qtpy import QtCore, QtWidgets, QtGui
class PythonCodeEditor(QtWidgets.QPlainTextEdit):
execute_requested = QtCore.Signal()
def __init__(self, parent):
super().__init__(parent)
self.setObjectName("PythonCodeEditor")
self._indent = 4
def _tab_shift_right(self):
cursor = self.textCursor()
selected_text = cursor.selectedText()
if not selected_text:
cursor.insertText(" " * self._indent)
return
sel_start = cursor.selectionStart()
sel_end = cursor.selectionEnd()
cursor.setPosition(sel_end)
end_line = cursor.blockNumber()
cursor.setPosition(sel_start)
while True:
cursor.movePosition(QtGui.QTextCursor.StartOfLine)
text = cursor.block().text()
spaces = len(text) - len(text.lstrip(" "))
new_spaces = spaces % self._indent
if not new_spaces:
new_spaces = self._indent
cursor.insertText(" " * new_spaces)
if cursor.blockNumber() == end_line:
break
cursor.movePosition(QtGui.QTextCursor.NextBlock)
def _tab_shift_left(self):
tmp_cursor = self.textCursor()
sel_start = tmp_cursor.selectionStart()
sel_end = tmp_cursor.selectionEnd()
cursor = QtGui.QTextCursor(self.document())
cursor.setPosition(sel_end)
end_line = cursor.blockNumber()
cursor.setPosition(sel_start)
while True:
cursor.movePosition(QtGui.QTextCursor.StartOfLine)
text = cursor.block().text()
spaces = len(text) - len(text.lstrip(" "))
if spaces:
spaces_to_remove = (spaces % self._indent) or self._indent
if spaces_to_remove > spaces:
spaces_to_remove = spaces
cursor.setPosition(
cursor.position() + spaces_to_remove,
QtGui.QTextCursor.KeepAnchor
)
cursor.removeSelectedText()
if cursor.blockNumber() == end_line:
break
cursor.movePosition(QtGui.QTextCursor.NextBlock)
def keyPressEvent(self, event):
if event.key() == QtCore.Qt.Key_Backtab:
self._tab_shift_left()
event.accept()
return
if event.key() == QtCore.Qt.Key_Tab:
if event.modifiers() == QtCore.Qt.NoModifier:
self._tab_shift_right()
event.accept()
return
if (
event.key() == QtCore.Qt.Key_Return
and event.modifiers() == QtCore.Qt.ControlModifier
):
self.execute_requested.emit()
event.accept()
return
super().keyPressEvent(event)
class PythonTabWidget(QtWidgets.QWidget):
add_tab_requested = QtCore.Signal()
before_execute = QtCore.Signal(str)
def __init__(self, parent):
super().__init__(parent)
code_input = PythonCodeEditor(self)
self.setFocusProxy(code_input)
add_tab_btn = QtWidgets.QPushButton("Add tab...", self)
add_tab_btn.setDefault(False)
add_tab_btn.setToolTip("Add new tab")
execute_btn = QtWidgets.QPushButton("Execute", self)
execute_btn.setDefault(False)
execute_btn.setToolTip("Execute command (Ctrl + Enter)")
btns_layout = QtWidgets.QHBoxLayout()
btns_layout.setContentsMargins(0, 0, 0, 0)
btns_layout.addWidget(add_tab_btn)
btns_layout.addStretch(1)
btns_layout.addWidget(execute_btn)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(code_input, 1)
layout.addLayout(btns_layout, 0)
add_tab_btn.clicked.connect(self._on_add_tab_clicked)
execute_btn.clicked.connect(self._on_execute_clicked)
code_input.execute_requested.connect(self.execute)
self._code_input = code_input
self._interpreter = InteractiveInterpreter()
def _on_add_tab_clicked(self):
self.add_tab_requested.emit()
def _on_execute_clicked(self):
self.execute()
def get_code(self):
return self._code_input.toPlainText()
def set_code(self, code_text):
self._code_input.setPlainText(code_text)
def execute(self):
code_text = self._code_input.toPlainText()
self.before_execute.emit(code_text)
self._interpreter.runcode(code_text)
class TabNameDialog(QtWidgets.QDialog):
default_width = 330
default_height = 85
def __init__(self, parent):
super().__init__(parent)
self.setWindowTitle("Enter tab name")
name_label = QtWidgets.QLabel("Tab name:", self)
name_input = QtWidgets.QLineEdit(self)
inputs_layout = QtWidgets.QHBoxLayout()
inputs_layout.addWidget(name_label)
inputs_layout.addWidget(name_input)
ok_btn = QtWidgets.QPushButton("Ok", self)
cancel_btn = QtWidgets.QPushButton("Cancel", self)
btns_layout = QtWidgets.QHBoxLayout()
btns_layout.addStretch(1)
btns_layout.addWidget(ok_btn)
btns_layout.addWidget(cancel_btn)
layout = QtWidgets.QVBoxLayout(self)
layout.addLayout(inputs_layout)
layout.addStretch(1)
layout.addLayout(btns_layout)
ok_btn.clicked.connect(self._on_ok_clicked)
cancel_btn.clicked.connect(self._on_cancel_clicked)
self._name_input = name_input
self._ok_btn = ok_btn
self._cancel_btn = cancel_btn
self._result = None
self.resize(self.default_width, self.default_height)
def set_tab_name(self, name):
self._name_input.setText(name)
def result(self):
return self._result
def showEvent(self, event):
super().showEvent(event)
btns_width = max(
self._ok_btn.width(),
self._cancel_btn.width()
)
self._ok_btn.setMinimumWidth(btns_width)
self._cancel_btn.setMinimumWidth(btns_width)
def _on_ok_clicked(self):
self._result = self._name_input.text()
self.accept()
def _on_cancel_clicked(self):
self._result = None
self.reject()
class OutputTextWidget(QtWidgets.QTextEdit):
v_max_offset = 4
def vertical_scroll_at_max(self):
v_scroll = self.verticalScrollBar()
return v_scroll.value() > v_scroll.maximum() - self.v_max_offset
def scroll_to_bottom(self):
v_scroll = self.verticalScrollBar()
return v_scroll.setValue(v_scroll.maximum())
class EnhancedTabBar(QtWidgets.QTabBar):
double_clicked = QtCore.Signal(QtCore.QPoint)
right_clicked = QtCore.Signal(QtCore.QPoint)
mid_clicked = QtCore.Signal(QtCore.QPoint)
def __init__(self, parent):
super().__init__(parent)
self.setDrawBase(False)
def mouseDoubleClickEvent(self, event):
self.double_clicked.emit(event.globalPos())
event.accept()
def mouseReleaseEvent(self, event):
if event.button() == QtCore.Qt.RightButton:
self.right_clicked.emit(event.globalPos())
event.accept()
return
elif event.button() == QtCore.Qt.MidButton:
self.mid_clicked.emit(event.globalPos())
event.accept()
else:
super().mouseReleaseEvent(event)

View file

@ -0,0 +1,324 @@
import re
from typing import Optional
from qtpy import QtWidgets, QtGui, QtCore
from ayon_core import resources
from ayon_core.style import load_stylesheet
from ayon_core.tools.console_interpreter import (
AbstractInterpreterController,
InterpreterController,
)
from .utils import StdOEWrap
from .widgets import (
PythonTabWidget,
OutputTextWidget,
EnhancedTabBar,
TabNameDialog,
)
ANSI_ESCAPE = re.compile(
r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]"
)
AYON_ART = r"""
· · - =[ by YNPUT ]:[ http://ayon.ynput.io ]= - · ·
"""
class ConsoleInterpreterWindow(QtWidgets.QWidget):
default_width = 1000
default_height = 600
def __init__(
self,
controller: Optional[AbstractInterpreterController] = None,
parent: Optional[QtWidgets.QWidget] = None,
):
super().__init__(parent)
self.setWindowTitle("AYON Console")
self.setWindowIcon(QtGui.QIcon(resources.get_ayon_icon_filepath()))
if controller is None:
controller = InterpreterController()
output_widget = OutputTextWidget(self)
output_widget.setObjectName("PythonInterpreterOutput")
output_widget.setLineWrapMode(QtWidgets.QTextEdit.NoWrap)
output_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
tab_widget = QtWidgets.QTabWidget(self)
tab_bar = EnhancedTabBar(tab_widget)
tab_widget.setTabBar(tab_bar)
tab_widget.setTabsClosable(False)
tab_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
widgets_splitter = QtWidgets.QSplitter(self)
widgets_splitter.setOrientation(QtCore.Qt.Vertical)
widgets_splitter.addWidget(output_widget)
widgets_splitter.addWidget(tab_widget)
widgets_splitter.setStretchFactor(0, 1)
widgets_splitter.setStretchFactor(1, 1)
height = int(self.default_height / 2)
widgets_splitter.setSizes([height, self.default_height - height])
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(widgets_splitter)
line_check_timer = QtCore.QTimer()
line_check_timer.setInterval(200)
line_check_timer.timeout.connect(self._on_timer_timeout)
tab_bar.right_clicked.connect(self._on_tab_right_click)
tab_bar.double_clicked.connect(self._on_tab_double_click)
tab_bar.mid_clicked.connect(self._on_tab_mid_click)
tab_widget.tabCloseRequested.connect(self._on_tab_close_req)
self._tabs = []
self._stdout_err_wrapper = StdOEWrap()
self._widgets_splitter = widgets_splitter
self._output_widget = output_widget
self._tab_widget = tab_widget
self._line_check_timer = line_check_timer
self._append_lines([AYON_ART])
self._first_show = True
self._controller = controller
def showEvent(self, event):
self._line_check_timer.start()
super().showEvent(event)
# First show setup
if self._first_show:
self._first_show = False
self._on_first_show()
if self._tab_widget.count() < 1:
self.add_tab("Python")
self._output_widget.scroll_to_bottom()
def closeEvent(self, event):
self._save_registry()
super().closeEvent(event)
self._line_check_timer.stop()
def add_tab(self, tab_name, index=None):
widget = PythonTabWidget(self)
widget.before_execute.connect(self._on_before_execute)
widget.add_tab_requested.connect(self._on_add_requested)
if index is None:
if self._tab_widget.count() > 0:
index = self._tab_widget.currentIndex() + 1
else:
index = 0
self._tabs.append(widget)
self._tab_widget.insertTab(index, widget, tab_name)
self._tab_widget.setCurrentIndex(index)
if self._tab_widget.count() > 1:
self._tab_widget.setTabsClosable(True)
widget.setFocus()
return widget
def _on_first_show(self):
config = self._controller.get_config()
width = config.width
height = config.height
if width is None or width < 200:
width = self.default_width
if height is None or height < 200:
height = self.default_height
for tab_item in config.tabs:
widget = self.add_tab(tab_item.name)
widget.set_code(tab_item.code)
self.resize(width, height)
# Change stylesheet
self.setStyleSheet(load_stylesheet())
# Check if splitter sizes are set
splitters_count = len(self._widgets_splitter.sizes())
if len(config.splitter_sizes) == splitters_count:
self._widgets_splitter.setSizes(config.splitter_sizes)
def _save_registry(self):
tabs = []
for tab_idx in range(self._tab_widget.count()):
widget = self._tab_widget.widget(tab_idx)
tabs.append({
"name": self._tab_widget.tabText(tab_idx),
"code": widget.get_code()
})
self._controller.save_config(
self.width(),
self.height(),
self._widgets_splitter.sizes(),
tabs
)
def _on_tab_right_click(self, global_point):
point = self._tab_widget.mapFromGlobal(global_point)
tab_bar = self._tab_widget.tabBar()
tab_idx = tab_bar.tabAt(point)
last_index = tab_bar.count() - 1
if tab_idx < 0 or tab_idx > last_index:
return
menu = QtWidgets.QMenu(self._tab_widget)
add_tab_action = QtWidgets.QAction("Add tab...", menu)
add_tab_action.setToolTip("Add new tab")
rename_tab_action = QtWidgets.QAction("Rename...", menu)
rename_tab_action.setToolTip("Rename tab")
duplicate_tab_action = QtWidgets.QAction("Duplicate...", menu)
duplicate_tab_action.setToolTip("Duplicate code to new tab")
close_tab_action = QtWidgets.QAction("Close", menu)
close_tab_action.setToolTip("Close tab and lose content")
close_tab_action.setEnabled(self._tab_widget.tabsClosable())
menu.addAction(add_tab_action)
menu.addAction(rename_tab_action)
menu.addAction(duplicate_tab_action)
menu.addAction(close_tab_action)
result = menu.exec_(global_point)
if result is None:
return
if result is rename_tab_action:
self._rename_tab_req(tab_idx)
elif result is add_tab_action:
self._on_add_requested()
elif result is duplicate_tab_action:
self._duplicate_requested(tab_idx)
elif result is close_tab_action:
self._on_tab_close_req(tab_idx)
def _rename_tab_req(self, tab_idx):
dialog = TabNameDialog(self)
dialog.set_tab_name(self._tab_widget.tabText(tab_idx))
dialog.exec_()
tab_name = dialog.result()
if tab_name:
self._tab_widget.setTabText(tab_idx, tab_name)
def _duplicate_requested(self, tab_idx=None):
if tab_idx is None:
tab_idx = self._tab_widget.currentIndex()
src_widget = self._tab_widget.widget(tab_idx)
dst_widget = self._add_tab()
if dst_widget is None:
return
dst_widget.set_code(src_widget.get_code())
def _on_tab_mid_click(self, global_point):
point = self._tab_widget.mapFromGlobal(global_point)
tab_bar = self._tab_widget.tabBar()
tab_idx = tab_bar.tabAt(point)
last_index = tab_bar.count() - 1
if tab_idx < 0 or tab_idx > last_index:
return
self._on_tab_close_req(tab_idx)
def _on_tab_double_click(self, global_point):
point = self._tab_widget.mapFromGlobal(global_point)
tab_bar = self._tab_widget.tabBar()
tab_idx = tab_bar.tabAt(point)
last_index = tab_bar.count() - 1
if tab_idx < 0 or tab_idx > last_index:
return
self._rename_tab_req(tab_idx)
def _on_tab_close_req(self, tab_index):
if self._tab_widget.count() == 1:
return
widget = self._tab_widget.widget(tab_index)
if widget in self._tabs:
self._tabs.remove(widget)
self._tab_widget.removeTab(tab_index)
if self._tab_widget.count() == 1:
self._tab_widget.setTabsClosable(False)
def _append_lines(self, lines):
at_max = self._output_widget.vertical_scroll_at_max()
tmp_cursor = QtGui.QTextCursor(self._output_widget.document())
tmp_cursor.movePosition(QtGui.QTextCursor.End)
for line in lines:
tmp_cursor.insertText(line)
if at_max:
self._output_widget.scroll_to_bottom()
def _on_timer_timeout(self):
if self._stdout_err_wrapper.lines:
lines = []
while self._stdout_err_wrapper.lines:
line = self._stdout_err_wrapper.lines.popleft()
lines.append(ANSI_ESCAPE.sub("", line))
self._append_lines(lines)
def _on_add_requested(self):
self._add_tab()
def _add_tab(self):
dialog = TabNameDialog(self)
dialog.exec_()
tab_name = dialog.result()
if tab_name:
return self.add_tab(tab_name)
return None
def _on_before_execute(self, code_text):
at_max = self._output_widget.vertical_scroll_at_max()
document = self._output_widget.document()
tmp_cursor = QtGui.QTextCursor(document)
tmp_cursor.movePosition(QtGui.QTextCursor.End)
tmp_cursor.insertText("{}\nExecuting command:\n".format(20 * "-"))
code_block_format = QtGui.QTextFrameFormat()
code_block_format.setBackground(QtGui.QColor(27, 27, 27))
code_block_format.setPadding(4)
tmp_cursor.insertFrame(code_block_format)
char_format = tmp_cursor.charFormat()
char_format.setForeground(
QtGui.QBrush(QtGui.QColor(114, 224, 198))
)
tmp_cursor.setCharFormat(char_format)
tmp_cursor.insertText(code_text)
# Create new cursor
tmp_cursor = QtGui.QTextCursor(document)
tmp_cursor.movePosition(QtGui.QTextCursor.End)
tmp_cursor.insertText("{}\n".format(20 * "-"))
if at_max:
self._output_widget.scroll_to_bottom()

View file

@ -104,7 +104,7 @@ class ProductNameValidator(RegularExpressionValidatorClass):
def validate(self, text, pos):
results = super(ProductNameValidator, self).validate(text, pos)
if results[0] == self.Invalid:
if results[0] == RegularExpressionValidatorClass.Invalid:
self.invalid.emit(self.invalid_chars(text))
return results
@ -217,7 +217,9 @@ class ProductTypeDescriptionWidget(QtWidgets.QWidget):
product_type_label = QtWidgets.QLabel(self)
product_type_label.setObjectName("CreatorProductTypeLabel")
product_type_label.setAlignment(QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft)
product_type_label.setAlignment(
QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft
)
help_label = QtWidgets.QLabel(self)
help_label.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft)

View file

@ -7,6 +7,7 @@ from ayon_core.pipeline.actions import (
discover_launcher_actions,
LauncherAction,
LauncherActionSelection,
register_launcher_action_path,
)
from ayon_core.pipeline.workfile import should_use_last_workfile_on_launch
@ -21,9 +22,9 @@ except ImportError:
Application action based on 'ApplicationManager' system.
Handling of applications in launcher is not ideal and should be completely
redone from scratch. This is just a temporary solution to keep backwards
compatibility with AYON launcher.
Handling of applications in launcher is not ideal and should be
completely redone from scratch. This is just a temporary solution
to keep backwards compatibility with AYON launcher.
Todos:
Move handling of errors to frontend.
@ -459,6 +460,14 @@ class ActionsModel:
def _get_discovered_action_classes(self):
if self._discovered_actions is None:
# NOTE We don't need to register the paths, but that would
# require to change discovery logic and deprecate all functions
# related to registering and discovering launcher actions.
addons_manager = self._get_addons_manager()
actions_paths = addons_manager.collect_launcher_action_paths()
for path in actions_paths:
if path and os.path.exists(path):
register_launcher_action_path(path)
self._discovered_actions = (
discover_launcher_actions()
+ self._get_applications_action_classes()

View file

@ -202,8 +202,9 @@ class LauncherWindow(QtWidgets.QWidget):
self._go_to_hierarchy_page(project_name)
def _on_projects_refresh(self):
# There is nothing to do, we're on projects page
# Refresh only actions on projects page
if self._is_on_projects_page:
self._actions_widget.refresh()
return
# No projects were found -> go back to projects page

View file

@ -372,17 +372,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
repre_ids = set()
for container in containers:
repre_id = container.get("representation")
# Ignore invalid representation ids.
# - invalid representation ids may be available if e.g. is
# opened scene from OpenPype whe 'ObjectId' was used instead
# of 'uuid'.
# NOTE: Server call would crash if there is any invalid id.
# That would cause crash we won't get any information.
try:
repre_id = container.get("representation")
# Ignore invalid representation ids.
# - invalid representation ids may be available if e.g. is
# opened scene from OpenPype whe 'ObjectId' was used
# instead of 'uuid'.
# NOTE: Server call would crash if there is any invalid id.
# That would cause crash we won't get any information.
uuid.UUID(repre_id)
repre_ids.add(repre_id)
except ValueError:
except (ValueError, TypeError, AttributeError):
pass
product_ids = self._products_model.get_product_ids_by_repre_ids(

View file

@ -517,7 +517,11 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
def setItemCheckState(self, index, state):
self.setItemData(index, state, QtCore.Qt.CheckStateRole)
def set_value(self, values: Optional[Iterable[Any]], role: Optional[int] = None):
def set_value(
self,
values: Optional[Iterable[Any]],
role: Optional[int] = None,
):
if role is None:
role = self._value_role

View file

@ -222,6 +222,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
editor = VersionComboBox(product_id, parent)
editor.setProperty("itemId", item_id)
editor.setFocusPolicy(QtCore.Qt.NoFocus)
editor.value_changed.connect(self._on_editor_change)
editor.destroyed.connect(self._on_destroy)

View file

@ -499,8 +499,10 @@ class ProductsModel(QtGui.QStandardItemModel):
version_item.version_id
for version_item in last_version_by_product_id.values()
}
repre_count_by_version_id = self._controller.get_versions_representation_count(
project_name, version_ids
repre_count_by_version_id = (
self._controller.get_versions_representation_count(
project_name, version_ids
)
)
sync_availability_by_version_id = (
self._controller.get_version_sync_availability(

View file

@ -366,7 +366,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
@abstractmethod
def get_creator_attribute_definitions(
self, instance_ids: Iterable[str]
) -> List[Tuple[AbstractAttrDef, List[str], List[Any]]]:
) -> List[Tuple[AbstractAttrDef, Dict[str, Dict[str, Any]]]]:
pass
@abstractmethod
@ -375,6 +375,14 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
):
pass
@abstractmethod
def revert_instances_create_attr_values(
self,
instance_ids: List["Union[str, None]"],
key: str,
):
pass
@abstractmethod
def get_publish_attribute_definitions(
self,
@ -383,7 +391,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
) -> List[Tuple[
str,
List[AbstractAttrDef],
Dict[str, List[Tuple[str, Any]]]
Dict[str, List[Tuple[str, Any, Any]]]
]]:
pass
@ -397,6 +405,15 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
):
pass
@abstractmethod
def revert_instances_publish_attr_values(
self,
instance_ids: List["Union[str, None]"],
plugin_name: str,
key: str,
):
pass
@abstractmethod
def get_product_name(
self,

View file

@ -412,6 +412,11 @@ class PublisherController(
instance_ids, key, value
)
def revert_instances_create_attr_values(self, instance_ids, key):
self._create_model.revert_instances_create_attr_values(
instance_ids, key
)
def get_publish_attribute_definitions(self, instance_ids, include_context):
"""Collect publish attribute definitions for passed instances.
@ -432,6 +437,13 @@ class PublisherController(
instance_ids, plugin_name, key, value
)
def revert_instances_publish_attr_values(
self, instance_ids, plugin_name, key
):
return self._create_model.revert_instances_publish_attr_values(
instance_ids, plugin_name, key
)
def get_product_name(
self,
creator_identifier,

View file

@ -40,6 +40,7 @@ from ayon_core.tools.publisher.abstract import (
)
CREATE_EVENT_SOURCE = "publisher.create.model"
_DEFAULT_VALUE = object()
class CreatorType:
@ -295,7 +296,7 @@ class InstanceItem:
return InstanceItem(
instance.id,
instance.creator_identifier,
instance.label,
instance.label or "N/A",
instance.group_label,
instance.product_type,
instance.product_name,
@ -752,24 +753,16 @@ class CreateModel:
self._remove_instances_from_context(instance_ids)
def set_instances_create_attr_values(self, instance_ids, key, value):
with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE):
for instance_id in instance_ids:
instance = self._get_instance_by_id(instance_id)
creator_attributes = instance["creator_attributes"]
attr_def = creator_attributes.get_attr_def(key)
if (
attr_def is None
or not attr_def.is_value_def
or not attr_def.visible
or not attr_def.enabled
or not attr_def.is_value_valid(value)
):
continue
creator_attributes[key] = value
self._set_instances_create_attr_values(instance_ids, key, value)
def revert_instances_create_attr_values(self, instance_ids, key):
self._set_instances_create_attr_values(
instance_ids, key, _DEFAULT_VALUE
)
def get_creator_attribute_definitions(
self, instance_ids: List[str]
) -> List[Tuple[AbstractAttrDef, List[str], List[Any]]]:
) -> List[Tuple[AbstractAttrDef, Dict[str, Dict[str, Any]]]]:
"""Collect creator attribute definitions for multuple instances.
Args:
@ -796,37 +789,38 @@ class CreateModel:
if found_idx is None:
idx = len(output)
output.append((attr_def, [instance_id], [value]))
output.append((
attr_def,
{
instance_id: {
"value": value,
"default": attr_def.default
}
}
))
_attr_defs[idx] = attr_def
else:
_, ids, values = output[found_idx]
ids.append(instance_id)
values.append(value)
_, info_by_id = output[found_idx]
info_by_id[instance_id] = {
"value": value,
"default": attr_def.default
}
return output
def set_instances_publish_attr_values(
self, instance_ids, plugin_name, key, value
self, instance_ids, plugin_name, key, value
):
with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE):
for instance_id in instance_ids:
if instance_id is None:
instance = self._create_context
else:
instance = self._get_instance_by_id(instance_id)
plugin_val = instance.publish_attributes[plugin_name]
attr_def = plugin_val.get_attr_def(key)
# Ignore if attribute is not available or enabled/visible
# on the instance, or the value is not valid for definition
if (
attr_def is None
or not attr_def.is_value_def
or not attr_def.visible
or not attr_def.enabled
or not attr_def.is_value_valid(value)
):
continue
self._set_instances_publish_attr_values(
instance_ids, plugin_name, key, value
)
plugin_val[key] = value
def revert_instances_publish_attr_values(
self, instance_ids, plugin_name, key
):
self._set_instances_publish_attr_values(
instance_ids, plugin_name, key, _DEFAULT_VALUE
)
def get_publish_attribute_definitions(
self,
@ -835,7 +829,7 @@ class CreateModel:
) -> List[Tuple[
str,
List[AbstractAttrDef],
Dict[str, List[Tuple[str, Any]]]
Dict[str, List[Tuple[str, Any, Any]]]
]]:
"""Collect publish attribute definitions for passed instances.
@ -865,21 +859,21 @@ class CreateModel:
attr_defs = attr_val.attr_defs
if not attr_defs:
continue
plugin_attr_defs = all_defs_by_plugin_name.setdefault(
plugin_name, []
)
plugin_attr_defs.append(attr_defs)
plugin_values = all_plugin_values.setdefault(plugin_name, {})
plugin_attr_defs.append(attr_defs)
for attr_def in attr_defs:
if isinstance(attr_def, UIDef):
continue
attr_values = plugin_values.setdefault(attr_def.key, [])
value = attr_val[attr_def.key]
attr_values.append((item_id, value))
attr_values.append(
(item_id, attr_val[attr_def.key], attr_def.default)
)
attr_defs_by_plugin_name = {}
for plugin_name, attr_defs in all_defs_by_plugin_name.items():
@ -893,7 +887,7 @@ class CreateModel:
output.append((
plugin_name,
attr_defs_by_plugin_name[plugin_name],
all_plugin_values
all_plugin_values[plugin_name],
))
return output
@ -1053,6 +1047,53 @@ class CreateModel:
CreatorItem.from_creator(creator)
)
def _set_instances_create_attr_values(self, instance_ids, key, value):
with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE):
for instance_id in instance_ids:
instance = self._get_instance_by_id(instance_id)
creator_attributes = instance["creator_attributes"]
attr_def = creator_attributes.get_attr_def(key)
if (
attr_def is None
or not attr_def.is_value_def
or not attr_def.visible
or not attr_def.enabled
):
continue
if value is _DEFAULT_VALUE:
creator_attributes[key] = attr_def.default
elif attr_def.is_value_valid(value):
creator_attributes[key] = value
def _set_instances_publish_attr_values(
self, instance_ids, plugin_name, key, value
):
with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE):
for instance_id in instance_ids:
if instance_id is None:
instance = self._create_context
else:
instance = self._get_instance_by_id(instance_id)
plugin_val = instance.publish_attributes[plugin_name]
attr_def = plugin_val.get_attr_def(key)
# Ignore if attribute is not available or enabled/visible
# on the instance, or the value is not valid for definition
if (
attr_def is None
or not attr_def.is_value_def
or not attr_def.visible
or not attr_def.enabled
):
continue
if value is _DEFAULT_VALUE:
plugin_val[key] = attr_def.default
elif attr_def.is_value_valid(value):
plugin_val[key] = value
def _cc_added_instance(self, event):
instance_ids = {
instance.id

View file

@ -484,6 +484,34 @@ class LoadedFilesView(QtWidgets.QTreeView):
self._time_delegate = time_delegate
self._remove_btn = remove_btn
def showEvent(self, event):
super().showEvent(event)
self._model.refresh()
header = self.header()
header.resizeSections(QtWidgets.QHeaderView.ResizeToContents)
self._update_remove_btn()
def resizeEvent(self, event):
super().resizeEvent(event)
self._update_remove_btn()
def add_filepaths(self, filepaths):
self._model.add_filepaths(filepaths)
self._fill_selection()
def remove_item_by_id(self, item_id):
self._model.remove_item_by_id(item_id)
self._fill_selection()
def get_current_report(self):
index = self.currentIndex()
item_id = index.data(ITEM_ID_ROLE)
return self._model.get_report_by_id(item_id)
def refresh(self):
self._model.refresh()
self._fill_selection()
def _update_remove_btn(self):
viewport = self.viewport()
height = viewport.height() + self.header().height()
@ -496,28 +524,9 @@ class LoadedFilesView(QtWidgets.QTreeView):
header.resizeSections(QtWidgets.QHeaderView.ResizeToContents)
self._update_remove_btn()
def resizeEvent(self, event):
super().resizeEvent(event)
self._update_remove_btn()
def showEvent(self, event):
super().showEvent(event)
self._model.refresh()
header = self.header()
header.resizeSections(QtWidgets.QHeaderView.ResizeToContents)
self._update_remove_btn()
def _on_selection_change(self):
self.selection_changed.emit()
def add_filepaths(self, filepaths):
self._model.add_filepaths(filepaths)
self._fill_selection()
def remove_item_by_id(self, item_id):
self._model.remove_item_by_id(item_id)
self._fill_selection()
def _on_remove_clicked(self):
index = self.currentIndex()
item_id = index.data(ITEM_ID_ROLE)
@ -533,11 +542,6 @@ class LoadedFilesView(QtWidgets.QTreeView):
if index.isValid():
self.setCurrentIndex(index)
def get_current_report(self):
index = self.currentIndex()
item_id = index.data(ITEM_ID_ROLE)
return self._model.get_report_by_id(item_id)
class LoadedFilesWidget(QtWidgets.QWidget):
report_changed = QtCore.Signal()
@ -577,15 +581,18 @@ class LoadedFilesWidget(QtWidgets.QWidget):
self._add_filepaths(filepaths)
event.accept()
def refresh(self):
self._view.refresh()
def get_current_report(self):
return self._view.get_current_report()
def _on_report_change(self):
self.report_changed.emit()
def _add_filepaths(self, filepaths):
self._view.add_filepaths(filepaths)
def get_current_report(self):
return self._view.get_current_report()
class PublishReportViewerWindow(QtWidgets.QWidget):
default_width = 1200
@ -624,9 +631,12 @@ class PublishReportViewerWindow(QtWidgets.QWidget):
self.resize(self.default_width, self.default_height)
self.setStyleSheet(style.load_stylesheet())
def _on_report_change(self):
report = self._loaded_files_widget.get_current_report()
self.set_report(report)
def refresh(self):
self._loaded_files_widget.refresh()
def set_report(self, report_data):
self._main_widget.set_report(report_data)
def _on_report_change(self):
report = self._loaded_files_widget.get_current_report()
self.set_report(report)

View file

@ -339,7 +339,9 @@ class OverviewWidget(QtWidgets.QFrame):
self._change_visibility_for_state()
self._product_content_layout.addWidget(self._create_widget, 7)
self._product_content_layout.addWidget(self._product_views_widget, 3)
self._product_content_layout.addWidget(self._product_attributes_wrap, 7)
self._product_content_layout.addWidget(
self._product_attributes_wrap, 7
)
def _change_visibility_for_state(self):
self._create_widget.setVisible(

View file

@ -1,13 +1,58 @@
import typing
from typing import Dict, List, Any
from qtpy import QtWidgets, QtCore
from ayon_core.lib.attribute_definitions import UnknownDef
from ayon_core.tools.attribute_defs import create_widget_for_attr_def
from ayon_core.lib.attribute_definitions import AbstractAttrDef, UnknownDef
from ayon_core.tools.attribute_defs import (
create_widget_for_attr_def,
AttributeDefinitionsLabel,
)
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
from ayon_core.tools.publisher.constants import (
INPUTS_LAYOUT_HSPACING,
INPUTS_LAYOUT_VSPACING,
)
if typing.TYPE_CHECKING:
from typing import Union
class _CreateAttrDefInfo:
"""Helper class to store information about create attribute definition."""
def __init__(
self,
attr_def: AbstractAttrDef,
instance_ids: List["Union[str, None]"],
defaults: List[Any],
label_widget: "Union[AttributeDefinitionsLabel, None]",
):
self.attr_def: AbstractAttrDef = attr_def
self.instance_ids: List["Union[str, None]"] = instance_ids
self.defaults: List[Any] = defaults
self.label_widget: "Union[AttributeDefinitionsLabel, None]" = (
label_widget
)
class _PublishAttrDefInfo:
"""Helper class to store information about publish attribute definition."""
def __init__(
self,
attr_def: AbstractAttrDef,
plugin_name: str,
instance_ids: List["Union[str, None]"],
defaults: List[Any],
label_widget: "Union[AttributeDefinitionsLabel, None]",
):
self.attr_def: AbstractAttrDef = attr_def
self.plugin_name: str = plugin_name
self.instance_ids: List["Union[str, None]"] = instance_ids
self.defaults: List[Any] = defaults
self.label_widget: "Union[AttributeDefinitionsLabel, None]" = (
label_widget
)
class CreatorAttrsWidget(QtWidgets.QWidget):
"""Widget showing creator specific attributes for selected instances.
@ -51,8 +96,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
self._controller: AbstractPublisherFrontend = controller
self._scroll_area = scroll_area
self._attr_def_id_to_instances = {}
self._attr_def_id_to_attr_def = {}
self._attr_def_info_by_id: Dict[str, _CreateAttrDefInfo] = {}
self._current_instance_ids = set()
# To store content of scroll area to prevent garbage collection
@ -81,8 +125,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
prev_content_widget.deleteLater()
self._content_widget = None
self._attr_def_id_to_instances = {}
self._attr_def_id_to_attr_def = {}
self._attr_def_info_by_id = {}
result = self._controller.get_creator_attribute_definitions(
self._current_instance_ids
@ -97,9 +140,21 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
content_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING)
row = 0
for attr_def, instance_ids, values in result:
widget = create_widget_for_attr_def(attr_def, content_widget)
for attr_def, info_by_id in result:
widget = create_widget_for_attr_def(
attr_def, content_widget, handle_revert_to_default=False
)
default_values = []
if attr_def.is_value_def:
values = []
for item in info_by_id.values():
values.append(item["value"])
# 'set' cannot be used for default values because they can
# be unhashable types, e.g. 'list'.
default = item["default"]
if default not in default_values:
default_values.append(default)
if len(values) == 1:
value = values[0]
if value is not None:
@ -108,8 +163,13 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
widget.set_value(values, True)
widget.value_changed.connect(self._input_value_changed)
self._attr_def_id_to_instances[attr_def.id] = instance_ids
self._attr_def_id_to_attr_def[attr_def.id] = attr_def
widget.revert_to_default_requested.connect(
self._on_request_revert_to_default
)
attr_def_info = _CreateAttrDefInfo(
attr_def, list(info_by_id), default_values, None
)
self._attr_def_info_by_id[attr_def.id] = attr_def_info
if not attr_def.visible:
continue
@ -121,10 +181,18 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
col_num = 2 - expand_cols
label = None
is_overriden = False
if attr_def.is_value_def:
is_overriden = any(
item["value"] != item["default"]
for item in info_by_id.values()
)
label = attr_def.label or attr_def.key
if label:
label_widget = QtWidgets.QLabel(label, self)
label_widget = AttributeDefinitionsLabel(
attr_def.id, label, self
)
tooltip = attr_def.tooltip
if tooltip:
label_widget.setToolTip(tooltip)
@ -138,6 +206,11 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
)
if not attr_def.is_label_horizontal:
row += 1
attr_def_info.label_widget = label_widget
label_widget.set_overridden(is_overriden)
label_widget.revert_to_default_requested.connect(
self._on_request_revert_to_default
)
content_layout.addWidget(
widget, row, col_num, 1, expand_cols
@ -159,20 +232,37 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
for instance_id, changes in event["instance_changes"].items():
if (
instance_id in self._current_instance_ids
and "creator_attributes" not in changes
and "creator_attributes" in changes
):
self._refresh_content()
break
def _input_value_changed(self, value, attr_id):
instance_ids = self._attr_def_id_to_instances.get(attr_id)
attr_def = self._attr_def_id_to_attr_def.get(attr_id)
if not instance_ids or not attr_def:
attr_def_info = self._attr_def_info_by_id.get(attr_id)
if attr_def_info is None:
return
if attr_def_info.label_widget is not None:
defaults = attr_def_info.defaults
is_overriden = len(defaults) != 1 or value not in defaults
attr_def_info.label_widget.set_overridden(is_overriden)
self._controller.set_instances_create_attr_values(
instance_ids, attr_def.key, value
attr_def_info.instance_ids,
attr_def_info.attr_def.key,
value
)
def _on_request_revert_to_default(self, attr_id):
attr_def_info = self._attr_def_info_by_id.get(attr_id)
if attr_def_info is None:
return
self._controller.revert_instances_create_attr_values(
attr_def_info.instance_ids,
attr_def_info.attr_def.key,
)
self._refresh_content()
class PublishPluginAttrsWidget(QtWidgets.QWidget):
"""Widget showing publish plugin attributes for selected instances.
@ -223,9 +313,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
self._controller: AbstractPublisherFrontend = controller
self._scroll_area = scroll_area
self._attr_def_id_to_instances = {}
self._attr_def_id_to_attr_def = {}
self._attr_def_id_to_plugin_name = {}
self._attr_def_info_by_id: Dict[str, _PublishAttrDefInfo] = {}
# Store content of scroll area to prevent garbage collection
self._content_widget = None
@ -254,9 +342,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
self._content_widget = None
self._attr_def_id_to_instances = {}
self._attr_def_id_to_attr_def = {}
self._attr_def_id_to_plugin_name = {}
self._attr_def_info_by_id = {}
result = self._controller.get_publish_attribute_definitions(
self._current_instance_ids, self._context_selected
@ -275,12 +361,10 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
content_layout.addStretch(1)
row = 0
for plugin_name, attr_defs, all_plugin_values in result:
plugin_values = all_plugin_values[plugin_name]
for plugin_name, attr_defs, plugin_values in result:
for attr_def in attr_defs:
widget = create_widget_for_attr_def(
attr_def, content_widget
attr_def, content_widget, handle_revert_to_default=False
)
visible_widget = attr_def.visible
# Hide unknown values of publish plugins
@ -290,6 +374,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
widget.setVisible(False)
visible_widget = False
label_widget = None
if visible_widget:
expand_cols = 2
if attr_def.is_value_def and attr_def.is_label_horizontal:
@ -300,7 +385,12 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
if attr_def.is_value_def:
label = attr_def.label or attr_def.key
if label:
label_widget = QtWidgets.QLabel(label, content_widget)
label_widget = AttributeDefinitionsLabel(
attr_def.id, label, content_widget
)
label_widget.revert_to_default_requested.connect(
self._on_request_revert_to_default
)
tooltip = attr_def.tooltip
if tooltip:
label_widget.setToolTip(tooltip)
@ -323,38 +413,76 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
continue
widget.value_changed.connect(self._input_value_changed)
widget.revert_to_default_requested.connect(
self._on_request_revert_to_default
)
attr_values = plugin_values[attr_def.key]
multivalue = len(attr_values) > 1
instance_ids = []
values = []
instances = []
for instance, value in attr_values:
default_values = []
is_overriden = False
for (instance_id, value, default_value) in (
plugin_values.get(attr_def.key, [])
):
instance_ids.append(instance_id)
values.append(value)
instances.append(instance)
if not is_overriden and value != default_value:
is_overriden = True
# 'set' cannot be used for default values because they can
# be unhashable types, e.g. 'list'.
if default_value not in default_values:
default_values.append(default_value)
self._attr_def_id_to_attr_def[attr_def.id] = attr_def
self._attr_def_id_to_instances[attr_def.id] = instances
self._attr_def_id_to_plugin_name[attr_def.id] = plugin_name
multivalue = len(values) > 1
self._attr_def_info_by_id[attr_def.id] = _PublishAttrDefInfo(
attr_def,
plugin_name,
instance_ids,
default_values,
label_widget,
)
if multivalue:
widget.set_value(values, multivalue)
else:
widget.set_value(values[0])
if label_widget is not None:
label_widget.set_overridden(is_overriden)
self._scroll_area.setWidget(content_widget)
self._content_widget = content_widget
def _input_value_changed(self, value, attr_id):
instance_ids = self._attr_def_id_to_instances.get(attr_id)
attr_def = self._attr_def_id_to_attr_def.get(attr_id)
plugin_name = self._attr_def_id_to_plugin_name.get(attr_id)
if not instance_ids or not attr_def or not plugin_name:
attr_def_info = self._attr_def_info_by_id.get(attr_id)
if attr_def_info is None:
return
if attr_def_info.label_widget is not None:
defaults = attr_def_info.defaults
is_overriden = len(defaults) != 1 or value not in defaults
attr_def_info.label_widget.set_overridden(is_overriden)
self._controller.set_instances_publish_attr_values(
instance_ids, plugin_name, attr_def.key, value
attr_def_info.instance_ids,
attr_def_info.plugin_name,
attr_def_info.attr_def.key,
value
)
def _on_request_revert_to_default(self, attr_id):
attr_def_info = self._attr_def_info_by_id.get(attr_id)
if attr_def_info is None:
return
self._controller.revert_instances_publish_attr_values(
attr_def_info.instance_ids,
attr_def_info.plugin_name,
attr_def_info.attr_def.key,
)
self._refresh_content()
def _on_instance_attr_defs_change(self, event):
for instance_id in event.data:
if (
@ -370,7 +498,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget):
for instance_id, changes in event["instance_changes"].items():
if (
instance_id in self._current_instance_ids
and "publish_attributes" not in changes
and "publish_attributes" in changes
):
self._refresh_content()
break

View file

@ -214,8 +214,8 @@ class TasksCombobox(QtWidgets.QComboBox):
Combobox gives ability to select only from intersection of task names for
folder paths in selected instances.
If folder paths in selected instances does not have same tasks then combobox
will be empty.
If folder paths in selected instances does not have same tasks
then combobox will be empty.
"""
value_changed = QtCore.Signal()
@ -604,7 +604,7 @@ class VariantInputWidget(PlaceholderLineEdit):
class GlobalAttrsWidget(QtWidgets.QWidget):
"""Global attributes mainly to define context and product name of instances.
"""Global attributes to define context and product name of instances.
product name is or may be affected on context. Gives abiity to modify
context and product name of instance. This change is not autopromoted but

View file

@ -22,8 +22,8 @@ class TasksModel(QtGui.QStandardItemModel):
tasks with same names then model is empty too.
Args:
controller (AbstractPublisherFrontend): Controller which handles creation and
publishing.
controller (AbstractPublisherFrontend): Controller which handles
creation and publishing.
"""
def __init__(

View file

@ -998,7 +998,11 @@ class PublisherWindow(QtWidgets.QDialog):
new_item["label"] = new_item.pop("creator_label")
new_item["identifier"] = new_item.pop("creator_identifier")
new_failed_info.append(new_item)
self.add_error_message_dialog(event["title"], new_failed_info, "Creator:")
self.add_error_message_dialog(
event["title"],
new_failed_info,
"Creator:"
)
def _on_convertor_error(self, event):
new_failed_info = []

View file

@ -321,7 +321,7 @@ class PushToContextController:
return False
if (
not self._user_values.new_folder_name
self._user_values.new_folder_name is None
and not self._selection_model.get_selected_folder_id()
):
return False

View file

@ -26,7 +26,7 @@ from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.version_start import get_versioning_start
from ayon_core.pipeline.template_data import get_template_data
from ayon_core.pipeline.publish import get_publish_template_name
from ayon_core.pipeline.create import get_product_name
from ayon_core.pipeline.create import get_product_name, TaskNotSetError
UNKNOWN = object()
@ -823,15 +823,25 @@ class ProjectPushItemProcess:
task_name = task_info["name"]
task_type = task_info["taskType"]
product_name = get_product_name(
self._item.dst_project_name,
task_name,
task_type,
self.host_name,
product_type,
self._item.variant,
project_settings=self._project_settings
)
try:
product_name = get_product_name(
self._item.dst_project_name,
task_name,
task_type,
self.host_name,
product_type,
self._item.variant,
project_settings=self._project_settings
)
except TaskNotSetError:
self._status.set_failed(
"Target product name template requires task name. To continue"
" you have to select target task or change settings"
" <b>ayon+settings://core/tools/creator/product_name_profiles"
f"?project={self._item.dst_project_name}</b>."
)
raise PushToProjectError(self._status.fail_reason)
self._log_info(
f"Push will be integrating to product with name '{product_name}'"
)

View file

@ -84,8 +84,11 @@ class UserPublishValuesModel:
return
self._new_folder_name = folder_name
is_valid = True
if folder_name:
if folder_name is None:
is_valid = True
elif not folder_name:
is_valid = False
else:
is_valid = (
self.folder_name_regex.match(folder_name) is not None
)

View file

@ -8,12 +8,69 @@ from ayon_core.tools.utils import (
ProjectsCombobox,
FoldersWidget,
TasksWidget,
NiceCheckbox,
)
from ayon_core.tools.push_to_project.control import (
PushToContextController,
)
class ErrorDetailDialog(QtWidgets.QDialog):
def __init__(self, parent):
super().__init__(parent)
self.setWindowTitle("Error detail")
self.setWindowIcon(QtGui.QIcon(get_app_icon_path()))
title_label = QtWidgets.QLabel(self)
sep_1 = SeparatorWidget(parent=self)
detail_widget = QtWidgets.QTextBrowser(self)
detail_widget.setReadOnly(True)
detail_widget.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction
)
sep_2 = SeparatorWidget(parent=self)
btns_widget = QtWidgets.QWidget(self)
copy_btn = QtWidgets.QPushButton("Copy", btns_widget)
close_btn = QtWidgets.QPushButton("Close", btns_widget)
btns_layout = QtWidgets.QHBoxLayout(btns_widget)
btns_layout.setContentsMargins(0, 0, 0, 0)
btns_layout.addStretch(1)
btns_layout.addWidget(copy_btn, 0)
btns_layout.addWidget(close_btn, 0)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.addWidget(title_label, 0)
main_layout.addWidget(sep_1, 0)
main_layout.addWidget(detail_widget, 1)
main_layout.addWidget(sep_2, 0)
main_layout.addWidget(btns_widget, 0)
copy_btn.clicked.connect(self._on_copy_click)
close_btn.clicked.connect(self._on_close_click)
self._title_label = title_label
self._detail_widget = detail_widget
def set_detail(self, title, detail):
self._title_label.setText(title)
self._detail_widget.setText(detail)
def _on_copy_click(self):
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(self._detail_widget.toPlainText())
def _on_close_click(self):
self.close()
class PushToContextSelectWindow(QtWidgets.QWidget):
def __init__(self, controller=None):
super(PushToContextSelectWindow, self).__init__()
@ -66,9 +123,12 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
# --- Inputs widget ---
inputs_widget = QtWidgets.QWidget(main_splitter)
new_folder_checkbox = NiceCheckbox(True, parent=inputs_widget)
folder_name_input = PlaceholderLineEdit(inputs_widget)
folder_name_input.setPlaceholderText("< Name of new folder >")
folder_name_input.setObjectName("ValidatedLineEdit")
folder_name_input.setEnabled(new_folder_checkbox.isChecked())
variant_input = PlaceholderLineEdit(inputs_widget)
variant_input.setPlaceholderText("< Variant >")
@ -79,6 +139,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
inputs_layout = QtWidgets.QFormLayout(inputs_widget)
inputs_layout.setContentsMargins(0, 0, 0, 0)
inputs_layout.addRow("Create new folder", new_folder_checkbox)
inputs_layout.addRow("New folder name", folder_name_input)
inputs_layout.addRow("Variant", variant_input)
inputs_layout.addRow("Comment", comment_input)
@ -113,6 +174,10 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
overlay_label = QtWidgets.QLabel(overlay_widget)
overlay_label.setAlignment(QtCore.Qt.AlignCenter)
overlay_label.setWordWrap(True)
overlay_label.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction
)
overlay_btns_widget = QtWidgets.QWidget(overlay_widget)
overlay_btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
@ -121,13 +186,28 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
overlay_try_btn = QtWidgets.QPushButton(
"Try again", overlay_btns_widget
)
overlay_try_btn.setToolTip(
"Hide overlay and modify submit information."
)
show_detail_btn = QtWidgets.QPushButton(
"Show error detail", overlay_btns_widget
)
show_detail_btn.setToolTip(
"Show error detail dialog to copy full error."
)
overlay_close_btn = QtWidgets.QPushButton(
"Close", overlay_btns_widget
)
overlay_close_btn.setToolTip("Discard changes and close window.")
overlay_btns_layout = QtWidgets.QHBoxLayout(overlay_btns_widget)
overlay_btns_layout.setContentsMargins(0, 0, 0, 0)
overlay_btns_layout.setSpacing(10)
overlay_btns_layout.addStretch(1)
overlay_btns_layout.addWidget(overlay_try_btn, 0)
overlay_btns_layout.addWidget(show_detail_btn, 0)
overlay_btns_layout.addWidget(overlay_close_btn, 0)
overlay_btns_layout.addStretch(1)
@ -156,12 +236,14 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
main_thread_timer.timeout.connect(self._on_main_thread_timer)
show_timer.timeout.connect(self._on_show_timer)
user_input_changed_timer.timeout.connect(self._on_user_input_timer)
new_folder_checkbox.stateChanged.connect(self._on_new_folder_check)
folder_name_input.textChanged.connect(self._on_new_folder_change)
variant_input.textChanged.connect(self._on_variant_change)
comment_input.textChanged.connect(self._on_comment_change)
publish_btn.clicked.connect(self._on_select_click)
cancel_btn.clicked.connect(self._on_close_click)
show_detail_btn.clicked.connect(self._on_show_detail_click)
overlay_close_btn.clicked.connect(self._on_close_click)
overlay_try_btn.clicked.connect(self._on_try_again_click)
@ -203,23 +285,28 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._tasks_widget = tasks_widget
self._variant_input = variant_input
self._new_folder_checkbox = new_folder_checkbox
self._folder_name_input = folder_name_input
self._comment_input = comment_input
self._publish_btn = publish_btn
self._overlay_widget = overlay_widget
self._show_detail_btn = show_detail_btn
self._overlay_close_btn = overlay_close_btn
self._overlay_try_btn = overlay_try_btn
self._overlay_label = overlay_label
self._error_detail_dialog = ErrorDetailDialog(self)
self._user_input_changed_timer = user_input_changed_timer
# Store current value on input text change
# The value is unset when is passed to controller
# The goal is to have controll over changes happened during user change
# in UI and controller auto-changes
self._variant_input_text = None
self._new_folder_name_enabled = None
self._new_folder_name_input_text = None
self._variant_input_text = None
self._comment_input_text = None
self._first_show = True
@ -235,6 +322,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._folder_is_valid = None
publish_btn.setEnabled(False)
show_detail_btn.setVisible(False)
overlay_close_btn.setVisible(False)
overlay_try_btn.setVisible(False)
@ -289,6 +377,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self.refresh()
def _on_new_folder_check(self):
self._new_folder_name_enabled = self._new_folder_checkbox.isChecked()
self._folder_name_input.setEnabled(self._new_folder_name_enabled)
self._user_input_changed_timer.start()
def _on_new_folder_change(self, text):
self._new_folder_name_input_text = text
self._user_input_changed_timer.start()
@ -302,9 +395,15 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._user_input_changed_timer.start()
def _on_user_input_timer(self):
folder_name_enabled = self._new_folder_name_enabled
folder_name = self._new_folder_name_input_text
if folder_name is not None:
if folder_name is not None or folder_name_enabled is not None:
self._new_folder_name_input_text = None
self._new_folder_name_enabled = None
if not self._new_folder_checkbox.isChecked():
folder_name = None
elif folder_name is None:
folder_name = self._folder_name_input.text()
self._controller.set_user_value_folder_name(folder_name)
variant = self._variant_input_text
@ -350,16 +449,13 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._header_label.setText(self._controller.get_source_label())
def _invalidate_new_folder_name(self, folder_name, is_valid):
self._tasks_widget.setVisible(not folder_name)
self._tasks_widget.setVisible(folder_name is None)
if self._folder_is_valid is is_valid:
return
self._folder_is_valid = is_valid
state = ""
if folder_name:
if is_valid is True:
state = "valid"
elif is_valid is False:
state = "invalid"
if folder_name is not None:
state = "valid" if is_valid else "invalid"
set_style_property(
self._folder_name_input, "state", state
)
@ -374,6 +470,9 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
def _on_submission_change(self, event):
self._publish_btn.setEnabled(event["enabled"])
def _on_show_detail_click(self):
self._error_detail_dialog.show()
def _on_close_click(self):
self.close()
@ -384,8 +483,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._process_item_id = None
self._last_submit_message = None
self._error_detail_dialog.close()
self._overlay_close_btn.setVisible(False)
self._overlay_try_btn.setVisible(False)
self._show_detail_btn.setVisible(False)
self._main_layout.setCurrentWidget(self._main_context_widget)
def _on_main_thread_timer(self):
@ -401,13 +503,24 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
if self._main_thread_timer_can_stop:
self._main_thread_timer.stop()
self._overlay_close_btn.setVisible(True)
if push_failed and not fail_traceback:
if push_failed:
self._overlay_try_btn.setVisible(True)
if fail_traceback:
self._show_detail_btn.setVisible(True)
if push_failed:
message = "Push Failed:\n{}".format(process_status["fail_reason"])
reason = process_status["fail_reason"]
if fail_traceback:
message += "\n{}".format(fail_traceback)
message = (
"Unhandled error happened."
" Check error detail for more information."
)
self._error_detail_dialog.set_detail(
reason, fail_traceback
)
else:
message = f"Push Failed:\n{reason}"
self._overlay_label.setText(message)
set_style_property(self._overlay_close_btn, "state", "error")

View file

@ -86,8 +86,9 @@ class SceneInventoryController:
self._current_folder_set = True
return self._current_folder_id
def get_project_status_items(self):
project_name = self.get_current_project_name()
def get_project_status_items(self, project_name=None):
if project_name is None:
project_name = self.get_current_project_name()
return self._projects_model.get_project_status_items(
project_name, None
)
@ -105,32 +106,39 @@ class SceneInventoryController:
def get_container_items_by_id(self, item_ids):
return self._containers_model.get_container_items_by_id(item_ids)
def get_representation_info_items(self, representation_ids):
def get_representation_info_items(self, project_name, representation_ids):
return self._containers_model.get_representation_info_items(
representation_ids
project_name, representation_ids
)
def get_version_items(self, product_ids):
return self._containers_model.get_version_items(product_ids)
def get_version_items(self, project_name, product_ids):
return self._containers_model.get_version_items(
project_name, product_ids)
# Site Sync methods
def is_sitesync_enabled(self):
return self._sitesync_model.is_sitesync_enabled()
def get_sites_information(self):
return self._sitesync_model.get_sites_information()
def get_sites_information(self, project_name):
return self._sitesync_model.get_sites_information(project_name)
def get_site_provider_icons(self):
return self._sitesync_model.get_site_provider_icons()
def get_representations_site_progress(self, representation_ids):
def get_representations_site_progress(
self, project_name, representation_ids
):
return self._sitesync_model.get_representations_site_progress(
representation_ids
project_name, representation_ids
)
def resync_representations(self, representation_ids, site_type):
def resync_representations(
self, project_name, representation_ids, site_type
):
return self._sitesync_model.resync_representations(
representation_ids, site_type
project_name,
representation_ids,
site_type
)
# Switch dialog methods

View file

@ -36,6 +36,7 @@ REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 23
# This value hold unique value of container that should be used to identify
# containers inbetween refresh.
ITEM_UNIQUE_NAME_ROLE = QtCore.Qt.UserRole + 24
PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 25
class InventoryModel(QtGui.QStandardItemModel):
@ -52,6 +53,7 @@ class InventoryModel(QtGui.QStandardItemModel):
"Object name",
"Active site",
"Remote site",
"Project",
]
name_col = column_labels.index("Name")
version_col = column_labels.index("Version")
@ -63,6 +65,7 @@ class InventoryModel(QtGui.QStandardItemModel):
object_name_col = column_labels.index("Object name")
active_site_col = column_labels.index("Active site")
remote_site_col = column_labels.index("Remote site")
project_col = column_labels.index("Project")
display_role_by_column = {
name_col: QtCore.Qt.DisplayRole,
version_col: VERSION_LABEL_ROLE,
@ -72,6 +75,7 @@ class InventoryModel(QtGui.QStandardItemModel):
product_group_col: PRODUCT_GROUP_NAME_ROLE,
loader_col: LOADER_NAME_ROLE,
object_name_col: OBJECT_NAME_ROLE,
project_col: PROJECT_NAME_ROLE,
active_site_col: ACTIVE_SITE_PROGRESS_ROLE,
remote_site_col: REMOTE_SITE_PROGRESS_ROLE,
}
@ -85,7 +89,7 @@ class InventoryModel(QtGui.QStandardItemModel):
foreground_role_by_column = {
name_col: NAME_COLOR_ROLE,
version_col: VERSION_COLOR_ROLE,
status_col: STATUS_COLOR_ROLE
status_col: STATUS_COLOR_ROLE,
}
width_by_column = {
name_col: 250,
@ -95,6 +99,7 @@ class InventoryModel(QtGui.QStandardItemModel):
product_type_col: 150,
product_group_col: 120,
loader_col: 150,
project_col: 150,
}
OUTDATED_COLOR = QtGui.QColor(235, 30, 30)
@ -116,8 +121,8 @@ class InventoryModel(QtGui.QStandardItemModel):
self._default_icon_color = get_default_entity_icon_color()
self._last_project_statuses = {}
self._last_status_icons_by_name = {}
self._last_project_statuses = collections.defaultdict(dict)
self._last_status_icons_by_name = collections.defaultdict(dict)
def outdated(self, item):
return item.get("isOutdated", True)
@ -129,45 +134,73 @@ class InventoryModel(QtGui.QStandardItemModel):
self._clear_items()
items_by_repre_id = {}
project_names = set()
repre_ids_by_project = collections.defaultdict(set)
version_items_by_project = collections.defaultdict(dict)
repre_info_by_id_by_project = collections.defaultdict(dict)
item_by_repre_id_by_project = collections.defaultdict(
lambda: collections.defaultdict(list))
for container_item in container_items:
# if (
# selected is not None
# and container_item.item_id not in selected
# ):
# continue
repre_id = container_item.representation_id
items = items_by_repre_id.setdefault(repre_id, [])
items.append(container_item)
project_name = container_item.project_name
representation_id = container_item.representation_id
project_names.add(project_name)
repre_ids_by_project[project_name].add(representation_id)
(
item_by_repre_id_by_project
[project_name]
[representation_id]
).append(container_item)
for project_name, representation_ids in repre_ids_by_project.items():
repre_info = self._controller.get_representation_info_items(
project_name, representation_ids
)
repre_info_by_id_by_project[project_name] = repre_info
product_ids = {
repre_info.product_id
for repre_info in repre_info.values()
if repre_info.is_valid
}
version_items = self._controller.get_version_items(
project_name, product_ids
)
version_items_by_project[project_name] = version_items
repre_id = set(items_by_repre_id.keys())
repre_info_by_id = self._controller.get_representation_info_items(
repre_id
)
product_ids = {
repre_info.product_id
for repre_info in repre_info_by_id.values()
if repre_info.is_valid
}
version_items_by_product_id = self._controller.get_version_items(
product_ids
)
# SiteSync addon information
progress_by_id = self._controller.get_representations_site_progress(
repre_id
)
sites_info = self._controller.get_sites_information()
progress_by_project = {
project_name: self._controller.get_representations_site_progress(
project_name, repre_ids
)
for project_name, repre_ids in repre_ids_by_project.items()
}
sites_info_by_project_name = {
project_name: self._controller.get_sites_information(project_name)
for project_name in project_names
}
site_icons = {
provider: get_qt_icon(icon_def)
for provider, icon_def in (
self._controller.get_site_provider_icons().items()
)
}
self._last_project_statuses = {
status_item.name: status_item
for status_item in self._controller.get_project_status_items()
}
self._last_status_icons_by_name = {}
last_project_statuses = collections.defaultdict(dict)
for project_name in project_names:
status_items_by_name = {
status_item.name: status_item
for status_item in self._controller.get_project_status_items(
project_name
)
}
last_project_statuses[project_name] = status_items_by_name
self._last_project_statuses = last_project_statuses
self._last_status_icons_by_name = collections.defaultdict(dict)
group_item_icon = qtawesome.icon(
"fa.folder", color=self._default_icon_color
@ -187,117 +220,130 @@ class InventoryModel(QtGui.QStandardItemModel):
group_item_font = QtGui.QFont()
group_item_font.setBold(True)
active_site_icon = site_icons.get(sites_info["active_site_provider"])
remote_site_icon = site_icons.get(sites_info["remote_site_provider"])
root_item = self.invisibleRootItem()
group_items = []
for repre_id, container_items in items_by_repre_id.items():
repre_info = repre_info_by_id[repre_id]
version_label = "N/A"
version_color = None
is_latest = False
is_hero = False
status_name = None
if not repre_info.is_valid:
group_name = "< Entity N/A >"
item_icon = invalid_item_icon
for project_name, items_by_repre_id in (
item_by_repre_id_by_project.items()
):
sites_info = sites_info_by_project_name[project_name]
active_site_icon = site_icons.get(
sites_info["active_site_provider"]
)
remote_site_icon = site_icons.get(
sites_info["remote_site_provider"]
)
else:
group_name = "{}_{}: ({})".format(
repre_info.folder_path.rsplit("/")[-1],
repre_info.product_name,
repre_info.representation_name
progress_by_id = progress_by_project[project_name]
repre_info_by_id = repre_info_by_id_by_project[project_name]
version_items_by_product_id = (
version_items_by_project[project_name]
)
for repre_id, container_items in items_by_repre_id.items():
repre_info = repre_info_by_id[repre_id]
version_color = None
if not repre_info.is_valid:
version_label = "N/A"
group_name = "< Entity N/A >"
item_icon = invalid_item_icon
is_latest = False
is_hero = False
status_name = None
else:
group_name = "{}_{}: ({})".format(
repre_info.folder_path.rsplit("/")[-1],
repre_info.product_name,
repre_info.representation_name
)
item_icon = valid_item_icon
version_items = (
version_items_by_product_id[repre_info.product_id]
)
version_item = version_items[repre_info.version_id]
version_label = format_version(version_item.version)
is_hero = version_item.version < 0
is_latest = version_item.is_latest
if not version_item.is_latest:
version_color = self.OUTDATED_COLOR
status_name = version_item.status
(
status_color, status_short, status_icon
) = self._get_status_data(project_name, status_name)
repre_name = (
repre_info.representation_name or
"<unknown representation>"
)
item_icon = valid_item_icon
container_model_items = []
for container_item in container_items:
object_name = container_item.object_name or "<none>"
unique_name = repre_name + object_name
item = QtGui.QStandardItem()
item.setColumnCount(root_item.columnCount())
item.setData(container_item.namespace,
QtCore.Qt.DisplayRole)
item.setData(self.GRAYOUT_COLOR, NAME_COLOR_ROLE)
item.setData(self.GRAYOUT_COLOR, VERSION_COLOR_ROLE)
item.setData(item_icon, QtCore.Qt.DecorationRole)
item.setData(repre_info.product_id, PRODUCT_ID_ROLE)
item.setData(container_item.item_id, ITEM_ID_ROLE)
item.setData(version_label, VERSION_LABEL_ROLE)
item.setData(container_item.loader_name, LOADER_NAME_ROLE)
item.setData(container_item.object_name, OBJECT_NAME_ROLE)
item.setData(True, IS_CONTAINER_ITEM_ROLE)
item.setData(unique_name, ITEM_UNIQUE_NAME_ROLE)
container_model_items.append(item)
version_items = (
version_items_by_product_id[repre_info.product_id]
progress = progress_by_id[repre_id]
active_site_progress = "{}%".format(
max(progress["active_site"], 0) * 100
)
remote_site_progress = "{}%".format(
max(progress["remote_site"], 0) * 100
)
version_item = version_items[repre_info.version_id]
version_label = format_version(version_item.version)
is_hero = version_item.version < 0
if not version_item.is_latest:
version_color = self.OUTDATED_COLOR
status_name = version_item.status
status_color, status_short, status_icon = self._get_status_data(
status_name
)
group_item = QtGui.QStandardItem()
group_item.setColumnCount(root_item.columnCount())
group_item.setData(group_name, QtCore.Qt.DisplayRole)
group_item.setData(group_name, ITEM_UNIQUE_NAME_ROLE)
group_item.setData(group_item_icon, QtCore.Qt.DecorationRole)
group_item.setData(group_item_font, QtCore.Qt.FontRole)
group_item.setData(repre_info.product_id, PRODUCT_ID_ROLE)
group_item.setData(repre_info.product_type, PRODUCT_TYPE_ROLE)
group_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE)
group_item.setData(is_latest, VERSION_IS_LATEST_ROLE)
group_item.setData(is_hero, VERSION_IS_HERO_ROLE)
group_item.setData(version_label, VERSION_LABEL_ROLE)
group_item.setData(len(container_items), COUNT_ROLE)
group_item.setData(status_name, STATUS_NAME_ROLE)
group_item.setData(status_short, STATUS_SHORT_ROLE)
group_item.setData(status_color, STATUS_COLOR_ROLE)
group_item.setData(status_icon, STATUS_ICON_ROLE)
group_item.setData(project_name, PROJECT_NAME_ROLE)
repre_name = (
repre_info.representation_name or "<unknown representation>"
)
container_model_items = []
for container_item in container_items:
object_name = container_item.object_name or "<none>"
unique_name = repre_name + object_name
item = QtGui.QStandardItem()
item.setColumnCount(root_item.columnCount())
item.setData(container_item.namespace, QtCore.Qt.DisplayRole)
item.setData(self.GRAYOUT_COLOR, NAME_COLOR_ROLE)
item.setData(self.GRAYOUT_COLOR, VERSION_COLOR_ROLE)
item.setData(item_icon, QtCore.Qt.DecorationRole)
item.setData(repre_info.product_id, PRODUCT_ID_ROLE)
item.setData(container_item.item_id, ITEM_ID_ROLE)
item.setData(version_label, VERSION_LABEL_ROLE)
item.setData(container_item.loader_name, LOADER_NAME_ROLE)
item.setData(container_item.object_name, OBJECT_NAME_ROLE)
item.setData(True, IS_CONTAINER_ITEM_ROLE)
item.setData(unique_name, ITEM_UNIQUE_NAME_ROLE)
container_model_items.append(item)
if not container_model_items:
continue
progress = progress_by_id[repre_id]
active_site_progress = "{}%".format(
max(progress["active_site"], 0) * 100
)
remote_site_progress = "{}%".format(
max(progress["remote_site"], 0) * 100
)
group_item = QtGui.QStandardItem()
group_item.setColumnCount(root_item.columnCount())
group_item.setData(group_name, QtCore.Qt.DisplayRole)
group_item.setData(group_name, ITEM_UNIQUE_NAME_ROLE)
group_item.setData(group_item_icon, QtCore.Qt.DecorationRole)
group_item.setData(group_item_font, QtCore.Qt.FontRole)
group_item.setData(repre_info.product_id, PRODUCT_ID_ROLE)
group_item.setData(repre_info.product_type, PRODUCT_TYPE_ROLE)
group_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE)
group_item.setData(is_latest, VERSION_IS_LATEST_ROLE)
group_item.setData(is_hero, VERSION_IS_HERO_ROLE)
group_item.setData(version_label, VERSION_LABEL_ROLE)
group_item.setData(len(container_items), COUNT_ROLE)
group_item.setData(status_name, STATUS_NAME_ROLE)
group_item.setData(status_short, STATUS_SHORT_ROLE)
group_item.setData(status_color, STATUS_COLOR_ROLE)
group_item.setData(status_icon, STATUS_ICON_ROLE)
group_item.setData(
active_site_progress, ACTIVE_SITE_PROGRESS_ROLE
)
group_item.setData(
remote_site_progress, REMOTE_SITE_PROGRESS_ROLE
)
group_item.setData(active_site_icon, ACTIVE_SITE_ICON_ROLE)
group_item.setData(remote_site_icon, REMOTE_SITE_ICON_ROLE)
group_item.setData(False, IS_CONTAINER_ITEM_ROLE)
if version_color is not None:
group_item.setData(version_color, VERSION_COLOR_ROLE)
if repre_info.product_group:
group_item.setData(
repre_info.product_group, PRODUCT_GROUP_NAME_ROLE
active_site_progress, ACTIVE_SITE_PROGRESS_ROLE
)
group_item.setData(group_icon, PRODUCT_GROUP_ICON_ROLE)
group_item.setData(
remote_site_progress, REMOTE_SITE_PROGRESS_ROLE
)
group_item.setData(active_site_icon, ACTIVE_SITE_ICON_ROLE)
group_item.setData(remote_site_icon, REMOTE_SITE_ICON_ROLE)
group_item.setData(False, IS_CONTAINER_ITEM_ROLE)
group_item.appendRows(container_model_items)
group_items.append(group_item)
if version_color is not None:
group_item.setData(version_color, VERSION_COLOR_ROLE)
if repre_info.product_group:
group_item.setData(
repre_info.product_group, PRODUCT_GROUP_NAME_ROLE
)
group_item.setData(group_icon, PRODUCT_GROUP_ICON_ROLE)
group_item.appendRows(container_model_items)
group_items.append(group_item)
if group_items:
root_item.appendRows(group_items)
@ -358,17 +404,21 @@ class InventoryModel(QtGui.QStandardItemModel):
root_item = self.invisibleRootItem()
root_item.removeRows(0, root_item.rowCount())
def _get_status_data(self, status_name):
status_item = self._last_project_statuses.get(status_name)
status_icon = self._get_status_icon(status_name, status_item)
def _get_status_data(self, project_name, status_name):
status_item = self._last_project_statuses[project_name].get(
status_name
)
status_icon = self._get_status_icon(
project_name, status_name, status_item
)
status_color = status_short = None
if status_item is not None:
status_color = status_item.color
status_short = status_item.short
return status_color, status_short, status_icon
def _get_status_icon(self, status_name, status_item):
icon = self._last_status_icons_by_name.get(status_name)
def _get_status_icon(self, project_name, status_name, status_item):
icon = self._last_status_icons_by_name[project_name].get(status_name)
if icon is not None:
return icon
@ -381,7 +431,7 @@ class InventoryModel(QtGui.QStandardItemModel):
})
if icon is None:
icon = QtGui.QIcon()
self._last_status_icons_by_name[status_name] = icon
self._last_status_icons_by_name[project_name][status_name] = icon
return icon
@ -425,7 +475,7 @@ class FilterProxyModel(QtCore.QSortFilterProxyModel):
state = bool(state)
if state != self._filter_outdated:
self._filter_outdated = bool(state)
self._filter_outdated = state
self.invalidateFilter()
def set_hierarchy_view(self, state):

View file

@ -4,6 +4,7 @@ import collections
import ayon_api
from ayon_api.graphql import GraphQlQuery
from ayon_core.lib import Logger
from ayon_core.host import ILoadHost
from ayon_core.tools.common_models.projects import StatusStates
@ -93,22 +94,27 @@ class ContainerItem:
loader_name,
namespace,
object_name,
item_id
item_id,
project_name
):
self.representation_id = representation_id
self.loader_name = loader_name
self.object_name = object_name
self.namespace = namespace
self.item_id = item_id
self.project_name = project_name
@classmethod
def from_container_data(cls, container):
def from_container_data(cls, current_project_name, container):
return cls(
representation_id=container["representation"],
loader_name=container["loader"],
namespace=container["namespace"],
object_name=container["objectName"],
item_id=uuid.uuid4().hex,
project_name=container.get(
"project_name", current_project_name
)
)
@ -191,6 +197,7 @@ class ContainersModel:
self._container_items_by_id = {}
self._version_items_by_product_id = {}
self._repre_info_by_id = {}
self._log = Logger.get_logger("ContainersModel")
def reset(self):
self._items_cache = None
@ -219,26 +226,23 @@ class ContainersModel:
for item_id in item_ids
}
def get_representation_info_items(self, representation_ids):
def get_representation_info_items(self, project_name, representation_ids):
output = {}
missing_repre_ids = set()
for repre_id in representation_ids:
try:
uuid.UUID(repre_id)
except ValueError:
except (ValueError, TypeError, AttributeError):
output[repre_id] = RepresentationInfo.new_invalid()
continue
repre_info = self._repre_info_by_id.get(repre_id)
if repre_info is None:
missing_repre_ids.add(repre_id)
else:
output[repre_id] = repre_info
if not missing_repre_ids:
return output
project_name = self._controller.get_current_project_name()
repre_hierarchy_by_id = get_representations_hierarchy(
project_name, missing_repre_ids
)
@ -276,10 +280,9 @@ class ContainersModel:
output[repre_id] = repre_info
return output
def get_version_items(self, product_ids):
def get_version_items(self, project_name, product_ids):
if not product_ids:
return {}
missing_ids = {
product_id
for product_id in product_ids
@ -294,7 +297,6 @@ class ContainersModel:
def version_sorted(entity):
return entity["version"]
project_name = self._controller.get_current_project_name()
version_entities_by_product_id = {
product_id: []
for product_id in missing_ids
@ -348,34 +350,45 @@ class ContainersModel:
return
host = self._controller.get_host()
if isinstance(host, ILoadHost):
containers = list(host.get_containers())
elif hasattr(host, "ls"):
containers = list(host.ls())
else:
containers = []
containers = []
try:
if isinstance(host, ILoadHost):
containers = list(host.get_containers())
elif hasattr(host, "ls"):
containers = list(host.ls())
except Exception:
self._log.error("Failed to get containers", exc_info=True)
container_items = []
containers_by_id = {}
container_items_by_id = {}
invalid_ids_mapping = {}
current_project_name = self._controller.get_current_project_name()
for container in containers:
if not container:
continue
try:
item = ContainerItem.from_container_data(container)
item = ContainerItem.from_container_data(
current_project_name, container)
repre_id = item.representation_id
try:
uuid.UUID(repre_id)
except (ValueError, TypeError, AttributeError):
# Fake not existing representation id so container is shown in UI
# but as invalid
self._log.warning(
"Container contains invalid representation id."
f"\n{container}"
)
# Fake not existing representation id so container
# is shown in UI but as invalid
item.representation_id = invalid_ids_mapping.setdefault(
repre_id, uuid.uuid4().hex
)
except Exception as e:
except Exception:
# skip item if required data are missing
self._controller.log_error(
f"Failed to create item: {e}"
self._log.warning(
"Failed to create container item", exc_info=True
)
continue
@ -383,7 +396,6 @@ class ContainersModel:
container_items_by_id[item.item_id] = item
container_items.append(item)
self._containers_by_id = containers_by_id
self._container_items_by_id = container_items_by_id
self._items_cache = container_items

View file

@ -11,18 +11,18 @@ class SiteSyncModel:
self._sitesync_addon = NOT_SET
self._sitesync_enabled = None
self._active_site = NOT_SET
self._remote_site = NOT_SET
self._active_site_provider = NOT_SET
self._remote_site_provider = NOT_SET
self._active_site = {}
self._remote_site = {}
self._active_site_provider = {}
self._remote_site_provider = {}
def reset(self):
self._sitesync_addon = NOT_SET
self._sitesync_enabled = None
self._active_site = NOT_SET
self._remote_site = NOT_SET
self._active_site_provider = NOT_SET
self._remote_site_provider = NOT_SET
self._active_site = {}
self._remote_site = {}
self._active_site_provider = {}
self._remote_site_provider = {}
def is_sitesync_enabled(self):
"""Site sync is enabled.
@ -46,15 +46,21 @@ class SiteSyncModel:
sitesync_addon = self._get_sitesync_addon()
return sitesync_addon.get_site_icons()
def get_sites_information(self):
def get_sites_information(self, project_name):
return {
"active_site": self._get_active_site(),
"active_site_provider": self._get_active_site_provider(),
"remote_site": self._get_remote_site(),
"remote_site_provider": self._get_remote_site_provider()
"active_site": self._get_active_site(project_name),
"remote_site": self._get_remote_site(project_name),
"active_site_provider": self._get_active_site_provider(
project_name
),
"remote_site_provider": self._get_remote_site_provider(
project_name
)
}
def get_representations_site_progress(self, representation_ids):
def get_representations_site_progress(
self, project_name, representation_ids
):
"""Get progress of representations sync."""
representation_ids = set(representation_ids)
@ -68,13 +74,12 @@ class SiteSyncModel:
if not self.is_sitesync_enabled():
return output
project_name = self._controller.get_current_project_name()
sitesync_addon = self._get_sitesync_addon()
repre_entities = ayon_api.get_representations(
project_name, representation_ids
)
active_site = self._get_active_site()
remote_site = self._get_remote_site()
active_site = self._get_active_site(project_name)
remote_site = self._get_remote_site(project_name)
for repre_entity in repre_entities:
repre_output = output[repre_entity["id"]]
@ -86,20 +91,21 @@ class SiteSyncModel:
return output
def resync_representations(self, representation_ids, site_type):
def resync_representations(
self, project_name, representation_ids, site_type
):
"""
Args:
project_name (str): Project name.
representation_ids (Iterable[str]): Representation ids.
site_type (Literal[active_site, remote_site]): Site type.
"""
project_name = self._controller.get_current_project_name()
sitesync_addon = self._get_sitesync_addon()
active_site = self._get_active_site()
remote_site = self._get_remote_site()
active_site = self._get_active_site(project_name)
remote_site = self._get_remote_site(project_name)
progress = self.get_representations_site_progress(
representation_ids
project_name, representation_ids
)
for repre_id in representation_ids:
repre_progress = progress.get(repre_id)
@ -132,48 +138,49 @@ class SiteSyncModel:
self._sitesync_addon = sitesync_addon
self._sitesync_enabled = sync_enabled
def _get_active_site(self):
if self._active_site is NOT_SET:
self._cache_sites()
return self._active_site
def _get_active_site(self, project_name):
if project_name not in self._active_site:
self._cache_sites(project_name)
return self._active_site[project_name]
def _get_remote_site(self):
if self._remote_site is NOT_SET:
self._cache_sites()
return self._remote_site
def _get_remote_site(self, project_name):
if project_name not in self._remote_site:
self._cache_sites(project_name)
return self._remote_site[project_name]
def _get_active_site_provider(self):
if self._active_site_provider is NOT_SET:
self._cache_sites()
return self._active_site_provider
def _get_active_site_provider(self, project_name):
if project_name not in self._active_site_provider:
self._cache_sites(project_name)
return self._active_site_provider[project_name]
def _get_remote_site_provider(self):
if self._remote_site_provider is NOT_SET:
self._cache_sites()
return self._remote_site_provider
def _get_remote_site_provider(self, project_name):
if project_name not in self._remote_site_provider:
self._cache_sites(project_name)
return self._remote_site_provider[project_name]
def _cache_sites(self):
active_site = None
remote_site = None
active_site_provider = None
remote_site_provider = None
if self.is_sitesync_enabled():
sitesync_addon = self._get_sitesync_addon()
project_name = self._controller.get_current_project_name()
active_site = sitesync_addon.get_active_site(project_name)
remote_site = sitesync_addon.get_remote_site(project_name)
active_site_provider = "studio"
remote_site_provider = "studio"
if active_site != "studio":
active_site_provider = sitesync_addon.get_provider_for_site(
project_name, active_site
)
if remote_site != "studio":
remote_site_provider = sitesync_addon.get_provider_for_site(
project_name, remote_site
)
def _cache_sites(self, project_name):
self._active_site[project_name] = None
self._remote_site[project_name] = None
self._active_site_provider[project_name] = None
self._remote_site_provider[project_name] = None
if not self.is_sitesync_enabled():
return
self._active_site = active_site
self._remote_site = remote_site
self._active_site_provider = active_site_provider
self._remote_site_provider = remote_site_provider
sitesync_addon = self._get_sitesync_addon()
active_site = sitesync_addon.get_active_site(project_name)
remote_site = sitesync_addon.get_remote_site(project_name)
active_site_provider = "studio"
remote_site_provider = "studio"
if active_site != "studio":
active_site_provider = sitesync_addon.get_provider_for_site(
project_name, active_site
)
if remote_site != "studio":
remote_site_provider = sitesync_addon.get_provider_for_site(
project_name, remote_site
)
self._active_site[project_name] = active_site
self._remote_site[project_name] = remote_site
self._active_site_provider[project_name] = active_site_provider
self._remote_site_provider[project_name] = remote_site_provider

View file

@ -46,8 +46,13 @@ class SwitchAssetDialog(QtWidgets.QDialog):
switched = QtCore.Signal()
def __init__(self, controller, parent=None, items=None):
super(SwitchAssetDialog, self).__init__(parent)
def __init__(self, controller, project_name, items, parent=None):
super().__init__(parent)
current_project_name = controller.get_current_project_name()
folder_id = None
if current_project_name == project_name:
folder_id = controller.get_current_folder_id()
self.setWindowTitle("Switch selected items ...")
@ -147,11 +152,10 @@ class SwitchAssetDialog(QtWidgets.QDialog):
self._init_repre_name = None
self._fill_check = False
self._project_name = project_name
self._folder_id = folder_id
self._project_name = controller.get_current_project_name()
self._folder_id = controller.get_current_folder_id()
self._current_folder_btn.setEnabled(self._folder_id is not None)
self._current_folder_btn.setEnabled(folder_id is not None)
self._controller = controller
@ -159,7 +163,7 @@ class SwitchAssetDialog(QtWidgets.QDialog):
self._prepare_content_data()
def showEvent(self, event):
super(SwitchAssetDialog, self).showEvent(event)
super().showEvent(event)
self._show_timer.start()
def refresh(self, init_refresh=False):

View file

@ -192,29 +192,46 @@ class SceneInventoryView(QtWidgets.QTreeView):
container_item = container_items_by_id[item_id]
active_repre_id = container_item.representation_id
break
repre_ids_by_project = collections.defaultdict(set)
for container_item in container_items_by_id.values():
repre_id = container_item.representation_id
project_name = container_item.project_name
repre_ids_by_project[project_name].add(repre_id)
repre_info_by_id = self._controller.get_representation_info_items({
container_item.representation_id
for container_item in container_items_by_id.values()
})
valid_repre_ids = {
repre_id
for repre_id, repre_info in repre_info_by_id.items()
if repre_info.is_valid
}
repre_info_by_project = {}
repre_ids_by_project_name = {}
version_ids_by_project = {}
product_ids_by_project = {}
for project_name, repre_ids in repre_ids_by_project.items():
repres_info = self._controller.get_representation_info_items(
project_name, repre_ids
)
repre_info_by_project[project_name] = repres_info
repre_ids = set()
version_ids = set()
product_ids = set()
for repre_id, repre_info in repres_info.items():
if not repre_info.is_valid:
continue
repre_ids.add(repre_id)
version_ids.add(repre_info.version_id)
product_ids.add(repre_info.product_id)
repre_ids_by_project_name[project_name] = repre_ids
version_ids_by_project[project_name] = version_ids
product_ids_by_project[project_name] = product_ids
# Exclude items that are "NOT FOUND" since setting versions, updating
# and removal won't work for those items.
filtered_items = []
product_ids = set()
version_ids = set()
for container_item in container_items_by_id.values():
project_name = container_item.project_name
repre_id = container_item.representation_id
repre_info_by_id = repre_info_by_project.get(project_name, {})
repre_info = repre_info_by_id.get(repre_id)
if repre_info and repre_info.is_valid:
filtered_items.append(container_item)
version_ids.add(repre_info.version_id)
product_ids.add(repre_info.product_id)
# remove
remove_icon = qtawesome.icon("fa.remove", color=DEFAULT_COLOR)
@ -227,43 +244,51 @@ class SceneInventoryView(QtWidgets.QTreeView):
menu.addAction(remove_action)
return
version_items_by_product_id = self._controller.get_version_items(
product_ids
)
version_items_by_project = {
project_name: self._controller.get_version_items(
project_name, product_ids
)
for project_name, product_ids in product_ids_by_project.items()
}
has_outdated = False
has_loaded_hero_versions = False
has_available_hero_version = False
has_outdated_approved = False
last_version_by_product_id = {}
for product_id, version_items_by_id in (
version_items_by_product_id.items()
for project_name, version_items_by_product_id in (
version_items_by_project.items()
):
_has_outdated_approved = False
_last_approved_version_item = None
for version_item in version_items_by_id.values():
if version_item.is_hero:
has_available_hero_version = True
elif version_item.is_last_approved:
_last_approved_version_item = version_item
_has_outdated_approved = True
if version_item.version_id not in version_ids:
continue
if version_item.is_hero:
has_loaded_hero_versions = True
elif not version_item.is_latest:
has_outdated = True
if (
_has_outdated_approved
and _last_approved_version_item is not None
version_ids = version_ids_by_project[project_name]
for product_id, version_items_by_id in (
version_items_by_product_id.items()
):
last_version_by_product_id[product_id] = (
_last_approved_version_item
)
has_outdated_approved = True
_has_outdated_approved = False
_last_approved_version_item = None
for version_item in version_items_by_id.values():
if version_item.is_hero:
has_available_hero_version = True
elif version_item.is_last_approved:
_last_approved_version_item = version_item
_has_outdated_approved = True
if version_item.version_id not in version_ids:
continue
if version_item.is_hero:
has_loaded_hero_versions = True
elif not version_item.is_latest:
has_outdated = True
if (
_has_outdated_approved
and _last_approved_version_item is not None
):
last_version_by_product_id[product_id] = (
_last_approved_version_item
)
has_outdated_approved = True
switch_to_versioned = None
if has_loaded_hero_versions:
@ -284,8 +309,9 @@ class SceneInventoryView(QtWidgets.QTreeView):
approved_version_by_item_id = {}
if has_outdated_approved:
for container_item in container_items_by_id.values():
project_name = container_item.project_name
repre_id = container_item.representation_id
repre_info = repre_info_by_id.get(repre_id)
repre_info = repre_info_by_project[project_name][repre_id]
if not repre_info or not repre_info.is_valid:
continue
version_item = last_version_by_product_id.get(
@ -397,14 +423,15 @@ class SceneInventoryView(QtWidgets.QTreeView):
menu.addAction(remove_action)
self._handle_sitesync(menu, valid_repre_ids)
self._handle_sitesync(menu, repre_ids_by_project_name)
def _handle_sitesync(self, menu, repre_ids):
def _handle_sitesync(self, menu, repre_ids_by_project_name):
"""Adds actions for download/upload when SyncServer is enabled
Args:
menu (OptionMenu)
repre_ids (list) of object_ids
repre_ids_by_project_name (Dict[str, Set[str]]): Representation
ids by project name.
Returns:
(OptionMenu)
@ -413,7 +440,7 @@ class SceneInventoryView(QtWidgets.QTreeView):
if not self._controller.is_sitesync_enabled():
return
if not repre_ids:
if not repre_ids_by_project_name:
return
menu.addSeparator()
@ -425,7 +452,10 @@ class SceneInventoryView(QtWidgets.QTreeView):
menu
)
download_active_action.triggered.connect(
lambda: self._add_sites(repre_ids, "active_site"))
lambda: self._add_sites(
repre_ids_by_project_name, "active_site"
)
)
upload_icon = qtawesome.icon("fa.upload", color=DEFAULT_COLOR)
upload_remote_action = QtWidgets.QAction(
@ -434,23 +464,30 @@ class SceneInventoryView(QtWidgets.QTreeView):
menu
)
upload_remote_action.triggered.connect(
lambda: self._add_sites(repre_ids, "remote_site"))
lambda: self._add_sites(
repre_ids_by_project_name, "remote_site"
)
)
menu.addAction(download_active_action)
menu.addAction(upload_remote_action)
def _add_sites(self, repre_ids, site_type):
def _add_sites(self, repre_ids_by_project_name, site_type):
"""(Re)sync all 'repre_ids' to specific site.
It checks if opposite site has fully available content to limit
accidents. (ReSync active when no remote >> losing active content)
Args:
repre_ids (list)
repre_ids_by_project_name (Dict[str, Set[str]]): Representation
ids by project name.
site_type (Literal[active_site, remote_site]): Site type.
"""
self._controller.resync_representations(repre_ids, site_type)
"""
for project_name, repre_ids in repre_ids_by_project_name.items():
self._controller.resync_representations(
project_name, repre_ids, site_type
)
self.data_changed.emit()
@ -735,42 +772,68 @@ class SceneInventoryView(QtWidgets.QTreeView):
container_items_by_id = self._controller.get_container_items_by_id(
item_ids
)
repre_ids = {
container_item.representation_id
for container_item in container_items_by_id.values()
}
repre_info_by_id = self._controller.get_representation_info_items(
repre_ids
)
project_names = set()
repre_ids_by_project = collections.defaultdict(set)
for container_item in container_items_by_id.values():
repre_id = container_item.representation_id
project_name = container_item.project_name
project_names.add(project_name)
repre_ids_by_project[project_name].add(repre_id)
# active_project_name = None
active_repre_info = None
repre_info_by_project = {}
version_items_by_project = {}
for project_name, repre_ids in repre_ids_by_project.items():
repres_info = self._controller.get_representation_info_items(
project_name, repre_ids
)
if active_repre_info is None:
# active_project_name = project_name
active_repre_info = repres_info.get(active_repre_id)
product_ids = {
repre_info.product_id
for repre_info in repres_info.values()
if repre_info.is_valid
}
version_items_by_product_id = self._controller.get_version_items(
project_name, product_ids
)
repre_info_by_project[project_name] = repres_info
version_items_by_project[project_name] = (
version_items_by_product_id
)
product_ids = {
repre_info.product_id
for repre_info in repre_info_by_id.values()
}
active_repre_info = repre_info_by_id[active_repre_id]
active_version_id = active_repre_info.version_id
active_product_id = active_repre_info.product_id
version_items_by_product_id = self._controller.get_version_items(
product_ids
)
version_items = list(
version_items_by_product_id[active_product_id].values()
)
versions = {version_item.version for version_item in version_items}
product_ids_by_version = collections.defaultdict(set)
for version_items_by_id in version_items_by_product_id.values():
for version_item in version_items_by_id.values():
version = version_item.version
_prod_version = version
if _prod_version < 0:
_prod_version = -1
product_ids_by_version[_prod_version].add(
version_item.product_id
)
if version in versions:
continue
versions.add(version)
version_items.append(version_item)
# active_product_id = active_repre_info.product_id
versions = set()
product_ids = set()
version_items = []
product_ids_by_version_by_project = {}
for project_name, version_items_by_product_id in (
version_items_by_project.items()
):
product_ids_by_version = collections.defaultdict(set)
product_ids_by_version_by_project[project_name] = (
product_ids_by_version
)
for version_items_by_id in version_items_by_product_id.values():
for version_item in version_items_by_id.values():
version = version_item.version
_prod_version = version
if _prod_version < 0:
_prod_version = -1
product_ids_by_version[_prod_version].add(
version_item.product_id
)
product_ids.add(version_item.product_id)
if version in versions:
continue
versions.add(version)
version_items.append(version_item)
def version_sorter(item):
hero_value = 0
@ -831,12 +894,15 @@ class SceneInventoryView(QtWidgets.QTreeView):
product_version = -1
version = HeroVersionType(version)
product_ids = product_ids_by_version[product_version]
filtered_item_ids = set()
for container_item in container_items_by_id.values():
project_name = container_item.project_name
product_ids_by_version = (
product_ids_by_version_by_project[project_name]
)
product_ids = product_ids_by_version[product_version]
repre_id = container_item.representation_id
repre_info = repre_info_by_id[repre_id]
repre_info = repre_info_by_project[project_name][repre_id]
if repre_info.product_id in product_ids:
filtered_item_ids.add(container_item.item_id)
@ -846,14 +912,28 @@ class SceneInventoryView(QtWidgets.QTreeView):
def _show_switch_dialog(self, item_ids):
"""Display Switch dialog"""
containers_by_id = self._controller.get_containers_by_item_ids(
container_items_by_id = self._controller.get_container_items_by_id(
item_ids
)
dialog = SwitchAssetDialog(
self._controller, self, list(containers_by_id.values())
)
dialog.switched.connect(self.data_changed.emit)
dialog.show()
container_ids_by_project_name = collections.defaultdict(set)
for container_id, container_item in container_items_by_id.items():
project_name = container_item.project_name
container_ids_by_project_name[project_name].add(container_id)
for project_name, container_ids in (
container_ids_by_project_name.items()
):
containers_by_id = self._controller.get_containers_by_item_ids(
container_ids
)
dialog = SwitchAssetDialog(
self._controller,
project_name,
list(containers_by_id.values()),
self
)
dialog.switched.connect(self.data_changed.emit)
dialog.show()
def _show_remove_warning_dialog(self, item_ids):
"""Prompt a dialog to inform the user the action will remove items"""
@ -927,38 +1007,58 @@ class SceneInventoryView(QtWidgets.QTreeView):
self._update_containers_to_version(item_ids, version=-1)
def _on_switch_to_versioned(self, item_ids):
# Get container items by ID
containers_items_by_id = self._controller.get_container_items_by_id(
item_ids
)
repre_ids = {
container_item.representation_id
for container_item in containers_items_by_id.values()
}
repre_info_by_id = self._controller.get_representation_info_items(
repre_ids
)
product_ids = {
repre_info.product_id
for repre_info in repre_info_by_id.values()
if repre_info.is_valid
}
version_items_by_product_id = self._controller.get_version_items(
product_ids
)
item_ids)
# Extract project names and their corresponding representation IDs
repre_ids_by_project = collections.defaultdict(set)
for container_item in containers_items_by_id.values():
project_name = container_item.project_name
repre_id = container_item.representation_id
repre_ids_by_project[project_name].add(repre_id)
# Get representation info items by ID
repres_info_by_project = {}
version_items_by_project = {}
for project_name, repre_ids in repre_ids_by_project.items():
repre_info_by_id = self._controller.get_representation_info_items(
project_name, repre_ids
)
repres_info_by_project[project_name] = repre_info_by_id
product_ids = {
repre_info.product_id
for repre_info in repre_info_by_id.values()
if repre_info.is_valid
}
version_items_by_product_id = self._controller.get_version_items(
project_name, product_ids
)
version_items_by_project[project_name] = (
version_items_by_product_id
)
update_containers = []
update_versions = []
for item_id, container_item in containers_items_by_id.items():
for container_item in containers_items_by_id.values():
project_name = container_item.project_name
repre_id = container_item.representation_id
repre_info_by_id = repres_info_by_project[project_name]
repre_info = repre_info_by_id[repre_id]
version_items_by_product_id = (
version_items_by_project[project_name]
)
product_id = repre_info.product_id
version_items_id = version_items_by_product_id[product_id]
version_item = version_items_id.get(repre_info.version_id, {})
version_items_by_id = version_items_by_product_id[product_id]
version_item = version_items_by_id.get(repre_info.version_id, {})
if not version_item or not version_item.is_hero:
continue
version = abs(version_item.version)
version_found = False
for version_item in version_items_id.values():
for version_item in version_items_by_id.values():
if version_item.is_hero:
continue
if version_item.version == version:
@ -971,8 +1071,8 @@ class SceneInventoryView(QtWidgets.QTreeView):
update_containers.append(container_item.item_id)
update_versions.append(version)
# Specify version per item to update to
self._update_containers(update_containers, update_versions)
# Specify version per item to update to
self._update_containers(update_containers, update_versions)
def _update_containers(self, item_ids, versions):
"""Helper to update items to given version (or version per item)

View file

@ -3,12 +3,9 @@ import sys
import json
import hashlib
import platform
import subprocess
import csv
import time
import signal
import locale
from typing import Optional, Dict, Tuple, Any
from typing import Optional, List, Dict, Tuple, Any
import requests
from ayon_api.utils import get_default_settings_variant
@ -53,15 +50,101 @@ def _get_server_and_variant(
return server_url, variant
def _windows_get_pid_args(pid: int) -> Optional[List[str]]:
import ctypes
from ctypes import wintypes
# Define constants
PROCESS_COMMANDLINE_INFO = 60
STATUS_NOT_FOUND = 0xC0000225
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
# Define the UNICODE_STRING structure
class UNICODE_STRING(ctypes.Structure):
_fields_ = [
("Length", wintypes.USHORT),
("MaximumLength", wintypes.USHORT),
("Buffer", wintypes.LPWSTR)
]
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
CommandLineToArgvW = shell32.CommandLineToArgvW
CommandLineToArgvW.argtypes = [
wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)
]
CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR)
output = None
# Open the process
handle = ctypes.windll.kernel32.OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION, False, pid
)
if not handle:
return output
try:
buffer_len = wintypes.ULONG()
# Get the right buffer size first
status = ctypes.windll.ntdll.NtQueryInformationProcess(
handle,
PROCESS_COMMANDLINE_INFO,
ctypes.c_void_p(None),
0,
ctypes.byref(buffer_len)
)
if status == STATUS_NOT_FOUND:
return output
# Create buffer with collected size
buffer = ctypes.create_string_buffer(buffer_len.value)
# Get the command line
status = ctypes.windll.ntdll.NtQueryInformationProcess(
handle,
PROCESS_COMMANDLINE_INFO,
buffer,
buffer_len,
ctypes.byref(buffer_len)
)
if status:
return output
# Build the string
tmp = ctypes.cast(buffer, ctypes.POINTER(UNICODE_STRING)).contents
size = tmp.Length // 2 + 1
cmdline_buffer = ctypes.create_unicode_buffer(size)
ctypes.cdll.msvcrt.wcscpy(cmdline_buffer, tmp.Buffer)
args_len = ctypes.c_int()
args = CommandLineToArgvW(
cmdline_buffer, ctypes.byref(args_len)
)
output = [args[idx] for idx in range(args_len.value)]
ctypes.windll.kernel32.LocalFree(args)
finally:
ctypes.windll.kernel32.CloseHandle(handle)
return output
def _windows_pid_is_running(pid: int) -> bool:
args = ["tasklist.exe", "/fo", "csv", "/fi", f"PID eq {pid}"]
output = subprocess.check_output(args)
encoding = locale.getpreferredencoding()
csv_content = csv.DictReader(output.decode(encoding).splitlines())
# if "PID" not in csv_content.fieldnames:
# return False
for _ in csv_content:
args = _windows_get_pid_args(pid)
if not args:
return False
executable_path = args[0]
filename = os.path.basename(executable_path).lower()
if "ayon" in filename:
return True
# Try to handle tray running from code
# - this might be potential danger that kills other python process running
# 'start.py' script (low chance, but still)
if "python" in filename and len(args) > 1:
script_filename = os.path.basename(args[1].lower())
if script_filename == "start.py":
return True
return False

View file

@ -20,9 +20,10 @@ from ayon_core.lib import (
)
from ayon_core.settings import get_studio_settings
from ayon_core.addon import (
ITrayAction,
ITrayAddon,
ITrayService,
)
from ayon_core.pipeline import install_ayon_plugins
from ayon_core.tools.utils import (
WrappedCallbackItem,
get_ayon_qt_app,
@ -32,6 +33,12 @@ from ayon_core.tools.tray.lib import (
remove_tray_server_url,
TrayIsRunningError,
)
from ayon_core.tools.launcher.ui import LauncherWindow
from ayon_core.tools.loader.ui import LoaderWindow
from ayon_core.tools.console_interpreter.ui import ConsoleInterpreterWindow
from ayon_core.tools.publisher.publish_report_viewer import (
PublishReportViewerWindow,
)
from .addons_manager import TrayAddonsManager
from .host_console_listener import HostListener
@ -82,6 +89,11 @@ class TrayManager:
self._outdated_dialog = None
self._launcher_window = None
self._browser_window = None
self._console_window = ConsoleInterpreterWindow()
self._publish_report_viewer_window = PublishReportViewerWindow()
self._update_check_timer = update_check_timer
self._update_check_interval = update_check_interval
self._main_thread_timer = main_thread_timer
@ -109,12 +121,15 @@ class TrayManager:
@property
def doubleclick_callback(self):
"""Double-click callback for Tray icon."""
return self._addons_manager.get_doubleclick_callback()
callback = self._addons_manager.get_doubleclick_callback()
if callback is None:
callback = self._show_launcher_window
return callback
def execute_doubleclick(self):
"""Execute double click callback in main thread."""
callback = self.doubleclick_callback
if callback:
if callback is not None:
self.execute_in_main_thread(callback)
def show_tray_message(self, title, message, icon=None, msecs=None):
@ -144,8 +159,34 @@ class TrayManager:
return
tray_menu = self.tray_widget.menu
# Add launcher at first place
launcher_action = QtWidgets.QAction(
"Launcher", tray_menu
)
launcher_action.triggered.connect(self._show_launcher_window)
tray_menu.addAction(launcher_action)
console_action = ITrayAddon.add_action_to_admin_submenu(
"Console", tray_menu
)
console_action.triggered.connect(self._show_console_window)
publish_report_viewer_action = ITrayAddon.add_action_to_admin_submenu(
"Publish report viewer", tray_menu
)
publish_report_viewer_action.triggered.connect(
self._show_publish_report_viewer
)
self._addons_manager.initialize(tray_menu)
# Add browser action after addon actions
browser_action = QtWidgets.QAction(
"Browser", tray_menu
)
browser_action.triggered.connect(self._show_browser_window)
tray_menu.addAction(browser_action)
self._addons_manager.add_route(
"GET", "/tray", self._web_get_tray_info
)
@ -153,7 +194,7 @@ class TrayManager:
"POST", "/tray/message", self._web_show_tray_message
)
admin_submenu = ITrayAction.admin_submenu(tray_menu)
admin_submenu = ITrayAddon.admin_submenu(tray_menu)
tray_menu.addMenu(admin_submenu)
# Add services if they are
@ -522,6 +563,35 @@ class TrayManager:
self._info_widget.raise_()
self._info_widget.activateWindow()
def _show_launcher_window(self):
if self._launcher_window is None:
self._launcher_window = LauncherWindow()
self._launcher_window.show()
self._launcher_window.raise_()
self._launcher_window.activateWindow()
def _show_browser_window(self):
if self._browser_window is None:
self._browser_window = LoaderWindow()
self._browser_window.setWindowTitle("AYON Browser")
install_ayon_plugins()
self._browser_window.show()
self._browser_window.raise_()
self._browser_window.activateWindow()
def _show_console_window(self):
self._console_window.show()
self._console_window.raise_()
self._console_window.activateWindow()
def _show_publish_report_viewer(self):
self._publish_report_viewer_window.refresh()
self._publish_report_viewer_window.show()
self._publish_report_viewer_window.raise_()
self._publish_report_viewer_window.activateWindow()
class SystemTrayIcon(QtWidgets.QSystemTrayIcon):
"""Tray widget.

View file

@ -556,9 +556,10 @@ class _IconsCache:
log.info("Didn't find icon \"{}\"".format(icon_name))
elif used_variant != icon_name:
log.debug("Icon \"{}\" was not found \"{}\" is used instead".format(
icon_name, used_variant
))
log.debug(
f"Icon \"{icon_name}\" was not found"
f" \"{used_variant}\" is used instead"
)
cls._qtawesome_cache[full_icon_name] = icon
return icon

View file

@ -1,6 +1,6 @@
import os
from qtpy import QtWidgets
from qtpy import QtWidgets, QT6
class Action(QtWidgets.QAction):
@ -112,20 +112,21 @@ module.{module_name}()"""
Run the command of the instance or copy the command to the active shelf
based on the current modifiers.
If callbacks have been registered with fouind modifier integer the
If callbacks have been registered with found modifier integer the
function will trigger all callbacks. When a callback function returns a
non zero integer it will not execute the action's command
"""
# get the current application and its linked keyboard modifiers
app = QtWidgets.QApplication.instance()
modifiers = app.keyboardModifiers()
if not QT6:
modifiers = int(modifiers)
# If the menu has a callback registered for the current modifier
# we run the callback instead of the action itself.
registered = self._root.registered_callbacks
callbacks = registered.get(int(modifiers), [])
callbacks = registered.get(modifiers, [])
for callback in callbacks:
signal = callback(self)
if signal != 0:

View file

@ -4,7 +4,7 @@ import maya.cmds as cmds
import maya.mel as mel
import scriptsmenu
from qtpy import QtCore, QtWidgets
from qtpy import QtCore, QtWidgets, QT6
log = logging.getLogger(__name__)
@ -130,7 +130,7 @@ def main(title="Scripts", parent=None, objectName=None):
# Register control + shift callback to add to shelf (maya behavior)
modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier
if int(cmds.about(version=True)) < 2025:
if not QT6:
modifiers = int(modifiers)
menu.register_callback(modifiers, to_shelf)

View file

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

View file

@ -16,6 +16,6 @@ pydantic = "^2.9.2"
aiohttp-middlewares = "^2.0.0"
Click = "^8"
OpenTimelineIO = "0.16.0"
opencolorio = "^2.3.2"
opencolorio = "^2.3.2,<2.4.0"
Pillow = "9.5.0"
websocket-client = ">=0.40.0,<2"

Some files were not shown because too many files have changed in this diff Show more