Merge branch 'develop' into enhancement/OP-8218_Loader-OTIO-export-action

This commit is contained in:
Jakub Jezek 2024-09-17 10:11:03 +02:00
commit 23a63d6abf
No known key found for this signature in database
GPG key ID: 06DBD609ADF27FD9
90 changed files with 4350 additions and 2838 deletions

View file

@ -9,10 +9,6 @@ AYON_CORE_ROOT = os.path.dirname(os.path.abspath(__file__))
# -------------------------
PACKAGE_DIR = AYON_CORE_ROOT
PLUGINS_DIR = os.path.join(AYON_CORE_ROOT, "plugins")
AYON_SERVER_ENABLED = True
# Indicate if AYON entities should be used instead of OpenPype entities
USE_AYON_ENTITIES = True
# -------------------------
@ -23,6 +19,4 @@ __all__ = (
"AYON_CORE_ROOT",
"PACKAGE_DIR",
"PLUGINS_DIR",
"AYON_SERVER_ENABLED",
"USE_AYON_ENTITIES",
)

View file

@ -9,11 +9,18 @@ from .interfaces import (
)
from .base import (
ProcessPreparationError,
ProcessContext,
AYONAddon,
AddonsManager,
load_addons,
)
from .utils import (
ensure_addons_are_process_context_ready,
ensure_addons_are_process_ready,
)
__all__ = (
"click_wrap",
@ -24,7 +31,12 @@ __all__ = (
"ITrayService",
"IHostAddon",
"ProcessPreparationError",
"ProcessContext",
"AYONAddon",
"AddonsManager",
"load_addons",
"ensure_addons_are_process_context_ready",
"ensure_addons_are_process_ready",
)

View file

@ -10,13 +10,18 @@ import threading
import collections
from uuid import uuid4
from abc import ABC, abstractmethod
from typing import Optional
import appdirs
import ayon_api
from semver import VersionInfo
from ayon_core import AYON_CORE_ROOT
from ayon_core.lib import Logger, is_dev_mode_enabled
from ayon_core.lib import (
Logger,
is_dev_mode_enabled,
get_launcher_storage_dir,
is_headless_mode_enabled,
)
from ayon_core.settings import get_studio_settings
from .interfaces import (
@ -31,9 +36,6 @@ IGNORED_FILENAMES = {
# Files ignored on addons import from "./ayon_core/modules"
IGNORED_DEFAULT_FILENAMES = {
"__init__.py",
"base.py",
"interfaces.py",
"click_wrap.py",
}
# When addon was moved from ayon-core codebase
@ -64,77 +66,65 @@ MOVED_ADDON_MILESTONE_VERSIONS = {
}
# Inherit from `object` for Python 2 hosts
class _ModuleClass(object):
"""Fake module class for storing AYON addons.
class ProcessPreparationError(Exception):
"""Exception that can be used when process preparation failed.
The message is shown to user (either as UI dialog or printed). If
different error is raised a "generic" error message is shown to user
with option to copy error message to clipboard.
Object of this class can be stored to `sys.modules` and used for storing
dynamically imported modules.
"""
pass
def __init__(self, name):
# Call setattr on super class
super(_ModuleClass, self).__setattr__("name", name)
super(_ModuleClass, self).__setattr__("__name__", name)
# Where modules and interfaces are stored
super(_ModuleClass, self).__setattr__("__attributes__", dict())
super(_ModuleClass, self).__setattr__("__defaults__", set())
class ProcessContext:
"""Hold context of process that is going to be started.
super(_ModuleClass, self).__setattr__("_log", None)
Right now the context is simple, having information about addon that wants
to trigger preparation and possibly project name for which it should
happen.
def __getattr__(self, attr_name):
if attr_name not in self.__attributes__:
if attr_name in ("__path__", "__file__"):
return None
raise AttributeError("'{}' has not attribute '{}'".format(
self.name, attr_name
))
return self.__attributes__[attr_name]
Preparation for process can be required for ayon-core or any other addon.
It can be, change of environment variables, or request login to
a project management.
def __iter__(self):
for module in self.values():
yield module
At the moment of creation is 'ProcessContext' only data holder, but that
might change in future if there will be need.
def __setattr__(self, attr_name, value):
if attr_name in self.__attributes__:
self.log.warning(
"Duplicated name \"{}\" in {}. Overriding.".format(
attr_name, self.name
)
)
self.__attributes__[attr_name] = value
Args:
addon_name (str): Addon name which triggered process.
addon_version (str): Addon version which triggered process.
project_name (Optional[str]): Project name. Can be filled in case
process is triggered for specific project. Some addons can have
different behavior based on project. Value is NOT autofilled.
headless (Optional[bool]): Is process running in headless mode. Value
is filled with value based on state set in AYON launcher.
def __setitem__(self, key, value):
self.__setattr__(key, value)
"""
def __init__(
self,
addon_name: str,
addon_version: str,
project_name: Optional[str] = None,
headless: Optional[bool] = None,
**kwargs,
):
if headless is None:
headless = is_headless_mode_enabled()
self.addon_name: str = addon_name
self.addon_version: str = addon_version
self.project_name: Optional[str] = project_name
self.headless: bool = headless
def __getitem__(self, key):
return getattr(self, key)
@property
def log(self):
if self._log is None:
super(_ModuleClass, self).__setattr__(
"_log", Logger.get_logger(self.name)
)
return self._log
def get(self, key, default=None):
return self.__attributes__.get(key, default)
def keys(self):
return self.__attributes__.keys()
def values(self):
return self.__attributes__.values()
def items(self):
return self.__attributes__.items()
if kwargs:
unknown_keys = ", ".join([f'"{key}"' for key in kwargs.keys()])
print(f"Unknown keys in ProcessContext: {unknown_keys}")
class _LoadCache:
addons_lock = threading.Lock()
addons_loaded = False
addon_modules = []
def load_addons(force=False):
@ -248,7 +238,7 @@ def _handle_moved_addons(addon_name, milestone_version, log):
return addon_dir
def _load_ayon_addons(openpype_modules, modules_key, log):
def _load_ayon_addons(log):
"""Load AYON addons based on information from server.
This function should not trigger downloading of any addons but only use
@ -256,30 +246,18 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
development).
Args:
openpype_modules (_ModuleClass): Module object where modules are
stored.
modules_key (str): Key under which will be modules imported in
`sys.modules`.
log (logging.Logger): Logger object.
Returns:
List[str]: List of v3 addons to skip to load because v4 alternative is
imported.
"""
addons_to_skip_in_core = []
all_addon_modules = []
bundle_info = _get_ayon_bundle_data()
addons_info = _get_ayon_addons_information(bundle_info)
if not addons_info:
return addons_to_skip_in_core
return all_addon_modules
addons_dir = os.environ.get("AYON_ADDONS_DIR")
if not addons_dir:
addons_dir = os.path.join(
appdirs.user_data_dir("AYON", "Ynput"),
"addons"
)
addons_dir = get_launcher_storage_dir("addons")
dev_mode_enabled = is_dev_mode_enabled()
dev_addons_info = {}
@ -298,7 +276,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
addon_version = addon_info["version"]
# core addon does not have any addon object
if addon_name in ("openpype", "core"):
if addon_name == "core":
continue
dev_addon_info = dev_addons_info.get(addon_name, {})
@ -337,7 +315,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
continue
sys.path.insert(0, addon_dir)
imported_modules = []
addon_modules = []
for name in os.listdir(addon_dir):
# Ignore of files is implemented to be able to run code from code
# where usually is more files than just the addon
@ -364,7 +342,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
inspect.isclass(attr)
and issubclass(attr, AYONAddon)
):
imported_modules.append(mod)
addon_modules.append(mod)
break
except BaseException:
@ -373,50 +351,37 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
exc_info=True
)
if not imported_modules:
if not addon_modules:
log.warning("Addon {} {} has no content to import".format(
addon_name, addon_version
))
continue
if len(imported_modules) > 1:
if len(addon_modules) > 1:
log.warning((
"Skipping addon '{}'."
" Multiple modules were found ({}) in dir {}."
"Multiple modules ({}) were found in addon '{}' in dir {}."
).format(
", ".join([m.__name__ for m in addon_modules]),
addon_name,
", ".join([m.__name__ for m in imported_modules]),
addon_dir,
))
continue
all_addon_modules.extend(addon_modules)
mod = imported_modules[0]
addon_alias = getattr(mod, "V3_ALIAS", None)
if not addon_alias:
addon_alias = addon_name
addons_to_skip_in_core.append(addon_alias)
new_import_str = "{}.{}".format(modules_key, addon_alias)
sys.modules[new_import_str] = mod
setattr(openpype_modules, addon_alias, mod)
return addons_to_skip_in_core
return all_addon_modules
def _load_addons_in_core(
ignore_addon_names, openpype_modules, modules_key, log
):
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
return addon_modules
ignored_filenames = IGNORED_FILENAMES | IGNORED_DEFAULT_FILENAMES
for filename in os.listdir(modules_dir):
# Ignore filenames
if filename in ignored_filenames:
@ -425,9 +390,6 @@ def _load_addons_in_core(
fullpath = os.path.join(modules_dir, filename)
basename, ext = os.path.splitext(filename)
if basename in ignore_addon_names:
continue
# Validations
if os.path.isdir(fullpath):
# Check existence of init file
@ -446,69 +408,43 @@ def _load_addons_in_core(
# - check manifest and content of manifest
try:
# Don't import dynamically current directory modules
new_import_str = f"{modules_key}.{basename}"
import_str = f"ayon_core.modules.{basename}"
default_module = __import__(import_str, fromlist=("", ))
sys.modules[new_import_str] = default_module
setattr(openpype_modules, basename, default_module)
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():
# Key under which will be modules imported in `sys.modules`
modules_key = "openpype_modules"
# Change `sys.modules`
sys.modules[modules_key] = openpype_modules = _ModuleClass(modules_key)
log = Logger.get_logger("AddonsLoader")
ignore_addon_names = _load_ayon_addons(
openpype_modules, modules_key, log
)
_load_addons_in_core(
ignore_addon_names, openpype_modules, modules_key, log
)
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))
_MARKING_ATTR = "_marking"
def mark_func(func):
"""Mark function to be used in report.
Args:
func (Callable): Function to mark.
Returns:
Callable: Marked function.
"""
setattr(func, _MARKING_ATTR, True)
return func
def is_func_marked(func):
return getattr(func, _MARKING_ATTR, False)
# Store modules to local cache
_LoadCache.addon_modules = addon_modules
class AYONAddon(ABC):
"""Base class of AYON addon.
Attributes:
id (UUID): Addon object id.
enabled (bool): Is addon enabled.
name (str): Addon name.
Args:
manager (AddonsManager): Manager object who discovered addon.
settings (dict[str, Any]): AYON settings.
"""
"""
enabled = True
_id = None
@ -528,8 +464,8 @@ class AYONAddon(ABC):
Returns:
str: Object id.
"""
"""
if self._id is None:
self._id = uuid4()
return self._id
@ -541,8 +477,8 @@ class AYONAddon(ABC):
Returns:
str: Addon name.
"""
"""
pass
@property
@ -573,18 +509,40 @@ class AYONAddon(ABC):
Args:
settings (dict[str, Any]): Settings.
"""
"""
pass
@mark_func
def connect_with_addons(self, enabled_addons):
"""Connect with other enabled addons.
Args:
enabled_addons (list[AYONAddon]): Addons that are enabled.
"""
"""
pass
def ensure_is_process_ready(
self, process_context: ProcessContext
):
"""Make sure addon is prepared for a process.
This method is called when some action makes sure that addon has set
necessary data. For example if user should be logged in
and filled credentials in environment variables this method should
ask user for credentials.
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.
Args:
process_context (ProcessContext): Context of child
process.
"""
pass
def get_global_environments(self):
@ -594,8 +552,8 @@ class AYONAddon(ABC):
Returns:
dict[str, str]: Environment variables.
"""
"""
return {}
def modify_application_launch_arguments(self, application, env):
@ -607,8 +565,8 @@ class AYONAddon(ABC):
Args:
application (Application): Application that is launched.
env (dict[str, str]): Current environment variables.
"""
"""
pass
def on_host_install(self, host, host_name, project_name):
@ -627,8 +585,8 @@ class AYONAddon(ABC):
host_name (str): Name of host.
project_name (str): Project name which is main part of host
context.
"""
"""
pass
def cli(self, addon_click_group):
@ -655,31 +613,11 @@ class AYONAddon(ABC):
Args:
addon_click_group (click.Group): Group to which can be added
commands.
"""
pass
class OpenPypeModule(AYONAddon):
"""Base class of OpenPype module.
Deprecated:
Use `AYONAddon` instead.
Args:
manager (AddonsManager): Manager object who discovered addon.
settings (dict[str, Any]): Module settings (OpenPype settings).
"""
# Disable by default
enabled = False
class OpenPypeAddOn(OpenPypeModule):
# Enable Addon by default
enabled = True
class _AddonReportInfo:
def __init__(
self, class_name, name, version, report_value_by_label
@ -711,8 +649,8 @@ class AddonsManager:
settings (Optional[dict[str, Any]]): AYON studio settings.
initialize (Optional[bool]): Initialize addons on init.
True by default.
"""
"""
# Helper attributes for report
_report_total_key = "Total"
_log = None
@ -748,8 +686,8 @@ class AddonsManager:
Returns:
Union[AYONAddon, Any]: Addon found by name or `default`.
"""
"""
return self._addons_by_name.get(addon_name, default)
@property
@ -776,8 +714,8 @@ class AddonsManager:
Returns:
Union[AYONAddon, None]: Enabled addon found by name or None.
"""
"""
addon = self.get(addon_name)
if addon is not None and addon.enabled:
return addon
@ -788,8 +726,8 @@ class AddonsManager:
Returns:
list[AYONAddon]: Initialized and enabled addons.
"""
"""
return [
addon
for addon in self._addons
@ -801,8 +739,6 @@ class AddonsManager:
# Make sure modules are loaded
load_addons()
import openpype_modules
self.log.debug("*** AYON addons initialization.")
# Prepare settings for addons
@ -810,14 +746,12 @@ class AddonsManager:
if settings is None:
settings = get_studio_settings()
modules_settings = {}
report = {}
time_start = time.time()
prev_start_time = time_start
addon_classes = []
for module in openpype_modules:
for module in _LoadCache.addon_modules:
# Go through globals in `ayon_core.modules`
for name in dir(module):
modules_item = getattr(module, name, None)
@ -826,8 +760,6 @@ class AddonsManager:
if (
not inspect.isclass(modules_item)
or modules_item is AYONAddon
or modules_item is OpenPypeModule
or modules_item is OpenPypeAddOn
or not issubclass(modules_item, AYONAddon)
):
continue
@ -853,33 +785,14 @@ class AddonsManager:
addon_classes.append(modules_item)
aliased_names = []
for addon_cls in addon_classes:
name = addon_cls.__name__
if issubclass(addon_cls, OpenPypeModule):
# TODO change to warning
self.log.debug((
"Addon '{}' is inherited from 'OpenPypeModule'."
" Please use 'AYONAddon'."
).format(name))
try:
# Try initialize module
if issubclass(addon_cls, OpenPypeModule):
addon = addon_cls(self, modules_settings)
else:
addon = addon_cls(self, settings)
addon = addon_cls(self, settings)
# Store initialized object
self._addons.append(addon)
self._addons_by_id[addon.id] = addon
self._addons_by_name[addon.name] = addon
# NOTE This will be removed with release 1.0.0 of ayon-core
# please use carefully.
# Gives option to use alias name for addon for cases when
# name in OpenPype was not the same as in AYON.
name_alias = getattr(addon, "openpype_alias", None)
if name_alias:
aliased_names.append((name_alias, addon))
now = time.time()
report[addon.__class__.__name__] = now - prev_start_time
@ -898,17 +811,6 @@ class AddonsManager:
f"[{enabled_str}] {addon.name} ({addon.version})"
)
for item in aliased_names:
name_alias, addon = item
if name_alias not in self._addons_by_name:
self._addons_by_name[name_alias] = addon
continue
self.log.warning(
"Alias name '{}' of addon '{}' is already assigned.".format(
name_alias, addon.name
)
)
if self._report is not None:
report[self._report_total_key] = time.time() - time_start
self._report["Initialization"] = report
@ -925,16 +827,7 @@ class AddonsManager:
self.log.debug("Has {} enabled addons.".format(len(enabled_addons)))
for addon in enabled_addons:
try:
if not is_func_marked(addon.connect_with_addons):
addon.connect_with_addons(enabled_addons)
elif hasattr(addon, "connect_with_modules"):
self.log.warning((
"DEPRECATION WARNING: Addon '{}' still uses"
" 'connect_with_modules' method. Please switch to use"
" 'connect_with_addons' method."
).format(addon.name))
addon.connect_with_modules(enabled_addons)
addon.connect_with_addons(enabled_addons)
except Exception:
self.log.error(
@ -1283,56 +1176,3 @@ class AddonsManager:
# Join rows with newline char and add new line at the end
output = "\n".join(formatted_rows) + "\n"
print(output)
# DEPRECATED - Module compatibility
@property
def modules(self):
self.log.warning(
"DEPRECATION WARNING: Used deprecated property"
" 'modules' please use 'addons' instead."
)
return self.addons
@property
def modules_by_id(self):
self.log.warning(
"DEPRECATION WARNING: Used deprecated property"
" 'modules_by_id' please use 'addons_by_id' instead."
)
return self.addons_by_id
@property
def modules_by_name(self):
self.log.warning(
"DEPRECATION WARNING: Used deprecated property"
" 'modules_by_name' please use 'addons_by_name' instead."
)
return self.addons_by_name
def get_enabled_module(self, *args, **kwargs):
self.log.warning(
"DEPRECATION WARNING: Used deprecated method"
" 'get_enabled_module' please use 'get_enabled_addon' instead."
)
return self.get_enabled_addon(*args, **kwargs)
def initialize_modules(self):
self.log.warning(
"DEPRECATION WARNING: Used deprecated method"
" 'initialize_modules' please use 'initialize_addons' instead."
)
self.initialize_addons()
def get_enabled_modules(self):
self.log.warning(
"DEPRECATION WARNING: Used deprecated method"
" 'get_enabled_modules' please use 'get_enabled_addons' instead."
)
return self.get_enabled_addons()
def get_host_module(self, host_name):
self.log.warning(
"DEPRECATION WARNING: Used deprecated method"
" 'get_host_module' please use 'get_host_addon' instead."
)
return self.get_host_addon(host_name)

View file

@ -0,0 +1,132 @@
import sys
import json
from typing import Optional
from qtpy import QtWidgets, QtCore
from ayon_core.style import load_stylesheet
from ayon_core.tools.utils import get_ayon_qt_app
class DetailDialog(QtWidgets.QDialog):
def __init__(self, detail, parent):
super().__init__(parent)
self.setWindowTitle("Detail")
detail_input = QtWidgets.QPlainTextEdit(self)
detail_input.setPlainText(detail)
detail_input.setReadOnly(True)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(detail_input, 1)
def showEvent(self, event):
self.resize(600, 400)
super().showEvent(event)
class ErrorDialog(QtWidgets.QDialog):
def __init__(
self,
message: str,
detail: Optional[str],
parent: Optional[QtWidgets.QWidget] = None
):
super().__init__(parent)
self.setWindowTitle("Preparation failed")
self.setWindowFlags(
self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint
)
message_label = QtWidgets.QLabel(self)
detail_wrapper = QtWidgets.QWidget(self)
detail_label = QtWidgets.QLabel(detail_wrapper)
detail_layout = QtWidgets.QVBoxLayout(detail_wrapper)
detail_layout.setContentsMargins(0, 0, 0, 0)
detail_layout.addWidget(detail_label)
btns_wrapper = QtWidgets.QWidget(self)
copy_detail_btn = QtWidgets.QPushButton("Copy detail", btns_wrapper)
show_detail_btn = QtWidgets.QPushButton("Show detail", btns_wrapper)
confirm_btn = QtWidgets.QPushButton("Close", btns_wrapper)
btns_layout = QtWidgets.QHBoxLayout(btns_wrapper)
btns_layout.setContentsMargins(0, 0, 0, 0)
btns_layout.addWidget(copy_detail_btn, 0)
btns_layout.addWidget(show_detail_btn, 0)
btns_layout.addStretch(1)
btns_layout.addWidget(confirm_btn, 0)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(message_label, 0)
layout.addWidget(detail_wrapper, 1)
layout.addWidget(btns_wrapper, 0)
copy_detail_btn.clicked.connect(self._on_copy_clicked)
show_detail_btn.clicked.connect(self._on_show_detail_clicked)
confirm_btn.clicked.connect(self._on_confirm_clicked)
self._message_label = message_label
self._detail_wrapper = detail_wrapper
self._detail_label = detail_label
self._copy_detail_btn = copy_detail_btn
self._show_detail_btn = show_detail_btn
self._confirm_btn = confirm_btn
self._detail_dialog = None
self._detail = detail
self.set_message(message, detail)
def showEvent(self, event):
self.setStyleSheet(load_stylesheet())
self.resize(320, 140)
super().showEvent(event)
def set_message(self, message, detail):
self._message_label.setText(message)
self._detail = detail
for widget in (
self._copy_detail_btn,
self._show_detail_btn,
):
widget.setVisible(bool(detail))
def _on_copy_clicked(self):
if self._detail:
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(self._detail)
def _on_show_detail_clicked(self):
if self._detail_dialog is None:
self._detail_dialog = DetailDialog(self._detail, self)
self._detail_dialog.show()
def _on_confirm_clicked(self):
self.accept()
def main():
json_path = sys.argv[-1]
with open(json_path, "r") as stream:
data = json.load(stream)
message = data["message"]
detail = data["detail"]
app = get_ayon_qt_app()
dialog = ErrorDialog(message, detail)
dialog.show()
app.exec_()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,201 @@
import os
import sys
import contextlib
import tempfile
import json
import traceback
from io import StringIO
from typing import Optional
from ayon_core.lib import run_ayon_launcher_process
from .base import AddonsManager, ProcessContext, ProcessPreparationError
def _handle_error(
process_context: ProcessContext,
message: str,
detail: Optional[str],
):
"""Handle error in process ready preparation.
Shows UI to inform user about the error, or prints the message
to stdout if running in headless mode.
Todos:
Make this functionality with the dialog as unified function, so it can
be used elsewhere.
Args:
process_context (ProcessContext): The context in which the
error occurred.
message (str): The message to show.
detail (Optional[str]): The detail message to show (usually
traceback).
"""
if process_context.headless:
if detail:
print(detail)
print(f"{10*'*'}\n{message}\n{10*'*'}")
return
current_dir = os.path.dirname(os.path.abspath(__file__))
script_path = os.path.join(current_dir, "ui", "process_ready_error.py")
with tempfile.NamedTemporaryFile("w", delete=False) as tmp:
tmp_path = tmp.name
json.dump(
{"message": message, "detail": detail},
tmp.file
)
try:
run_ayon_launcher_process(
"--skip-bootstrap",
script_path,
tmp_path,
add_sys_paths=True,
creationflags=0,
)
finally:
os.remove(tmp_path)
def _start_tray():
from ayon_core.tools.tray import make_sure_tray_is_running
make_sure_tray_is_running()
def ensure_addons_are_process_context_ready(
process_context: ProcessContext,
addons_manager: Optional[AddonsManager] = None,
exit_on_failure: bool = True,
) -> bool:
"""Ensure all enabled addons are ready to be used in the given context.
Call this method only in AYON launcher process and as first thing
to avoid possible clashes with preparation. For example 'QApplication'
should not be created.
Todos:
Run all preparations and allow to "ignore" failed preparations.
Right now single addon can block using certain actions.
Args:
process_context (ProcessContext): The context in which the
addons should be prepared.
addons_manager (Optional[AddonsManager]): The addons
manager to use. If not provided, a new one will be created.
exit_on_failure (bool, optional): If True, the process will exit
if an error occurs. Defaults to True.
Returns:
bool: True if all addons are ready, False otherwise.
"""
if addons_manager is None:
addons_manager = AddonsManager()
message = None
failed = False
use_detail = False
# Wrap the output in StringIO to capture it for details on fail
# - but in case stdout was invalid on start of process also store
# the tracebacks
tracebacks = []
output = StringIO()
with contextlib.redirect_stdout(output):
with contextlib.redirect_stderr(output):
for addon in addons_manager.get_enabled_addons():
addon_failed = True
try:
addon.ensure_is_process_ready(process_context)
addon_failed = False
except ProcessPreparationError as exc:
message = str(exc)
print(f"Addon preparation failed: '{addon.name}'")
print(message)
except BaseException:
use_detail = True
message = "An unexpected error occurred."
formatted_traceback = "".join(traceback.format_exception(
*sys.exc_info()
))
tracebacks.append(formatted_traceback)
print(f"Addon preparation failed: '{addon.name}'")
print(message)
# Print the traceback so it is in the stdout
print(formatted_traceback)
if addon_failed:
failed = True
break
output_str = output.getvalue()
# Print stdout/stderr to console as it was redirected
print(output_str)
if not failed:
if not process_context.headless:
_start_tray()
return True
detail = None
if use_detail:
# In case stdout was not captured, use the tracebacks as detail
if not output_str:
output_str = "\n".join(tracebacks)
detail = output_str
_handle_error(process_context, message, detail)
if exit_on_failure:
sys.exit(1)
return False
def ensure_addons_are_process_ready(
addon_name: str,
addon_version: str,
project_name: Optional[str] = None,
headless: Optional[bool] = None,
*,
addons_manager: Optional[AddonsManager] = None,
exit_on_failure: bool = True,
**kwargs,
) -> bool:
"""Ensure all enabled addons are ready to be used in the given context.
Call this method only in AYON launcher process and as first thing
to avoid possible clashes with preparation. For example 'QApplication'
should not be created.
Args:
addon_name (str): Addon name which triggered process.
addon_version (str): Addon version which triggered process.
project_name (Optional[str]): Project name. Can be filled in case
process is triggered for specific project. Some addons can have
different behavior based on project. Value is NOT autofilled.
headless (Optional[bool]): Is process running in headless mode. Value
is filled with value based on state set in AYON launcher.
addons_manager (Optional[AddonsManager]): The addons
manager to use. If not provided, a new one will be created.
exit_on_failure (bool, optional): If True, the process will exit
if an error occurs. Defaults to True.
kwargs: The keyword arguments to pass to the ProcessContext.
Returns:
bool: True if all addons are ready, False otherwise.
"""
context: ProcessContext = ProcessContext(
addon_name,
addon_version,
project_name,
headless,
**kwargs
)
return ensure_addons_are_process_context_ready(
context, addons_manager, exit_on_failure
)

View file

@ -21,21 +21,7 @@ from ayon_core.lib import (
class AliasedGroup(click.Group):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._aliases = {}
def set_alias(self, src_name, dst_name):
self._aliases[dst_name] = src_name
def get_command(self, ctx, cmd_name):
if cmd_name in self._aliases:
cmd_name = self._aliases[cmd_name]
return super().get_command(ctx, cmd_name)
@click.group(cls=AliasedGroup, invoke_without_command=True)
@click.group(invoke_without_command=True)
@click.pass_context
@click.option("--use-staging", is_flag=True,
expose_value=False, help="use staging variants")
@ -86,10 +72,6 @@ def addon(ctx):
pass
# Add 'addon' as alias for module
main_cli.set_alias("addon", "module")
@main_cli.command()
@click.pass_context
@click.argument("output_json_path")

View file

@ -94,4 +94,4 @@ class GlobalHostDataHook(PreLaunchHook):
task_entity = get_task_by_name(
project_name, folder_entity["id"], task_name
)
self.data["task_entity"] = task_entity
self.data["task_entity"] = task_entity

View file

@ -7,11 +7,10 @@ from .local_settings import (
JSONSettingRegistry,
AYONSecureRegistry,
AYONSettingsRegistry,
OpenPypeSecureRegistry,
OpenPypeSettingsRegistry,
get_launcher_local_dir,
get_launcher_storage_dir,
get_local_site_id,
get_ayon_username,
get_openpype_username,
)
from .ayon_connection import initialize_ayon_connection
from .cache import (
@ -57,13 +56,11 @@ from .env_tools import (
from .terminal import Terminal
from .execute import (
get_ayon_launcher_args,
get_openpype_execute_args,
get_linux_launcher_args,
execute,
run_subprocess,
run_detached_process,
run_ayon_launcher_process,
run_openpype_process,
path_to_subprocess_arg,
CREATE_NO_WINDOW
)
@ -109,6 +106,7 @@ from .transcoding import (
convert_ffprobe_fps_value,
convert_ffprobe_fps_to_float,
get_rescaled_command_arguments,
get_media_mime_type,
get_image_info_metadata,
)
@ -130,6 +128,7 @@ from .ayon_info import (
is_in_ayon_launcher_process,
is_running_from_build,
is_using_ayon_console,
is_headless_mode_enabled,
is_staging_enabled,
is_dev_mode_enabled,
is_in_tests,
@ -142,11 +141,10 @@ __all__ = [
"JSONSettingRegistry",
"AYONSecureRegistry",
"AYONSettingsRegistry",
"OpenPypeSecureRegistry",
"OpenPypeSettingsRegistry",
"get_launcher_local_dir",
"get_launcher_storage_dir",
"get_local_site_id",
"get_ayon_username",
"get_openpype_username",
"initialize_ayon_connection",
@ -157,13 +155,11 @@ __all__ = [
"register_event_callback",
"get_ayon_launcher_args",
"get_openpype_execute_args",
"get_linux_launcher_args",
"execute",
"run_subprocess",
"run_detached_process",
"run_ayon_launcher_process",
"run_openpype_process",
"path_to_subprocess_arg",
"CREATE_NO_WINDOW",
@ -210,6 +206,7 @@ __all__ = [
"convert_ffprobe_fps_value",
"convert_ffprobe_fps_to_float",
"get_rescaled_command_arguments",
"get_media_mime_type",
"get_image_info_metadata",
"compile_list_of_regexes",
@ -241,6 +238,7 @@ __all__ = [
"is_in_ayon_launcher_process",
"is_running_from_build",
"is_using_ayon_console",
"is_headless_mode_enabled",
"is_staging_enabled",
"is_dev_mode_enabled",
"is_in_tests",

View file

@ -4,7 +4,7 @@ import collections
import uuid
import json
import copy
from abc import ABCMeta, abstractmethod, abstractproperty
from abc import ABCMeta, abstractmethod
import clique
@ -16,7 +16,7 @@ _attr_defs_by_type = {}
def register_attr_def_class(cls):
"""Register attribute definition.
Currently are registered definitions used to deserialize data to objects.
Currently registered definitions are used to deserialize data to objects.
Attrs:
cls (AbstractAttrDef): Non-abstract class to be registered with unique
@ -60,7 +60,7 @@ def get_default_values(attribute_definitions):
for which default values should be collected.
Returns:
Dict[str, Any]: Default values for passet attribute definitions.
Dict[str, Any]: Default values for passed attribute definitions.
"""
output = {}
@ -75,13 +75,13 @@ def get_default_values(attribute_definitions):
class AbstractAttrDefMeta(ABCMeta):
"""Metaclass to validate existence of 'key' attribute.
"""Metaclass to validate the existence of 'key' attribute.
Each object of `AbstractAttrDef` mus have defined 'key' attribute.
Each object of `AbstractAttrDef` must have defined 'key' attribute.
"""
def __call__(self, *args, **kwargs):
obj = super(AbstractAttrDefMeta, self).__call__(*args, **kwargs)
def __call__(cls, *args, **kwargs):
obj = super(AbstractAttrDefMeta, cls).__call__(*args, **kwargs)
init_class = getattr(obj, "__init__class__", None)
if init_class is not AbstractAttrDef:
raise TypeError("{} super was not called in __init__.".format(
@ -162,7 +162,8 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
def __ne__(self, other):
return not self.__eq__(other)
@abstractproperty
@property
@abstractmethod
def type(self):
"""Attribute definition type also used as identifier of class.
@ -215,7 +216,7 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
# -----------------------------------------
# UI attribute definitoins won't hold value
# UI attribute definitions won't hold value
# -----------------------------------------
class UIDef(AbstractAttrDef):
@ -245,7 +246,7 @@ class UILabelDef(UIDef):
# ---------------------------------------
# Attribute defintioins should hold value
# Attribute definitions should hold value
# ---------------------------------------
class UnknownDef(AbstractAttrDef):
@ -311,7 +312,7 @@ class NumberDef(AbstractAttrDef):
):
minimum = 0 if minimum is None else minimum
maximum = 999999 if maximum is None else maximum
# Swap min/max when are passed in opposited order
# Swap min/max when are passed in opposite order
if minimum > maximum:
maximum, minimum = minimum, maximum
@ -364,10 +365,10 @@ class NumberDef(AbstractAttrDef):
class TextDef(AbstractAttrDef):
"""Text definition.
Text can have multiline option so endline characters are allowed regex
Text can have multiline option so end-line characters are allowed regex
validation can be applied placeholder for UI purposes and default value.
Regex validation is not part of attribute implemntentation.
Regex validation is not part of attribute implementation.
Args:
multiline(bool): Text has single or multiline support.
@ -577,7 +578,7 @@ class BoolDef(AbstractAttrDef):
return self.default
class FileDefItem(object):
class FileDefItem:
def __init__(
self, directory, filenames, frames=None, template=None
):
@ -949,7 +950,8 @@ def deserialize_attr_def(attr_def_data):
"""Deserialize attribute definition from data.
Args:
attr_def (Dict[str, Any]): Attribute definition data to deserialize.
attr_def_data (Dict[str, Any]): Attribute definition data to
deserialize.
"""
attr_type = attr_def_data.pop("type")

View file

@ -78,6 +78,10 @@ def is_using_ayon_console():
return "ayon_console" in executable_filename
def is_headless_mode_enabled():
return os.getenv("AYON_HEADLESS_MODE") == "1"
def is_staging_enabled():
return os.getenv("AYON_USE_STAGING") == "1"

View file

@ -8,7 +8,6 @@ import logging
import weakref
from uuid import uuid4
from .python_2_comp import WeakMethod
from .python_module_tools import is_func_signature_supported
@ -18,7 +17,7 @@ class MissingEventSystem(Exception):
def _get_func_ref(func):
if inspect.ismethod(func):
return WeakMethod(func)
return weakref.WeakMethod(func)
return weakref.ref(func)
@ -123,7 +122,7 @@ class weakref_partial:
)
class EventCallback(object):
class EventCallback:
"""Callback registered to a topic.
The callback function is registered to a topic. Topic is a string which
@ -380,8 +379,7 @@ class EventCallback(object):
self._partial_func = None
# Inherit from 'object' for Python 2 hosts
class Event(object):
class Event:
"""Base event object.
Can be used for any event because is not specific. Only required argument
@ -488,7 +486,7 @@ class Event(object):
return obj
class EventSystem(object):
class EventSystem:
"""Encapsulate event handling into an object.
System wraps registered callbacks and triggered events into single object,

View file

@ -108,6 +108,20 @@ def run_subprocess(*args, **kwargs):
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
)
# Escape parentheses for bash
if (
kwargs.get("shell") is True
and len(args) == 1
and isinstance(args[0], str)
and os.getenv("SHELL") in ("/bin/bash", "/bin/sh")
):
new_arg = (
args[0]
.replace("(", "\\(")
.replace(")", "\\)")
)
args = (new_arg, )
# Get environents from kwarg or use current process environments if were
# not passed.
env = kwargs.get("env") or os.environ
@ -179,7 +193,7 @@ def clean_envs_for_ayon_process(env=None):
return env
def run_ayon_launcher_process(*args, **kwargs):
def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs):
"""Execute AYON process with passed arguments and wait.
Wrapper for 'run_process' which prepends AYON executable arguments
@ -209,29 +223,18 @@ def run_ayon_launcher_process(*args, **kwargs):
# - fill more if you find more
env = clean_envs_for_ayon_process(os.environ)
if add_sys_paths:
new_pythonpath = list(sys.path)
lookup_set = set(new_pythonpath)
for path in (env.get("PYTHONPATH") or "").split(os.pathsep):
if path and path not in lookup_set:
new_pythonpath.append(path)
lookup_set.add(path)
env["PYTHONPATH"] = os.pathsep.join(new_pythonpath)
return run_subprocess(args, env=env, **kwargs)
def run_openpype_process(*args, **kwargs):
"""Execute AYON process with passed arguments and wait.
Wrapper for 'run_process' which prepends AYON executable arguments
before passed arguments and define environments if are not passed.
Values from 'os.environ' are used for environments if are not passed.
They are cleaned using 'clean_envs_for_ayon_process' function.
Example:
>>> run_openpype_process("version")
Args:
*args (tuple): AYON cli arguments.
**kwargs (dict): Keyword arguments for subprocess.Popen.
"""
return run_ayon_launcher_process(*args, **kwargs)
def run_detached_process(args, **kwargs):
"""Execute process with passed arguments as separated process.
@ -318,14 +321,12 @@ def path_to_subprocess_arg(path):
def get_ayon_launcher_args(*args):
"""Arguments to run ayon-launcher process.
"""Arguments to run AYON launcher process.
Arguments for subprocess when need to spawn new pype process. Which may be
needed when new python process for pype scripts must be executed in build
pype.
Arguments for subprocess when need to spawn new AYON launcher process.
Reasons:
Ayon-launcher started from code has different executable set to
AYON launcher started from code has different executable set to
virtual env python and must have path to script as first argument
which is not needed for built application.
@ -333,7 +334,8 @@ def get_ayon_launcher_args(*args):
*args (str): Any arguments that will be added after executables.
Returns:
list[str]: List of arguments to run ayon-launcher process.
list[str]: List of arguments to run AYON launcher process.
"""
executable = os.environ["AYON_EXECUTABLE"]
launch_args = [executable]
@ -391,21 +393,3 @@ def get_linux_launcher_args(*args):
launch_args.extend(args)
return launch_args
def get_openpype_execute_args(*args):
"""Arguments to run pype command.
Arguments for subprocess when need to spawn new pype process. Which may be
needed when new python process for pype scripts must be executed in build
pype.
## Why is this needed?
Pype executed from code has different executable set to virtual env python
and must have path to script as first argument which is not needed for
build pype.
It is possible to pass any arguments that will be added after pype
executables.
"""
return get_ayon_launcher_args(*args)

View file

@ -22,7 +22,7 @@ class DuplicateDestinationError(ValueError):
"""
class FileTransaction(object):
class FileTransaction:
"""File transaction with rollback options.
The file transaction is a three-step process.

View file

@ -3,26 +3,11 @@
import os
import json
import platform
import configparser
import warnings
from datetime import datetime
from abc import ABC, abstractmethod
# disable lru cache in Python 2
try:
from functools import lru_cache
except ImportError:
def lru_cache(maxsize):
def max_size(func):
def wrapper(*args, **kwargs):
value = func(*args, **kwargs)
return value
return wrapper
return max_size
# ConfigParser was renamed in python3 to configparser
try:
import configparser
except ImportError:
import ConfigParser as configparser
from functools import lru_cache
import appdirs
import ayon_api
@ -30,6 +15,87 @@ import ayon_api
_PLACEHOLDER = object()
def _get_ayon_appdirs(*args):
return os.path.join(
appdirs.user_data_dir("AYON", "Ynput"),
*args
)
def get_ayon_appdirs(*args):
"""Local app data directory of AYON client.
Deprecated:
Use 'get_launcher_local_dir' or 'get_launcher_storage_dir' based on
use-case. Deprecation added 24/08/09 (0.4.4-dev.1).
Args:
*args (Iterable[str]): Subdirectories/files in local app data dir.
Returns:
str: Path to directory/file in local app data dir.
"""
warnings.warn(
(
"Function 'get_ayon_appdirs' is deprecated. Should be replaced"
" with 'get_launcher_local_dir' or 'get_launcher_storage_dir'"
" based on use-case."
),
DeprecationWarning
)
return _get_ayon_appdirs(*args)
def get_launcher_storage_dir(*subdirs: str) -> str:
"""Get storage directory for launcher.
Storage directory is used for storing shims, addons, dependencies, etc.
It is not recommended, but the location can be shared across
multiple machines.
Note:
This function should be called at least once on bootstrap.
Args:
*subdirs (str): Subdirectories relative to storage dir.
Returns:
str: Path to storage directory.
"""
storage_dir = os.getenv("AYON_LAUNCHER_STORAGE_DIR")
if not storage_dir:
storage_dir = _get_ayon_appdirs()
return os.path.join(storage_dir, *subdirs)
def get_launcher_local_dir(*subdirs: str) -> str:
"""Get local directory for launcher.
Local directory is used for storing machine or user specific data.
The location is user specific.
Note:
This function should be called at least once on bootstrap.
Args:
*subdirs (str): Subdirectories relative to local dir.
Returns:
str: Path to local directory.
"""
storage_dir = os.getenv("AYON_LAUNCHER_LOCAL_DIR")
if not storage_dir:
storage_dir = _get_ayon_appdirs()
return os.path.join(storage_dir, *subdirs)
class AYONSecureRegistry:
"""Store information using keyring.
@ -470,55 +536,17 @@ class JSONSettingRegistry(ASettingRegistry):
class AYONSettingsRegistry(JSONSettingRegistry):
"""Class handling AYON general settings registry.
Attributes:
vendor (str): Name used for path construction.
product (str): Additional name used for path construction.
Args:
name (Optional[str]): Name of the registry.
"""
def __init__(self, name=None):
self.vendor = "Ynput"
self.product = "AYON"
if not name:
name = "AYON_settings"
path = appdirs.user_data_dir(self.product, self.vendor)
path = get_launcher_storage_dir()
super(AYONSettingsRegistry, self).__init__(name, path)
def _create_local_site_id(registry=None):
"""Create a local site identifier."""
from coolname import generate_slug
if registry is None:
registry = AYONSettingsRegistry()
new_id = generate_slug(3)
print("Created local site id \"{}\"".format(new_id))
registry.set_item("localId", new_id)
return new_id
def get_ayon_appdirs(*args):
"""Local app data directory of AYON client.
Args:
*args (Iterable[str]): Subdirectories/files in local app data dir.
Returns:
str: Path to directory/file in local app data dir.
"""
return os.path.join(
appdirs.user_data_dir("AYON", "Ynput"),
*args
)
def get_local_site_id():
"""Get local site identifier.
@ -529,7 +557,7 @@ def get_local_site_id():
if site_id:
return site_id
site_id_path = get_ayon_appdirs("site_id")
site_id_path = get_launcher_local_dir("site_id")
if os.path.exists(site_id_path):
with open(site_id_path, "r") as stream:
site_id = stream.read()
@ -556,11 +584,3 @@ def get_ayon_username():
"""
return ayon_api.get_user()["name"]
def get_openpype_username():
return get_ayon_username()
OpenPypeSecureRegistry = AYONSecureRegistry
OpenPypeSettingsRegistry = AYONSettingsRegistry

View file

@ -38,7 +38,7 @@ class TemplateUnsolved(Exception):
)
class StringTemplate(object):
class StringTemplate:
"""String that can be formatted."""
def __init__(self, template):
if not isinstance(template, str):
@ -410,7 +410,7 @@ class TemplatePartResult:
self._invalid_types[key] = type(value)
class FormatObject(object):
class FormatObject:
"""Object that can be used for formatting.
This is base that is valid for to be used in 'StringTemplate' value.
@ -460,6 +460,34 @@ class FormattingPart:
return True
return False
@staticmethod
def validate_key_is_matched(key):
"""Validate that opening has closing at correct place.
Future-proof, only square brackets are currently used in keys.
Example:
>>> is_matched("[]()()(((([])))")
False
>>> is_matched("[](){{{[]}}}")
True
Returns:
bool: Openings and closing are valid.
"""
mapping = dict(zip("({[", ")}]"))
opening = set(mapping.keys())
closing = set(mapping.values())
queue = []
for letter in key:
if letter in opening:
queue.append(mapping[letter])
elif letter in closing:
if not queue or letter != queue.pop():
return False
return not queue
def format(self, data, result):
"""Format the formattings string.
@ -472,6 +500,12 @@ class FormattingPart:
result.add_output(result.realy_used_values[key])
return result
# ensure key is properly formed [({})] properly closed.
if not self.validate_key_is_matched(key):
result.add_missing_key(key)
result.add_output(self.template)
return result
# check if key expects subdictionary keys (e.g. project[name])
existence_check = key
key_padding = list(KEY_PADDING_PATTERN.findall(existence_check))

View file

@ -1,44 +1,17 @@
# Deprecated file
# - the file container 'WeakMethod' implementation for Python 2 which is not
# needed anymore.
import warnings
import weakref
WeakMethod = getattr(weakref, "WeakMethod", None)
WeakMethod = weakref.WeakMethod
if WeakMethod is None:
class _WeakCallable:
def __init__(self, obj, func):
self.im_self = obj
self.im_func = func
def __call__(self, *args, **kws):
if self.im_self is None:
return self.im_func(*args, **kws)
else:
return self.im_func(self.im_self, *args, **kws)
class WeakMethod:
""" Wraps a function or, more importantly, a bound method in
a way that allows a bound method's object to be GCed, while
providing the same interface as a normal weak reference. """
def __init__(self, fn):
try:
self._obj = weakref.ref(fn.im_self)
self._meth = fn.im_func
except AttributeError:
# It's not a bound method
self._obj = None
self._meth = fn
def __call__(self):
if self._dead():
return None
return _WeakCallable(self._getobj(), self._meth)
def _dead(self):
return self._obj is not None and self._obj() is None
def _getobj(self):
if self._obj is None:
return None
return self._obj()
warnings.warn(
(
"'ayon_core.lib.python_2_comp' is deprecated."
"Please use 'weakref.WeakMethod'."
),
DeprecationWarning,
stacklevel=2
)

View file

@ -5,43 +5,30 @@ import importlib
import inspect
import logging
import six
log = logging.getLogger(__name__)
def import_filepath(filepath, module_name=None):
"""Import python file as python module.
Python 2 and Python 3 compatibility.
Args:
filepath(str): Path to python file.
module_name(str): Name of loaded module. Only for Python 3. By default
filepath (str): Path to python file.
module_name (str): Name of loaded module. Only for Python 3. By default
is filled with filename of filepath.
"""
if module_name is None:
module_name = os.path.splitext(os.path.basename(filepath))[0]
# Make sure it is not 'unicode' in Python 2
module_name = str(module_name)
# Prepare module object where content of file will be parsed
module = types.ModuleType(module_name)
module.__file__ = filepath
if six.PY3:
# Use loader so module has full specs
module_loader = importlib.machinery.SourceFileLoader(
module_name, filepath
)
module_loader.exec_module(module)
else:
# Execute module code and store content to module
with open(filepath) as _stream:
# Execute content and store it to module object
six.exec_(_stream.read(), module.__dict__)
# Use loader so module has full specs
module_loader = importlib.machinery.SourceFileLoader(
module_name, filepath
)
module_loader.exec_module(module)
return module
@ -139,35 +126,31 @@ def classes_from_module(superclass, module):
return classes
def _import_module_from_dirpath_py2(dirpath, module_name, dst_module_name):
"""Import passed dirpath as python module using `imp`."""
def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None):
"""Import passed directory as a python module.
Imported module can be assigned as a child attribute of already loaded
module from `sys.modules` if has support of `setattr`. That is not default
behavior of python modules so parent module must be a custom module with
that ability.
It is not possible to reimport already cached module. If you need to
reimport module you have to remove it from caches manually.
Args:
dirpath (str): Parent directory path of loaded folder.
folder_name (str): Folder name which should be imported inside passed
directory.
dst_module_name (str): Parent module name under which can be loaded
module added.
"""
# Import passed dirpath as python module
if dst_module_name:
full_module_name = "{}.{}".format(dst_module_name, module_name)
full_module_name = "{}.{}".format(dst_module_name, folder_name)
dst_module = sys.modules[dst_module_name]
else:
full_module_name = module_name
dst_module = None
if full_module_name in sys.modules:
return sys.modules[full_module_name]
import imp
fp, pathname, description = imp.find_module(module_name, [dirpath])
module = imp.load_module(full_module_name, fp, pathname, description)
if dst_module is not None:
setattr(dst_module, module_name, module)
return module
def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name):
"""Import passed dirpath as python module using Python 3 modules."""
if dst_module_name:
full_module_name = "{}.{}".format(dst_module_name, module_name)
dst_module = sys.modules[dst_module_name]
else:
full_module_name = module_name
full_module_name = folder_name
dst_module = None
# Skip import if is already imported
@ -191,7 +174,7 @@ def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name):
# Store module to destination module and `sys.modules`
# WARNING this mus be done before module execution
if dst_module is not None:
setattr(dst_module, module_name, module)
setattr(dst_module, folder_name, module)
sys.modules[full_module_name] = module
@ -201,37 +184,6 @@ def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name):
return module
def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None):
"""Import passed directory as a python module.
Python 2 and 3 compatible.
Imported module can be assigned as a child attribute of already loaded
module from `sys.modules` if has support of `setattr`. That is not default
behavior of python modules so parent module must be a custom module with
that ability.
It is not possible to reimport already cached module. If you need to
reimport module you have to remove it from caches manually.
Args:
dirpath(str): Parent directory path of loaded folder.
folder_name(str): Folder name which should be imported inside passed
directory.
dst_module_name(str): Parent module name under which can be loaded
module added.
"""
if six.PY3:
module = _import_module_from_dirpath_py3(
dirpath, folder_name, dst_module_name
)
else:
module = _import_module_from_dirpath_py2(
dirpath, folder_name, dst_module_name
)
return module
def is_func_signature_supported(func, *args, **kwargs):
"""Check if a function signature supports passed args and kwargs.
@ -275,25 +227,12 @@ def is_func_signature_supported(func, *args, **kwargs):
Returns:
bool: Function can pass in arguments.
"""
if hasattr(inspect, "signature"):
# Python 3 using 'Signature' object where we try to bind arg
# or kwarg. Using signature is recommended approach based on
# documentation.
sig = inspect.signature(func)
try:
sig.bind(*args, **kwargs)
return True
except TypeError:
pass
else:
# In Python 2 'signature' is not available so 'getcallargs' is used
# - 'getcallargs' is marked as deprecated since Python 3.0
try:
inspect.getcallargs(func, *args, **kwargs)
return True
except TypeError:
pass
sig = inspect.signature(func)
try:
sig.bind(*args, **kwargs)
return True
except TypeError:
pass
return False

View file

@ -6,6 +6,7 @@ import collections
import tempfile
import subprocess
import platform
from typing import Optional
import xml.etree.ElementTree
@ -1583,3 +1584,87 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None):
input_arg += ":ch={}".format(input_channels_str)
return input_arg, channels_arg
def _get_media_mime_type_from_ftyp(content):
if content[8:10] == b"qt":
return "video/quicktime"
if content[8:12] == b"isom":
return "video/mp4"
if content[8:12] in (b"M4V\x20", b"mp42"):
return "video/mp4v"
# (
# b"avc1", b"iso2", b"isom", b"mmp4", b"mp41", b"mp71",
# b"msnv", b"ndas", b"ndsc", b"ndsh", b"ndsm", b"ndsp", b"ndss",
# b"ndxc", b"ndxh", b"ndxm", b"ndxp", b"ndxs"
# )
return None
def get_media_mime_type(filepath: str) -> Optional[str]:
"""Determine Mime-Type of a file.
Args:
filepath (str): Path to file.
Returns:
Optional[str]: Mime type or None if is unknown mime type.
"""
if not filepath or not os.path.exists(filepath):
return None
with open(filepath, "rb") as stream:
content = stream.read()
content_len = len(content)
# Pre-validation (largest definition check)
# - hopefully there cannot be media defined in less than 12 bytes
if content_len < 12:
return None
# FTYP
if content[4:8] == b"ftyp":
return _get_media_mime_type_from_ftyp(content)
# BMP
if content[0:2] == b"BM":
return "image/bmp"
# Tiff
if content[0:2] in (b"MM", b"II"):
return "tiff"
# PNG
if content[0:4] == b"\211PNG":
return "image/png"
# SVG
if b'xmlns="http://www.w3.org/2000/svg"' in content:
return "image/svg+xml"
# JPEG, JFIF or Exif
if (
content[0:4] == b"\xff\xd8\xff\xdb"
or content[6:10] in (b"JFIF", b"Exif")
):
return "image/jpeg"
# Webp
if content[0:4] == b"RIFF" and content[8:12] == b"WEBP":
return "image/webp"
# Gif
if content[0:6] in (b"GIF87a", b"GIF89a"):
return "gif"
# Adobe PhotoShop file (8B > Adobe, PS > PhotoShop)
if content[0:4] == b"8BPS":
return "image/vnd.adobe.photoshop"
# Windows ICO > this might be wild guess as multiple files can start
# with this header
if content[0:4] == b"\x00\x00\x01\x00":
return "image/x-icon"
return None

View file

@ -1,40 +0,0 @@
# -*- coding: utf-8 -*-
from . import click_wrap
from .interfaces import (
IPluginPaths,
ITrayAddon,
ITrayModule,
ITrayAction,
ITrayService,
IHostAddon,
)
from .base import (
AYONAddon,
OpenPypeModule,
OpenPypeAddOn,
load_modules,
ModulesManager,
)
__all__ = (
"click_wrap",
"IPluginPaths",
"ITrayAddon",
"ITrayModule",
"ITrayAction",
"ITrayService",
"IHostAddon",
"AYONAddon",
"OpenPypeModule",
"OpenPypeAddOn",
"load_modules",
"ModulesManager",
)

View file

@ -1,25 +0,0 @@
# Backwards compatibility support
# - TODO should be removed before release 1.0.0
from ayon_core.addon import (
AYONAddon,
AddonsManager,
load_addons,
)
from ayon_core.addon.base import (
OpenPypeModule,
OpenPypeAddOn,
)
ModulesManager = AddonsManager
load_modules = load_addons
__all__ = (
"AYONAddon",
"AddonsManager",
"load_addons",
"OpenPypeModule",
"OpenPypeAddOn",
"ModulesManager",
"load_modules",
)

View file

@ -1 +0,0 @@
from ayon_core.addon.click_wrap import *

View file

@ -1,21 +0,0 @@
from ayon_core.addon.interfaces import (
IPluginPaths,
ITrayAddon,
ITrayAction,
ITrayService,
IHostAddon,
)
ITrayModule = ITrayAddon
ILaunchHookPaths = object
__all__ = (
"IPluginPaths",
"ITrayAddon",
"ITrayAction",
"ITrayService",
"IHostAddon",
"ITrayModule",
"ILaunchHookPaths",
)

View file

@ -55,7 +55,6 @@ from .publish import (
PublishXmlValidationError,
KnownPublishError,
AYONPyblishPluginMixin,
OpenPypePyblishPluginMixin,
OptionalPyblishPluginMixin,
)
@ -77,7 +76,6 @@ from .actions import (
from .context_tools import (
install_ayon_plugins,
install_openpype_plugins,
install_host,
uninstall_host,
is_installed,
@ -168,7 +166,6 @@ __all__ = (
"PublishXmlValidationError",
"KnownPublishError",
"AYONPyblishPluginMixin",
"OpenPypePyblishPluginMixin",
"OptionalPyblishPluginMixin",
# --- Actions ---
@ -187,7 +184,6 @@ __all__ = (
# --- Process context ---
"install_ayon_plugins",
"install_openpype_plugins",
"install_host",
"uninstall_host",
"is_installed",

View file

@ -234,16 +234,6 @@ def install_ayon_plugins(project_name=None, host_name=None):
register_inventory_action_path(path)
def install_openpype_plugins(project_name=None, host_name=None):
"""Install AYON core plugins and make sure the core is initialized.
Deprecated:
Use `install_ayon_plugins` instead.
"""
install_ayon_plugins(project_name, host_name)
def uninstall_host():
"""Undo all of what `install()` did"""
host = registered_host()

View file

@ -4,21 +4,41 @@ from .constants import (
PRE_CREATE_THUMBNAIL_KEY,
DEFAULT_VARIANT_VALUE,
)
from .exceptions import (
UnavailableSharedData,
ImmutableKeyError,
HostMissRequiredMethod,
ConvertorsOperationFailed,
ConvertorsFindFailed,
ConvertorsConversionFailed,
CreatorError,
CreatorsCreateFailed,
CreatorsCollectionFailed,
CreatorsSaveFailed,
CreatorsRemoveFailed,
CreatorsOperationFailed,
TaskNotSetError,
TemplateFillError,
)
from .structures import (
CreatedInstance,
ConvertorItem,
AttributeValues,
CreatorAttributeValues,
PublishAttributeValues,
PublishAttributes,
)
from .utils import (
get_last_versions_for_instances,
get_next_versions_for_instances,
)
from .product_name import (
TaskNotSetError,
get_product_name,
get_product_name_template,
)
from .creator_plugins import (
CreatorError,
BaseCreator,
Creator,
AutoCreator,
@ -36,10 +56,7 @@ from .creator_plugins import (
cache_and_get_instances,
)
from .context import (
CreatedInstance,
CreateContext
)
from .context import CreateContext
from .legacy_create import (
LegacyCreator,
@ -53,10 +70,31 @@ __all__ = (
"PRE_CREATE_THUMBNAIL_KEY",
"DEFAULT_VARIANT_VALUE",
"UnavailableSharedData",
"ImmutableKeyError",
"HostMissRequiredMethod",
"ConvertorsOperationFailed",
"ConvertorsFindFailed",
"ConvertorsConversionFailed",
"CreatorError",
"CreatorsCreateFailed",
"CreatorsCollectionFailed",
"CreatorsSaveFailed",
"CreatorsRemoveFailed",
"CreatorsOperationFailed",
"TaskNotSetError",
"TemplateFillError",
"CreatedInstance",
"ConvertorItem",
"AttributeValues",
"CreatorAttributeValues",
"PublishAttributeValues",
"PublishAttributes",
"get_last_versions_for_instances",
"get_next_versions_for_instances",
"TaskNotSetError",
"get_product_name",
"get_product_name_template",
@ -78,7 +116,6 @@ __all__ = (
"cache_and_get_instances",
"CreatedInstance",
"CreateContext",
"LegacyCreator",

View file

@ -0,0 +1,313 @@
import copy
_EMPTY_VALUE = object()
class TrackChangesItem:
"""Helper object to track changes in data.
Has access to full old and new data and will create deep copy of them,
so it is not needed to create copy before passed in.
Can work as a dictionary if old or new value is a dictionary. In
that case received object is another object of 'TrackChangesItem'.
Goal is to be able to get old or new value as was or only changed values
or get information about removed/changed keys, and all of that on
any "dictionary level".
```
# Example of possible usages
>>> old_value = {
... "key_1": "value_1",
... "key_2": {
... "key_sub_1": 1,
... "key_sub_2": {
... "enabled": True
... }
... },
... "key_3": "value_2"
... }
>>> new_value = {
... "key_1": "value_1",
... "key_2": {
... "key_sub_2": {
... "enabled": False
... },
... "key_sub_3": 3
... },
... "key_3": "value_3"
... }
>>> changes = TrackChangesItem(old_value, new_value)
>>> changes.changed
True
>>> changes["key_2"]["key_sub_1"].new_value is None
True
>>> list(sorted(changes.changed_keys))
['key_2', 'key_3']
>>> changes["key_2"]["key_sub_2"]["enabled"].changed
True
>>> changes["key_2"].removed_keys
{'key_sub_1'}
>>> list(sorted(changes["key_2"].available_keys))
['key_sub_1', 'key_sub_2', 'key_sub_3']
>>> changes.new_value == new_value
True
# Get only changed values
only_changed_new_values = {
key: changes[key].new_value
for key in changes.changed_keys
}
```
Args:
old_value (Any): Old value.
new_value (Any): New value.
"""
def __init__(self, old_value, new_value):
self._changed = old_value != new_value
# Resolve if value is '_EMPTY_VALUE' after comparison of the values
if old_value is _EMPTY_VALUE:
old_value = None
if new_value is _EMPTY_VALUE:
new_value = None
self._old_value = copy.deepcopy(old_value)
self._new_value = copy.deepcopy(new_value)
self._old_is_dict = isinstance(old_value, dict)
self._new_is_dict = isinstance(new_value, dict)
self._old_keys = None
self._new_keys = None
self._available_keys = None
self._removed_keys = None
self._changed_keys = None
self._sub_items = None
def __getitem__(self, key):
"""Getter looks into subitems if object is dictionary."""
if self._sub_items is None:
self._prepare_sub_items()
return self._sub_items[key]
def __bool__(self):
"""Boolean of object is if old and new value are the same."""
return self._changed
def get(self, key, default=None):
"""Try to get sub item."""
if self._sub_items is None:
self._prepare_sub_items()
return self._sub_items.get(key, default)
@property
def old_value(self):
"""Get copy of old value.
Returns:
Any: Whatever old value was.
"""
return copy.deepcopy(self._old_value)
@property
def new_value(self):
"""Get copy of new value.
Returns:
Any: Whatever new value was.
"""
return copy.deepcopy(self._new_value)
@property
def changed(self):
"""Value changed.
Returns:
bool: If data changed.
"""
return self._changed
@property
def is_dict(self):
"""Object can be used as dictionary.
Returns:
bool: When can be used that way.
"""
return self._old_is_dict or self._new_is_dict
@property
def changes(self):
"""Get changes in raw data.
This method should be used only if 'is_dict' value is 'True'.
Returns:
Dict[str, Tuple[Any, Any]]: Changes are by key in tuple
(<old value>, <new value>). If 'is_dict' is 'False' then
output is always empty dictionary.
"""
output = {}
if not self.is_dict:
return output
old_value = self.old_value
new_value = self.new_value
for key in self.changed_keys:
_old = None
_new = None
if self._old_is_dict:
_old = old_value.get(key)
if self._new_is_dict:
_new = new_value.get(key)
output[key] = (_old, _new)
return output
# Methods/properties that can be used when 'is_dict' is 'True'
@property
def old_keys(self):
"""Keys from old value.
Empty set is returned if old value is not a dict.
Returns:
Set[str]: Keys from old value.
"""
if self._old_keys is None:
self._prepare_keys()
return set(self._old_keys)
@property
def new_keys(self):
"""Keys from new value.
Empty set is returned if old value is not a dict.
Returns:
Set[str]: Keys from new value.
"""
if self._new_keys is None:
self._prepare_keys()
return set(self._new_keys)
@property
def changed_keys(self):
"""Keys that has changed from old to new value.
Empty set is returned if both old and new value are not a dict.
Returns:
Set[str]: Keys of changed keys.
"""
if self._changed_keys is None:
self._prepare_sub_items()
return set(self._changed_keys)
@property
def available_keys(self):
"""All keys that are available in old and new value.
Empty set is returned if both old and new value are not a dict.
Output is Union of 'old_keys' and 'new_keys'.
Returns:
Set[str]: All keys from old and new value.
"""
if self._available_keys is None:
self._prepare_keys()
return set(self._available_keys)
@property
def removed_keys(self):
"""Key that are not available in new value but were in old value.
Returns:
Set[str]: All removed keys.
"""
if self._removed_keys is None:
self._prepare_sub_items()
return set(self._removed_keys)
def _prepare_keys(self):
old_keys = set()
new_keys = set()
if self._old_is_dict and self._new_is_dict:
old_keys = set(self._old_value.keys())
new_keys = set(self._new_value.keys())
elif self._old_is_dict:
old_keys = set(self._old_value.keys())
elif self._new_is_dict:
new_keys = set(self._new_value.keys())
self._old_keys = old_keys
self._new_keys = new_keys
self._available_keys = old_keys | new_keys
self._removed_keys = old_keys - new_keys
def _prepare_sub_items(self):
sub_items = {}
changed_keys = set()
old_keys = self.old_keys
new_keys = self.new_keys
new_value = self.new_value
old_value = self.old_value
if self._old_is_dict and self._new_is_dict:
for key in self.available_keys:
item = TrackChangesItem(
old_value.get(key), new_value.get(key)
)
sub_items[key] = item
if item.changed or key not in old_keys or key not in new_keys:
changed_keys.add(key)
elif self._old_is_dict:
old_keys = set(old_value.keys())
available_keys = set(old_keys)
changed_keys = set(available_keys)
for key in available_keys:
# NOTE Use '_EMPTY_VALUE' because old value could be 'None'
# which would result in "unchanged" item
sub_items[key] = TrackChangesItem(
old_value.get(key), _EMPTY_VALUE
)
elif self._new_is_dict:
new_keys = set(new_value.keys())
available_keys = set(new_keys)
changed_keys = set(available_keys)
for key in available_keys:
# NOTE Use '_EMPTY_VALUE' because new value could be 'None'
# which would result in "unchanged" item
sub_items[key] = TrackChangesItem(
_EMPTY_VALUE, new_value.get(key)
)
self._sub_items = sub_items
self._changed_keys = changed_keys

File diff suppressed because it is too large Load diff

View file

@ -26,16 +26,6 @@ if TYPE_CHECKING:
from .context import CreateContext, CreatedInstance, UpdateData # noqa: F401
class CreatorError(Exception):
"""Should be raised when creator failed because of known issue.
Message of error should be user readable.
"""
def __init__(self, message):
super(CreatorError, self).__init__(message)
class ProductConvertorPlugin(ABC):
"""Helper for conversion of instances created using legacy creators.
@ -654,7 +644,7 @@ class Creator(BaseCreator):
cls._get_default_variant_wrap,
cls._set_default_variant_wrap
)
super(Creator, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
@property
def show_order(self):

View file

@ -0,0 +1,127 @@
import os
import inspect
class UnavailableSharedData(Exception):
"""Shared data are not available at the moment when are accessed."""
pass
class ImmutableKeyError(TypeError):
"""Accessed key is immutable so does not allow changes or removals."""
def __init__(self, key, msg=None):
self.immutable_key = key
if not msg:
msg = "Key \"{}\" is immutable and does not allow changes.".format(
key
)
super().__init__(msg)
class HostMissRequiredMethod(Exception):
"""Host does not have implemented required functions for creation."""
def __init__(self, host, missing_methods):
self.missing_methods = missing_methods
self.host = host
joined_methods = ", ".join(
['"{}"'.format(name) for name in missing_methods]
)
dirpath = os.path.dirname(
os.path.normpath(inspect.getsourcefile(host))
)
dirpath_parts = dirpath.split(os.path.sep)
host_name = dirpath_parts.pop(-1)
if host_name == "api":
host_name = dirpath_parts.pop(-1)
msg = "Host \"{}\" does not have implemented method/s {}".format(
host_name, joined_methods
)
super().__init__(msg)
class ConvertorsOperationFailed(Exception):
def __init__(self, msg, failed_info):
super().__init__(msg)
self.failed_info = failed_info
class ConvertorsFindFailed(ConvertorsOperationFailed):
def __init__(self, failed_info):
msg = "Failed to find incompatible products"
super().__init__(msg, failed_info)
class ConvertorsConversionFailed(ConvertorsOperationFailed):
def __init__(self, failed_info):
msg = "Failed to convert incompatible products"
super().__init__(msg, failed_info)
class CreatorError(Exception):
"""Should be raised when creator failed because of known issue.
Message of error should be artist friendly.
"""
pass
class CreatorsOperationFailed(Exception):
"""Raised when a creator process crashes in 'CreateContext'.
The exception contains information about the creator and error. The data
are prepared using 'prepare_failed_creator_operation_info' and can be
serialized using json.
Usage is for UI purposes which may not have access to exceptions directly
and would not have ability to catch exceptions 'per creator'.
Args:
msg (str): General error message.
failed_info (list[dict[str, Any]]): List of failed creators with
exception message and optionally formatted traceback.
"""
def __init__(self, msg, failed_info):
super().__init__(msg)
self.failed_info = failed_info
class CreatorsCollectionFailed(CreatorsOperationFailed):
def __init__(self, failed_info):
msg = "Failed to collect instances"
super().__init__(msg, failed_info)
class CreatorsSaveFailed(CreatorsOperationFailed):
def __init__(self, failed_info):
msg = "Failed update instance changes"
super().__init__(msg, failed_info)
class CreatorsRemoveFailed(CreatorsOperationFailed):
def __init__(self, failed_info):
msg = "Failed to remove instances"
super().__init__(msg, failed_info)
class CreatorsCreateFailed(CreatorsOperationFailed):
def __init__(self, failed_info):
msg = "Failed to create instances"
super().__init__(msg, failed_info)
class TaskNotSetError(KeyError):
def __init__(self, msg=None):
if not msg:
msg = "Creator's product name template requires task name."
super().__init__(msg)
class TemplateFillError(Exception):
def __init__(self, msg=None):
if not msg:
msg = "Creator's product name template is missing key value."
super().__init__(msg)

View file

@ -14,7 +14,7 @@ from ayon_core.pipeline.constants import AVALON_INSTANCE_ID
from .product_name import get_product_name
class LegacyCreator(object):
class LegacyCreator:
"""Determine how assets are created"""
label = None
product_type = None

View file

@ -1,23 +1,9 @@
import ayon_api
from ayon_core.lib import StringTemplate, filter_profiles, prepare_template_data
from ayon_core.settings import get_project_settings
from ayon_core.lib import filter_profiles, prepare_template_data
from .constants import DEFAULT_PRODUCT_TEMPLATE
class TaskNotSetError(KeyError):
def __init__(self, msg=None):
if not msg:
msg = "Creator's product name template requires task name."
super(TaskNotSetError, self).__init__(msg)
class TemplateFillError(Exception):
def __init__(self, msg=None):
if not msg:
msg = "Creator's product name template is missing key value."
super(TemplateFillError, self).__init__(msg)
from .exceptions import TaskNotSetError, TemplateFillError
def get_product_name_template(
@ -183,7 +169,10 @@ def get_product_name(
fill_pairs[key] = value
try:
return template.format(**prepare_template_data(fill_pairs))
return StringTemplate.format_strict_template(
template=template,
data=prepare_template_data(fill_pairs)
)
except KeyError as exp:
raise TemplateFillError(
"Value for {} key is missing in template '{}'."

View file

@ -0,0 +1,855 @@
import copy
import collections
from uuid import uuid4
from typing import Optional
from ayon_core.lib.attribute_definitions import (
UnknownDef,
serialize_attr_defs,
deserialize_attr_defs,
)
from ayon_core.pipeline import (
AYON_INSTANCE_ID,
AVALON_INSTANCE_ID,
)
from .exceptions import ImmutableKeyError
from .changes import TrackChangesItem
class ConvertorItem:
"""Item representing convertor plugin.
Args:
identifier (str): Identifier of convertor.
label (str): Label which will be shown in UI.
"""
def __init__(self, identifier, label):
self._id = str(uuid4())
self.identifier = identifier
self.label = label
@property
def id(self):
return self._id
def to_data(self):
return {
"id": self.id,
"identifier": self.identifier,
"label": self.label
}
@classmethod
def from_data(cls, data):
obj = cls(data["identifier"], data["label"])
obj._id = data["id"]
return obj
class InstanceMember:
"""Representation of instance member.
TODO:
Implement and use!
"""
def __init__(self, instance, name):
self.instance = instance
instance.add_members(self)
self.name = name
self._actions = []
def add_action(self, label, callback):
self._actions.append({
"label": label,
"callback": callback
})
class AttributeValues:
"""Container which keep values of Attribute definitions.
Goal is to have one object which hold values of attribute definitions for
single instance.
Has dictionary like methods. Not all of them are allowed all the time.
Args:
attr_defs(AbstractAttrDef): Definitions of value type and properties.
values(dict): Values after possible conversion.
origin_data(dict): Values loaded from host before conversion.
"""
def __init__(self, attr_defs, values, origin_data=None):
if origin_data is None:
origin_data = copy.deepcopy(values)
self._origin_data = origin_data
attr_defs_by_key = {
attr_def.key: attr_def
for attr_def in attr_defs
if attr_def.is_value_def
}
for key, value in values.items():
if key not in attr_defs_by_key:
new_def = UnknownDef(key, label=key, default=value)
attr_defs.append(new_def)
attr_defs_by_key[key] = new_def
self._attr_defs = attr_defs
self._attr_defs_by_key = attr_defs_by_key
self._data = {}
for attr_def in attr_defs:
value = values.get(attr_def.key)
if value is not None:
self._data[attr_def.key] = value
def __setitem__(self, key, value):
if key not in self._attr_defs_by_key:
raise KeyError("Key \"{}\" was not found.".format(key))
self.update({key: value})
def __getitem__(self, key):
if key not in self._attr_defs_by_key:
return self._data[key]
return self._data.get(key, self._attr_defs_by_key[key].default)
def __contains__(self, key):
return key in self._attr_defs_by_key
def get(self, key, default=None):
if key in self._attr_defs_by_key:
return self[key]
return default
def keys(self):
return self._attr_defs_by_key.keys()
def values(self):
for key in self._attr_defs_by_key.keys():
yield self._data.get(key)
def items(self):
for key in self._attr_defs_by_key.keys():
yield key, self._data.get(key)
def update(self, value):
changes = {}
for _key, _value in dict(value).items():
if _key in self._data and self._data.get(_key) == _value:
continue
self._data[_key] = _value
changes[_key] = _value
def pop(self, key, default=None):
value = self._data.pop(key, default)
# Remove attribute definition if is 'UnknownDef'
# - gives option to get rid of unknown values
attr_def = self._attr_defs_by_key.get(key)
if isinstance(attr_def, UnknownDef):
self._attr_defs_by_key.pop(key)
self._attr_defs.remove(attr_def)
return value
def reset_values(self):
self._data = {}
def mark_as_stored(self):
self._origin_data = copy.deepcopy(self._data)
@property
def attr_defs(self):
"""Pointer to attribute definitions.
Returns:
List[AbstractAttrDef]: Attribute definitions.
"""
return list(self._attr_defs)
@property
def origin_data(self):
return copy.deepcopy(self._origin_data)
def data_to_store(self):
"""Create new dictionary with data to store.
Returns:
Dict[str, Any]: Attribute values that should be stored.
"""
output = {}
for key in self._data:
output[key] = self[key]
for key, attr_def in self._attr_defs_by_key.items():
if key not in output:
output[key] = attr_def.default
return output
def get_serialized_attr_defs(self):
"""Serialize attribute definitions to json serializable types.
Returns:
List[Dict[str, Any]]: Serialized attribute definitions.
"""
return serialize_attr_defs(self._attr_defs)
class CreatorAttributeValues(AttributeValues):
"""Creator specific attribute values of an instance.
Args:
instance (CreatedInstance): Instance for which are values hold.
"""
def __init__(self, instance, *args, **kwargs):
self.instance = instance
super().__init__(*args, **kwargs)
class PublishAttributeValues(AttributeValues):
"""Publish plugin specific attribute values.
Values are for single plugin which can be on `CreatedInstance`
or context values stored on `CreateContext`.
Args:
publish_attributes(PublishAttributes): Wrapper for multiple publish
attributes is used as parent object.
"""
def __init__(self, publish_attributes, *args, **kwargs):
self.publish_attributes = publish_attributes
super().__init__(*args, **kwargs)
@property
def parent(self):
return self.publish_attributes.parent
class PublishAttributes:
"""Wrapper for publish plugin attribute definitions.
Cares about handling attribute definitions of multiple publish plugins.
Keep information about attribute definitions and their values.
Args:
parent(CreatedInstance, CreateContext): Parent for which will be
data stored and from which are data loaded.
origin_data(dict): Loaded data by plugin class name.
attr_plugins(Union[List[pyblish.api.Plugin], None]): List of publish
plugins that may have defined attribute definitions.
"""
def __init__(self, parent, origin_data, attr_plugins=None):
self.parent = parent
self._origin_data = copy.deepcopy(origin_data)
attr_plugins = attr_plugins or []
self.attr_plugins = attr_plugins
self._data = copy.deepcopy(origin_data)
self._plugin_names_order = []
self._missing_plugins = []
self.set_publish_plugins(attr_plugins)
def __getitem__(self, key):
return self._data[key]
def __contains__(self, key):
return key in self._data
def keys(self):
return self._data.keys()
def values(self):
return self._data.values()
def items(self):
return self._data.items()
def pop(self, key, default=None):
"""Remove or reset value for plugin.
Plugin values are reset to defaults if plugin is available but
data of plugin which was not found are removed.
Args:
key(str): Plugin name.
default: Default value if plugin was not found.
"""
if key not in self._data:
return default
if key in self._missing_plugins:
self._missing_plugins.remove(key)
removed_item = self._data.pop(key)
return removed_item.data_to_store()
value_item = self._data[key]
# Prepare value to return
output = value_item.data_to_store()
# Reset values
value_item.reset_values()
return output
def plugin_names_order(self):
"""Plugin names order by their 'order' attribute."""
for name in self._plugin_names_order:
yield name
def mark_as_stored(self):
self._origin_data = copy.deepcopy(self.data_to_store())
def data_to_store(self):
"""Convert attribute values to "data to store"."""
output = {}
for key, attr_value in self._data.items():
output[key] = attr_value.data_to_store()
return output
@property
def origin_data(self):
return copy.deepcopy(self._origin_data)
def set_publish_plugins(self, attr_plugins):
"""Set publish plugins attribute definitions."""
self._plugin_names_order = []
self._missing_plugins = []
self.attr_plugins = attr_plugins or []
origin_data = self._origin_data
data = self._data
self._data = {}
added_keys = set()
for plugin in attr_plugins:
output = plugin.convert_attribute_values(data)
if output is not None:
data = output
attr_defs = plugin.get_attribute_defs()
if not attr_defs:
continue
key = plugin.__name__
added_keys.add(key)
self._plugin_names_order.append(key)
value = data.get(key) or {}
orig_value = copy.deepcopy(origin_data.get(key) or {})
self._data[key] = PublishAttributeValues(
self, attr_defs, value, orig_value
)
for key, value in data.items():
if key not in added_keys:
self._missing_plugins.append(key)
self._data[key] = PublishAttributeValues(
self, [], value, value
)
def serialize_attributes(self):
return {
"attr_defs": {
plugin_name: attrs_value.get_serialized_attr_defs()
for plugin_name, attrs_value in self._data.items()
},
"plugin_names_order": self._plugin_names_order,
"missing_plugins": self._missing_plugins
}
def deserialize_attributes(self, data):
self._plugin_names_order = data["plugin_names_order"]
self._missing_plugins = data["missing_plugins"]
attr_defs = deserialize_attr_defs(data["attr_defs"])
origin_data = self._origin_data
data = self._data
self._data = {}
added_keys = set()
for plugin_name, attr_defs_data in attr_defs.items():
attr_defs = deserialize_attr_defs(attr_defs_data)
value = data.get(plugin_name) or {}
orig_value = copy.deepcopy(origin_data.get(plugin_name) or {})
self._data[plugin_name] = PublishAttributeValues(
self, attr_defs, value, orig_value
)
for key, value in data.items():
if key not in added_keys:
self._missing_plugins.append(key)
self._data[key] = PublishAttributeValues(
self, [], value, value
)
class InstanceContextInfo:
def __init__(
self,
folder_path: Optional[str],
task_name: Optional[str],
folder_is_valid: bool,
task_is_valid: bool,
):
self.folder_path: Optional[str] = folder_path
self.task_name: Optional[str] = task_name
self.folder_is_valid: bool = folder_is_valid
self.task_is_valid: bool = task_is_valid
@property
def is_valid(self) -> bool:
return self.folder_is_valid and self.task_is_valid
class CreatedInstance:
"""Instance entity with data that will be stored to workfile.
I think `data` must be required argument containing all minimum information
about instance like "folderPath" and "task" and all data used for filling
product name as creators may have custom data for product name filling.
Notes:
Object have 2 possible initialization. One using 'creator' object which
is recommended for api usage. Second by passing information about
creator.
Args:
product_type (str): Product type that will be created.
product_name (str): Name of product that will be created.
data (Dict[str, Any]): Data used for filling product name or override
data from already existing instance.
creator (Union[BaseCreator, None]): Creator responsible for instance.
creator_identifier (str): Identifier of creator plugin.
creator_label (str): Creator plugin label.
group_label (str): Default group label from creator plugin.
creator_attr_defs (List[AbstractAttrDef]): Attribute definitions from
creator.
"""
# Keys that can't be changed or removed from data after loading using
# creator.
# - 'creator_attributes' and 'publish_attributes' can change values of
# their individual children but not on their own
__immutable_keys = (
"id",
"instance_id",
"product_type",
"creator_identifier",
"creator_attributes",
"publish_attributes"
)
def __init__(
self,
product_type,
product_name,
data,
creator=None,
creator_identifier=None,
creator_label=None,
group_label=None,
creator_attr_defs=None,
):
if creator is not None:
creator_identifier = creator.identifier
group_label = creator.get_group_label()
creator_label = creator.label
creator_attr_defs = creator.get_instance_attr_defs()
self._creator_label = creator_label
self._group_label = group_label or creator_identifier
# Instance members may have actions on them
# TODO implement members logic
self._members = []
# Data that can be used for lifetime of object
self._transient_data = {}
# Create a copy of passed data to avoid changing them on the fly
data = copy.deepcopy(data or {})
# Pop dictionary values that will be converted to objects to be able
# catch changes
orig_creator_attributes = data.pop("creator_attributes", None) or {}
orig_publish_attributes = data.pop("publish_attributes", None) or {}
# Store original value of passed data
self._orig_data = copy.deepcopy(data)
# Pop 'productType' and 'productName' to prevent unexpected changes
data.pop("productType", None)
data.pop("productName", None)
# Backwards compatibility with OpenPype instances
data.pop("family", None)
data.pop("subset", None)
asset_name = data.pop("asset", None)
if "folderPath" not in data:
data["folderPath"] = asset_name
# QUESTION Does it make sense to have data stored as ordered dict?
self._data = collections.OrderedDict()
# QUESTION Do we need this "id" information on instance?
item_id = data.get("id")
# TODO use only 'AYON_INSTANCE_ID' when all hosts support it
if item_id not in {AYON_INSTANCE_ID, AVALON_INSTANCE_ID}:
item_id = AVALON_INSTANCE_ID
self._data["id"] = item_id
self._data["productType"] = product_type
self._data["productName"] = product_name
self._data["active"] = data.get("active", True)
self._data["creator_identifier"] = creator_identifier
# Pop from source data all keys that are defined in `_data` before
# this moment and through their values away
# - they should be the same and if are not then should not change
# already set values
for key in self._data.keys():
if key in data:
data.pop(key)
self._data["variant"] = self._data.get("variant") or ""
# Stored creator specific attribute values
# {key: value}
creator_values = copy.deepcopy(orig_creator_attributes)
self._data["creator_attributes"] = CreatorAttributeValues(
self,
list(creator_attr_defs),
creator_values,
orig_creator_attributes
)
# Stored publish specific attribute values
# {<plugin name>: {key: value}}
# - must be set using 'set_publish_plugins'
self._data["publish_attributes"] = PublishAttributes(
self, orig_publish_attributes, None
)
if data:
self._data.update(data)
if not self._data.get("instance_id"):
self._data["instance_id"] = str(uuid4())
def __str__(self):
return (
"<CreatedInstance {product[name]}"
" ({product[type]}[{creator_identifier}])> {data}"
).format(
creator_identifier=self.creator_identifier,
product={"name": self.product_name, "type": self.product_type},
data=str(self._data)
)
# --- Dictionary like methods ---
def __getitem__(self, key):
return self._data[key]
def __contains__(self, key):
return key in self._data
def __setitem__(self, key, value):
# Validate immutable keys
if key not in self.__immutable_keys:
self._data[key] = value
elif value != self._data.get(key):
# Raise exception if key is immutable and value has changed
raise ImmutableKeyError(key)
def get(self, key, default=None):
return self._data.get(key, default)
def pop(self, key, *args, **kwargs):
# Raise exception if is trying to pop key which is immutable
if key in self.__immutable_keys:
raise ImmutableKeyError(key)
self._data.pop(key, *args, **kwargs)
def keys(self):
return self._data.keys()
def values(self):
return self._data.values()
def items(self):
return self._data.items()
# ------
@property
def product_type(self):
return self._data["productType"]
@property
def product_name(self):
return self._data["productName"]
@property
def label(self):
label = self._data.get("label")
if not label:
label = self.product_name
return label
@property
def group_label(self):
label = self._data.get("group")
if label:
return label
return self._group_label
@property
def origin_data(self):
output = copy.deepcopy(self._orig_data)
output["creator_attributes"] = self.creator_attributes.origin_data
output["publish_attributes"] = self.publish_attributes.origin_data
return output
@property
def creator_identifier(self):
return self._data["creator_identifier"]
@property
def creator_label(self):
return self._creator_label or self.creator_identifier
@property
def id(self):
"""Instance identifier.
Returns:
str: UUID of instance.
"""
return self._data["instance_id"]
@property
def data(self):
"""Legacy access to data.
Access to data is needed to modify values.
Returns:
CreatedInstance: Object can be used as dictionary but with
validations of immutable keys.
"""
return self
@property
def transient_data(self):
"""Data stored for lifetime of instance object.
These data are not stored to scene and will be lost on object
deletion.
Can be used to store objects. In some host implementations is not
possible to reference to object in scene with some unique identifier
(e.g. node in Fusion.). In that case it is handy to store the object
here. Should be used that way only if instance data are stored on the
node itself.
Returns:
Dict[str, Any]: Dictionary object where you can store data related
to instance for lifetime of instance object.
"""
return self._transient_data
def changes(self):
"""Calculate and return changes."""
return TrackChangesItem(self.origin_data, self.data_to_store())
def mark_as_stored(self):
"""Should be called when instance data are stored.
Origin data are replaced by current data so changes are cleared.
"""
orig_keys = set(self._orig_data.keys())
for key, value in self._data.items():
orig_keys.discard(key)
if key in ("creator_attributes", "publish_attributes"):
continue
self._orig_data[key] = copy.deepcopy(value)
for key in orig_keys:
self._orig_data.pop(key)
self.creator_attributes.mark_as_stored()
self.publish_attributes.mark_as_stored()
@property
def creator_attributes(self):
return self._data["creator_attributes"]
@property
def creator_attribute_defs(self):
"""Attribute definitions defined by creator plugin.
Returns:
List[AbstractAttrDef]: Attribute definitions.
"""
return self.creator_attributes.attr_defs
@property
def publish_attributes(self):
return self._data["publish_attributes"]
@property
def has_promised_context(self) -> bool:
"""Get context data that are promised to be set by creator.
Returns:
bool: Has context that won't bo validated. Artist can't change
value when set to True.
"""
return self._data.get("has_promised_context", False)
def data_to_store(self):
"""Collect data that contain json parsable types.
It is possible to recreate the instance using these data.
Todos:
We probably don't need OrderedDict. When data are loaded they
are not ordered anymore.
Returns:
OrderedDict: Ordered dictionary with instance data.
"""
output = collections.OrderedDict()
for key, value in self._data.items():
if key in ("creator_attributes", "publish_attributes"):
continue
output[key] = value
output["creator_attributes"] = self.creator_attributes.data_to_store()
output["publish_attributes"] = self.publish_attributes.data_to_store()
return output
@classmethod
def from_existing(cls, instance_data, creator):
"""Convert instance data from workfile to CreatedInstance.
Args:
instance_data (Dict[str, Any]): Data in a structure ready for
'CreatedInstance' object.
creator (BaseCreator): Creator plugin which is creating the
instance of for which the instance belong.
"""
instance_data = copy.deepcopy(instance_data)
product_type = instance_data.get("productType")
if product_type is None:
product_type = instance_data.get("family")
if product_type is None:
product_type = creator.product_type
product_name = instance_data.get("productName")
if product_name is None:
product_name = instance_data.get("subset")
return cls(
product_type, product_name, instance_data, creator
)
def set_publish_plugins(self, attr_plugins):
"""Set publish plugins with attribute definitions.
This method should be called only from 'CreateContext'.
Args:
attr_plugins (List[pyblish.api.Plugin]): Pyblish plugins which
inherit from 'AYONPyblishPluginMixin' and may contain
attribute definitions.
"""
self.publish_attributes.set_publish_plugins(attr_plugins)
def add_members(self, members):
"""Currently unused method."""
for member in members:
if member not in self._members:
self._members.append(member)
def serialize_for_remote(self):
"""Serialize object into data to be possible recreated object.
Returns:
Dict[str, Any]: Serialized data.
"""
creator_attr_defs = self.creator_attributes.get_serialized_attr_defs()
publish_attributes = self.publish_attributes.serialize_attributes()
return {
"data": self.data_to_store(),
"orig_data": self.origin_data,
"creator_attr_defs": creator_attr_defs,
"publish_attributes": publish_attributes,
"creator_label": self._creator_label,
"group_label": self._group_label,
}
@classmethod
def deserialize_on_remote(cls, serialized_data):
"""Convert instance data to CreatedInstance.
This is fake instance in remote process e.g. in UI process. The creator
is not a full creator and should not be used for calling methods when
instance is created from this method (matters on implementation).
Args:
serialized_data (Dict[str, Any]): Serialized data for remote
recreating. Should contain 'data' and 'orig_data'.
"""
instance_data = copy.deepcopy(serialized_data["data"])
creator_identifier = instance_data["creator_identifier"]
product_type = instance_data["productType"]
product_name = instance_data.get("productName", None)
creator_label = serialized_data["creator_label"]
group_label = serialized_data["group_label"]
creator_attr_defs = deserialize_attr_defs(
serialized_data["creator_attr_defs"]
)
publish_attributes = serialized_data["publish_attributes"]
obj = cls(
product_type,
product_name,
instance_data,
creator_identifier=creator_identifier,
creator_label=creator_label,
group_label=group_label,
creator_attr_defs=creator_attr_defs
)
obj._orig_data = serialized_data["orig_data"]
obj.publish_attributes.deserialize_attributes(publish_attributes)
return obj

View file

@ -1,5 +1,5 @@
import os
import copy
import os
import re
import warnings
from copy import deepcopy
@ -7,14 +7,11 @@ from copy import deepcopy
import attr
import ayon_api
import clique
from ayon_core.pipeline import (
get_current_project_name,
get_representation_path,
)
from ayon_core.lib import Logger
from ayon_core.pipeline.publish import KnownPublishError
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
@attr.s
@ -250,6 +247,9 @@ def create_skeleton_instance(
"colorspace": data.get("colorspace")
}
if data.get("renderlayer"):
instance_skeleton_data["renderlayer"] = data["renderlayer"]
# skip locking version if we are creating v01
instance_version = data.get("version") # take this if exists
if instance_version != 1:
@ -464,7 +464,9 @@ def create_instances_for_aov(instance, skeleton, aov_filter,
Args:
instance (pyblish.api.Instance): Original instance.
skeleton (dict): Skeleton instance data.
aov_filter (dict): AOV filter.
skip_integration_repre_list (list): skip
do_not_add_review (bool): Explicitly disable reviews
Returns:
list of pyblish.api.Instance: Instances created from
@ -515,6 +517,131 @@ def create_instances_for_aov(instance, skeleton, aov_filter,
)
def _get_legacy_product_name_and_group(
product_type,
source_product_name,
task_name,
dynamic_data):
"""Get product name with legacy logic.
This function holds legacy behaviour of creating product name
that is deprecated. This wasn't using product name templates
at all, only hardcoded values. It shouldn't be used anymore,
but transition to templates need careful checking of the project
and studio settings.
Deprecated:
since 0.4.4
Args:
product_type (str): Product type.
source_product_name (str): Source product name.
task_name (str): Task name.
dynamic_data (dict): Dynamic data (camera, aov, ...)
Returns:
tuple: product name and group name
"""
warnings.warn("Using legacy product name for renders",
DeprecationWarning)
if not source_product_name.startswith(product_type):
resulting_group_name = '{}{}{}{}{}'.format(
product_type,
task_name[0].upper(), task_name[1:],
source_product_name[0].upper(), source_product_name[1:])
else:
resulting_group_name = source_product_name
# create product name `<product type><Task><Product name>`
if not source_product_name.startswith(product_type):
resulting_group_name = '{}{}{}{}{}'.format(
product_type,
task_name[0].upper(), task_name[1:],
source_product_name[0].upper(), source_product_name[1:])
else:
resulting_group_name = source_product_name
resulting_product_name = resulting_group_name
camera = dynamic_data.get("camera")
aov = dynamic_data.get("aov")
if camera:
if not aov:
resulting_product_name = '{}_{}'.format(
resulting_group_name, camera)
elif not aov.startswith(camera):
resulting_product_name = '{}_{}_{}'.format(
resulting_group_name, camera, aov)
else:
resulting_product_name = "{}_{}".format(
resulting_group_name, aov)
else:
if aov:
resulting_product_name = '{}_{}'.format(
resulting_group_name, aov)
return resulting_product_name, resulting_group_name
def get_product_name_and_group_from_template(
project_name,
task_entity,
product_type,
variant,
host_name,
dynamic_data=None):
"""Get product name and group name from template.
This will get product name and group name from template based on
data provided. It is doing similar work as
`func::_get_legacy_product_name_and_group` but using templates.
To get group name, template is called without any dynamic data, so
(depending on the template itself) it should be product name without
aov.
Todo:
Maybe we should introduce templates for the groups themselves.
Args:
task_entity (dict): Task entity.
project_name (str): Project name.
host_name (str): Host name.
product_type (str): Product type.
variant (str): Variant.
dynamic_data (dict): Dynamic data (aov, renderlayer, camera, ...).
Returns:
tuple: product name and group name.
"""
# remove 'aov' from data used to format group. See todo comment above
# for possible solution.
_dynamic_data = deepcopy(dynamic_data) or {}
_dynamic_data.pop("aov", None)
resulting_group_name = get_product_name(
project_name=project_name,
task_name=task_entity["name"],
task_type=task_entity["taskType"],
host_name=host_name,
product_type=product_type,
dynamic_data=_dynamic_data,
variant=variant,
)
resulting_product_name = get_product_name(
project_name=project_name,
task_name=task_entity["name"],
task_type=task_entity["taskType"],
host_name=host_name,
product_type=product_type,
dynamic_data=dynamic_data,
variant=variant,
)
return resulting_product_name, resulting_group_name
def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
skip_integration_repre_list, do_not_add_review):
"""Create instance for each AOV found.
@ -526,10 +653,10 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
instance (pyblish.api.Instance): Original instance.
skeleton (dict): Skeleton data for instance (those needed) later
by collector.
additional_data (dict): ..
additional_data (dict): ...
skip_integration_repre_list (list): list of extensions that shouldn't
be published
do_not_addbe _review (bool): explicitly disable review
do_not_add_review (bool): explicitly disable review
Returns:
@ -539,68 +666,70 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
ValueError:
"""
# TODO: this needs to be taking the task from context or instance
task = os.environ["AYON_TASK_NAME"]
anatomy = instance.context.data["anatomy"]
s_product_name = skeleton["productName"]
source_product_name = skeleton["productName"]
cameras = instance.data.get("cameras", [])
exp_files = instance.data["expectedFiles"]
expected_files = instance.data["expectedFiles"]
log = Logger.get_logger("farm_publishing")
instances = []
# go through AOVs in expected files
for aov, files in exp_files[0].items():
cols, rem = clique.assemble(files)
# we shouldn't have any reminders. And if we do, it should
# be just one item for single frame renders.
if not cols and rem:
if len(rem) != 1:
raise ValueError("Found multiple non related files "
"to render, don't know what to do "
"with them.")
col = rem[0]
ext = os.path.splitext(col)[1].lstrip(".")
else:
# 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
ext = cols[0].tail.lstrip(".")
col = list(cols[0])
for aov, files in expected_files[0].items():
collected_files = _collect_expected_files_for_aov(files)
# create product name `<product type><Task><Product name>`
# TODO refactor/remove me
product_type = skeleton["productType"]
if not s_product_name.startswith(product_type):
group_name = '{}{}{}{}{}'.format(
product_type,
task[0].upper(), task[1:],
s_product_name[0].upper(), s_product_name[1:])
else:
group_name = s_product_name
expected_filepath = collected_files
if isinstance(collected_files, (list, tuple)):
expected_filepath = collected_files[0]
# if there are multiple cameras, we need to add camera name
expected_filepath = col[0] if isinstance(col, (list, tuple)) else col
cams = [cam for cam in cameras if cam in expected_filepath]
if cams:
for cam in cams:
if not aov:
product_name = '{}_{}'.format(group_name, cam)
elif not aov.startswith(cam):
product_name = '{}_{}_{}'.format(group_name, cam, aov)
else:
product_name = "{}_{}".format(group_name, aov)
else:
if aov:
product_name = '{}_{}'.format(group_name, aov)
else:
product_name = '{}'.format(group_name)
dynamic_data = {
"aov": aov,
"renderlayer": instance.data.get("renderlayer"),
}
# find if camera is used in the file path
# TODO: this must be changed to be more robust. Any coincidence
# of camera name in the file path will be considered as
# camera name. This is not correct.
camera = [cam for cam in cameras if cam in expected_filepath]
# Is there just one camera matching?
# TODO: this is not true, we can have multiple cameras in the scene
# and we should be able to detect them all. Currently, we are
# keeping the old behavior, taking the first one found.
if camera:
dynamic_data["camera"] = camera[0]
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
except KeyError:
warnings.warn(
("use_legacy_for_renders not found in project settings. "
"Using legacy product name for renders. Please update "
"your ayon-core version."), DeprecationWarning)
use_legacy_product_name = True
if use_legacy_product_name:
product_name, group_name = _get_legacy_product_name_and_group(
product_type=skeleton["productType"],
source_product_name=source_product_name,
task_name=instance.data["task"],
dynamic_data=dynamic_data)
if isinstance(col, (list, tuple)):
staging = os.path.dirname(col[0])
else:
staging = os.path.dirname(col)
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"],
product_type=skeleton["productType"],
variant=instance.data.get("variant", source_product_name),
dynamic_data=dynamic_data
)
staging = os.path.dirname(expected_filepath)
try:
staging = remap_source(staging, anatomy)
@ -611,10 +740,8 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
app = os.environ.get("AYON_HOST_NAME", "")
if isinstance(col, list):
render_file_name = os.path.basename(col[0])
else:
render_file_name = os.path.basename(col)
render_file_name = os.path.basename(expected_filepath)
aov_patterns = aov_filter
preview = match_aov_pattern(app, aov_patterns, render_file_name)
@ -622,9 +749,10 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
new_instance = deepcopy(skeleton)
new_instance["productName"] = product_name
new_instance["productGroup"] = group_name
new_instance["aov"] = aov
# toggle preview on if multipart is on
# Because we cant query the multipartExr data member of each AOV we'll
# Because we can't query the multipartExr data member of each AOV we'll
# need to have hardcoded rule of excluding any renders with
# "cryptomatte" in the file name from being a multipart EXR. This issue
# happens with Redshift that forces Cryptomatte renders to be separate
@ -650,10 +778,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
new_instance["review"] = True
# create representation
if isinstance(col, (list, tuple)):
files = [os.path.basename(f) for f in col]
else:
files = os.path.basename(col)
ext = os.path.splitext(render_file_name)[-1].lstrip(".")
# Copy render product "colorspace" data to representation.
colorspace = ""
@ -708,6 +833,35 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
return instances
def _collect_expected_files_for_aov(files):
"""Collect expected files.
Args:
files (list): List of files.
Returns:
list or str: Collection of files or single file.
Raises:
ValueError: If there are multiple collections.
"""
cols, rem = clique.assemble(files)
# we shouldn't have any reminders. And if we do, it should
# be just one item for single frame renders.
if not cols and rem:
if len(rem) != 1:
raise ValueError("Found multiple non related files "
"to render, don't know what to do "
"with them.")
return rem[0]
# 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
return list(cols[0])
def get_resources(project_name, version_entity, extension=None):
"""Get the files from the specific version.

View file

@ -1,24 +0,0 @@
import pyblish.api
from ayon_core.pipeline import Anatomy
from typing import Tuple, List
class TimeData:
start: int
end: int
fps: float | int
step: int
handle_start: int
handle_end: int
def __init__(self, start: int, end: int, fps: float | int, step: int, handle_start: int, handle_end: int):
...
...
def remap_source(source: str, anatomy: Anatomy): ...
def extend_frames(folder_path: str, product_name: str, start: int, end: int) -> Tuple[int, int]: ...
def get_time_data_from_instance_or_context(instance: pyblish.api.Instance) -> TimeData: ...
def get_transferable_representations(instance: pyblish.api.Instance) -> list: ...
def create_skeleton_instance(instance: pyblish.api.Instance, families_transfer: list = ..., instance_transfer: dict = ...) -> dict: ...
def create_instances_for_aov(instance: pyblish.api.Instance, skeleton: dict, aov_filter: dict) -> List[pyblish.api.Instance]: ...
def attach_instances_to_product(attach_to: list, instances: list) -> list: ...

View file

@ -1,5 +1,5 @@
# Publish
AYON is using `pyblish` for publishing process which is a little bit extented and modified mainly for UI purposes. OpenPype's (new) publish UI does not allow to enable/disable instances or plugins that can be done during creation part. Also does support actions only for validators after validation exception.
AYON is using `pyblish` for publishing process which is a little bit extented and modified mainly for UI purposes. AYON's (new) publish UI does not allow to enable/disable instances or plugins that can be done during creation part. Also does support actions only for validators after validation exception.
## Exceptions
AYON define few specific exceptions that should be used in publish plugins.

View file

@ -13,7 +13,6 @@ from .publish_plugins import (
PublishXmlValidationError,
KnownPublishError,
AYONPyblishPluginMixin,
OpenPypePyblishPluginMixin,
OptionalPyblishPluginMixin,
RepairAction,
@ -66,7 +65,6 @@ __all__ = (
"PublishXmlValidationError",
"KnownPublishError",
"AYONPyblishPluginMixin",
"OpenPypePyblishPluginMixin",
"OptionalPyblishPluginMixin",
"RepairAction",

View file

@ -379,7 +379,7 @@ def get_plugin_settings(plugin, project_settings, log, category=None):
plugin_kind = split_path[-2]
# TODO: change after all plugins are moved one level up
if category_from_file in ("ayon_core", "openpype"):
if category_from_file == "ayon_core":
category_from_file = "core"
try:

View file

@ -165,9 +165,6 @@ class AYONPyblishPluginMixin:
return self.get_attr_values_from_data_for_plugin(self.__class__, data)
OpenPypePyblishPluginMixin = AYONPyblishPluginMixin
class OptionalPyblishPluginMixin(AYONPyblishPluginMixin):
"""Prepare mixin for optional plugins.

View file

@ -25,13 +25,7 @@ def create_custom_tempdir(project_name, anatomy=None):
"""
env_tmpdir = os.getenv("AYON_TMPDIR")
if not env_tmpdir:
env_tmpdir = os.getenv("OPENPYPE_TMPDIR")
if not env_tmpdir:
return
print(
"DEPRECATION WARNING: Used 'OPENPYPE_TMPDIR' environment"
" variable. Please use 'AYON_TMPDIR' instead."
)
return
custom_tempdir = None
if "{" in env_tmpdir:

View file

@ -4,7 +4,7 @@ import collections
import ayon_api
from ayon_core.lib.local_settings import get_ayon_appdirs
from ayon_core.lib.local_settings import get_launcher_local_dir
FileInfo = collections.namedtuple(
@ -54,7 +54,7 @@ class ThumbnailsCache:
"""
if self._thumbnails_dir is None:
self._thumbnails_dir = get_ayon_appdirs("thumbnails")
self._thumbnails_dir = get_launcher_local_dir("thumbnails")
return self._thumbnails_dir
thumbnails_dir = property(get_thumbnails_dir)

View file

@ -859,7 +859,7 @@ class AbstractTemplateBuilder(ABC):
"Settings\\Profiles"
).format(host_name.title()))
# Try fill path with environments and anatomy roots
# Try to fill path with environments and anatomy roots
anatomy = Anatomy(project_name)
fill_data = {
key: value
@ -872,9 +872,7 @@ class AbstractTemplateBuilder(ABC):
"code": anatomy.project_code,
}
result = StringTemplate.format_template(path, fill_data)
if result.solved:
path = result.normalized()
path = self.resolve_template_path(path, fill_data)
if path and os.path.exists(path):
self.log.info("Found template at: '{}'".format(path))
@ -914,6 +912,27 @@ class AbstractTemplateBuilder(ABC):
"create_first_version": create_first_version
}
def resolve_template_path(self, path, fill_data) -> str:
"""Resolve the template path.
By default, this does nothing except returning the path directly.
This can be overridden in host integrations to perform additional
resolving over the template. Like, `hou.text.expandString` in Houdini.
Arguments:
path (str): The input path.
fill_data (dict[str, str]): Data to use for template formatting.
Returns:
str: The resolved path.
"""
result = StringTemplate.format_template(path, fill_data)
if result.solved:
path = result.normalized()
return path
def emit_event(self, topic, data=None, source=None) -> Event:
return self._event_system.emit(topic, data, source)
@ -1519,9 +1538,10 @@ class PlaceholderLoadMixin(object):
if "asset" in placeholder.data:
return []
representation_name = placeholder.data["representation"]
if not representation_name:
return []
representation_names = None
representation_name: str = placeholder.data["representation"]
if representation_name:
representation_names = [representation_name]
project_name = self.builder.project_name
current_folder_entity = self.builder.current_folder_entity
@ -1578,7 +1598,7 @@ class PlaceholderLoadMixin(object):
)
return list(get_representations(
project_name,
representation_names={representation_name},
representation_names=representation_names,
version_ids=version_ids
))

View file

@ -15,5 +15,3 @@ class CollectAddons(pyblish.api.ContextPlugin):
manager = AddonsManager()
context.data["ayonAddonsManager"] = manager
context.data["ayonAddons"] = manager.addons_by_name
# Backwards compatibility - remove
context.data["openPypeModules"] = manager.addons_by_name

View file

@ -138,7 +138,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
folder_path_by_id = {}
for instance in context:
folder_entity = instance.data.get("folderEntity")
# Skip if instnace does not have filled folder entity
# Skip if instance does not have filled folder entity
if not folder_entity:
continue
folder_id = folder_entity["id"]
@ -217,9 +217,8 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
joined_paths = ", ".join(
["\"{}\"".format(path) for path in not_found_task_paths]
)
self.log.warning((
"Not found task entities with paths \"{}\"."
).format(joined_paths))
self.log.warning(
f"Not found task entities with paths {joined_paths}.")
def fill_latest_versions(self, context, project_name):
"""Try to find latest version for each instance's product name.
@ -321,7 +320,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
use_context_version = instance.data["followWorkfileVersion"]
if use_context_version:
version_number = context.data("version")
version_number = context.data.get("version")
# Even if 'follow_workfile_version' is enabled, it may not be set
# because workfile version was not collected to 'context.data'
@ -385,8 +384,19 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
json.dumps(anatomy_data, indent=4)
))
# make render layer available in anatomy data
render_layer = instance.data.get("renderlayer")
if render_layer:
anatomy_data["renderlayer"] = render_layer
# make aov name available in anatomy data
aov = instance.data.get("aov")
if aov:
anatomy_data["aov"] = aov
def _fill_folder_data(self, instance, project_entity, anatomy_data):
# QUESTION should we make sure that all folder data are poped if
# QUESTION: should we make sure that all folder data are popped if
# folder data cannot be found?
# - 'folder', 'hierarchy', 'parent', 'folder'
folder_entity = instance.data.get("folderEntity")
@ -426,7 +436,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
})
def _fill_task_data(self, instance, task_types_by_name, anatomy_data):
# QUESTION should we make sure that all task data are poped if task
# QUESTION: should we make sure that all task data are popped if task
# data cannot be resolved?
# - 'task'

View file

@ -113,4 +113,4 @@ class CollectContextEntities(pyblish.api.ContextPlugin):
"Task '{}' was not found in project '{}'.".format(
task_path, project_name)
)
return task_entity
return task_entity

View file

@ -7,7 +7,7 @@ class CollectInputRepresentationsToVersions(pyblish.api.ContextPlugin):
"""Converts collected input representations to input versions.
Any data in `instance.data["inputRepresentations"]` gets converted into
`instance.data["inputVersions"]` as supported in OpenPype v3.
`instance.data["inputVersions"]` as supported in OpenPype.
"""
# This is a ContextPlugin because then we can query the database only once

View file

@ -138,10 +138,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
def process(self, context):
self._context = context
publish_data_paths = (
os.environ.get("AYON_PUBLISH_DATA")
or os.environ.get("OPENPYPE_PUBLISH_DATA")
)
publish_data_paths = os.environ.get("AYON_PUBLISH_DATA")
if not publish_data_paths:
raise KnownPublishError("Missing `AYON_PUBLISH_DATA`")

View file

@ -47,8 +47,9 @@ class CollectSceneVersion(pyblish.api.ContextPlugin):
return
if not context.data.get('currentFile'):
raise KnownPublishError("Cannot get current workfile path. "
"Make sure your scene is saved.")
self.log.error("Cannot get current workfile path. "
"Make sure your scene is saved.")
return
filename = os.path.basename(context.data.get('currentFile'))

View file

@ -49,7 +49,6 @@ class ExtractOTIOReview(publish.Extractor):
hosts = ["resolve", "hiero", "flame"]
# plugin default attributes
temp_file_head = "tempFile."
to_width = 1280
to_height = 720
output_ext = ".jpg"
@ -62,6 +61,9 @@ class ExtractOTIOReview(publish.Extractor):
make_sequence_collection
)
# TODO refactore from using instance variable
self.temp_file_head = self._get_folder_name_based_prefix(instance)
# TODO: convert resulting image sequence to mp4
# get otio clip and other time info from instance clip
@ -104,10 +106,19 @@ class ExtractOTIOReview(publish.Extractor):
media_metadata = otio_media.metadata
# get from media reference metadata source
if media_metadata.get("openpype.source.width"):
width = int(media_metadata.get("openpype.source.width"))
if media_metadata.get("openpype.source.height"):
height = int(media_metadata.get("openpype.source.height"))
# TODO 'openpype' prefix should be removed (added 24/09/03)
# NOTE it looks like it is set only in hiero integration
for key in {"ayon.source.width", "openpype.source.width"}:
value = media_metadata.get(key)
if value is not None:
width = int(value)
break
for key in {"ayon.source.height", "openpype.source.height"}:
value = media_metadata.get(key)
if value is not None:
height = int(value)
break
# compare and reset
if width != self.to_width:
@ -491,3 +502,21 @@ class ExtractOTIOReview(publish.Extractor):
out_frame_start = self.used_frames[-1]
return output_path, out_frame_start
def _get_folder_name_based_prefix(self, instance):
"""Creates 'unique' human readable file prefix to differentiate.
Multiple instances might share same temp folder, but each instance
would be differentiated by asset, eg. folder name.
It ix expected that there won't be multiple instances for same asset.
"""
folder_path = instance.data["folderPath"]
folder_name = folder_path.split("/")[-1]
folder_path = folder_path.replace("/", "_").lstrip("_")
file_prefix = f"{folder_path}_{folder_name}."
self.log.debug(f"file_prefix::{file_prefix}")
return file_prefix

View file

@ -95,7 +95,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
]
# Supported extensions
image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga"]
image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"]
video_exts = ["mov", "mp4"]
supported_exts = image_exts + video_exts
@ -1900,7 +1900,7 @@ class OverscanCrop:
string_value = re.sub(r"([ ]+)?px", " ", string_value)
string_value = re.sub(r"([ ]+)%", "%", string_value)
# Make sure +/- sign at the beginning of string is next to number
string_value = re.sub(r"^([\+\-])[ ]+", "\g<1>", string_value)
string_value = re.sub(r"^([\+\-])[ ]+", r"\g<1>", string_value)
# Make sure +/- sign in the middle has zero spaces before number under
# which belongs
string_value = re.sub(

View file

@ -455,6 +455,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
# output file
jpeg_items.append(path_to_subprocess_arg(dst_path))
subprocess_command = " ".join(jpeg_items)
try:
run_subprocess(
subprocess_command, shell=True, logger=self.log

View file

@ -4,7 +4,10 @@ import os
from typing import Dict
import pyblish.api
from pxr import Sdf
try:
from pxr import Sdf
except ImportError:
Sdf = None
from ayon_core.lib import (
TextDef,
@ -13,21 +16,24 @@ from ayon_core.lib import (
UILabelDef,
EnumDef
)
from ayon_core.pipeline.usdlib import (
get_or_define_prim_spec,
add_ordered_reference,
variant_nested_prim_path,
setup_asset_layer,
add_ordered_sublayer,
set_layer_defaults
)
try:
from ayon_core.pipeline.usdlib import (
get_or_define_prim_spec,
add_ordered_reference,
variant_nested_prim_path,
setup_asset_layer,
add_ordered_sublayer,
set_layer_defaults
)
except ImportError:
pass
from ayon_core.pipeline.entity_uri import (
construct_ayon_entity_uri,
parse_ayon_entity_uri
)
from ayon_core.pipeline.load.utils import get_representation_path_by_names
from ayon_core.pipeline.publish.lib import get_instance_expected_output_path
from ayon_core.pipeline import publish
from ayon_core.pipeline import publish, KnownPublishError
# This global toggle is here mostly for debugging purposes and should usually
@ -77,7 +83,7 @@ def get_representation_path_in_publish_context(
Allow resolving 'latest' paths from a publishing context's instances
as if they will exist after publishing without them being integrated yet.
Use first instance that has same folder path and product name,
and contains representation with passed name.
@ -138,13 +144,14 @@ def get_instance_uri_path(
folder_path = instance.data["folderPath"]
product_name = instance.data["productName"]
project_name = context.data["projectName"]
version_name = instance.data["version"]
# Get the layer's published path
path = construct_ayon_entity_uri(
project_name=project_name,
folder_path=folder_path,
product=product_name,
version="latest",
version=version_name,
representation_name="usd"
)
@ -231,7 +238,7 @@ def add_representation(instance, name,
class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
publish.OpenPypePyblishPluginMixin):
publish.AYONPyblishPluginMixin):
"""Collect the USD Layer Contributions and create dependent instances.
Our contributions go to the layer
@ -555,12 +562,24 @@ class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions):
return defs
class ValidateUSDDependencies(pyblish.api.InstancePlugin):
families = ["usdLayer"]
order = pyblish.api.ValidatorOrder
def process(self, instance):
if Sdf is None:
raise KnownPublishError("USD library 'Sdf' is not available.")
class ExtractUSDLayerContribution(publish.Extractor):
families = ["usdLayer"]
label = "Extract USD Layer Contributions (Asset/Shot)"
order = pyblish.api.ExtractorOrder + 0.45
use_ayon_entity_uri = False
def process(self, instance):
folder_path = instance.data["folderPath"]
@ -578,7 +597,8 @@ class ExtractUSDLayerContribution(publish.Extractor):
contributions = instance.data.get("usd_contributions", [])
for contribution in sorted(contributions, key=attrgetter("order")):
path = get_instance_uri_path(contribution.instance)
path = get_instance_uri_path(contribution.instance,
resolve=not self.use_ayon_entity_uri)
if isinstance(contribution, VariantContribution):
# Add contribution as a reference inside a variant
self.log.debug(f"Adding variant: {contribution}")
@ -652,14 +672,14 @@ class ExtractUSDLayerContribution(publish.Extractor):
)
def remove_previous_reference_contribution(self,
prim_spec: Sdf.PrimSpec,
prim_spec: "Sdf.PrimSpec",
instance: pyblish.api.Instance):
# Remove existing contributions of the same product - ignoring
# the picked version and representation. We assume there's only ever
# one version of a product you want to have referenced into a Prim.
remove_indices = set()
for index, ref in enumerate(prim_spec.referenceList.prependedItems):
ref: Sdf.Reference # type hint
ref: "Sdf.Reference"
uri = ref.customData.get("ayon_uri")
if uri and self.instance_match_ayon_uri(instance, uri):
@ -674,8 +694,8 @@ class ExtractUSDLayerContribution(publish.Extractor):
]
def add_reference_contribution(self,
layer: Sdf.Layer,
prim_path: Sdf.Path,
layer: "Sdf.Layer",
prim_path: "Sdf.Path",
filepath: str,
contribution: VariantContribution):
instance = contribution.instance
@ -720,6 +740,8 @@ class ExtractUSDAssetContribution(publish.Extractor):
label = "Extract USD Asset/Shot Contributions"
order = ExtractUSDLayerContribution.order + 0.01
use_ayon_entity_uri = False
def process(self, instance):
folder_path = instance.data["folderPath"]
@ -795,15 +817,15 @@ class ExtractUSDAssetContribution(publish.Extractor):
layer_id = layer_instance.data["usd_layer_id"]
order = layer_instance.data["usd_layer_order"]
path = get_instance_uri_path(instance=layer_instance)
path = get_instance_uri_path(instance=layer_instance,
resolve=not self.use_ayon_entity_uri)
add_ordered_sublayer(target_layer,
contribution_path=path,
layer_id=layer_id,
order=order,
# Add the sdf argument metadata which allows
# us to later detect whether another path
# has the same layer id, so we can replace it
# it.
# has the same layer id, so we can replace it.
add_sdf_arguments_metadata=True)
# Save the file

View file

@ -11,7 +11,11 @@ Multiples instances from your scene are set to publish into the same folder > pr
### How to repair?
Remove the offending instances or rename to have a unique name.
Remove the offending instances or rename to have a unique name. Also, please
check your product name templates to ensure that resolved names are
sufficiently unique. You can find that settings:
ayon+settings://core/tools/creator/product_name_profiles
</description>
</error>
</root>
</root>

View file

@ -114,18 +114,19 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
# the database even if not used by the destination template
db_representation_context_keys = [
"project",
"asset",
"hierarchy",
"folder",
"task",
"product",
"subset",
"family",
"version",
"representation",
"username",
"user",
"output"
"output",
# OpenPype keys - should be removed
"asset", # folder[name]
"subset", # product[name]
"family", # product[type]
]
def process(self, instance):
@ -743,6 +744,11 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
if not is_udim:
repre_context["frame"] = first_index_padded
# store renderlayer in context if it exists
# to be later used for example by delivery templates
if instance.data.get("renderlayer"):
repre_context["renderlayer"] = instance.data["renderlayer"]
# Update the destination indexes and padding
dst_collection = clique.assemble(dst_filepaths)[0][0]
dst_collection.padding = destination_padding

View file

@ -265,6 +265,9 @@ class IntegrateHeroVersion(
project_name, "version", new_hero_version
)
# Store hero entity to 'instance.data'
instance.data["heroVersionEntity"] = new_hero_version
# Separate old representations into `to replace` and `to delete`
old_repres_to_replace = {}
old_repres_to_delete = {}

View file

@ -0,0 +1,102 @@
import os
import pyblish.api
import ayon_api
from ayon_api.server_api import RequestTypes
from ayon_core.lib import get_media_mime_type
from ayon_core.pipeline.publish import get_publish_repre_path
class IntegrateAYONReview(pyblish.api.InstancePlugin):
label = "Integrate AYON Review"
# Must happen after IntegrateAsset
order = pyblish.api.IntegratorOrder + 0.15
def process(self, instance):
project_name = instance.context.data["projectName"]
src_version_entity = instance.data.get("versionEntity")
src_hero_version_entity = instance.data.get("heroVersionEntity")
for version_entity in (
src_version_entity,
src_hero_version_entity,
):
if not version_entity:
continue
version_id = version_entity["id"]
self._upload_reviewable(project_name, version_id, instance)
def _upload_reviewable(self, project_name, version_id, instance):
ayon_con = ayon_api.get_server_api_connection()
major, minor, _, _, _ = ayon_con.get_server_version_tuple()
if (major, minor) < (1, 3):
self.log.info(
"Skipping reviewable upload, supported from server 1.3.x."
f" Current server version {ayon_con.get_server_version()}"
)
return
uploaded_labels = set()
for repre in instance.data["representations"]:
repre_tags = repre.get("tags") or []
# Ignore representations that are not reviewable
if "webreview" not in repre_tags:
continue
# exclude representations with are going to be published on farm
if "publish_on_farm" in repre_tags:
continue
# Skip thumbnails
if repre.get("thumbnail") or "thumbnail" in repre_tags:
continue
repre_path = get_publish_repre_path(
instance, repre, False
)
if not repre_path or not os.path.exists(repre_path):
# TODO log skipper path
continue
content_type = get_media_mime_type(repre_path)
if not content_type:
self.log.warning(
f"Could not determine Content-Type for {repre_path}"
)
continue
label = self._get_review_label(repre, uploaded_labels)
query = ""
if label:
query = f"?label={label}"
endpoint = (
f"/projects/{project_name}"
f"/versions/{version_id}/reviewables{query}"
)
filename = os.path.basename(repre_path)
# Upload the reviewable
self.log.info(f"Uploading reviewable '{label or filename}' ...")
headers = ayon_con.get_headers(content_type)
headers["x-file-name"] = filename
self.log.info(f"Uploading reviewable {repre_path}")
ayon_con.upload_file(
endpoint,
repre_path,
headers=headers,
request_type=RequestTypes.post,
)
def _get_review_label(self, repre, uploaded_labels):
# Use output name as label if available
label = repre.get("outputName")
if not label:
return None
orig_label = label
idx = 0
while label in uploaded_labels:
idx += 1
label = f"{orig_label}_{idx}"
return label

View file

@ -1,17 +1,59 @@
import inspect
import pyblish.api
from ayon_core.pipeline.publish import PublishValidationError
from ayon_core.tools.utils.host_tools import show_workfiles
from ayon_core.pipeline.context_tools import version_up_current_workfile
class SaveByVersionUpAction(pyblish.api.Action):
"""Save Workfile."""
label = "Save Workfile"
on = "failed"
icon = "save"
def process(self, context, plugin):
version_up_current_workfile()
class ShowWorkfilesAction(pyblish.api.Action):
"""Save Workfile."""
label = "Show Workfiles Tool..."
on = "failed"
icon = "files-o"
def process(self, context, plugin):
show_workfiles()
class ValidateCurrentSaveFile(pyblish.api.ContextPlugin):
"""File must be saved before publishing"""
"""File must be saved before publishing
This does not validate for unsaved changes. It only validates whether
the current context was able to identify any 'currentFile'.
"""
label = "Validate File Saved"
order = pyblish.api.ValidatorOrder - 0.1
hosts = ["maya", "houdini", "nuke"]
hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter"]
actions = [SaveByVersionUpAction, ShowWorkfilesAction]
def process(self, context):
current_file = context.data["currentFile"]
if not current_file:
raise PublishValidationError("File not saved")
raise PublishValidationError(
"Workfile is not saved. Please save your scene to continue.",
title="File not saved",
description=self.get_description())
def get_description(self):
return inspect.cleandoc("""
### File not saved
Your workfile must be saved to continue publishing.
The **Save Workfile** action will save it for you with the first
available workfile version number in your current context.
""")

View file

@ -70,19 +70,3 @@ def get_ayon_splash_filepath(staging=None):
else:
splash_file_name = "AYON_splash.png"
return get_resource("icons", splash_file_name)
def get_openpype_production_icon_filepath():
return get_ayon_production_icon_filepath()
def get_openpype_staging_icon_filepath():
return get_ayon_staging_icon_filepath()
def get_openpype_icon_filepath(staging=None):
return get_ayon_icon_filepath(staging)
def get_openpype_splash_filepath(staging=None):
return get_ayon_splash_filepath(staging)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View file

@ -486,11 +486,11 @@ class TableField(BaseItem):
line = self.ellide_text
break
for idx, char in enumerate(_word):
for char_index, char in enumerate(_word):
_line = line + char + self.ellide_text
_line_width = font.getsize(_line)[0]
if _line_width > max_width:
if idx == 0:
if char_index == 0:
line = _line
break
line = line + char

View file

@ -1,79 +0,0 @@
# Structure of local settings
- local settings do not have any validation schemas right now this should help to see what is stored to local settings and how it works
- they are stored by identifier site_id which should be unified identifier of workstation
- all keys may and may not available on load
- contain main categories: `general`, `applications`, `projects`
## Categories
### General
- ATM contain only label of site
```json
{
"general": {
"site_label": "MySite"
}
}
```
### Applications
- modifications of application executables
- output should match application groups and variants
```json
{
"applications": {
"<app group>": {
"<app name>": {
"executable": "/my/path/to/nuke_12_2"
}
}
}
}
```
### Projects
- project specific modifications
- default project is stored under constant key defined in `pype.settings.contants`
```json
{
"projects": {
"<project name>": {
"active_site": "<name of active site>",
"remote_site": "<name of remote site>",
"roots": {
"<site name>": {
"<root name>": "<root dir path>"
}
}
}
}
}
```
## Final document
```json
{
"_id": "<ObjectId(...)>",
"site_id": "<site id>",
"general": {
"site_label": "MySite"
},
"applications": {
"<app group>": {
"<app name>": {
"executable": "<path to app executable>"
}
}
},
"projects": {
"<project name>": {
"active_site": "<name of active site>",
"remote_site": "<name of remote site>",
"roots": {
"<site name>": {
"<root name>": "<root dir path>"
}
}
}
}
}
```

View file

@ -739,6 +739,31 @@ OverlayMessageWidget QWidget {
background: transparent;
}
/* Hinted Line Edit */
HintedLineEditInput {
border-radius: 0.2em;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border: 1px solid {color:border};
}
HintedLineEditInput:hover {
border-color: {color:border-hover};
}
HintedLineEditInput:focus{
border-color: {color:border-focus};
}
HintedLineEditInput:disabled {
background: {color:bg-inputs-disabled};
}
HintedLineEditButton {
border: none;
border-radius: 0.2em;
border-bottom-left-radius: 0px;
border-top-left-radius: 0px;
padding: 0px;
qproperty-iconSize: 11px 11px;
}
/* Password dialog*/
#PasswordBtn {
border: none;
@ -969,17 +994,6 @@ PixmapButton:disabled {
#PublishLogConsole {
font-family: "Noto Sans Mono";
}
#VariantInputsWidget QLineEdit {
border-bottom-right-radius: 0px;
border-top-right-radius: 0px;
}
#VariantInputsWidget QToolButton {
border-bottom-left-radius: 0px;
border-top-left-radius: 0px;
padding-top: 0.5em;
padding-bottom: 0.5em;
width: 0.5em;
}
#VariantInput[state="new"], #VariantInput[state="new"]:focus, #VariantInput[state="new"]:hover {
border-color: {color:publisher:success};
}
@ -1231,6 +1245,15 @@ ValidationArtistMessage QLabel {
background: transparent;
}
#PluginDetailsContent {
background: {color:bg-inputs};
border-radius: 0.2em;
}
#PluginDetailsContent #PluginLabel {
font-size: 14pt;
font-weight: bold;
}
CreateNextPageOverlay {
font-size: 32pt;
}
@ -1449,14 +1472,6 @@ CreateNextPageOverlay {
border-radius: 5px;
}
#OpenPypeVersionLabel[state="success"] {
color: {color:settings:version-exists};
}
#OpenPypeVersionLabel[state="warning"] {
color: {color:settings:version-not-found};
}
#ShadowWidget {
font-size: 36pt;
}

View file

@ -13,8 +13,11 @@ from typing import (
from ayon_core.lib import AbstractAttrDef
from ayon_core.host import HostBase
from ayon_core.pipeline.create import CreateContext, CreatedInstance
from ayon_core.pipeline.create.context import ConvertorItem
from ayon_core.pipeline.create import (
CreateContext,
CreatedInstance,
ConvertorItem,
)
from ayon_core.tools.common_models import (
FolderItem,
TaskItem,
@ -319,6 +322,12 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
) -> Dict[str, Union[CreatedInstance, None]]:
pass
@abstractmethod
def get_instances_context_info(
self, instance_ids: Optional[Iterable[str]] = None
):
pass
@abstractmethod
def get_existing_product_names(self, folder_path: str) -> List[str]:
pass

View file

@ -190,6 +190,9 @@ class PublisherController(
def get_instances_by_id(self, instance_ids=None):
return self._create_model.get_instances_by_id(instance_ids)
def get_instances_context_info(self, instance_ids=None):
return self._create_model.get_instances_context_info(instance_ids)
def get_convertor_items(self):
return self._create_model.get_convertor_items()

View file

@ -60,9 +60,8 @@ class MainThreadProcess(QtCore.QObject):
self._timer.stop()
def clear(self):
if self._timer.isActive():
self._timer.stop()
self._items_to_process = collections.deque()
self.stop()
class QtPublisherController(PublisherController):
@ -77,21 +76,32 @@ class QtPublisherController(PublisherController):
self.register_event_callback(
"publish.process.stopped", self._qt_on_publish_stop
)
# Capture if '_next_publish_item_process' is in
# '_main_thread_processor' loop
self._item_process_in_loop = False
def reset(self):
self._main_thread_processor.clear()
self._item_process_in_loop = False
super().reset()
def _start_publish(self, up_validation):
self._publish_model.set_publish_up_validation(up_validation)
self._publish_model.start_publish(wait=False)
self._process_main_thread_item(
MainThreadItem(self._next_publish_item_process)
)
# Make sure '_next_publish_item_process' is only once in
# the '_main_thread_processor' loop
if not self._item_process_in_loop:
self._process_main_thread_item(
MainThreadItem(self._next_publish_item_process)
)
def _next_publish_item_process(self):
if not self._publish_model.is_running():
# This removes '_next_publish_item_process' from loop
self._item_process_in_loop = False
return
self._item_process_in_loop = True
func = self._publish_model.get_next_process_func()
self._process_main_thread_item(MainThreadItem(func))
self._process_main_thread_item(
@ -105,4 +115,6 @@ class QtPublisherController(PublisherController):
self._main_thread_processor.start()
def _qt_on_publish_stop(self):
self._main_thread_processor.stop()
self._process_main_thread_item(
MainThreadItem(self._main_thread_processor.stop)
)

View file

@ -18,7 +18,7 @@ from ayon_core.pipeline.create import (
CreateContext,
CreatedInstance,
)
from ayon_core.pipeline.create.context import (
from ayon_core.pipeline.create import (
CreatorsOperationFailed,
ConvertorsOperationFailed,
ConvertorItem,
@ -306,6 +306,14 @@ class CreateModel:
for instance_id in instance_ids
}
def get_instances_context_info(
self, instance_ids: Optional[Iterable[str]] = None
):
instances = self.get_instances_by_id(instance_ids).values()
return self._create_context.get_instances_context_info(
instances
)
def get_convertor_items(self) -> Dict[str, ConvertorItem]:
return self._create_context.convertor_items_by_id

View file

@ -172,7 +172,7 @@ class PublishReportMaker:
"crashed_file_paths": crashed_file_paths,
"id": uuid.uuid4().hex,
"created_at": now.isoformat(),
"report_version": "1.0.1",
"report_version": "1.1.0",
}
def _add_plugin_data_item(self, plugin: pyblish.api.Plugin):
@ -194,11 +194,23 @@ class PublishReportMaker:
if hasattr(plugin, "label"):
label = plugin.label
plugin_type = "instance" if plugin.__instanceEnabled__ else "context"
# Get docstring
# NOTE we do care only about docstring from the plugin so we can't
# use 'inspect.getdoc' which also looks for docstring in parent
# classes.
docstring = getattr(plugin, "__doc__", None)
if docstring:
docstring = inspect.cleandoc(docstring)
return {
"id": plugin.id,
"name": plugin.__name__,
"label": label,
"order": plugin.order,
"filepath": inspect.getfile(plugin),
"docstring": docstring,
"plugin_type": plugin_type,
"families": list(plugin.families),
"targets": list(plugin.targets),
"instances_data": [],
"actions_data": [],
@ -829,7 +841,9 @@ class PublishModel:
)
# Plugin iterator
self._main_thread_iter: Iterable[partial] = []
self._main_thread_iter: collections.abc.Generator[partial] = (
self._default_iterator()
)
def reset(self):
create_context = self._controller.get_create_context()
@ -895,29 +909,30 @@ class PublishModel:
func()
def get_next_process_func(self) -> partial:
# Validations of progress before using iterator
# - same conditions may be inside iterator but they may be used
# only in specific cases (e.g. when it happens for a first time)
# Raise error if this function is called when publishing
# is not running
if not self._publish_is_running:
raise ValueError("Publish is not running")
# Validations of progress before using iterator
# Any unexpected error happened
# - everything should stop
if self._publish_has_crashed:
return partial(self.stop_publish)
# Stop if validation is over and validation errors happened
# or publishing should stop at validation
if (
self._main_thread_iter is None
# There are validation errors and validation is passed
# - can't do any progree
or (
self._publish_has_validated
and self._publish_has_validation_errors
self._publish_has_validated
and (
self._publish_has_validation_errors
or self._publish_up_validation
)
# Any unexpected error happened
# - everything should stop
or self._publish_has_crashed
):
item = partial(self.stop_publish)
return partial(self.stop_publish)
# Everything is ok so try to get new processing item
else:
item = next(self._main_thread_iter)
return item
return next(self._main_thread_iter)
def stop_publish(self):
if self._publish_is_running:
@ -1070,6 +1085,19 @@ class PublishModel:
{"value": value}
)
def _default_iterator(self):
"""Iterator used on initialization.
Should be replaced by real iterator when 'reset' is called.
Returns:
collections.abc.Generator[partial]: Generator with partial
functions that should be called in main thread.
"""
while True:
yield partial(self.stop_publish)
def _start_publish(self):
"""Start or continue in publishing."""
if self._publish_is_running:
@ -1101,22 +1129,16 @@ class PublishModel:
self._publish_progress = idx
# Check if plugin is over validation order
if not self._publish_has_validated:
self._set_has_validated(
plugin.order >= self._validation_order
)
# Stop if plugin is over validation order and process
# should process up to validation.
if self._publish_up_validation and self._publish_has_validated:
yield partial(self.stop_publish)
# Stop if validation is over and validation errors happened
if (
self._publish_has_validated
and self.has_validation_errors()
not self._publish_has_validated
and plugin.order >= self._validation_order
):
yield partial(self.stop_publish)
self._set_has_validated(True)
if (
self._publish_up_validation
or self._publish_has_validation_errors
):
yield partial(self.stop_publish)
# Add plugin to publish report
self._publish_report.add_plugin_iter(

View file

@ -13,8 +13,16 @@ class PluginItem:
self.skipped = plugin_data["skipped"]
self.passed = plugin_data["passed"]
# Introduced in report '1.1.0'
self.docstring = plugin_data.get("docstring")
self.filepath = plugin_data.get("filepath")
self.plugin_type = plugin_data.get("plugin_type")
self.families = plugin_data.get("families")
errored = False
process_time = 0.0
for instance_data in plugin_data["instances_data"]:
process_time += instance_data["process_time"]
for log_item in instance_data["logs"]:
errored = log_item["type"] == "error"
if errored:
@ -22,6 +30,7 @@ class PluginItem:
if errored:
break
self.process_time = process_time
self.errored = errored
@property

View file

@ -1,7 +1,15 @@
from math import ceil
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils import NiceCheckbox
from ayon_core.tools.utils import (
NiceCheckbox,
ElideLabel,
SeparatorWidget,
IconButton,
paint_image_with_color,
)
from ayon_core.resources import get_image_path
from ayon_core.style import get_objected_colors
# from ayon_core.tools.utils import DeselectableTreeView
from .constants import (
@ -22,33 +30,89 @@ TRACEBACK_ROLE = QtCore.Qt.UserRole + 2
IS_DETAIL_ITEM_ROLE = QtCore.Qt.UserRole + 3
class PluginLoadReportModel(QtGui.QStandardItemModel):
def set_report(self, report):
parent = self.invisibleRootItem()
parent.removeRows(0, parent.rowCount())
def get_pretty_milliseconds(value):
if value < 1000:
return f"{value:.3f}ms"
value /= 1000
if value < 60:
return f"{value:.2f}s"
seconds = int(value % 60)
value /= 60
if value < 60:
return f"{value:.2f}m {seconds:.2f}s"
minutes = int(value % 60)
value /= 60
return f"{value:.2f}h {minutes:.2f}m"
class PluginLoadReportModel(QtGui.QStandardItemModel):
def __init__(self):
super().__init__()
self._traceback_by_filepath = {}
self._items_by_filepath = {}
self._is_active = True
self._need_refresh = False
def set_active(self, is_active):
if self._is_active is is_active:
return
self._is_active = is_active
self._update_items()
def set_report(self, report):
self._need_refresh = True
if report is None:
self._traceback_by_filepath.clear()
self._update_items()
return
filepaths = set(report.crashed_plugin_paths.keys())
to_remove = set(self._traceback_by_filepath) - filepaths
for filepath in filepaths:
self._traceback_by_filepath[filepath] = (
report.crashed_plugin_paths[filepath]
)
for filepath in to_remove:
self._traceback_by_filepath.pop(filepath)
self._update_items()
def _update_items(self):
if not self._is_active or not self._need_refresh:
return
parent = self.invisibleRootItem()
if not self._traceback_by_filepath:
parent.removeRows(0, parent.rowCount())
return
new_items = []
new_items_by_filepath = {}
for filepath in report.crashed_plugin_paths.keys():
to_remove = (
set(self._items_by_filepath) - set(self._traceback_by_filepath)
)
for filepath in self._traceback_by_filepath:
if filepath in self._items_by_filepath:
continue
item = QtGui.QStandardItem(filepath)
new_items.append(item)
new_items_by_filepath[filepath] = item
self._items_by_filepath[filepath] = item
if not new_items:
return
if new_items:
parent.appendRows(new_items)
parent.appendRows(new_items)
for filepath, item in new_items_by_filepath.items():
traceback_txt = report.crashed_plugin_paths[filepath]
traceback_txt = self._traceback_by_filepath[filepath]
detail_item = QtGui.QStandardItem()
detail_item.setData(filepath, FILEPATH_ROLE)
detail_item.setData(traceback_txt, TRACEBACK_ROLE)
detail_item.setData(True, IS_DETAIL_ITEM_ROLE)
item.appendRow(detail_item)
for filepath in to_remove:
item = self._items_by_filepath.pop(filepath)
parent.removeRow(item.row())
class DetailWidget(QtWidgets.QTextEdit):
def __init__(self, text, *args, **kwargs):
@ -95,10 +159,12 @@ class PluginLoadReportWidget(QtWidgets.QWidget):
self._model = model
self._widgets_by_filepath = {}
def _on_expand(self, index):
for row in range(self._model.rowCount(index)):
child_index = self._model.index(row, index.column(), index)
self._create_widget(child_index)
def set_active(self, is_active):
self._model.set_active(is_active)
def set_report(self, report):
self._widgets_by_filepath = {}
self._model.set_report(report)
def showEvent(self, event):
super().showEvent(event)
@ -108,6 +174,11 @@ class PluginLoadReportWidget(QtWidgets.QWidget):
super().resizeEvent(event)
self._update_widgets_size_hints()
def _on_expand(self, index):
for row in range(self._model.rowCount(index)):
child_index = self._model.index(row, index.column(), index)
self._create_widget(child_index)
def _update_widgets_size_hints(self):
for item in self._widgets_by_filepath.values():
widget, index = item
@ -136,10 +207,6 @@ class PluginLoadReportWidget(QtWidgets.QWidget):
self._view.setIndexWidget(index, widget)
self._widgets_by_filepath[filepath] = (widget, index)
def set_report(self, report):
self._widgets_by_filepath = {}
self._model.set_report(report)
class ZoomPlainText(QtWidgets.QPlainTextEdit):
min_point_size = 1.0
@ -229,6 +296,8 @@ class DetailsWidget(QtWidgets.QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(output_widget)
self._is_active = True
self._need_refresh = False
self._output_widget = output_widget
self._report_item = None
self._instance_filter = set()
@ -237,21 +306,33 @@ class DetailsWidget(QtWidgets.QWidget):
def clear(self):
self._output_widget.setPlainText("")
def set_active(self, is_active):
if self._is_active is is_active:
return
self._is_active = is_active
self._update_logs()
def set_report(self, report):
self._report_item = report
self._plugin_filter = set()
self._instance_filter = set()
self._need_refresh = True
self._update_logs()
def set_plugin_filter(self, plugin_filter):
self._plugin_filter = plugin_filter
self._need_refresh = True
self._update_logs()
def set_instance_filter(self, instance_filter):
self._instance_filter = instance_filter
self._need_refresh = True
self._update_logs()
def _update_logs(self):
if not self._is_active or not self._need_refresh:
return
if not self._report_item:
self._output_widget.setPlainText("")
return
@ -294,6 +375,242 @@ class DetailsWidget(QtWidgets.QWidget):
self._output_widget.setPlainText(text)
class PluginDetailsWidget(QtWidgets.QWidget):
def __init__(self, plugin_item, parent):
super().__init__(parent)
content_widget = QtWidgets.QFrame(self)
content_widget.setObjectName("PluginDetailsContent")
plugin_label_widget = QtWidgets.QLabel(content_widget)
plugin_label_widget.setObjectName("PluginLabel")
plugin_doc_widget = QtWidgets.QLabel(content_widget)
plugin_doc_widget.setWordWrap(True)
form_separator = SeparatorWidget(parent=content_widget)
plugin_class_label = QtWidgets.QLabel("Class:")
plugin_class_widget = QtWidgets.QLabel(content_widget)
plugin_order_label = QtWidgets.QLabel("Order:")
plugin_order_widget = QtWidgets.QLabel(content_widget)
plugin_families_label = QtWidgets.QLabel("Families:")
plugin_families_widget = QtWidgets.QLabel(content_widget)
plugin_families_widget.setWordWrap(True)
plugin_path_label = QtWidgets.QLabel("File Path:")
plugin_path_widget = ElideLabel(content_widget)
plugin_path_widget.set_elide_mode(QtCore.Qt.ElideLeft)
plugin_time_label = QtWidgets.QLabel("Time:")
plugin_time_widget = QtWidgets.QLabel(content_widget)
# Set interaction flags
for label_widget in (
plugin_label_widget,
plugin_doc_widget,
plugin_class_widget,
plugin_order_widget,
plugin_families_widget,
plugin_time_widget,
):
label_widget.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction
)
# Change style of form labels
for label_widget in (
plugin_class_label,
plugin_order_label,
plugin_families_label,
plugin_path_label,
plugin_time_label,
):
label_widget.setObjectName("PluginFormLabel")
plugin_label = plugin_item.label or plugin_item.name
if plugin_item.plugin_type:
plugin_label += " ({})".format(
plugin_item.plugin_type.capitalize()
)
time_label = "Not started"
if plugin_item.passed:
time_label = get_pretty_milliseconds(plugin_item.process_time)
elif plugin_item.skipped:
time_label = "Skipped plugin"
families = "N/A"
if plugin_item.families:
families = ", ".join(plugin_item.families)
order = "N/A"
if plugin_item.order is not None:
order = str(plugin_item.order)
plugin_label_widget.setText(plugin_label)
plugin_doc_widget.setText(plugin_item.docstring or "N/A")
plugin_class_widget.setText(plugin_item.name or "N/A")
plugin_order_widget.setText(order)
plugin_families_widget.setText(families)
plugin_path_widget.setText(plugin_item.filepath or "N/A")
plugin_path_widget.setToolTip(plugin_item.filepath or None)
plugin_time_widget.setText(time_label)
content_layout = QtWidgets.QGridLayout(content_widget)
content_layout.setContentsMargins(8, 8, 8, 8)
content_layout.setColumnStretch(0, 0)
content_layout.setColumnStretch(1, 1)
row = 0
content_layout.addWidget(plugin_label_widget, row, 0, 1, 2)
row += 1
# Hide docstring if it is empty
if plugin_item.docstring:
content_layout.addWidget(plugin_doc_widget, row, 0, 1, 2)
row += 1
else:
plugin_doc_widget.setVisible(False)
content_layout.addWidget(form_separator, row, 0, 1, 2)
row += 1
for label_widget, value_widget in (
(plugin_class_label, plugin_class_widget),
(plugin_order_label, plugin_order_widget),
(plugin_families_label, plugin_families_widget),
(plugin_path_label, plugin_path_widget),
(plugin_time_label, plugin_time_widget),
):
content_layout.addWidget(label_widget, row, 0)
content_layout.addWidget(value_widget, row, 1)
row += 1
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(content_widget, 0)
class PluginsDetailsWidget(QtWidgets.QWidget):
def __init__(self, parent):
super().__init__(parent)
scroll_area = QtWidgets.QScrollArea(self)
scroll_area.setWidgetResizable(True)
scroll_content_widget = QtWidgets.QWidget(scroll_area)
scroll_area.setWidget(scroll_content_widget)
empty_label = QtWidgets.QLabel(
"<br/><br/>Select plugins to view more information...",
scroll_content_widget
)
empty_label.setAlignment(QtCore.Qt.AlignCenter)
content_widget = QtWidgets.QWidget(scroll_content_widget)
content_layout = QtWidgets.QVBoxLayout(content_widget)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(10)
scroll_content_layout = QtWidgets.QVBoxLayout(scroll_content_widget)
scroll_content_layout.setContentsMargins(0, 0, 0, 0)
scroll_content_layout.addWidget(empty_label, 0)
scroll_content_layout.addWidget(content_widget, 0)
scroll_content_layout.addStretch(1)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(scroll_area, 1)
content_widget.setVisible(False)
self._scroll_area = scroll_area
self._empty_label = empty_label
self._content_layout = content_layout
self._content_widget = content_widget
self._widgets_by_plugin_id = {}
self._stretch_item_index = 0
self._is_active = True
self._need_refresh = False
self._report_item = None
self._plugin_filter = set()
self._plugin_ids = None
def set_active(self, is_active):
if self._is_active is is_active:
return
self._is_active = is_active
self._update_widgets()
def set_plugin_filter(self, plugin_filter):
self._need_refresh = True
self._plugin_filter = plugin_filter
self._update_widgets()
def set_report(self, report):
self._plugin_ids = None
self._plugin_filter = set()
self._need_refresh = True
self._report_item = report
self._update_widgets()
def _get_plugin_ids(self):
if self._plugin_ids is not None:
return self._plugin_ids
# Clear layout and clear widgets
while self._content_layout.count():
item = self._content_layout.takeAt(0)
widget = item.widget()
if widget:
widget.setVisible(False)
widget.deleteLater()
self._widgets_by_plugin_id.clear()
plugin_ids = []
if self._report_item is not None:
plugin_ids = list(self._report_item.plugins_id_order)
self._plugin_ids = plugin_ids
return plugin_ids
def _update_widgets(self):
if not self._is_active or not self._need_refresh:
return
self._need_refresh = False
# Hide content widget before updating
# - add widgets to layout can happen without recalculating
# the layout and widget size hints
self._content_widget.setVisible(False)
any_visible = False
for plugin_id in self._get_plugin_ids():
widget = self._widgets_by_plugin_id.get(plugin_id)
if widget is None:
plugin_item = self._report_item.plugins_items_by_id[plugin_id]
widget = PluginDetailsWidget(plugin_item, self._content_widget)
self._widgets_by_plugin_id[plugin_id] = widget
self._content_layout.addWidget(widget, 0)
is_visible = plugin_id in self._plugin_filter
widget.setVisible(is_visible)
if is_visible:
any_visible = True
self._content_widget.setVisible(any_visible)
self._empty_label.setVisible(not any_visible)
class DeselectableTreeView(QtWidgets.QTreeView):
"""A tree view that deselects on clicking on an empty area in the view"""
@ -337,11 +654,15 @@ class DetailsPopup(QtWidgets.QDialog):
def showEvent(self, event):
layout = self.layout()
cw_size = self._center_widget.size()
layout.insertWidget(0, self._center_widget)
super().showEvent(event)
if self._first_show:
self._first_show = False
self.resize(700, 400)
self.resize(
max(cw_size.width(), 700),
max(cw_size.height(), 400)
)
super().showEvent(event)
def closeEvent(self, event):
super().closeEvent(event)
@ -410,20 +731,42 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
details_widget = QtWidgets.QWidget(self)
details_tab_widget = QtWidgets.QTabWidget(details_widget)
details_popup_btn = QtWidgets.QPushButton("PopUp", details_widget)
btns_widget = QtWidgets.QWidget(details_widget)
popout_image = QtGui.QImage(get_image_path("popout.png"))
popout_color = get_objected_colors("font")
popout_icon = QtGui.QIcon(
paint_image_with_color(popout_image, popout_color.get_qcolor())
)
details_popup_btn = IconButton(btns_widget)
details_popup_btn.setIcon(popout_icon)
details_popup_btn.setToolTip("Pop Out")
btns_layout = QtWidgets.QHBoxLayout(btns_widget)
btns_layout.setContentsMargins(0, 0, 0, 0)
btns_layout.addStretch(1)
btns_layout.addWidget(details_popup_btn, 0)
details_layout = QtWidgets.QVBoxLayout(details_widget)
details_layout.setContentsMargins(0, 0, 0, 0)
details_layout.addWidget(details_tab_widget, 1)
details_layout.addWidget(details_popup_btn, 0)
details_layout.addWidget(btns_widget, 0)
details_popup = DetailsPopup(self, details_tab_widget)
logs_text_widget = DetailsWidget(details_tab_widget)
plugin_load_report_widget = PluginLoadReportWidget(details_tab_widget)
plugins_details_widget = PluginsDetailsWidget(details_tab_widget)
plugin_load_report_widget.set_active(False)
plugins_details_widget.set_active(False)
details_tab_widget.addTab(logs_text_widget, "Logs")
details_tab_widget.addTab(plugin_load_report_widget, "Crashed plugins")
details_tab_widget.addTab(plugins_details_widget, "Plugins Details")
details_tab_widget.addTab(
plugin_load_report_widget, "Crashed plugins"
)
middle_widget = QtWidgets.QWidget(self)
middle_layout = QtWidgets.QGridLayout(middle_widget)
@ -440,6 +783,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
layout.addWidget(middle_widget, 0)
layout.addWidget(details_widget, 1)
details_tab_widget.currentChanged.connect(self._on_tab_change)
instances_view.selectionModel().selectionChanged.connect(
self._on_instance_change
)
@ -458,10 +802,12 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
details_popup_btn.clicked.connect(self._on_details_popup)
details_popup.closed.connect(self._on_popup_close)
self._current_tab_idx = 0
self._ignore_selection_changes = False
self._report_item = None
self._logs_text_widget = logs_text_widget
self._plugin_load_report_widget = plugin_load_report_widget
self._plugins_details_widget = plugins_details_widget
self._removed_instances_check = removed_instances_check
self._instances_view = instances_view
@ -498,6 +844,14 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
else:
self._plugins_view.expand(index)
def set_active(self, active):
for idx in range(self._details_tab_widget.count()):
widget = self._details_tab_widget.widget(idx)
widget.set_active(active and idx == self._current_tab_idx)
if not active:
self.close_details_popup()
def set_report_data(self, report_data):
report = PublishReport(report_data)
self.set_report(report)
@ -511,12 +865,22 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
self._plugins_model.set_report(report)
self._logs_text_widget.set_report(report)
self._plugin_load_report_widget.set_report(report)
self._plugins_details_widget.set_report(report)
self._ignore_selection_changes = False
self._instances_view.expandAll()
self._plugins_view.expandAll()
def _on_tab_change(self, new_idx):
if self._current_tab_idx == new_idx:
return
old_widget = self._details_tab_widget.widget(self._current_tab_idx)
new_widget = self._details_tab_widget.widget(new_idx)
self._current_tab_idx = new_idx
old_widget.set_active(False)
new_widget.set_active(True)
def _on_instance_change(self, *_args):
if self._ignore_selection_changes:
return
@ -538,6 +902,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
plugin_ids.add(index.data(ITEM_ID_ROLE))
self._logs_text_widget.set_plugin_filter(plugin_ids)
self._plugins_details_widget.set_plugin_filter(plugin_ids)
def _on_skipped_plugin_check(self):
self._plugins_proxy.set_ignore_skipped(

View file

@ -2,11 +2,11 @@ import os
import json
import uuid
import appdirs
import arrow
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core import style
from ayon_core.lib import get_launcher_local_dir
from ayon_core.resources import get_ayon_icon_filepath
from ayon_core.tools import resources
from ayon_core.tools.utils import (
@ -35,12 +35,8 @@ def get_reports_dir():
str: Path to directory where reports are stored.
"""
report_dir = os.path.join(
appdirs.user_data_dir("AYON", "Ynput"),
"publish_report_viewer"
)
if not os.path.exists(report_dir):
os.makedirs(report_dir)
report_dir = get_launcher_local_dir("publish_report_viewer")
os.makedirs(report_dir, exist_ok=True)
return report_dir
@ -576,8 +572,7 @@ class LoadedFilesWidget(QtWidgets.QWidget):
filepaths = []
for url in mime_data.urls():
filepath = url.toLocalFile()
ext = os.path.splitext(filepath)[-1]
if os.path.exists(filepath) and ext == ".json":
if os.path.exists(filepath):
filepaths.append(filepath)
self._add_filepaths(filepaths)
event.accept()

View file

@ -217,20 +217,22 @@ class InstanceGroupWidget(BaseGroupWidget):
def update_icons(self, group_icons):
self._group_icons = group_icons
def update_instance_values(self):
def update_instance_values(self, context_info_by_id):
"""Trigger update on instance widgets."""
for widget in self._widgets_by_id.values():
widget.update_instance_values()
for instance_id, widget in self._widgets_by_id.items():
widget.update_instance_values(context_info_by_id[instance_id])
def update_instances(self, instances):
def update_instances(self, instances, context_info_by_id):
"""Update instances for the group.
Args:
instances(list<CreatedInstance>): List of instances in
instances (list[CreatedInstance]): List of instances in
CreateContext.
"""
context_info_by_id (Dict[str, InstanceContextInfo]): Instance
context info by instance id.
"""
# Store instances by id and by product name
instances_by_id = {}
instances_by_product_name = collections.defaultdict(list)
@ -249,13 +251,14 @@ class InstanceGroupWidget(BaseGroupWidget):
widget_idx = 1
for product_names in sorted_product_names:
for instance in instances_by_product_name[product_names]:
context_info = context_info_by_id[instance.id]
if instance.id in self._widgets_by_id:
widget = self._widgets_by_id[instance.id]
widget.update_instance(instance)
widget.update_instance(instance, context_info)
else:
group_icon = self._group_icons[instance.creator_identifier]
widget = InstanceCardWidget(
instance, group_icon, self
instance, context_info, group_icon, self
)
widget.selected.connect(self._on_widget_selection)
widget.active_changed.connect(self._on_active_changed)
@ -388,7 +391,7 @@ class ConvertorItemCardWidget(CardWidget):
self._icon_widget = icon_widget
self._label_widget = label_widget
def update_instance_values(self):
def update_instance_values(self, context_info):
pass
@ -397,7 +400,7 @@ class InstanceCardWidget(CardWidget):
active_changed = QtCore.Signal(str, bool)
def __init__(self, instance, group_icon, parent):
def __init__(self, instance, context_info, group_icon, parent):
super().__init__(parent)
self._id = instance.id
@ -458,7 +461,7 @@ class InstanceCardWidget(CardWidget):
self._active_checkbox = active_checkbox
self._expand_btn = expand_btn
self.update_instance_values()
self.update_instance_values(context_info)
def set_active_toggle_enabled(self, enabled):
self._active_checkbox.setEnabled(enabled)
@ -480,13 +483,13 @@ class InstanceCardWidget(CardWidget):
if checkbox_value != new_value:
self._active_checkbox.setChecked(new_value)
def update_instance(self, instance):
def update_instance(self, instance, context_info):
"""Update instance object and update UI."""
self.instance = instance
self.update_instance_values()
self.update_instance_values(context_info)
def _validate_context(self):
valid = self.instance.has_valid_context
def _validate_context(self, context_info):
valid = context_info.is_valid
self._icon_widget.setVisible(valid)
self._context_warning.setVisible(not valid)
@ -519,11 +522,11 @@ class InstanceCardWidget(CardWidget):
QtCore.Qt.NoTextInteraction
)
def update_instance_values(self):
def update_instance_values(self, context_info):
"""Update instance data"""
self._update_product_name()
self.set_active(self.instance["active"])
self._validate_context()
self._validate_context(context_info)
def _set_expanded(self, expanded=None):
if expanded is None:
@ -694,6 +697,8 @@ class InstanceCardView(AbstractInstanceView):
self._update_convertor_items_group()
context_info_by_id = self._controller.get_instances_context_info()
# Prepare instances by group and identifiers by group
instances_by_group = collections.defaultdict(list)
identifiers_by_group = collections.defaultdict(set)
@ -747,7 +752,7 @@ class InstanceCardView(AbstractInstanceView):
widget_idx += 1
group_widget.update_instances(
instances_by_group[group_name]
instances_by_group[group_name], context_info_by_id
)
group_widget.set_active_toggle_enabled(
self._active_toggle_enabled
@ -814,8 +819,9 @@ class InstanceCardView(AbstractInstanceView):
def refresh_instance_states(self):
"""Trigger update of instances on group widgets."""
context_info_by_id = self._controller.get_instances_context_info()
for widget in self._widgets_by_group.values():
widget.update_instance_values()
widget.update_instance_values(context_info_by_id)
def _on_active_changed(self, group_name, instance_id, value):
group_widget = self._widgets_by_group[group_name]

View file

@ -19,6 +19,7 @@ from ayon_core.tools.publisher.constants import (
INPUTS_LAYOUT_HSPACING,
INPUTS_LAYOUT_VSPACING,
)
from ayon_core.tools.utils import HintedLineEdit
from .thumbnail_widget import ThumbnailWidget
from .widgets import (
@ -28,8 +29,6 @@ from .widgets import (
from .create_context_widgets import CreateContextWidget
from .precreate_widget import PreCreateWidget
SEPARATORS = ("---separator---", "---")
class ResizeControlWidget(QtWidgets.QWidget):
resized = QtCore.Signal()
@ -168,25 +167,9 @@ class CreateWidget(QtWidgets.QWidget):
product_variant_widget = QtWidgets.QWidget(creator_basics_widget)
# Variant and product input
variant_widget = ResizeControlWidget(product_variant_widget)
variant_widget.setObjectName("VariantInputsWidget")
variant_input = QtWidgets.QLineEdit(variant_widget)
variant_input.setObjectName("VariantInput")
variant_input.setToolTip(VARIANT_TOOLTIP)
variant_hints_btn = QtWidgets.QToolButton(variant_widget)
variant_hints_btn.setArrowType(QtCore.Qt.DownArrow)
variant_hints_btn.setIconSize(QtCore.QSize(12, 12))
variant_hints_menu = QtWidgets.QMenu(variant_widget)
variant_hints_group = QtWidgets.QActionGroup(variant_hints_menu)
variant_layout = QtWidgets.QHBoxLayout(variant_widget)
variant_layout.setContentsMargins(0, 0, 0, 0)
variant_layout.setSpacing(0)
variant_layout.addWidget(variant_input, 1)
variant_layout.addWidget(variant_hints_btn, 0, QtCore.Qt.AlignVCenter)
variant_widget = HintedLineEdit(parent=product_variant_widget)
variant_widget.set_text_widget_object_name("VariantInput")
variant_widget.setToolTip(VARIANT_TOOLTIP)
product_name_input = QtWidgets.QLineEdit(product_variant_widget)
product_name_input.setEnabled(False)
@ -262,15 +245,12 @@ class CreateWidget(QtWidgets.QWidget):
prereq_timer.timeout.connect(self._invalidate_prereq)
create_btn.clicked.connect(self._on_create)
variant_widget.resized.connect(self._on_variant_widget_resize)
creator_basics_widget.resized.connect(self._on_creator_basics_resize)
variant_input.returnPressed.connect(self._on_create)
variant_input.textChanged.connect(self._on_variant_change)
variant_widget.returnPressed.connect(self._on_create)
variant_widget.textChanged.connect(self._on_variant_change)
creators_view.selectionModel().currentChanged.connect(
self._on_creator_item_change
)
variant_hints_btn.clicked.connect(self._on_variant_btn_click)
variant_hints_menu.triggered.connect(self._on_variant_action)
context_widget.folder_changed.connect(self._on_folder_change)
context_widget.task_changed.connect(self._on_task_change)
thumbnail_widget.thumbnail_created.connect(self._on_thumbnail_create)
@ -291,10 +271,7 @@ class CreateWidget(QtWidgets.QWidget):
self.product_name_input = product_name_input
self.variant_input = variant_input
self.variant_hints_btn = variant_hints_btn
self.variant_hints_menu = variant_hints_menu
self.variant_hints_group = variant_hints_group
self._variant_widget = variant_widget
self._creators_model = creators_model
self._creators_sort_model = creators_sort_model
@ -314,6 +291,7 @@ class CreateWidget(QtWidgets.QWidget):
self._last_current_context_folder_path = None
self._last_current_context_task = None
self._use_current_context = True
self._current_creator_variant_hints = []
def get_current_folder_path(self):
return self._controller.get_current_folder_path()
@ -438,8 +416,7 @@ class CreateWidget(QtWidgets.QWidget):
self._create_btn.setEnabled(prereq_available)
self.variant_input.setEnabled(prereq_available)
self.variant_hints_btn.setEnabled(prereq_available)
self._variant_widget.setEnabled(prereq_available)
tooltip = ""
if creator_btn_tooltips:
@ -611,35 +588,15 @@ class CreateWidget(QtWidgets.QWidget):
if not default_variant:
default_variant = default_variants[0]
for action in tuple(self.variant_hints_menu.actions()):
self.variant_hints_menu.removeAction(action)
action.deleteLater()
for variant in default_variants:
if variant in SEPARATORS:
self.variant_hints_menu.addSeparator()
elif variant:
self.variant_hints_menu.addAction(variant)
self._current_creator_variant_hints = list(default_variants)
self._variant_widget.set_options(default_variants)
variant_text = default_variant or DEFAULT_VARIANT_VALUE
# Make sure product name is updated to new plugin
if variant_text == self.variant_input.text():
if variant_text == self._variant_widget.text():
self._on_variant_change()
else:
self.variant_input.setText(variant_text)
def _on_variant_widget_resize(self):
self.variant_hints_btn.setFixedHeight(self.variant_input.height())
def _on_variant_btn_click(self):
pos = self.variant_hints_btn.rect().bottomLeft()
point = self.variant_hints_btn.mapToGlobal(pos)
self.variant_hints_menu.popup(point)
def _on_variant_action(self, action):
value = action.text()
if self.variant_input.text() != value:
self.variant_input.setText(value)
self._variant_widget.setText(variant_text)
def _on_variant_change(self, variant_value=None):
if not self._prereq_available:
@ -652,7 +609,7 @@ class CreateWidget(QtWidgets.QWidget):
return
if variant_value is None:
variant_value = self.variant_input.text()
variant_value = self._variant_widget.text()
if not self._compiled_name_pattern.match(variant_value):
self._create_btn.setEnabled(False)
@ -707,20 +664,12 @@ class CreateWidget(QtWidgets.QWidget):
if _result:
variant_hints |= set(_result.groups())
# Remove previous hints from menu
for action in tuple(self.variant_hints_group.actions()):
self.variant_hints_group.removeAction(action)
self.variant_hints_menu.removeAction(action)
action.deleteLater()
# Add separator if there are hints and menu already has actions
if variant_hints and self.variant_hints_menu.actions():
self.variant_hints_menu.addSeparator()
options = list(self._current_creator_variant_hints)
if options:
options.append("---")
options.extend(variant_hints)
# Add hints to actions
for variant_hint in variant_hints:
action = self.variant_hints_menu.addAction(variant_hint)
self.variant_hints_group.addAction(action)
self._variant_widget.set_options(options)
# Indicate product existence
if not variant_value:
@ -741,10 +690,7 @@ class CreateWidget(QtWidgets.QWidget):
self._create_btn.setEnabled(variant_is_valid)
def _set_variant_state_property(self, state):
current_value = self.variant_input.property("state")
if current_value != state:
self.variant_input.setProperty("state", state)
self.variant_input.style().polish(self.variant_input)
self._variant_widget.set_text_widget_property("state", state)
def _on_first_show(self):
width = self.width()
@ -776,7 +722,7 @@ class CreateWidget(QtWidgets.QWidget):
index = indexes[0]
creator_identifier = index.data(CREATOR_IDENTIFIER_ROLE)
product_type = index.data(PRODUCT_TYPE_ROLE)
variant = self.variant_input.text()
variant = self._variant_widget.text()
# Care about product name only if context change is enabled
product_name = None
folder_path = None
@ -810,7 +756,7 @@ class CreateWidget(QtWidgets.QWidget):
if success:
self._set_creator(self._selected_creator)
self.variant_input.setText(variant)
self._variant_widget.setText(variant)
self._controller.emit_card_message("Creation finished...")
self._last_thumbnail_path = None
self._thumbnail_widget.set_current_thumbnails()

View file

@ -115,7 +115,7 @@ class InstanceListItemWidget(QtWidgets.QWidget):
active_changed = QtCore.Signal(str, bool)
double_clicked = QtCore.Signal()
def __init__(self, instance, parent):
def __init__(self, instance, context_info, parent):
super().__init__(parent)
self.instance = instance
@ -151,7 +151,7 @@ class InstanceListItemWidget(QtWidgets.QWidget):
self._has_valid_context = None
self._set_valid_property(instance.has_valid_context)
self._set_valid_property(context_info.is_valid)
def mouseDoubleClickEvent(self, event):
widget = self.childAt(event.pos())
@ -188,12 +188,12 @@ class InstanceListItemWidget(QtWidgets.QWidget):
if checkbox_value != new_value:
self._active_checkbox.setChecked(new_value)
def update_instance(self, instance):
def update_instance(self, instance, context_info):
"""Update instance object."""
self.instance = instance
self.update_instance_values()
self.update_instance_values(context_info)
def update_instance_values(self):
def update_instance_values(self, context_info):
"""Update instance data propagated to widgets."""
# Check product name
label = self.instance.label
@ -202,7 +202,7 @@ class InstanceListItemWidget(QtWidgets.QWidget):
# Check active state
self.set_active(self.instance["active"])
# Check valid states
self._set_valid_property(self.instance.has_valid_context)
self._set_valid_property(context_info.is_valid)
def _on_active_change(self):
new_value = self._active_checkbox.isChecked()
@ -583,6 +583,8 @@ class InstanceListView(AbstractInstanceView):
self._update_convertor_items_group()
context_info_by_id = self._controller.get_instances_context_info()
# Prepare instances by their groups
instances_by_group_name = collections.defaultdict(list)
group_names = set()
@ -643,13 +645,15 @@ class InstanceListView(AbstractInstanceView):
elif activity != instance["active"]:
activity = -1
context_info = context_info_by_id[instance_id]
self._group_by_instance_id[instance_id] = group_name
# Remove instance id from `to_remove` if already exists and
# trigger update of widget
if instance_id in to_remove:
to_remove.remove(instance_id)
widget = self._widgets_by_id[instance_id]
widget.update_instance(instance)
widget.update_instance(instance, context_info)
continue
# Create new item and store it as new
@ -695,7 +699,8 @@ class InstanceListView(AbstractInstanceView):
group_item.appendRows(new_items)
for item, instance in new_items_with_instance:
if not instance.has_valid_context:
context_info = context_info_by_id[instance.id]
if not context_info.is_valid:
expand_groups.add(group_name)
item_index = self._instance_model.index(
item.row(),
@ -704,7 +709,7 @@ class InstanceListView(AbstractInstanceView):
)
proxy_index = self._proxy_model.mapFromSource(item_index)
widget = InstanceListItemWidget(
instance, self._instance_view
instance, context_info, self._instance_view
)
widget.set_active_toggle_enabled(
self._active_toggle_enabled
@ -870,8 +875,10 @@ class InstanceListView(AbstractInstanceView):
def refresh_instance_states(self):
"""Trigger update of all instances."""
for widget in self._widgets_by_id.values():
widget.update_instance_values()
context_info_by_id = self._controller.get_instances_context_info()
for instance_id, widget in self._widgets_by_id.items():
context_info = context_info_by_id[instance_id]
widget.update_instance_values(context_info)
def _on_active_changed(self, changed_instance_id, new_value):
selected_instance_ids, _, _ = self.get_selected_items()

View file

@ -387,7 +387,7 @@ class OverviewWidget(QtWidgets.QFrame):
Returns:
list[str]: Selected legacy convertor identifiers.
Example: ['io.openpype.creators.houdini.legacy']
Example: ['io.ayon.creators.houdini.legacy']
"""
_, _, convertor_identifiers = self.get_selected_items()

View file

@ -1,10 +1,171 @@
import os
import tempfile
import uuid
from qtpy import QtCore, QtGui, QtWidgets
class ScreenMarquee(QtWidgets.QDialog):
class ScreenMarqueeDialog(QtWidgets.QDialog):
mouse_moved = QtCore.Signal()
mouse_pressed = QtCore.Signal(QtCore.QPoint, str)
mouse_released = QtCore.Signal(QtCore.QPoint)
close_requested = QtCore.Signal()
def __init__(self, screen: QtCore.QObject, screen_id: str):
super().__init__()
self.setWindowFlags(
QtCore.Qt.Window
| QtCore.Qt.FramelessWindowHint
| QtCore.Qt.WindowStaysOnTopHint
| QtCore.Qt.CustomizeWindowHint
)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
self.setCursor(QtCore.Qt.CrossCursor)
self.setMouseTracking(True)
screen.geometryChanged.connect(self._fit_screen_geometry)
self._screen = screen
self._opacity = 100
self._click_pos = None
self._screen_id = screen_id
def set_click_pos(self, pos):
self._click_pos = pos
self.repaint()
def convert_end_pos(self, pos):
glob_pos = self.mapFromGlobal(pos)
new_pos = self._convert_pos(glob_pos)
return self.mapToGlobal(new_pos)
def paintEvent(self, event):
"""Paint event"""
# Convert click and current mouse positions to local space.
mouse_pos = self._convert_pos(self.mapFromGlobal(QtGui.QCursor.pos()))
rect = event.rect()
fill_path = QtGui.QPainterPath()
fill_path.addRect(rect)
capture_rect = None
if self._click_pos is not None:
click_pos = self.mapFromGlobal(self._click_pos)
capture_rect = QtCore.QRect(click_pos, mouse_pos)
# Clear the capture area
sub_path = QtGui.QPainterPath()
sub_path.addRect(capture_rect)
fill_path = fill_path.subtracted(sub_path)
painter = QtGui.QPainter(self)
painter.setRenderHints(
QtGui.QPainter.Antialiasing
| QtGui.QPainter.SmoothPixmapTransform
)
# Draw background. Aside from aesthetics, this makes the full
# tool region accept mouse events.
painter.setBrush(QtGui.QColor(0, 0, 0, self._opacity))
painter.setPen(QtCore.Qt.NoPen)
painter.drawPath(fill_path)
# Draw cropping markers at current mouse position
pen_color = QtGui.QColor(255, 255, 255, self._opacity)
pen = QtGui.QPen(pen_color, 1, QtCore.Qt.DotLine)
painter.setPen(pen)
painter.drawLine(
rect.left(), mouse_pos.y(),
rect.right(), mouse_pos.y()
)
painter.drawLine(
mouse_pos.x(), rect.top(),
mouse_pos.x(), rect.bottom()
)
# Draw rectangle around selection area
if capture_rect is not None:
pen_color = QtGui.QColor(92, 173, 214)
pen = QtGui.QPen(pen_color, 2)
painter.setPen(pen)
painter.setBrush(QtCore.Qt.NoBrush)
l_x = capture_rect.left()
r_x = capture_rect.right()
if l_x > r_x:
l_x, r_x = r_x, l_x
t_y = capture_rect.top()
b_y = capture_rect.bottom()
if t_y > b_y:
t_y, b_y = b_y, t_y
# -1 to draw 1px over the border
r_x -= 1
b_y -= 1
sel_rect = QtCore.QRect(
QtCore.QPoint(l_x, t_y),
QtCore.QPoint(r_x, b_y)
)
painter.drawRect(sel_rect)
painter.end()
def mousePressEvent(self, event):
"""Mouse click event"""
if event.button() == QtCore.Qt.LeftButton:
# Begin click drag operation
self._click_pos = event.globalPos()
self.mouse_pressed.emit(self._click_pos, self._screen_id)
def mouseReleaseEvent(self, event):
"""Mouse release event"""
if event.button() == QtCore.Qt.LeftButton:
# End click drag operation and commit the current capture rect
self._click_pos = None
self.mouse_released.emit(event.globalPos())
def mouseMoveEvent(self, event):
"""Mouse move event"""
self.mouse_moved.emit()
def keyPressEvent(self, event):
"""Mouse press event"""
if event.key() == QtCore.Qt.Key_Escape:
self._click_pos = None
event.accept()
self.close_requested.emit()
return
return super().keyPressEvent(event)
def showEvent(self, event):
super().showEvent(event)
self._fit_screen_geometry()
def closeEvent(self, event):
self._click_pos = None
super().closeEvent(event)
def _convert_pos(self, pos):
geo = self.geometry()
if pos.x() > geo.width():
pos.setX(geo.width() - 1)
elif pos.x() < 0:
pos.setX(0)
if pos.y() > geo.height():
pos.setY(geo.height() - 1)
elif pos.y() < 0:
pos.setY(0)
return pos
def _fit_screen_geometry(self):
# On macOs it is required to set screen explicitly
if hasattr(self, "setScreen"):
self.setScreen(self._screen)
self.setGeometry(self._screen.geometry())
class ScreenMarquee(QtCore.QObject):
"""Dialog to interactively define screen area.
This allows to select a screen area through a marquee selection.
@ -17,187 +178,186 @@ class ScreenMarquee(QtWidgets.QDialog):
def __init__(self, parent=None):
super().__init__(parent=parent)
self.setWindowFlags(
QtCore.Qt.Window
| QtCore.Qt.FramelessWindowHint
| QtCore.Qt.WindowStaysOnTopHint
| QtCore.Qt.CustomizeWindowHint
)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
self.setCursor(QtCore.Qt.CrossCursor)
self.setMouseTracking(True)
app = QtWidgets.QApplication.instance()
if hasattr(app, "screenAdded"):
app.screenAdded.connect(self._on_screen_added)
app.screenRemoved.connect(self._fit_screen_geometry)
elif hasattr(app, "desktop"):
desktop = app.desktop()
desktop.screenCountChanged.connect(self._fit_screen_geometry)
screens_by_id = {}
for screen in QtWidgets.QApplication.screens():
screen.geometryChanged.connect(self._fit_screen_geometry)
screen_id = uuid.uuid4().hex
screen_dialog = ScreenMarqueeDialog(screen, screen_id)
screens_by_id[screen_id] = screen_dialog
screen_dialog.mouse_moved.connect(self._on_mouse_move)
screen_dialog.mouse_pressed.connect(self._on_mouse_press)
screen_dialog.mouse_released.connect(self._on_mouse_release)
screen_dialog.close_requested.connect(self._on_close_request)
self._opacity = 50
self._click_pos = None
self._capture_rect = None
self._screens_by_id = screens_by_id
self._finished = False
self._captured = False
self._start_pos = None
self._end_pos = None
self._start_screen_id = None
self._pix = None
def get_captured_pixmap(self):
if self._capture_rect is None:
if self._pix is None:
return QtGui.QPixmap()
return self._pix
return self.get_desktop_pixmap(self._capture_rect)
def _close_dialogs(self):
for dialog in self._screens_by_id.values():
dialog.close()
def paintEvent(self, event):
"""Paint event"""
def _on_close_request(self):
self._close_dialogs()
self._finished = True
# Convert click and current mouse positions to local space.
mouse_pos = self.mapFromGlobal(QtGui.QCursor.pos())
click_pos = None
if self._click_pos is not None:
click_pos = self.mapFromGlobal(self._click_pos)
painter = QtGui.QPainter(self)
painter.setRenderHints(
QtGui.QPainter.Antialiasing
| QtGui.QPainter.SmoothPixmapTransform
)
# Draw background. Aside from aesthetics, this makes the full
# tool region accept mouse events.
painter.setBrush(QtGui.QColor(0, 0, 0, self._opacity))
painter.setPen(QtCore.Qt.NoPen)
rect = event.rect()
fill_path = QtGui.QPainterPath()
fill_path.addRect(rect)
# Clear the capture area
if click_pos is not None:
sub_path = QtGui.QPainterPath()
capture_rect = QtCore.QRect(click_pos, mouse_pos)
sub_path.addRect(capture_rect)
fill_path = fill_path.subtracted(sub_path)
painter.drawPath(fill_path)
pen_color = QtGui.QColor(255, 255, 255, self._opacity)
pen = QtGui.QPen(pen_color, 1, QtCore.Qt.DotLine)
painter.setPen(pen)
# Draw cropping markers at click position
if click_pos is not None:
painter.drawLine(
rect.left(), click_pos.y(),
rect.right(), click_pos.y()
)
painter.drawLine(
click_pos.x(), rect.top(),
click_pos.x(), rect.bottom()
)
# Draw cropping markers at current mouse position
painter.drawLine(
rect.left(), mouse_pos.y(),
rect.right(), mouse_pos.y()
)
painter.drawLine(
mouse_pos.x(), rect.top(),
mouse_pos.x(), rect.bottom()
)
painter.end()
def mousePressEvent(self, event):
"""Mouse click event"""
if event.button() == QtCore.Qt.LeftButton:
# Begin click drag operation
self._click_pos = event.globalPos()
def mouseReleaseEvent(self, event):
"""Mouse release event"""
if (
self._click_pos is not None
and event.button() == QtCore.Qt.LeftButton
):
# End click drag operation and commit the current capture rect
self._capture_rect = QtCore.QRect(
self._click_pos, event.globalPos()
).normalized()
self._click_pos = None
self.close()
def mouseMoveEvent(self, event):
"""Mouse move event"""
self.repaint()
def keyPressEvent(self, event):
"""Mouse press event"""
if event.key() == QtCore.Qt.Key_Escape:
self._click_pos = None
self._capture_rect = None
event.accept()
self.close()
def _on_mouse_release(self, pos):
start_screen_dialog = self._screens_by_id.get(self._start_screen_id)
if start_screen_dialog is None:
self._finished = True
self._captured = False
return
return super().keyPressEvent(event)
def showEvent(self, event):
self._fit_screen_geometry()
end_pos = start_screen_dialog.convert_end_pos(pos)
def _fit_screen_geometry(self):
# Compute the union of all screen geometries, and resize to fit.
workspace_rect = QtCore.QRect()
for screen in QtWidgets.QApplication.screens():
workspace_rect = workspace_rect.united(screen.geometry())
self.setGeometry(workspace_rect)
self._close_dialogs()
self._end_pos = end_pos
self._finished = True
self._captured = True
def _on_screen_added(self):
for screen in QtGui.QGuiApplication.screens():
screen.geometryChanged.connect(self._fit_screen_geometry)
def _on_mouse_press(self, pos, screen_id):
self._start_pos = pos
self._start_screen_id = screen_id
def _on_mouse_move(self):
for dialog in self._screens_by_id.values():
dialog.repaint()
def start_capture(self):
for dialog in self._screens_by_id.values():
dialog.show()
# Activate so Escape event is not ignored.
dialog.setWindowState(QtCore.Qt.WindowActive)
app = QtWidgets.QApplication.instance()
while not self._finished:
app.processEvents()
# Give time to cloe dialogs
for _ in range(2):
app.processEvents()
if self._captured:
self._pix = self.get_desktop_pixmap(
self._start_pos, self._end_pos
)
@classmethod
def get_desktop_pixmap(cls, rect):
def get_desktop_pixmap(cls, pos_start, pos_end):
"""Performs a screen capture on the specified rectangle.
Args:
rect (QtCore.QRect): The rectangle to capture.
pos_start (QtCore.QPoint): Start of screen capture.
pos_end (QtCore.QPoint): End of screen capture.
Returns:
QtGui.QPixmap: Captured pixmap image
"""
"""
# Unify start and end points
# - start is top left
# - end is bottom right
if pos_start.y() > pos_end.y():
pos_start, pos_end = pos_end, pos_start
if pos_start.x() > pos_end.x():
new_start = QtCore.QPoint(pos_end.x(), pos_start.y())
new_end = QtCore.QPoint(pos_start.x(), pos_end.y())
pos_start = new_start
pos_end = new_end
# Validate if the rectangle is valid
rect = QtCore.QRect(pos_start, pos_end)
if rect.width() < 1 or rect.height() < 1:
return QtGui.QPixmap()
screen_pixes = []
for screen in QtWidgets.QApplication.screens():
screen_geo = screen.geometry()
if not screen_geo.intersects(rect):
continue
screen = QtWidgets.QApplication.screenAt(pos_start)
return screen.grabWindow(
0,
pos_start.x() - screen.geometry().x(),
pos_start.y() - screen.geometry().y(),
pos_end.x() - pos_start.x(),
pos_end.y() - pos_start.y()
)
# Multiscreen capture that does not work
# - does not handle pixel aspect ratio and positioning of screens
screen_pix_rect = screen_geo.intersected(rect)
screen_pix = screen.grabWindow(
0,
screen_pix_rect.x() - screen_geo.x(),
screen_pix_rect.y() - screen_geo.y(),
screen_pix_rect.width(), screen_pix_rect.height()
)
paste_point = QtCore.QPoint(
screen_pix_rect.x() - rect.x(),
screen_pix_rect.y() - rect.y()
)
screen_pixes.append((screen_pix, paste_point))
output_pix = QtGui.QPixmap(rect.width(), rect.height())
output_pix.fill(QtCore.Qt.transparent)
pix_painter = QtGui.QPainter()
pix_painter.begin(output_pix)
for item in screen_pixes:
(screen_pix, offset) = item
pix_painter.drawPixmap(offset, screen_pix)
pix_painter.end()
return output_pix
# most_left = None
# most_top = None
# for screen in QtWidgets.QApplication.screens():
# screen_geo = screen.geometry()
# if most_left is None or most_left > screen_geo.x():
# most_left = screen_geo.x()
#
# if most_top is None or most_top > screen_geo.y():
# most_top = screen_geo.y()
#
# most_left = most_left or 0
# most_top = most_top or 0
#
# screen_pixes = []
# for screen in QtWidgets.QApplication.screens():
# screen_geo = screen.geometry()
# if not screen_geo.intersects(rect):
# continue
#
# pos_l_x = screen_geo.x()
# pos_l_y = screen_geo.y()
# pos_r_x = screen_geo.x() + screen_geo.width()
# pos_r_y = screen_geo.y() + screen_geo.height()
# if pos_start.x() > pos_l_x:
# pos_l_x = pos_start.x()
#
# if pos_start.y() > pos_l_y:
# pos_l_y = pos_start.y()
#
# if pos_end.x() < pos_r_x:
# pos_r_x = pos_end.x()
#
# if pos_end.y() < pos_r_y:
# pos_r_y = pos_end.y()
#
# capture_pos_x = pos_l_x - screen_geo.x()
# capture_pos_y = pos_l_y - screen_geo.y()
# capture_screen_width = pos_r_x - pos_l_x
# capture_screen_height = pos_r_y - pos_l_y
# screen_pix = screen.grabWindow(
# 0,
# capture_pos_x, capture_pos_y,
# capture_screen_width, capture_screen_height
# )
# paste_point = QtCore.QPoint(
# (pos_l_x - screen_geo.x()) - rect.x(),
# (pos_l_y - screen_geo.y()) - rect.y()
# )
# screen_pixes.append((screen_pix, paste_point))
#
# output_pix = QtGui.QPixmap(rect.width(), rect.height())
# output_pix.fill(QtCore.Qt.transparent)
# pix_painter = QtGui.QPainter()
# pix_painter.begin(output_pix)
# render_hints = (
# QtGui.QPainter.Antialiasing
# | QtGui.QPainter.SmoothPixmapTransform
# )
# if hasattr(QtGui.QPainter, "HighQualityAntialiasing"):
# render_hints |= QtGui.QPainter.HighQualityAntialiasing
# pix_painter.setRenderHints(render_hints)
# for item in screen_pixes:
# (screen_pix, offset) = item
# pix_painter.drawPixmap(offset, screen_pix)
#
# pix_painter.end()
#
# return output_pix
@classmethod
def capture_to_pixmap(cls):
@ -209,12 +369,8 @@ class ScreenMarquee(QtWidgets.QDialog):
Returns:
QtGui.QPixmap: Captured pixmap image.
"""
tool = cls()
# Activate so Escape event is not ignored.
tool.setWindowState(QtCore.Qt.WindowActive)
# Exec dialog and return captured pixmap.
tool.exec_()
tool.start_capture()
return tool.get_captured_pixmap()
@classmethod

View file

@ -1182,6 +1182,10 @@ class GlobalAttrsWidget(QtWidgets.QWidget):
invalid_tasks = False
folder_paths = []
for instance in self._current_instances:
# Ignore instances that have promised context
if instance.has_promised_context:
continue
new_variant_value = instance.get("variant")
new_folder_path = instance.get("folderPath")
new_task_name = instance.get("task")
@ -1206,7 +1210,6 @@ class GlobalAttrsWidget(QtWidgets.QWidget):
except TaskNotSetError:
invalid_tasks = True
instance.set_task_invalid(True)
product_names.add(instance["productName"])
continue
@ -1216,11 +1219,9 @@ class GlobalAttrsWidget(QtWidgets.QWidget):
if folder_path is not None:
instance["folderPath"] = folder_path
instance.set_folder_invalid(False)
if task_name is not None:
instance["task"] = task_name or None
instance.set_task_invalid(False)
instance["productName"] = new_product_name
@ -1306,7 +1307,13 @@ class GlobalAttrsWidget(QtWidgets.QWidget):
editable = False
folder_task_combinations = []
context_editable = None
for instance in instances:
if not instance.has_promised_context:
context_editable = True
elif context_editable is None:
context_editable = False
# NOTE I'm not sure how this can even happen?
if instance.creator_identifier is None:
editable = False
@ -1319,6 +1326,11 @@ class GlobalAttrsWidget(QtWidgets.QWidget):
folder_task_combinations.append((folder_path, task_name))
product_names.add(instance.get("productName") or self.unknown_value)
if not editable:
context_editable = False
elif context_editable is None:
context_editable = True
self.variant_input.set_value(variants)
# Set context of folder widget
@ -1329,8 +1341,21 @@ class GlobalAttrsWidget(QtWidgets.QWidget):
self.product_value_widget.set_value(product_names)
self.variant_input.setEnabled(editable)
self.folder_value_widget.setEnabled(editable)
self.task_value_widget.setEnabled(editable)
self.folder_value_widget.setEnabled(context_editable)
self.task_value_widget.setEnabled(context_editable)
if not editable:
folder_tooltip = "Select instances to change folder path."
task_tooltip = "Select instances to change task name."
elif not context_editable:
folder_tooltip = "Folder path is defined by Create plugin."
task_tooltip = "Task is defined by Create plugin."
else:
folder_tooltip = "Change folder path of selected instances."
task_tooltip = "Change task of selected instances."
self.folder_value_widget.setToolTip(folder_tooltip)
self.task_value_widget.setToolTip(task_tooltip)
class CreatorAttrsWidget(QtWidgets.QWidget):
@ -1339,7 +1364,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
Attributes are defined on creator so are dynamic. Their look and type is
based on attribute definitions that are defined in
`~/ayon_core/lib/attribute_definitions.py` and their widget
representation in `~/openpype/tools/attribute_defs/*`.
representation in `~/ayon_core/tools/attribute_defs/*`.
Widgets are disabled if context of instance is not valid.
@ -1768,9 +1793,16 @@ class ProductAttributesWidget(QtWidgets.QWidget):
self.bottom_separator = bottom_separator
def _on_instance_context_changed(self):
instance_ids = {
instance.id
for instance in self._current_instances
}
context_info_by_id = self._controller.get_instances_context_info(
instance_ids
)
all_valid = True
for instance in self._current_instances:
if not instance.has_valid_context:
for instance_id, context_info in context_info_by_id.items():
if not context_info.is_valid:
all_valid = False
break
@ -1795,9 +1827,17 @@ class ProductAttributesWidget(QtWidgets.QWidget):
convertor_identifiers(List[str]): Identifiers of convert items.
"""
instance_ids = {
instance.id
for instance in instances
}
context_info_by_id = self._controller.get_instances_context_info(
instance_ids
)
all_valid = True
for instance in instances:
if not instance.has_valid_context:
for context_info in context_info_by_id.values():
if not context_info.is_valid:
all_valid = False
break

View file

@ -687,13 +687,14 @@ class PublisherWindow(QtWidgets.QDialog):
def _on_tab_change(self, old_tab, new_tab):
if old_tab == "details":
self._publish_details_widget.close_details_popup()
self._publish_details_widget.set_active(False)
if new_tab == "details":
self._content_stacked_layout.setCurrentWidget(
self._publish_details_widget
)
self._update_publish_details_widget()
self._publish_details_widget.set_active(True)
elif new_tab == "report":
self._content_stacked_layout.setCurrentWidget(
@ -912,12 +913,18 @@ class PublisherWindow(QtWidgets.QDialog):
self._set_footer_enabled(True)
return
active_instances_by_id = {
instance.id: instance
for instance in self._controller.get_instances()
if instance["active"]
}
context_info_by_id = self._controller.get_instances_context_info(
active_instances_by_id.keys()
)
all_valid = None
for instance in self._controller.get_instances():
if not instance["active"]:
continue
if not instance.has_valid_context:
for instance_id, instance in active_instances_by_id.items():
context_info = context_info_by_id[instance_id]
if not context_info.is_valid:
all_valid = False
break

View file

@ -135,7 +135,6 @@ class OrderGroups:
def env_variable_to_bool(env_key, default=False):
"""Boolean based on environment variable value."""
# TODO: move to pype lib
value = os.environ.get(env_key)
if value is not None:
value = value.lower()

View file

@ -217,10 +217,7 @@ class InventoryModel(QtGui.QStandardItemModel):
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
# TODO maybe use different colors for last approved and last
# version? Or don't care about color at all?
if not is_latest and not version_item.is_last_approved:
if not version_item.is_latest:
version_color = self.OUTDATED_COLOR
status_name = version_item.status

View file

@ -10,11 +10,16 @@ import signal
import locale
from typing import Optional, Dict, Tuple, Any
import ayon_api
import requests
from ayon_api.utils import get_default_settings_variant
from ayon_core.lib import Logger, get_ayon_launcher_args, run_detached_process
from ayon_core.lib.local_settings import get_ayon_appdirs
from ayon_core.lib import (
Logger,
get_ayon_launcher_args,
run_detached_process,
get_ayon_username,
)
from ayon_core.lib.local_settings import get_launcher_local_dir
class TrayState:
@ -34,7 +39,7 @@ def _get_default_server_url() -> str:
def _get_default_variant() -> str:
"""Get default settings variant."""
return ayon_api.get_default_settings_variant()
return get_default_settings_variant()
def _get_server_and_variant(
@ -141,18 +146,7 @@ def get_tray_storage_dir() -> str:
str: Tray storage directory where metadata files are stored.
"""
return get_ayon_appdirs("tray")
def _get_tray_information(tray_url: str) -> Optional[Dict[str, Any]]:
if not tray_url:
return None
try:
response = requests.get(f"{tray_url}/tray")
response.raise_for_status()
return response.json()
except (requests.HTTPError, requests.ConnectionError):
return None
return get_launcher_local_dir("tray")
def _get_tray_info_filepath(
@ -165,6 +159,51 @@ def _get_tray_info_filepath(
return os.path.join(hash_dir, filename)
def _get_tray_file_info(
server_url: Optional[str] = None,
variant: Optional[str] = None
) -> Tuple[Optional[Dict[str, Any]], Optional[float]]:
filepath = _get_tray_info_filepath(server_url, variant)
if not os.path.exists(filepath):
return None, None
file_modified = os.path.getmtime(filepath)
try:
with open(filepath, "r") as stream:
data = json.load(stream)
except Exception:
return None, file_modified
return data, file_modified
def _remove_tray_server_url(
server_url: Optional[str],
variant: Optional[str],
file_modified: Optional[float],
):
"""Remove tray information file.
Called from tray logic, do not use on your own.
Args:
server_url (Optional[str]): AYON server url.
variant (Optional[str]): Settings variant.
file_modified (Optional[float]): File modified timestamp. Is validated
against current state of file.
"""
filepath = _get_tray_info_filepath(server_url, variant)
if not os.path.exists(filepath):
return
if (
file_modified is not None
and os.path.getmtime(filepath) != file_modified
):
return
os.remove(filepath)
def get_tray_file_info(
server_url: Optional[str] = None,
variant: Optional[str] = None
@ -182,15 +221,156 @@ def get_tray_file_info(
Optional[Dict[str, Any]]: Tray information.
"""
filepath = _get_tray_info_filepath(server_url, variant)
if not os.path.exists(filepath):
file_info, _ = _get_tray_file_info(server_url, variant)
return file_info
def _get_tray_rest_information(tray_url: str) -> Optional[Dict[str, Any]]:
if not tray_url:
return None
try:
with open(filepath, "r") as stream:
data = json.load(stream)
except Exception:
response = requests.get(f"{tray_url}/tray")
response.raise_for_status()
return response.json()
except (requests.HTTPError, requests.ConnectionError):
return None
return data
class TrayInfo:
def __init__(
self,
server_url: str,
variant: str,
timeout: Optional[int] = None
):
self.server_url = server_url
self.variant = variant
if timeout is None:
timeout = 10
self._timeout = timeout
self._file_modified = None
self._file_info = None
self._file_info_cached = False
self._tray_info = None
self._tray_info_cached = False
self._file_state = None
self._state = None
@classmethod
def new(
cls,
server_url: Optional[str] = None,
variant: Optional[str] = None,
timeout: Optional[int] = None,
wait_to_start: Optional[bool] = True
) -> "TrayInfo":
server_url, variant = _get_server_and_variant(server_url, variant)
obj = cls(server_url, variant, timeout=timeout)
if wait_to_start:
obj.wait_to_start()
return obj
def get_pid(self) -> Optional[int]:
file_info = self.get_file_info()
if file_info:
return file_info.get("pid")
return None
def reset(self):
self._file_modified = None
self._file_info = None
self._file_info_cached = False
self._tray_info = None
self._tray_info_cached = False
self._state = None
self._file_state = None
def get_file_info(self) -> Optional[Dict[str, Any]]:
if not self._file_info_cached:
file_info, file_modified = _get_tray_file_info(
self.server_url, self.variant
)
self._file_info = file_info
self._file_modified = file_modified
self._file_info_cached = True
return self._file_info
def get_file_url(self) -> Optional[str]:
file_info = self.get_file_info()
if file_info:
return file_info.get("url")
return None
def get_tray_url(self) -> Optional[str]:
info = self.get_tray_info()
if info:
return self.get_file_url()
return None
def get_tray_info(self) -> Optional[Dict[str, Any]]:
if self._tray_info_cached:
return self._tray_info
tray_url = self.get_file_url()
tray_info = None
if tray_url:
tray_info = _get_tray_rest_information(tray_url)
self._tray_info = tray_info
self._tray_info_cached = True
return self._tray_info
def get_file_state(self) -> int:
if self._file_state is not None:
return self._file_state
state = TrayState.NOT_RUNNING
file_info = self.get_file_info()
if file_info:
state = TrayState.STARTING
if file_info.get("started") is True:
state = TrayState.RUNNING
self._file_state = state
return self._file_state
def get_state(self) -> int:
if self._state is not None:
return self._state
state = self.get_file_state()
if state == TrayState.RUNNING and not self.get_tray_info():
state = TrayState.NOT_RUNNING
pid = self.pid
if pid:
_kill_tray_process(pid)
# Remove the file as tray is not running anymore and update
# the state of this object.
_remove_tray_server_url(
self.server_url, self.variant, self._file_modified
)
self.reset()
self._state = state
return self._state
def get_ayon_username(self) -> Optional[str]:
tray_info = self.get_tray_info()
if tray_info:
return tray_info.get("username")
return None
def wait_to_start(self) -> bool:
_wait_for_starting_tray(
self.server_url, self.variant, self._timeout
)
self.reset()
return self.get_file_state() == TrayState.RUNNING
pid = property(get_pid)
state = property(get_state)
def get_tray_server_url(
@ -214,25 +394,12 @@ def get_tray_server_url(
Optional[str]: Tray server url.
"""
data = get_tray_file_info(server_url, variant)
if data is None:
return None
if data.get("started") is False:
data = _wait_for_starting_tray(server_url, variant, timeout)
if data is None:
return None
url = data.get("url")
if not url:
return None
if not validate:
return url
if _get_tray_information(url):
return url
return None
tray_info = TrayInfo.new(
server_url, variant, timeout, wait_to_start=True
)
if validate:
return tray_info.get_tray_url()
return tray_info.get_file_url()
def set_tray_server_url(tray_url: Optional[str], started: bool):
@ -246,10 +413,13 @@ def set_tray_server_url(tray_url: Optional[str], started: bool):
that tray is starting up.
"""
file_info = get_tray_file_info()
if file_info and file_info["pid"] != os.getpid():
if not file_info["started"] or _get_tray_information(file_info["url"]):
raise TrayIsRunningError("Tray is already running.")
info = TrayInfo.new(wait_to_start=False)
if (
info.pid
and info.pid != os.getpid()
and info.state in (TrayState.RUNNING, TrayState.STARTING)
):
raise TrayIsRunningError("Tray is already running.")
filepath = _get_tray_info_filepath()
os.makedirs(os.path.dirname(filepath), exist_ok=True)
@ -292,20 +462,21 @@ def remove_tray_server_url(force: Optional[bool] = False):
def get_tray_information(
server_url: Optional[str] = None,
variant: Optional[str] = None
) -> Optional[Dict[str, Any]]:
variant: Optional[str] = None,
timeout: Optional[int] = None,
) -> TrayInfo:
"""Get information about tray.
Args:
server_url (Optional[str]): AYON server url.
variant (Optional[str]): Settings variant.
timeout (Optional[int]): Timeout for tray start-up.
Returns:
Optional[Dict[str, Any]]: Tray information.
TrayInfo: Tray information.
"""
tray_url = get_tray_server_url(server_url, variant)
return _get_tray_information(tray_url)
return TrayInfo.new(server_url, variant, timeout)
def get_tray_state(
@ -322,20 +493,8 @@ def get_tray_state(
int: Tray state.
"""
file_info = get_tray_file_info(server_url, variant)
if file_info is None:
return TrayState.NOT_RUNNING
if file_info.get("started") is False:
return TrayState.STARTING
tray_url = file_info.get("url")
info = _get_tray_information(tray_url)
if not info:
# Remove the information as the tray is not running
remove_tray_server_url(force=True)
return TrayState.NOT_RUNNING
return TrayState.RUNNING
tray_info = get_tray_information(server_url, variant)
return tray_info.state
def is_tray_running(
@ -392,6 +551,7 @@ def show_message_in_tray(
def make_sure_tray_is_running(
ayon_url: Optional[str] = None,
variant: Optional[str] = None,
username: Optional[str] = None,
env: Optional[Dict[str, str]] = None
):
"""Make sure that tray for AYON url and variant is running.
@ -399,23 +559,26 @@ def make_sure_tray_is_running(
Args:
ayon_url (Optional[str]): AYON server url.
variant (Optional[str]): Settings variant.
username (Optional[str]): Username under which should be tray running.
env (Optional[Dict[str, str]]): Environment variables for the process.
"""
state = get_tray_state(ayon_url, variant)
if state == TrayState.RUNNING:
return
tray_info = TrayInfo.new(
ayon_url, variant, wait_to_start=False
)
if tray_info.state == TrayState.STARTING:
tray_info.wait_to_start()
if state == TrayState.STARTING:
_wait_for_starting_tray(ayon_url, variant)
state = get_tray_state(ayon_url, variant)
if state == TrayState.RUNNING:
if tray_info.state == TrayState.RUNNING:
if not username:
username = get_ayon_username()
if tray_info.get_ayon_username() == username:
return
args = get_ayon_launcher_args("tray", "--force")
if env is None:
env = os.environ.copy()
# Make sure 'QT_API' is not set
env.pop("QT_API", None)
@ -435,38 +598,51 @@ def main(force=False):
Logger.set_process_name("Tray")
state = get_tray_state()
if force and state in (TrayState.RUNNING, TrayState.STARTING):
file_info = get_tray_file_info() or {}
pid = file_info.get("pid")
tray_info = TrayInfo.new(wait_to_start=False)
file_state = tray_info.get_file_state()
if force and file_state in (TrayState.RUNNING, TrayState.STARTING):
pid = tray_info.pid
if pid is not None:
_kill_tray_process(pid)
remove_tray_server_url(force=True)
state = TrayState.NOT_RUNNING
file_state = TrayState.NOT_RUNNING
if state == TrayState.RUNNING:
show_message_in_tray(
"Tray is already running",
"Your AYON tray application is already running."
)
print("Tray is already running.")
return
if file_state in (TrayState.RUNNING, TrayState.STARTING):
expected_username = get_ayon_username()
username = tray_info.get_ayon_username()
# TODO probably show some message to the user???
if expected_username != username:
pid = tray_info.pid
if pid is not None:
_kill_tray_process(pid)
remove_tray_server_url(force=True)
file_state = TrayState.NOT_RUNNING
if state == TrayState.STARTING:
if file_state == TrayState.RUNNING:
if tray_info.get_state() == TrayState.RUNNING:
show_message_in_tray(
"Tray is already running",
"Your AYON tray application is already running."
)
print("Tray is already running.")
return
file_state = tray_info.get_file_state()
if file_state == TrayState.STARTING:
print("Tray is starting. Waiting for it to start.")
_wait_for_starting_tray()
state = get_tray_state()
if state == TrayState.RUNNING:
tray_info.wait_to_start()
file_state = tray_info.get_file_state()
if file_state == TrayState.RUNNING:
print("Tray started. Exiting.")
return
if state == TrayState.STARTING:
if file_state == TrayState.STARTING:
print(
"Tray did not start in expected time."
" Killing the process and starting new."
)
file_info = get_tray_file_info() or {}
pid = file_info.get("pid")
pid = tray_info.pid
if pid is not None:
_kill_tray_process(pid)
remove_tray_server_url(force=True)

View file

@ -237,11 +237,8 @@ class TrayAddonsManager(AddonsManager):
webserver_url = self.webserver_url
statics_url = f"{webserver_url}/res"
# Deprecated
# TODO stop using these env variables
# - function 'get_tray_server_url' should be used instead
os.environ[self.webserver_url_env] = webserver_url
os.environ["AYON_STATICS_SERVER"] = statics_url
# Deprecated
os.environ["OPENPYPE_WEBSERVER_URL"] = webserver_url
os.environ["OPENPYPE_STATICS_SERVER"] = statics_url

View file

@ -5,6 +5,8 @@ from .widgets import (
ComboBox,
CustomTextComboBox,
PlaceholderLineEdit,
ElideLabel,
HintedLineEdit,
ExpandingTextEdit,
BaseClickableFrame,
ClickableFrame,
@ -36,7 +38,6 @@ from .lib import (
qt_app_context,
get_qt_app,
get_ayon_qt_app,
get_openpype_qt_app,
get_qt_icon,
)
@ -88,6 +89,8 @@ __all__ = (
"ComboBox",
"CustomTextComboBox",
"PlaceholderLineEdit",
"ElideLabel",
"HintedLineEdit",
"ExpandingTextEdit",
"BaseClickableFrame",
"ClickableFrame",
@ -118,7 +121,6 @@ __all__ = (
"qt_app_context",
"get_qt_app",
"get_ayon_qt_app",
"get_openpype_qt_app",
"get_qt_icon",
"RecursiveSortFilterProxyModel",

View file

@ -196,10 +196,6 @@ def get_ayon_qt_app():
return app
def get_openpype_qt_app():
return get_ayon_qt_app()
def iter_model_rows(model, column=0, include_root=False):
"""Iterate over all row indices in a model"""
indexes_queue = collections.deque()

View file

@ -1,4 +1,5 @@
import logging
from typing import Optional, List, Set, Any
from qtpy import QtWidgets, QtCore, QtGui
import qargparse
@ -11,7 +12,7 @@ from ayon_core.style import (
)
from ayon_core.lib.attribute_definitions import AbstractAttrDef
from .lib import get_qta_icon_by_name_and_color
from .lib import get_qta_icon_by_name_and_color, set_style_property
log = logging.getLogger(__name__)
@ -104,6 +105,253 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit):
self.setPalette(filter_palette)
class ElideLabel(QtWidgets.QLabel):
"""Label which elide text.
By default, elide happens on right side. Can be changed with
'set_elide_mode' method.
It is not possible to use other features of QLabel like word wrap or
interactive text. This is a simple label which elide text.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setSizePolicy(
QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Preferred
)
# Store text set during init
self._text = self.text()
# Define initial elide mode
self._elide_mode = QtCore.Qt.ElideRight
# Make sure that text of QLabel is empty
super().setText("")
def setText(self, text):
# Update private text attribute and force update
self._text = text
self.update()
def setWordWrap(self, word_wrap):
# Word wrap is not supported in 'ElideLabel'
if word_wrap:
raise ValueError("Word wrap is not supported in 'ElideLabel'.")
def contextMenuEvent(self, event):
menu = self.create_context_menu(event.pos())
if menu is None:
event.ignore()
return
event.accept()
menu.setAttribute(QtCore.Qt.WA_DeleteOnClose)
menu.popup(event.globalPos())
def create_context_menu(self, pos):
if not self._text:
return None
menu = QtWidgets.QMenu(self)
# Copy text action
copy_action = menu.addAction("Copy")
copy_action.setObjectName("edit-copy")
icon = QtGui.QIcon.fromTheme("edit-copy")
if not icon.isNull():
copy_action.setIcon(icon)
copy_action.triggered.connect(self._on_copy_text)
return menu
def set_set(self, text):
self.setText(text)
def set_elide_mode(self, elide_mode):
"""Change elide type.
Args:
elide_mode: Possible elide type. Available in 'QtCore.Qt'
'ElideLeft', 'ElideRight' and 'ElideMiddle'.
"""
if elide_mode == QtCore.Qt.ElideNone:
raise ValueError(
"Invalid elide type. 'ElideNone' is not supported."
)
if elide_mode not in (
QtCore.Qt.ElideLeft,
QtCore.Qt.ElideRight,
QtCore.Qt.ElideMiddle,
):
raise ValueError(f"Unknown value '{elide_mode}'")
self._elide_mode = elide_mode
self.update()
def paintEvent(self, event):
super().paintEvent(event)
painter = QtGui.QPainter(self)
fm = painter.fontMetrics()
elided_line = fm.elidedText(
self._text, self._elide_mode, self.width()
)
painter.drawText(QtCore.QPoint(0, fm.ascent()), elided_line)
def _on_copy_text(self):
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(self._text)
class _LocalCache:
down_arrow_icon = None
def get_down_arrow_icon() -> QtGui.QIcon:
if _LocalCache.down_arrow_icon is not None:
return _LocalCache.down_arrow_icon
normal_pixmap = QtGui.QPixmap(
get_style_image_path("down_arrow")
)
on_pixmap = QtGui.QPixmap(
get_style_image_path("down_arrow_on")
)
disabled_pixmap = QtGui.QPixmap(
get_style_image_path("down_arrow_disabled")
)
icon = QtGui.QIcon(normal_pixmap)
icon.addPixmap(on_pixmap, QtGui.QIcon.Active)
icon.addPixmap(disabled_pixmap, QtGui.QIcon.Disabled)
_LocalCache.down_arrow_icon = icon
return icon
# These are placeholders for adding style
class HintedLineEditInput(PlaceholderLineEdit):
pass
class HintedLineEditButton(QtWidgets.QPushButton):
pass
class HintedLineEdit(QtWidgets.QWidget):
SEPARATORS: Set[str] = {"---", "---separator---"}
returnPressed = QtCore.Signal()
textChanged = QtCore.Signal(str)
textEdited = QtCore.Signal(str)
def __init__(
self,
options: Optional[List[str]] = None,
parent: Optional[QtWidgets.QWidget] = None
):
super().__init__(parent)
text_input = HintedLineEditInput(self)
options_button = HintedLineEditButton(self)
options_button.setIcon(get_down_arrow_icon())
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_layout.addWidget(text_input, 1)
main_layout.addWidget(options_button, 0)
# Expand line edit and button vertically so they have same height
for widget in (text_input, options_button):
w_size_policy = widget.sizePolicy()
w_size_policy.setVerticalPolicy(
QtWidgets.QSizePolicy.MinimumExpanding)
widget.setSizePolicy(w_size_policy)
# Set size hint of this frame to fixed so size hint height is
# used as fixed height
size_policy = self.sizePolicy()
size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Fixed)
self.setSizePolicy(size_policy)
text_input.returnPressed.connect(self.returnPressed)
text_input.textChanged.connect(self.textChanged)
text_input.textEdited.connect(self.textEdited)
options_button.clicked.connect(self._on_options_button_clicked)
self._text_input = text_input
self._options_button = options_button
self._options = None
# Set default state
self.set_options(options)
def text(self) -> str:
return self._text_input.text()
def setText(self, text: str):
self._text_input.setText(text)
def setPlaceholderText(self, text: str):
self._text_input.setPlaceholderText(text)
def placeholderText(self) -> str:
return self._text_input.placeholderText()
def setReadOnly(self, state: bool):
self._text_input.setReadOnly(state)
def setIcon(self, icon: QtGui.QIcon):
self._options_button.setIcon(icon)
def setToolTip(self, text: str):
self._text_input.setToolTip(text)
def set_button_tool_tip(self, text: str):
self._options_button.setToolTip(text)
def set_options(self, options: Optional[List[str]] = None):
self._options = options
self._options_button.setEnabled(bool(options))
def sizeHint(self) -> QtCore.QSize:
hint = super().sizeHint()
tsz = self._text_input.sizeHint()
bsz = self._options_button.sizeHint()
hint.setHeight(max(tsz.height(), bsz.height()))
return hint
# Adds ability to change style of the widgets
# - because style change of the 'HintedLineEdit' may not propagate
# correctly 'HintedLineEditInput' and 'HintedLineEditButton'
def set_text_widget_object_name(self, name: str):
self._text_input.setObjectName(name)
def set_text_widget_property(self, name: str, value: Any):
set_style_property(self._text_input, name, value)
def set_button_widget_object_name(self, name: str):
self._text_input.setObjectName(name)
def set_button_widget_property(self, name: str, value: Any):
set_style_property(self._options_button, name, value)
def _on_options_button_clicked(self):
if not self._options:
return
menu = QtWidgets.QMenu(self)
menu.triggered.connect(self._on_option_action)
for option in self._options:
if option in self.SEPARATORS:
menu.addSeparator()
else:
menu.addAction(option)
rect = self._options_button.rect()
pos = self._options_button.mapToGlobal(rect.bottomLeft())
menu.exec_(pos)
def _on_option_action(self, action):
self.setText(action.text())
class ExpandingTextEdit(QtWidgets.QTextEdit):
"""QTextEdit which does not have sroll area but expands height."""
@ -206,6 +454,8 @@ class ExpandBtnLabel(QtWidgets.QLabel):
"""Label showing expand icon meant for ExpandBtn."""
state_changed = QtCore.Signal()
branch_closed_path = get_style_image_path("branch_closed")
branch_open_path = get_style_image_path("branch_open")
def __init__(self, parent):
super(ExpandBtnLabel, self).__init__(parent)
@ -216,14 +466,10 @@ class ExpandBtnLabel(QtWidgets.QLabel):
self._collapsed = True
def _create_collapsed_pixmap(self):
return QtGui.QPixmap(
get_style_image_path("branch_closed")
)
return QtGui.QPixmap(self.branch_closed_path)
def _create_expanded_pixmap(self):
return QtGui.QPixmap(
get_style_image_path("branch_open")
)
return QtGui.QPixmap(self.branch_open_path)
@property
def collapsed(self):
@ -291,15 +537,14 @@ class ExpandBtn(ClickableFrame):
class ClassicExpandBtnLabel(ExpandBtnLabel):
right_arrow_path = get_style_image_path("right_arrow")
down_arrow_path = get_style_image_path("down_arrow")
def _create_collapsed_pixmap(self):
return QtGui.QPixmap(
get_style_image_path("right_arrow")
)
return QtGui.QPixmap(self.right_arrow_path)
def _create_expanded_pixmap(self):
return QtGui.QPixmap(
get_style_image_path("down_arrow")
)
return QtGui.QPixmap(self.down_arrow_path)
class ClassicExpandBtn(ExpandBtn):

View file

@ -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 int(cmds.about(version=True)) < 2025:
modifiers = int(modifiers)
menu.register_callback(modifiers, to_shelf)

View file

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

View file

@ -15,6 +15,6 @@ qtawesome = "0.7.3"
aiohttp-middlewares = "^2.0.0"
Click = "^8"
OpenTimelineIO = "0.16.0"
opencolorio = "2.2.1"
opencolorio = "^2.3.2"
Pillow = "9.5.0"
websocket-client = ">=0.40.0,<2"

View file

@ -1,6 +1,6 @@
name = "core"
title = "Core"
version = "0.4.3-dev.1"
version = "0.4.5-dev.1"
client_dir = "ayon_core"

View file

@ -68,7 +68,7 @@ target-version = "py39"
[tool.ruff.lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
select = ["E4", "E7", "E9", "F"]
select = ["E4", "E7", "E9", "F", "W"]
ignore = []
# Allow fix for all enabled rules (when `--fix`) is provided.
@ -85,7 +85,6 @@ exclude = [
[tool.ruff.lint.per-file-ignores]
"client/ayon_core/lib/__init__.py" = ["E402"]
"client/ayon_core/hosts/max/startup/startup.py" = ["E402"]
[tool.ruff.format]
# Like Black, use double quotes for strings.

View file

@ -57,7 +57,7 @@ class CollectFramesFixDefModel(BaseSettingsModel):
True,
title="Show 'Rewrite latest version' toggle"
)
class ContributionLayersModel(BaseSettingsModel):
_layout = "compact"
@ -84,6 +84,17 @@ class CollectUSDLayerContributionsModel(BaseSettingsModel):
return value
class AyonEntityURIModel(BaseSettingsModel):
use_ayon_entity_uri: bool = SettingsField(
title="Use AYON Entity URI",
description=(
"When enabled the USD paths written using the contribution "
"workflow will use ayon entity URIs instead of resolved published "
"paths. You can only load these if you use the AYON USD Resolver."
)
)
class PluginStateByHostModelProfile(BaseSettingsModel):
_layout = "expanded"
# Filtering
@ -562,12 +573,12 @@ class ExtractBurninDef(BaseSettingsModel):
_isGroup = True
_layout = "expanded"
name: str = SettingsField("")
TOP_LEFT: str = SettingsField("", topic="Top Left")
TOP_CENTERED: str = SettingsField("", topic="Top Centered")
TOP_RIGHT: str = SettingsField("", topic="Top Right")
BOTTOM_LEFT: str = SettingsField("", topic="Bottom Left")
BOTTOM_CENTERED: str = SettingsField("", topic="Bottom Centered")
BOTTOM_RIGHT: str = SettingsField("", topic="Bottom Right")
TOP_LEFT: str = SettingsField("", title="Top Left")
TOP_CENTERED: str = SettingsField("", title="Top Centered")
TOP_RIGHT: str = SettingsField("", title="Top Right")
BOTTOM_LEFT: str = SettingsField("", title="Bottom Left")
BOTTOM_CENTERED: str = SettingsField("", title="Bottom Centered")
BOTTOM_RIGHT: str = SettingsField("", title="Bottom Right")
filter: ExtractBurninDefFilter = SettingsField(
default_factory=ExtractBurninDefFilter,
title="Additional filtering"
@ -857,6 +868,14 @@ class PublishPuginsModel(BaseSettingsModel):
default_factory=ExtractBurninModel,
title="Extract Burnin"
)
ExtractUSDAssetContribution: AyonEntityURIModel = SettingsField(
default_factory=AyonEntityURIModel,
title="Extract USD Asset Contribution",
)
ExtractUSDLayerContribution: AyonEntityURIModel = SettingsField(
default_factory=AyonEntityURIModel,
title="Extract USD Layer Contribution",
)
PreIntegrateThumbnails: PreIntegrateThumbnailsModel = SettingsField(
default_factory=PreIntegrateThumbnailsModel,
title="Override Integrate Thumbnail Representations"
@ -1012,7 +1031,8 @@ DEFAULT_PUBLISH_VALUES = {
"ext": "png",
"tags": [
"ftrackreview",
"kitsureview"
"kitsureview",
"webreview"
],
"burnins": [],
"ffmpeg_args": {
@ -1052,7 +1072,8 @@ DEFAULT_PUBLISH_VALUES = {
"tags": [
"burnin",
"ftrackreview",
"kitsureview"
"kitsureview",
"webreview"
],
"burnins": [],
"ffmpeg_args": {
@ -1064,7 +1085,10 @@ DEFAULT_PUBLISH_VALUES = {
"output": [
"-pix_fmt yuv420p",
"-crf 18",
"-intra"
"-c:a aac",
"-b:a 192k",
"-g 1",
"-movflags faststart"
]
},
"filter": {
@ -1162,6 +1186,12 @@ DEFAULT_PUBLISH_VALUES = {
}
]
},
"ExtractUSDAssetContribution": {
"use_ayon_entity_uri": False,
},
"ExtractUSDLayerContribution": {
"use_ayon_entity_uri": False,
},
"PreIntegrateThumbnails": {
"enabled": True,
"integrate_profiles": []

View file

@ -22,6 +22,7 @@ class ProductTypeSmartSelectModel(BaseSettingsModel):
class ProductNameProfile(BaseSettingsModel):
_layout = "expanded"
product_types: list[str] = SettingsField(
default_factory=list, title="Product types"
)
@ -65,6 +66,15 @@ class CreatorToolModel(BaseSettingsModel):
title="Create Smart Select"
)
)
# TODO: change to False in next releases
use_legacy_product_names_for_renders: bool = SettingsField(
True,
title="Use legacy product names for renders",
description="Use product naming templates for renders. "
"This is for backwards compatibility enabled by default."
"When enabled, it will ignore any templates for renders "
"that are set in the product name profiles.")
product_name_profiles: list[ProductNameProfile] = SettingsField(
default_factory=list,
title="Product name profiles"