mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
Merge branch 'develop' into enhancement/better_error_on_unsaved_workfile
This commit is contained in:
commit
bacef0b54c
70 changed files with 4184 additions and 2186 deletions
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -235,10 +295,10 @@ def _handle_moved_addons(addon_name, milestone_version, log):
|
|||
"client",
|
||||
)
|
||||
if not os.path.exists(addon_dir):
|
||||
log.error((
|
||||
"Addon '{}' is not be available."
|
||||
" Please update applications addon to '{}' or higher."
|
||||
).format(addon_name, milestone_version))
|
||||
log.error(
|
||||
f"Addon '{addon_name}' is not available. Please update "
|
||||
f"{addon_name} addon to '{milestone_version}' or higher."
|
||||
)
|
||||
return None
|
||||
|
||||
log.warning((
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
132
client/ayon_core/addon/ui/process_ready_error.py
Normal file
132
client/ayon_core/addon/ui/process_ready_error.py
Normal 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()
|
||||
201
client/ayon_core/addon/utils.py
Normal file
201
client/ayon_core/addon/utils.py
Normal 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
|
||||
)
|
||||
|
|
@ -5,6 +5,7 @@ import sys
|
|||
import code
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
import warnings
|
||||
|
||||
import click
|
||||
import acre
|
||||
|
|
@ -18,7 +19,6 @@ from ayon_core.lib import (
|
|||
Logger,
|
||||
)
|
||||
|
||||
from .cli_commands import Commands
|
||||
|
||||
|
||||
class AliasedGroup(click.Group):
|
||||
|
|
@ -43,7 +43,8 @@ class AliasedGroup(click.Group):
|
|||
help="Enable debug")
|
||||
@click.option("--verbose", expose_value=False,
|
||||
help=("Change AYON log level (debug - critical or 0-50)"))
|
||||
def main_cli(ctx):
|
||||
@click.option("--force", is_flag=True, hidden=True)
|
||||
def main_cli(ctx, force):
|
||||
"""AYON is main command serving as entry point to pipeline system.
|
||||
|
||||
It wraps different commands together.
|
||||
|
|
@ -55,17 +56,24 @@ def main_cli(ctx):
|
|||
print(ctx.get_help())
|
||||
sys.exit(0)
|
||||
else:
|
||||
ctx.invoke(tray)
|
||||
ctx.forward(tray)
|
||||
|
||||
|
||||
@main_cli.command()
|
||||
def tray():
|
||||
@click.option(
|
||||
"--force",
|
||||
is_flag=True,
|
||||
help="Force to start tray and close any existing one.")
|
||||
def tray(force):
|
||||
"""Launch AYON tray.
|
||||
|
||||
Default action of AYON command is to launch tray widget to control basic
|
||||
aspects of AYON. See documentation for more information.
|
||||
"""
|
||||
Commands.launch_tray()
|
||||
|
||||
from ayon_core.tools.tray import main
|
||||
|
||||
main(force)
|
||||
|
||||
|
||||
@main_cli.group(help="Run command line arguments of AYON addons")
|
||||
|
|
@ -108,14 +116,25 @@ def extractenvironments(
|
|||
This function is deprecated and will be removed in future. Please use
|
||||
'addon applications extractenvironments ...' instead.
|
||||
"""
|
||||
Commands.extractenvironments(
|
||||
output_json_path,
|
||||
project,
|
||||
asset,
|
||||
task,
|
||||
app,
|
||||
envgroup,
|
||||
ctx.obj["addons_manager"]
|
||||
warnings.warn(
|
||||
(
|
||||
"Command 'extractenvironments' is deprecated and will be"
|
||||
" removed in future. Please use"
|
||||
" 'addon applications extractenvironments ...' instead."
|
||||
),
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
addons_manager = ctx.obj["addons_manager"]
|
||||
applications_addon = addons_manager.get_enabled_addon("applications")
|
||||
if applications_addon is None:
|
||||
raise RuntimeError(
|
||||
"Applications addon is not available or enabled."
|
||||
)
|
||||
|
||||
# Please ignore the fact this is using private method
|
||||
applications_addon._cli_extract_environments(
|
||||
output_json_path, project, asset, task, app, envgroup
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -124,15 +143,15 @@ def extractenvironments(
|
|||
@click.argument("path", required=True)
|
||||
@click.option("-t", "--targets", help="Targets", default=None,
|
||||
multiple=True)
|
||||
@click.option("-g", "--gui", is_flag=True,
|
||||
help="Show Publish UI", default=False)
|
||||
def publish(ctx, path, targets, gui):
|
||||
def publish(ctx, path, targets):
|
||||
"""Start CLI publishing.
|
||||
|
||||
Publish collects json from path provided as an argument.
|
||||
|
||||
"""
|
||||
Commands.publish(path, targets, gui, ctx.obj["addons_manager"])
|
||||
from ayon_core.pipeline.publish import main_cli_publish
|
||||
|
||||
main_cli_publish(path, targets, ctx.obj["addons_manager"])
|
||||
|
||||
|
||||
@main_cli.command(context_settings={"ignore_unknown_options": True})
|
||||
|
|
@ -162,12 +181,10 @@ def contextselection(
|
|||
Context is project name, folder path and task name. The result is stored
|
||||
into json file which path is passed in first argument.
|
||||
"""
|
||||
Commands.contextselection(
|
||||
output_path,
|
||||
project,
|
||||
folder,
|
||||
strict
|
||||
)
|
||||
from ayon_core.tools.context_dialog import main
|
||||
|
||||
main(output_path, project, folder, strict)
|
||||
|
||||
|
||||
|
||||
@main_cli.command(
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Implementation of AYON commands."""
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from typing import Optional, List
|
||||
|
||||
from ayon_core.addon import AddonsManager
|
||||
|
||||
|
||||
class Commands:
|
||||
"""Class implementing commands used by AYON.
|
||||
|
||||
Most of its methods are called by :mod:`cli` module.
|
||||
"""
|
||||
@staticmethod
|
||||
def launch_tray():
|
||||
from ayon_core.tools.tray import main
|
||||
|
||||
main()
|
||||
|
||||
@staticmethod
|
||||
def publish(
|
||||
path: str,
|
||||
targets: Optional[List[str]] = None,
|
||||
gui: Optional[bool] = False,
|
||||
addons_manager: Optional[AddonsManager] = None,
|
||||
) -> None:
|
||||
"""Start headless publishing.
|
||||
|
||||
Publish use json from passed path argument.
|
||||
|
||||
Args:
|
||||
path (str): Path to JSON.
|
||||
targets (Optional[List[str]]): List of pyblish targets.
|
||||
gui (Optional[bool]): Show publish UI.
|
||||
addons_manager (Optional[AddonsManager]): Addons manager instance.
|
||||
|
||||
Raises:
|
||||
RuntimeError: When there is no path to process.
|
||||
RuntimeError: When executed with list of JSON paths.
|
||||
|
||||
"""
|
||||
from ayon_core.lib import Logger
|
||||
|
||||
from ayon_core.addon import AddonsManager
|
||||
from ayon_core.pipeline import (
|
||||
install_ayon_plugins,
|
||||
get_global_context,
|
||||
)
|
||||
|
||||
import ayon_api
|
||||
import pyblish.util
|
||||
|
||||
# Register target and host
|
||||
if not isinstance(path, str):
|
||||
raise RuntimeError("Path to JSON must be a string.")
|
||||
|
||||
# Fix older jobs
|
||||
for src_key, dst_key in (
|
||||
("AVALON_PROJECT", "AYON_PROJECT_NAME"),
|
||||
("AVALON_ASSET", "AYON_FOLDER_PATH"),
|
||||
("AVALON_TASK", "AYON_TASK_NAME"),
|
||||
("AVALON_WORKDIR", "AYON_WORKDIR"),
|
||||
("AVALON_APP_NAME", "AYON_APP_NAME"),
|
||||
("AVALON_APP", "AYON_HOST_NAME"),
|
||||
):
|
||||
if src_key in os.environ and dst_key not in os.environ:
|
||||
os.environ[dst_key] = os.environ[src_key]
|
||||
# Remove old keys, so we're sure they're not used
|
||||
os.environ.pop(src_key, None)
|
||||
|
||||
log = Logger.get_logger("CLI-publish")
|
||||
|
||||
# Make public ayon api behave as other user
|
||||
# - this works only if public ayon api is using service user
|
||||
username = os.environ.get("AYON_USERNAME")
|
||||
if username:
|
||||
# NOTE: ayon-python-api does not have public api function to find
|
||||
# out if is used service user. So we need to have try > except
|
||||
# block.
|
||||
con = ayon_api.get_server_api_connection()
|
||||
try:
|
||||
con.set_default_service_username(username)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
install_ayon_plugins()
|
||||
|
||||
if addons_manager is None:
|
||||
addons_manager = AddonsManager()
|
||||
|
||||
publish_paths = addons_manager.collect_plugin_paths()["publish"]
|
||||
|
||||
for plugin_path in publish_paths:
|
||||
pyblish.api.register_plugin_path(plugin_path)
|
||||
|
||||
applications_addon = addons_manager.get_enabled_addon("applications")
|
||||
if applications_addon is not None:
|
||||
context = get_global_context()
|
||||
env = applications_addon.get_farm_publish_environment_variables(
|
||||
context["project_name"],
|
||||
context["folder_path"],
|
||||
context["task_name"],
|
||||
)
|
||||
os.environ.update(env)
|
||||
|
||||
pyblish.api.register_host("shell")
|
||||
|
||||
if targets:
|
||||
for target in targets:
|
||||
print(f"setting target: {target}")
|
||||
pyblish.api.register_target(target)
|
||||
else:
|
||||
pyblish.api.register_target("farm")
|
||||
|
||||
os.environ["AYON_PUBLISH_DATA"] = path
|
||||
os.environ["HEADLESS_PUBLISH"] = 'true' # to use in app lib
|
||||
|
||||
log.info("Running publish ...")
|
||||
|
||||
plugins = pyblish.api.discover()
|
||||
print("Using plugins:")
|
||||
for plugin in plugins:
|
||||
print(plugin)
|
||||
|
||||
if gui:
|
||||
from ayon_core.tools.utils.host_tools import show_publish
|
||||
from ayon_core.tools.utils.lib import qt_app_context
|
||||
with qt_app_context():
|
||||
show_publish()
|
||||
else:
|
||||
# Error exit as soon as any error occurs.
|
||||
error_format = ("Failed {plugin.__name__}: "
|
||||
"{error} -- {error.traceback}")
|
||||
|
||||
for result in pyblish.util.publish_iter():
|
||||
if result["error"]:
|
||||
log.error(error_format.format(**result))
|
||||
# uninstall()
|
||||
sys.exit(1)
|
||||
|
||||
log.info("Publish finished.")
|
||||
|
||||
@staticmethod
|
||||
def extractenvironments(
|
||||
output_json_path, project, asset, task, app, env_group, addons_manager
|
||||
):
|
||||
"""Produces json file with environment based on project and app.
|
||||
|
||||
Called by Deadline plugin to propagate environment into render jobs.
|
||||
"""
|
||||
warnings.warn(
|
||||
(
|
||||
"Command 'extractenvironments' is deprecated and will be"
|
||||
" removed in future. Please use "
|
||||
"'addon applications extractenvironments ...' instead."
|
||||
),
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
applications_addon = addons_manager.get_enabled_addon("applications")
|
||||
if applications_addon is None:
|
||||
raise RuntimeError(
|
||||
"Applications addon is not available or enabled."
|
||||
)
|
||||
|
||||
# Please ignore the fact this is using private method
|
||||
applications_addon._cli_extract_environments(
|
||||
output_json_path, project, asset, task, app, env_group
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def contextselection(output_path, project_name, folder_path, strict):
|
||||
from ayon_core.tools.context_dialog import main
|
||||
|
||||
main(output_path, project_name, folder_path, strict)
|
||||
72
client/ayon_core/hooks/pre_filter_farm_environments.py
Normal file
72
client/ayon_core/hooks/pre_filter_farm_environments.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import re
|
||||
|
||||
from ayon_applications import PreLaunchHook, LaunchTypes
|
||||
from ayon_core.lib import filter_profiles
|
||||
|
||||
|
||||
class FilterFarmEnvironments(PreLaunchHook):
|
||||
"""Filter or modify calculated environment variables for farm rendering.
|
||||
|
||||
This hook must run last, only after all other hooks are finished to get
|
||||
correct environment for launch context.
|
||||
|
||||
Implemented modifications to self.launch_context.env:
|
||||
- skipping (list) of environment variable keys
|
||||
- removing value in environment variable:
|
||||
- supports regular expression in pattern
|
||||
"""
|
||||
order = 1000
|
||||
|
||||
launch_types = {LaunchTypes.farm_publish}
|
||||
|
||||
def execute(self):
|
||||
data = self.launch_context.data
|
||||
project_settings = data["project_settings"]
|
||||
filter_env_profiles = (
|
||||
project_settings["core"]["filter_env_profiles"])
|
||||
|
||||
if not filter_env_profiles:
|
||||
self.log.debug("No profiles found for env var filtering")
|
||||
return
|
||||
|
||||
task_entity = data["task_entity"]
|
||||
|
||||
filter_data = {
|
||||
"host_names": self.host_name,
|
||||
"task_types": task_entity["taskType"],
|
||||
"task_names": task_entity["name"],
|
||||
"folder_paths": data["folder_path"]
|
||||
}
|
||||
matching_profile = filter_profiles(
|
||||
filter_env_profiles, filter_data, logger=self.log
|
||||
)
|
||||
if not matching_profile:
|
||||
self.log.debug("No matching profile found for env var filtering "
|
||||
f"for {filter_data}")
|
||||
return
|
||||
|
||||
self._skip_environment_variables(
|
||||
self.launch_context.env, matching_profile)
|
||||
|
||||
self._modify_environment_variables(
|
||||
self.launch_context.env, matching_profile)
|
||||
|
||||
def _modify_environment_variables(self, calculated_env, matching_profile):
|
||||
"""Modify environment variable values."""
|
||||
for env_item in matching_profile["replace_in_environment"]:
|
||||
key = env_item["environment_key"]
|
||||
value = calculated_env.get(key)
|
||||
if not value:
|
||||
continue
|
||||
|
||||
value = re.sub(value, env_item["pattern"], env_item["replacement"])
|
||||
if value:
|
||||
calculated_env[key] = value
|
||||
else:
|
||||
calculated_env.pop(key)
|
||||
|
||||
def _skip_environment_variables(self, calculated_env, matching_profile):
|
||||
"""Skips list of environment variable names"""
|
||||
for skip_env in matching_profile["skip_env_keys"]:
|
||||
self.log.info(f"Skipping {skip_env}")
|
||||
calculated_env.pop(skip_env)
|
||||
|
|
@ -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,
|
||||
|
|
@ -109,6 +111,7 @@ from .transcoding import (
|
|||
convert_ffprobe_fps_value,
|
||||
convert_ffprobe_fps_to_float,
|
||||
get_rescaled_command_arguments,
|
||||
get_media_mime_type,
|
||||
)
|
||||
|
||||
from .plugin_tools import (
|
||||
|
|
@ -129,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,
|
||||
|
|
@ -143,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",
|
||||
|
|
@ -209,6 +215,7 @@ __all__ = [
|
|||
"convert_ffprobe_fps_value",
|
||||
"convert_ffprobe_fps_to_float",
|
||||
"get_rescaled_command_arguments",
|
||||
"get_media_mime_type",
|
||||
|
||||
"compile_list_of_regexes",
|
||||
|
||||
|
|
@ -239,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",
|
||||
|
|
|
|||
|
|
@ -577,7 +577,7 @@ class BoolDef(AbstractAttrDef):
|
|||
return self.default
|
||||
|
||||
|
||||
class FileDefItem(object):
|
||||
class FileDefItem:
|
||||
def __init__(
|
||||
self, directory, filenames, frames=None, template=None
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,6 +223,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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import collections
|
|||
import tempfile
|
||||
import subprocess
|
||||
import platform
|
||||
from typing import Optional
|
||||
|
||||
import xml.etree.ElementTree
|
||||
|
||||
|
|
@ -1455,3 +1456,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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
313
client/ayon_core/pipeline/create/changes.py
Normal file
313
client/ayon_core/pipeline/create/changes.py
Normal 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
|
|
@ -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):
|
||||
|
|
|
|||
127
client/ayon_core/pipeline/create/exceptions.py
Normal file
127
client/ayon_core/pipeline/create/exceptions.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 '{}'."
|
||||
|
|
|
|||
871
client/ayon_core/pipeline/create/structures.py
Normal file
871
client/ayon_core/pipeline/create/structures.py
Normal file
|
|
@ -0,0 +1,871 @@
|
|||
import copy
|
||||
import collections
|
||||
from uuid import uuid4
|
||||
|
||||
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 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())
|
||||
|
||||
self._folder_is_valid = self.has_set_folder
|
||||
self._task_is_valid = self.has_set_task
|
||||
|
||||
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"]
|
||||
|
||||
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
|
||||
|
||||
# Context validation related methods/properties
|
||||
@property
|
||||
def has_set_folder(self):
|
||||
"""Folder path is set in data."""
|
||||
|
||||
return "folderPath" in self._data
|
||||
|
||||
@property
|
||||
def has_set_task(self):
|
||||
"""Task name is set in data."""
|
||||
|
||||
return "task" in self._data
|
||||
|
||||
@property
|
||||
def has_valid_context(self):
|
||||
"""Context data are valid for publishing."""
|
||||
|
||||
return self.has_valid_folder and self.has_valid_task
|
||||
|
||||
@property
|
||||
def has_valid_folder(self):
|
||||
"""Folder set in context exists in project."""
|
||||
|
||||
if not self.has_set_folder:
|
||||
return False
|
||||
return self._folder_is_valid
|
||||
|
||||
@property
|
||||
def has_valid_task(self):
|
||||
"""Task set in context exists in project."""
|
||||
|
||||
if not self.has_set_task:
|
||||
return False
|
||||
return self._task_is_valid
|
||||
|
||||
def set_folder_invalid(self, invalid):
|
||||
# TODO replace with `set_folder_path`
|
||||
self._folder_is_valid = not invalid
|
||||
|
||||
def set_task_invalid(self, invalid):
|
||||
# TODO replace with `set_task_name`
|
||||
self._task_is_valid = not invalid
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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: ...
|
||||
|
|
@ -42,6 +42,8 @@ from .lib import (
|
|||
get_plugin_settings,
|
||||
get_publish_instance_label,
|
||||
get_publish_instance_families,
|
||||
|
||||
main_cli_publish,
|
||||
)
|
||||
|
||||
from .abstract_expected_files import ExpectedFiles
|
||||
|
|
@ -92,6 +94,8 @@ __all__ = (
|
|||
"get_publish_instance_label",
|
||||
"get_publish_instance_families",
|
||||
|
||||
"main_cli_publish",
|
||||
|
||||
"ExpectedFiles",
|
||||
|
||||
"RenderInstance",
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import inspect
|
|||
import copy
|
||||
import tempfile
|
||||
import xml.etree.ElementTree
|
||||
from typing import Optional, Union
|
||||
from typing import Optional, Union, List
|
||||
|
||||
import ayon_api
|
||||
import pyblish.util
|
||||
import pyblish.plugin
|
||||
import pyblish.api
|
||||
|
|
@ -16,6 +17,7 @@ from ayon_core.lib import (
|
|||
filter_profiles,
|
||||
)
|
||||
from ayon_core.settings import get_project_settings
|
||||
from ayon_core.addon import AddonsManager
|
||||
from ayon_core.pipeline import (
|
||||
tempdir,
|
||||
Anatomy
|
||||
|
|
@ -978,3 +980,113 @@ def get_instance_expected_output_path(
|
|||
path_template_obj = anatomy.get_template_item("publish", "default")["path"]
|
||||
template_filled = path_template_obj.format_strict(template_data)
|
||||
return os.path.normpath(template_filled)
|
||||
|
||||
|
||||
def main_cli_publish(
|
||||
path: str,
|
||||
targets: Optional[List[str]] = None,
|
||||
addons_manager: Optional[AddonsManager] = None,
|
||||
):
|
||||
"""Start headless publishing.
|
||||
|
||||
Publish use json from passed path argument.
|
||||
|
||||
Args:
|
||||
path (str): Path to JSON.
|
||||
targets (Optional[List[str]]): List of pyblish targets.
|
||||
addons_manager (Optional[AddonsManager]): Addons manager instance.
|
||||
|
||||
Raises:
|
||||
RuntimeError: When there is no path to process or when executed with
|
||||
list of JSON paths.
|
||||
|
||||
"""
|
||||
from ayon_core.pipeline import (
|
||||
install_ayon_plugins,
|
||||
get_global_context,
|
||||
)
|
||||
|
||||
# Register target and host
|
||||
if not isinstance(path, str):
|
||||
raise RuntimeError("Path to JSON must be a string.")
|
||||
|
||||
# Fix older jobs
|
||||
for src_key, dst_key in (
|
||||
("AVALON_PROJECT", "AYON_PROJECT_NAME"),
|
||||
("AVALON_ASSET", "AYON_FOLDER_PATH"),
|
||||
("AVALON_TASK", "AYON_TASK_NAME"),
|
||||
("AVALON_WORKDIR", "AYON_WORKDIR"),
|
||||
("AVALON_APP_NAME", "AYON_APP_NAME"),
|
||||
("AVALON_APP", "AYON_HOST_NAME"),
|
||||
):
|
||||
if src_key in os.environ and dst_key not in os.environ:
|
||||
os.environ[dst_key] = os.environ[src_key]
|
||||
# Remove old keys, so we're sure they're not used
|
||||
os.environ.pop(src_key, None)
|
||||
|
||||
log = Logger.get_logger("CLI-publish")
|
||||
|
||||
# Make public ayon api behave as other user
|
||||
# - this works only if public ayon api is using service user
|
||||
username = os.environ.get("AYON_USERNAME")
|
||||
if username:
|
||||
# NOTE: ayon-python-api does not have public api function to find
|
||||
# out if is used service user. So we need to have try > except
|
||||
# block.
|
||||
con = ayon_api.get_server_api_connection()
|
||||
try:
|
||||
con.set_default_service_username(username)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
install_ayon_plugins()
|
||||
|
||||
if addons_manager is None:
|
||||
addons_manager = AddonsManager()
|
||||
|
||||
# TODO validate if this has to happen
|
||||
# - it should happen during 'install_ayon_plugins'
|
||||
publish_paths = addons_manager.collect_plugin_paths()["publish"]
|
||||
for plugin_path in publish_paths:
|
||||
pyblish.api.register_plugin_path(plugin_path)
|
||||
|
||||
applications_addon = addons_manager.get_enabled_addon("applications")
|
||||
if applications_addon is not None:
|
||||
context = get_global_context()
|
||||
env = applications_addon.get_farm_publish_environment_variables(
|
||||
context["project_name"],
|
||||
context["folder_path"],
|
||||
context["task_name"],
|
||||
)
|
||||
os.environ.update(env)
|
||||
|
||||
pyblish.api.register_host("shell")
|
||||
|
||||
if targets:
|
||||
for target in targets:
|
||||
print(f"setting target: {target}")
|
||||
pyblish.api.register_target(target)
|
||||
else:
|
||||
pyblish.api.register_target("farm")
|
||||
|
||||
os.environ["AYON_PUBLISH_DATA"] = path
|
||||
os.environ["HEADLESS_PUBLISH"] = 'true' # to use in app lib
|
||||
|
||||
log.info("Running publish ...")
|
||||
|
||||
plugins = pyblish.api.discover()
|
||||
print("Using plugins:")
|
||||
for plugin in plugins:
|
||||
print(plugin)
|
||||
|
||||
# Error exit as soon as any error occurs.
|
||||
error_format = ("Failed {plugin.__name__}: "
|
||||
"{error} -- {error.traceback}")
|
||||
|
||||
for result in pyblish.util.publish_iter():
|
||||
if result["error"]:
|
||||
log.error(error_format.format(**result))
|
||||
# uninstall()
|
||||
sys.exit(1)
|
||||
|
||||
log.info("Publish finished.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -555,6 +561,16 @@ 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"]
|
||||
|
|
@ -652,14 +668,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 +690,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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
102
client/ayon_core/plugins/publish/integrate_review.py
Normal file
102
client/ayon_core/plugins/publish/integrate_review.py
Normal 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
|
||||
BIN
client/ayon_core/resources/images/popout.png
Normal file
BIN
client/ayon_core/resources/images/popout.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from typing import List
|
||||
|
||||
from ayon_core.lib.attribute_definitions import (
|
||||
AbstractAttrDef,
|
||||
|
|
@ -13,19 +14,16 @@ class ProductTypeItem:
|
|||
Args:
|
||||
name (str): Product type name.
|
||||
icon (dict[str, Any]): Product type icon definition.
|
||||
checked (bool): Is product type checked for filtering.
|
||||
"""
|
||||
|
||||
def __init__(self, name, icon, checked):
|
||||
def __init__(self, name, icon):
|
||||
self.name = name
|
||||
self.icon = icon
|
||||
self.checked = checked
|
||||
|
||||
def to_data(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"icon": self.icon,
|
||||
"checked": self.checked,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -346,6 +344,16 @@ class ActionItem:
|
|||
return cls(**data)
|
||||
|
||||
|
||||
class ProductTypesFilter:
|
||||
"""Product types filter.
|
||||
|
||||
Defines the filtering for product types.
|
||||
"""
|
||||
def __init__(self, product_types: List[str], is_allow_list: bool):
|
||||
self.product_types: List[str] = product_types
|
||||
self.is_allow_list: bool = is_allow_list
|
||||
|
||||
|
||||
class _BaseLoaderController(ABC):
|
||||
"""Base loader controller abstraction.
|
||||
|
||||
|
|
@ -1006,3 +1014,13 @@ class FrontendLoaderController(_BaseLoaderController):
|
|||
"""
|
||||
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_product_types_filter(self):
|
||||
"""Return product type filter for current context.
|
||||
|
||||
Returns:
|
||||
ProductTypesFilter: Product type filter for current context
|
||||
"""
|
||||
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import uuid
|
|||
|
||||
import ayon_api
|
||||
|
||||
from ayon_core.lib import NestedCacheItem, CacheItem
|
||||
from ayon_core.settings import get_project_settings
|
||||
from ayon_core.pipeline import get_current_host_name
|
||||
from ayon_core.lib import NestedCacheItem, CacheItem, filter_profiles
|
||||
from ayon_core.lib.events import QueuedEventSystem
|
||||
from ayon_core.pipeline import Anatomy, get_current_context
|
||||
from ayon_core.host import ILoadHost
|
||||
|
|
@ -13,7 +15,11 @@ from ayon_core.tools.common_models import (
|
|||
ThumbnailsModel,
|
||||
)
|
||||
|
||||
from .abstract import BackendLoaderController, FrontendLoaderController
|
||||
from .abstract import (
|
||||
BackendLoaderController,
|
||||
FrontendLoaderController,
|
||||
ProductTypesFilter
|
||||
)
|
||||
from .models import (
|
||||
SelectionModel,
|
||||
ProductsModel,
|
||||
|
|
@ -331,11 +337,11 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
|
|||
project_name = context.get("project_name")
|
||||
folder_path = context.get("folder_path")
|
||||
if project_name and folder_path:
|
||||
folder = ayon_api.get_folder_by_path(
|
||||
folder_entity = ayon_api.get_folder_by_path(
|
||||
project_name, folder_path, fields=["id"]
|
||||
)
|
||||
if folder:
|
||||
folder_id = folder["id"]
|
||||
if folder_entity:
|
||||
folder_id = folder_entity["id"]
|
||||
return {
|
||||
"project_name": project_name,
|
||||
"folder_id": folder_id,
|
||||
|
|
@ -425,3 +431,59 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
|
|||
|
||||
def _emit_event(self, topic, data=None):
|
||||
self._event_system.emit(topic, data or {}, "controller")
|
||||
|
||||
def get_product_types_filter(self):
|
||||
output = ProductTypesFilter(
|
||||
is_allow_list=False,
|
||||
product_types=[]
|
||||
)
|
||||
# Without host is not determined context
|
||||
if self._host is None:
|
||||
return output
|
||||
|
||||
context = self.get_current_context()
|
||||
project_name = context.get("project_name")
|
||||
if not project_name:
|
||||
return output
|
||||
settings = get_project_settings(project_name)
|
||||
profiles = (
|
||||
settings
|
||||
["core"]
|
||||
["tools"]
|
||||
["loader"]
|
||||
["product_type_filter_profiles"]
|
||||
)
|
||||
if not profiles:
|
||||
return output
|
||||
|
||||
folder_id = context.get("folder_id")
|
||||
task_name = context.get("task_name")
|
||||
task_type = None
|
||||
if folder_id and task_name:
|
||||
task_entity = ayon_api.get_task_by_name(
|
||||
project_name,
|
||||
folder_id,
|
||||
task_name,
|
||||
fields={"taskType"}
|
||||
)
|
||||
if task_entity:
|
||||
task_type = task_entity.get("taskType")
|
||||
|
||||
host_name = getattr(self._host, "name", get_current_host_name())
|
||||
profile = filter_profiles(
|
||||
profiles,
|
||||
{
|
||||
"hosts": host_name,
|
||||
"task_types": task_type,
|
||||
}
|
||||
)
|
||||
if profile:
|
||||
# TODO remove 'is_include' after release '0.4.3'
|
||||
is_allow_list = profile.get("is_include")
|
||||
if is_allow_list is None:
|
||||
is_allow_list = profile["filter_type"] == "is_allow_list"
|
||||
output = ProductTypesFilter(
|
||||
is_allow_list=is_allow_list,
|
||||
product_types=profile["filter_product_types"]
|
||||
)
|
||||
return output
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ def product_type_item_from_data(product_type_data):
|
|||
"color": "#0091B2",
|
||||
}
|
||||
# TODO implement checked logic
|
||||
return ProductTypeItem(product_type_data["name"], icon, True)
|
||||
return ProductTypeItem(product_type_data["name"], icon)
|
||||
|
||||
|
||||
def create_default_product_type_item(product_type):
|
||||
|
|
@ -132,7 +132,7 @@ def create_default_product_type_item(product_type):
|
|||
"name": "fa.folder",
|
||||
"color": "#0091B2",
|
||||
}
|
||||
return ProductTypeItem(product_type, icon, True)
|
||||
return ProductTypeItem(product_type, icon)
|
||||
|
||||
|
||||
class ProductsModel:
|
||||
|
|
|
|||
|
|
@ -13,10 +13,17 @@ class ProductTypesQtModel(QtGui.QStandardItemModel):
|
|||
super(ProductTypesQtModel, self).__init__()
|
||||
self._controller = controller
|
||||
|
||||
self._reset_filters_on_refresh = True
|
||||
self._refreshing = False
|
||||
self._bulk_change = False
|
||||
self._last_project = None
|
||||
self._items_by_name = {}
|
||||
|
||||
controller.register_event_callback(
|
||||
"controller.reset.finished",
|
||||
self._on_controller_reset_finish,
|
||||
)
|
||||
|
||||
def is_refreshing(self):
|
||||
return self._refreshing
|
||||
|
||||
|
|
@ -37,14 +44,19 @@ class ProductTypesQtModel(QtGui.QStandardItemModel):
|
|||
self._refreshing = True
|
||||
product_type_items = self._controller.get_product_type_items(
|
||||
project_name)
|
||||
self._last_project = project_name
|
||||
|
||||
items_to_remove = set(self._items_by_name.keys())
|
||||
new_items = []
|
||||
items_filter_required = {}
|
||||
for product_type_item in product_type_items:
|
||||
name = product_type_item.name
|
||||
items_to_remove.discard(name)
|
||||
item = self._items_by_name.get(product_type_item.name)
|
||||
item = self._items_by_name.get(name)
|
||||
# Apply filter to new items or if filters reset is requested
|
||||
filter_required = self._reset_filters_on_refresh
|
||||
if item is None:
|
||||
filter_required = True
|
||||
item = QtGui.QStandardItem(name)
|
||||
item.setData(name, PRODUCT_TYPE_ROLE)
|
||||
item.setEditable(False)
|
||||
|
|
@ -52,14 +64,26 @@ class ProductTypesQtModel(QtGui.QStandardItemModel):
|
|||
new_items.append(item)
|
||||
self._items_by_name[name] = item
|
||||
|
||||
item.setCheckState(
|
||||
QtCore.Qt.Checked
|
||||
if product_type_item.checked
|
||||
else QtCore.Qt.Unchecked
|
||||
)
|
||||
if filter_required:
|
||||
items_filter_required[name] = item
|
||||
|
||||
icon = get_qt_icon(product_type_item.icon)
|
||||
item.setData(icon, QtCore.Qt.DecorationRole)
|
||||
|
||||
if items_filter_required:
|
||||
product_types_filter = self._controller.get_product_types_filter()
|
||||
for product_type, item in items_filter_required.items():
|
||||
matching = (
|
||||
int(product_type in product_types_filter.product_types)
|
||||
+ int(product_types_filter.is_allow_list)
|
||||
)
|
||||
state = (
|
||||
QtCore.Qt.Checked
|
||||
if matching % 2 == 0
|
||||
else QtCore.Qt.Unchecked
|
||||
)
|
||||
item.setCheckState(state)
|
||||
|
||||
root_item = self.invisibleRootItem()
|
||||
if new_items:
|
||||
root_item.appendRows(new_items)
|
||||
|
|
@ -68,9 +92,13 @@ class ProductTypesQtModel(QtGui.QStandardItemModel):
|
|||
item = self._items_by_name.pop(name)
|
||||
root_item.removeRow(item.row())
|
||||
|
||||
self._reset_filters_on_refresh = False
|
||||
self._refreshing = False
|
||||
self.refreshed.emit()
|
||||
|
||||
def reset_product_types_filter_on_refresh(self):
|
||||
self._reset_filters_on_refresh = True
|
||||
|
||||
def setData(self, index, value, role=None):
|
||||
checkstate_changed = False
|
||||
if role is None:
|
||||
|
|
@ -122,6 +150,9 @@ class ProductTypesQtModel(QtGui.QStandardItemModel):
|
|||
if changed:
|
||||
self.filter_changed.emit()
|
||||
|
||||
def _on_controller_reset_finish(self):
|
||||
self.refresh(self._last_project)
|
||||
|
||||
|
||||
class ProductTypesView(QtWidgets.QListView):
|
||||
filter_changed = QtCore.Signal()
|
||||
|
|
@ -151,6 +182,7 @@ class ProductTypesView(QtWidgets.QListView):
|
|||
)
|
||||
|
||||
self._controller = controller
|
||||
self._refresh_product_types_filter = False
|
||||
|
||||
self._product_types_model = product_types_model
|
||||
self._product_types_proxy_model = product_types_proxy_model
|
||||
|
|
@ -158,11 +190,15 @@ class ProductTypesView(QtWidgets.QListView):
|
|||
def get_filter_info(self):
|
||||
return self._product_types_model.get_filter_info()
|
||||
|
||||
def reset_product_types_filter_on_refresh(self):
|
||||
self._product_types_model.reset_product_types_filter_on_refresh()
|
||||
|
||||
def _on_project_change(self, event):
|
||||
project_name = event["project_name"]
|
||||
self._product_types_model.refresh(project_name)
|
||||
|
||||
def _on_refresh_finished(self):
|
||||
# Apply product types filter on first show
|
||||
self.filter_changed.emit()
|
||||
|
||||
def _on_filter_change(self):
|
||||
|
|
|
|||
|
|
@ -345,6 +345,8 @@ class LoaderWindow(QtWidgets.QWidget):
|
|||
def closeEvent(self, event):
|
||||
super(LoaderWindow, self).closeEvent(event)
|
||||
|
||||
self._product_types_widget.reset_product_types_filter_on_refresh()
|
||||
|
||||
self._reset_on_show = True
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from datetime import datetime
|
|||
import websocket
|
||||
|
||||
from ayon_core.lib import Logger
|
||||
from ayon_core.tools.tray.webserver import HostMsgAction
|
||||
from ayon_core.tools.tray import HostMsgAction
|
||||
|
||||
log = Logger.get_logger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
from .webserver import HostMsgAction
|
||||
from .addons_manager import TrayAddonsManager
|
||||
from .structures import HostMsgAction
|
||||
from .lib import (
|
||||
TrayState,
|
||||
get_tray_state,
|
||||
is_tray_running,
|
||||
get_tray_server_url,
|
||||
make_sure_tray_is_running,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"HostMsgAction",
|
||||
"TrayAddonsManager",
|
||||
|
||||
"TrayState",
|
||||
"get_tray_state",
|
||||
"is_tray_running",
|
||||
"get_tray_server_url",
|
||||
"make_sure_tray_is_running",
|
||||
"main",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,13 +7,19 @@ import subprocess
|
|||
import csv
|
||||
import time
|
||||
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
|
||||
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:
|
||||
|
|
@ -33,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(
|
||||
|
|
@ -50,7 +56,8 @@ def _get_server_and_variant(
|
|||
def _windows_pid_is_running(pid: int) -> bool:
|
||||
args = ["tasklist.exe", "/fo", "csv", "/fi", f"PID eq {pid}"]
|
||||
output = subprocess.check_output(args)
|
||||
csv_content = csv.DictReader(output.decode("utf-8").splitlines())
|
||||
encoding = locale.getpreferredencoding()
|
||||
csv_content = csv.DictReader(output.decode(encoding).splitlines())
|
||||
# if "PID" not in csv_content.fieldnames:
|
||||
# return False
|
||||
for _ in csv_content:
|
||||
|
|
@ -122,6 +129,11 @@ def _wait_for_starting_tray(
|
|||
if data.get("started") is True:
|
||||
return data
|
||||
|
||||
pid = data.get("pid")
|
||||
if pid and not _is_process_running(pid):
|
||||
remove_tray_server_url()
|
||||
return None
|
||||
|
||||
if time.time() - started_at > timeout:
|
||||
return None
|
||||
time.sleep(0.1)
|
||||
|
|
@ -134,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(
|
||||
|
|
@ -158,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
|
||||
|
|
@ -175,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(
|
||||
|
|
@ -207,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):
|
||||
|
|
@ -239,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)
|
||||
|
|
@ -274,26 +451,32 @@ def remove_tray_server_url(force: Optional[bool] = False):
|
|||
except BaseException:
|
||||
data = {}
|
||||
|
||||
if force or not data or data.get("pid") == os.getpid():
|
||||
if (
|
||||
force
|
||||
or not data
|
||||
or data.get("pid") == os.getpid()
|
||||
or not _is_process_running(data.get("pid"))
|
||||
):
|
||||
os.remove(filepath)
|
||||
|
||||
|
||||
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(
|
||||
|
|
@ -310,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(
|
||||
|
|
@ -344,31 +515,134 @@ def is_tray_running(
|
|||
return state != TrayState.NOT_RUNNING
|
||||
|
||||
|
||||
def main():
|
||||
def show_message_in_tray(
|
||||
title, message, icon=None, msecs=None, tray_url=None
|
||||
):
|
||||
"""Show message in tray.
|
||||
|
||||
Args:
|
||||
title (str): Message title.
|
||||
message (str): Message content.
|
||||
icon (Optional[Literal["information", "warning", "critical"]]): Icon
|
||||
for the message.
|
||||
msecs (Optional[int]): Duration of the message.
|
||||
tray_url (Optional[str]): Tray server url.
|
||||
|
||||
"""
|
||||
if not tray_url:
|
||||
tray_url = get_tray_server_url()
|
||||
|
||||
# TODO handle this case, e.g. raise an error?
|
||||
if not tray_url:
|
||||
return
|
||||
|
||||
# TODO handle response, can fail whole request or can fail on status
|
||||
requests.post(
|
||||
f"{tray_url}/tray/message",
|
||||
json={
|
||||
"title": title,
|
||||
"message": message,
|
||||
"icon": icon,
|
||||
"msecs": msecs
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
"""
|
||||
tray_info = TrayInfo.new(
|
||||
ayon_url, variant, wait_to_start=False
|
||||
)
|
||||
if tray_info.state == TrayState.STARTING:
|
||||
tray_info.wait_to_start()
|
||||
|
||||
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)
|
||||
|
||||
if ayon_url:
|
||||
env["AYON_SERVER_URL"] = ayon_url
|
||||
|
||||
# TODO maybe handle variant in a better way
|
||||
if variant:
|
||||
if variant == "staging":
|
||||
args.append("--use-staging")
|
||||
|
||||
run_detached_process(args, env=env)
|
||||
|
||||
|
||||
def main(force=False):
|
||||
from ayon_core.tools.tray.ui import main
|
||||
|
||||
Logger.set_process_name("Tray")
|
||||
|
||||
state = get_tray_state()
|
||||
if state == TrayState.RUNNING:
|
||||
print("Tray is already running.")
|
||||
return
|
||||
tray_info = TrayInfo.new(wait_to_start=False)
|
||||
|
||||
if state == TrayState.STARTING:
|
||||
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)
|
||||
file_state = TrayState.NOT_RUNNING
|
||||
|
||||
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 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)
|
||||
|
|
|
|||
|
|
@ -3,12 +3,11 @@ import sys
|
|||
import time
|
||||
import collections
|
||||
import atexit
|
||||
import json
|
||||
import platform
|
||||
|
||||
from aiohttp.web_response import Response
|
||||
import ayon_api
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
from aiohttp.web import Response, json_response, Request
|
||||
|
||||
from ayon_core import resources, style
|
||||
from ayon_core.lib import (
|
||||
|
|
@ -28,13 +27,13 @@ from ayon_core.tools.utils import (
|
|||
WrappedCallbackItem,
|
||||
get_ayon_qt_app,
|
||||
)
|
||||
from ayon_core.tools.tray import TrayAddonsManager
|
||||
from ayon_core.tools.tray.lib import (
|
||||
set_tray_server_url,
|
||||
remove_tray_server_url,
|
||||
TrayIsRunningError,
|
||||
)
|
||||
|
||||
from .addons_manager import TrayAddonsManager
|
||||
from .host_console_listener import HostListener
|
||||
from .info_widget import InfoWidget
|
||||
from .dialogs import (
|
||||
|
|
@ -91,6 +90,10 @@ class TrayManager:
|
|||
self._services_submenu = None
|
||||
self._start_time = time.time()
|
||||
|
||||
# Cache AYON username used in process
|
||||
# - it can change only by changing ayon_api global connection
|
||||
# should be safe for tray application to cache the value only once
|
||||
self._cached_username = None
|
||||
self._closing = False
|
||||
try:
|
||||
set_tray_server_url(
|
||||
|
|
@ -133,6 +136,7 @@ class TrayManager:
|
|||
kwargs["msecs"] = msecs
|
||||
|
||||
self.tray_widget.showMessage(*args, **kwargs)
|
||||
# TODO validate 'self.tray_widget.supportsMessages()'
|
||||
|
||||
def initialize_addons(self):
|
||||
"""Add addons to tray."""
|
||||
|
|
@ -143,7 +147,10 @@ class TrayManager:
|
|||
self._addons_manager.initialize(tray_menu)
|
||||
|
||||
self._addons_manager.add_route(
|
||||
"GET", "/tray", self._get_web_tray_info
|
||||
"GET", "/tray", self._web_get_tray_info
|
||||
)
|
||||
self._addons_manager.add_route(
|
||||
"POST", "/tray/message", self._web_show_tray_message
|
||||
)
|
||||
|
||||
admin_submenu = ITrayAction.admin_submenu(tray_menu)
|
||||
|
|
@ -274,8 +281,12 @@ class TrayManager:
|
|||
|
||||
return item
|
||||
|
||||
async def _get_web_tray_info(self, request):
|
||||
return Response(text=json.dumps({
|
||||
async def _web_get_tray_info(self, _request: Request) -> Response:
|
||||
if self._cached_username is None:
|
||||
self._cached_username = ayon_api.get_user()["name"]
|
||||
|
||||
return json_response({
|
||||
"username": self._cached_username,
|
||||
"bundle": os.getenv("AYON_BUNDLE_NAME"),
|
||||
"dev_mode": is_dev_mode_enabled(),
|
||||
"staging_mode": is_staging_enabled(),
|
||||
|
|
@ -285,7 +296,37 @@ class TrayManager:
|
|||
},
|
||||
"installer_version": os.getenv("AYON_VERSION"),
|
||||
"running_time": time.time() - self._start_time,
|
||||
}))
|
||||
})
|
||||
|
||||
async def _web_show_tray_message(self, request: Request) -> Response:
|
||||
data = await request.json()
|
||||
try:
|
||||
title = data["title"]
|
||||
message = data["message"]
|
||||
icon = data.get("icon")
|
||||
msecs = data.get("msecs")
|
||||
except KeyError as exc:
|
||||
return json_response(
|
||||
{
|
||||
"error": f"Missing required data. {exc}",
|
||||
"success": False,
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if icon == "information":
|
||||
icon = QtWidgets.QSystemTrayIconInformation
|
||||
elif icon == "warning":
|
||||
icon = QtWidgets.QSystemTrayIconWarning
|
||||
elif icon == "critical":
|
||||
icon = QtWidgets.QSystemTrayIcon.Critical
|
||||
else:
|
||||
icon = None
|
||||
|
||||
self.execute_in_main_thread(
|
||||
self.show_tray_message, title, message, icon, msecs
|
||||
)
|
||||
return json_response({"success": True})
|
||||
|
||||
def _on_update_check_timer(self):
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
from .structures import HostMsgAction
|
||||
from .base_routes import RestApiEndpoint
|
||||
from .server import find_free_port, WebServerManager
|
||||
|
||||
|
||||
__all__ = (
|
||||
"HostMsgAction",
|
||||
"RestApiEndpoint",
|
||||
"find_free_port",
|
||||
"WebServerManager",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Package declaring AYON core addon version."""
|
||||
__version__ = "0.4.3-dev.1"
|
||||
__version__ = "0.4.4-dev.1"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name = "core"
|
||||
title = "Core"
|
||||
version = "0.4.3-dev.1"
|
||||
version = "0.4.4-dev.1"
|
||||
|
||||
client_dir = "ayon_core"
|
||||
|
||||
|
|
|
|||
|
|
@ -169,6 +169,46 @@ class VersionStartCategoryModel(BaseSettingsModel):
|
|||
)
|
||||
|
||||
|
||||
class EnvironmentReplacementModel(BaseSettingsModel):
|
||||
environment_key: str = SettingsField("", title="Enviroment variable")
|
||||
pattern: str = SettingsField("", title="Pattern")
|
||||
replacement: str = SettingsField("", title="Replacement")
|
||||
|
||||
|
||||
class FilterEnvsProfileModel(BaseSettingsModel):
|
||||
_layout = "expanded"
|
||||
|
||||
host_names: list[str] = SettingsField(
|
||||
default_factory=list,
|
||||
title="Host names"
|
||||
)
|
||||
|
||||
task_types: list[str] = SettingsField(
|
||||
default_factory=list,
|
||||
title="Task types",
|
||||
enum_resolver=task_types_enum
|
||||
)
|
||||
|
||||
task_names: list[str] = SettingsField(
|
||||
default_factory=list,
|
||||
title="Task names"
|
||||
)
|
||||
|
||||
folder_paths: list[str] = SettingsField(
|
||||
default_factory=list,
|
||||
title="Folder paths"
|
||||
)
|
||||
|
||||
skip_env_keys: list[str] = SettingsField(
|
||||
default_factory=list,
|
||||
title="Skip environment variables"
|
||||
)
|
||||
replace_in_environment: list[EnvironmentReplacementModel] = SettingsField(
|
||||
default_factory=list,
|
||||
title="Replace values in environment"
|
||||
)
|
||||
|
||||
|
||||
class CoreSettings(BaseSettingsModel):
|
||||
studio_name: str = SettingsField("", title="Studio name", scope=["studio"])
|
||||
studio_code: str = SettingsField("", title="Studio code", scope=["studio"])
|
||||
|
|
@ -219,6 +259,9 @@ class CoreSettings(BaseSettingsModel):
|
|||
title="Project environments",
|
||||
section="---"
|
||||
)
|
||||
filter_env_profiles: list[FilterEnvsProfileModel] = SettingsField(
|
||||
default_factory=list,
|
||||
)
|
||||
|
||||
@validator(
|
||||
"environments",
|
||||
|
|
@ -313,5 +356,6 @@ DEFAULT_VALUES = {
|
|||
"project_environments": json.dumps(
|
||||
{},
|
||||
indent=4
|
||||
)
|
||||
),
|
||||
"filter_env_profiles": [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -964,7 +964,8 @@ DEFAULT_PUBLISH_VALUES = {
|
|||
"nuke",
|
||||
"harmony",
|
||||
"photoshop",
|
||||
"aftereffects"
|
||||
"aftereffects",
|
||||
"fusion"
|
||||
],
|
||||
"enabled": True,
|
||||
"optional": True,
|
||||
|
|
@ -1011,7 +1012,8 @@ DEFAULT_PUBLISH_VALUES = {
|
|||
"ext": "png",
|
||||
"tags": [
|
||||
"ftrackreview",
|
||||
"kitsureview"
|
||||
"kitsureview",
|
||||
"webreview"
|
||||
],
|
||||
"burnins": [],
|
||||
"ffmpeg_args": {
|
||||
|
|
@ -1051,7 +1053,8 @@ DEFAULT_PUBLISH_VALUES = {
|
|||
"tags": [
|
||||
"burnin",
|
||||
"ftrackreview",
|
||||
"kitsureview"
|
||||
"kitsureview",
|
||||
"webreview"
|
||||
],
|
||||
"burnins": [],
|
||||
"ffmpeg_args": {
|
||||
|
|
@ -1063,7 +1066,10 @@ DEFAULT_PUBLISH_VALUES = {
|
|||
"output": [
|
||||
"-pix_fmt yuv420p",
|
||||
"-crf 18",
|
||||
"-intra"
|
||||
"-c:a aac",
|
||||
"-b:a 192k",
|
||||
"-g 1",
|
||||
"-movflags faststart"
|
||||
]
|
||||
},
|
||||
"filter": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -195,6 +205,7 @@ def _product_types_enum():
|
|||
"editorial",
|
||||
"gizmo",
|
||||
"image",
|
||||
"imagesequence",
|
||||
"layout",
|
||||
"look",
|
||||
"matchmove",
|
||||
|
|
@ -212,7 +223,6 @@ def _product_types_enum():
|
|||
"setdress",
|
||||
"take",
|
||||
"usd",
|
||||
"usdShade",
|
||||
"vdbcache",
|
||||
"vrayproxy",
|
||||
"workfile",
|
||||
|
|
@ -222,6 +232,13 @@ def _product_types_enum():
|
|||
]
|
||||
|
||||
|
||||
def filter_type_enum():
|
||||
return [
|
||||
{"value": "is_allow_list", "label": "Allow list"},
|
||||
{"value": "is_deny_list", "label": "Deny list"},
|
||||
]
|
||||
|
||||
|
||||
class LoaderProductTypeFilterProfile(BaseSettingsModel):
|
||||
_layout = "expanded"
|
||||
# TODO this should use hosts enum
|
||||
|
|
@ -231,9 +248,15 @@ class LoaderProductTypeFilterProfile(BaseSettingsModel):
|
|||
title="Task types",
|
||||
enum_resolver=task_types_enum
|
||||
)
|
||||
is_include: bool = SettingsField(True, title="Exclude / Include")
|
||||
filter_type: str = SettingsField(
|
||||
"is_allow_list",
|
||||
title="Filter type",
|
||||
section="Product type filter",
|
||||
enum_resolver=filter_type_enum
|
||||
)
|
||||
filter_product_types: list[str] = SettingsField(
|
||||
default_factory=list,
|
||||
title="Product types",
|
||||
enum_resolver=_product_types_enum
|
||||
)
|
||||
|
||||
|
|
@ -499,14 +522,7 @@ DEFAULT_TOOLS_VALUES = {
|
|||
"workfile_lock_profiles": []
|
||||
},
|
||||
"loader": {
|
||||
"product_type_filter_profiles": [
|
||||
{
|
||||
"hosts": [],
|
||||
"task_types": [],
|
||||
"is_include": True,
|
||||
"filter_product_types": []
|
||||
}
|
||||
]
|
||||
"product_type_filter_profiles": []
|
||||
},
|
||||
"publish": {
|
||||
"template_name_profiles": [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue