Merge remote-tracking branch 'origin/develop' into feature/OP-1188_better-representation-model

This commit is contained in:
Ondřej Samohel 2024-08-27 17:11:17 +02:00
commit a369f21c05
No known key found for this signature in database
GPG key ID: 02376E18990A97C6
31 changed files with 1901 additions and 423 deletions

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 (
@ -64,6 +69,61 @@ MOVED_ADDON_MILESTONE_VERSIONS = {
}
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.
"""
pass
class ProcessContext:
"""Hold context of process that is going to be started.
Right now the context is simple, having information about addon that wants
to trigger preparation and possibly project name for which it should
happen.
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.
At the moment of creation is 'ProcessContext' only data holder, but that
might change in future if there will be need.
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 __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
if kwargs:
unknown_keys = ", ".join([f'"{key}"' for key in kwargs.keys()])
print(f"Unknown keys in ProcessContext: {unknown_keys}")
# Inherit from `object` for Python 2 hosts
class _ModuleClass(object):
"""Fake module class for storing AYON addons.
@ -276,10 +336,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
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 = {}
@ -584,7 +641,29 @@ class AYONAddon(ABC):
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):

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

@ -9,6 +9,8 @@ from .local_settings import (
AYONSettingsRegistry,
OpenPypeSecureRegistry,
OpenPypeSettingsRegistry,
get_launcher_local_dir,
get_launcher_storage_dir,
get_local_site_id,
get_ayon_username,
get_openpype_username,
@ -130,6 +132,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,
@ -144,6 +147,8 @@ __all__ = [
"AYONSettingsRegistry",
"OpenPypeSecureRegistry",
"OpenPypeSettingsRegistry",
"get_launcher_local_dir",
"get_launcher_storage_dir",
"get_local_site_id",
"get_ayon_username",
"get_openpype_username",
@ -241,6 +246,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

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

@ -179,7 +179,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,6 +209,15 @@ 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)

View file

@ -3,6 +3,7 @@
import os
import json
import platform
import warnings
from datetime import datetime
from abc import ABC, abstractmethod
@ -30,6 +31,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 +552,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 +573,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()

View file

@ -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,7 +1,6 @@
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
@ -183,7 +182,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

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

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

@ -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"]
@ -385,8 +385,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 +437,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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

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;
}

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

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

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

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

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,17 +559,20 @@ 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")
@ -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

@ -5,6 +5,8 @@ from .widgets import (
ComboBox,
CustomTextComboBox,
PlaceholderLineEdit,
ElideLabel,
HintedLineEdit,
ExpandingTextEdit,
BaseClickableFrame,
ClickableFrame,
@ -88,6 +90,8 @@ __all__ = (
"ComboBox",
"CustomTextComboBox",
"PlaceholderLineEdit",
"ElideLabel",
"HintedLineEdit",
"ExpandingTextEdit",
"BaseClickableFrame",
"ClickableFrame",

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

@ -562,12 +562,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"
@ -1066,7 +1066,7 @@ DEFAULT_PUBLISH_VALUES = {
"output": [
"-pix_fmt yuv420p",
"-crf 18",
"-c:a acc",
"-c:a aac",
"-b:a 192k",
"-g 1",
"-movflags faststart"

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"