Merge branch 'develop' into enhancement/AY-6299_Scene-inventory-mark-all-outdated-containers

This commit is contained in:
Jakub Trllo 2024-08-07 14:30:05 +02:00 committed by GitHub
commit 5f3a82cb64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 675 additions and 89 deletions

View file

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

View file

@ -10,6 +10,7 @@ import threading
import collections
from uuid import uuid4
from abc import ABC, abstractmethod
from typing import Optional
import appdirs
import ayon_api
@ -64,6 +65,56 @@ 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:
"""Context of child process.
Notes:
This class is used to pass context to child process. It can be used
to use different behavior of addon based on information in
the context.
The context can be enhanced in future versions.
Args:
addon_name (Optional[str]): Addon name which triggered process.
addon_version (Optional[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.
headless (Optional[bool]): Is process running in headless mode.
"""
def __init__(
self,
addon_name: Optional[str] = None,
addon_version: Optional[str] = None,
project_name: Optional[str] = None,
headless: Optional[bool] = None,
**kwargs,
):
if headless is None:
# TODO use lib function to get headless mode
headless = os.getenv("AYON_HEADLESS_MODE") == "1"
self.addon_name: Optional[str] = addon_name
self.addon_version: Optional[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.
@ -584,7 +635,29 @@ class AYONAddon(ABC):
Args:
enabled_addons (list[AYONAddon]): Addons that are enabled.
"""
pass
def ensure_is_process_ready(
self, process_context: ProcessContext
):
"""Make sure addon is prepared for a process.
This method is called when some action makes sure that addon has set
necessary data. For example if user should be logged in
and filled credentials in environment variables this method should
ask user for credentials.
Implementation of this method is optional.
Note:
The logic can be similar to logic in tray, but tray does not require
to be logged in.
Args:
process_context (ProcessContext): Context of child
process.
"""
pass
def get_global_environments(self):

View file

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

View file

@ -0,0 +1,184 @@
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,
) -> Optional[Exception]:
"""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:
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:
Optional[Exception]: The exception that occurred during the
preparation, if any.
"""
if addons_manager is None:
addons_manager = AddonsManager()
exception = None
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:
exception = exc
message = str(exc)
print(f"Addon preparation failed: '{addon.name}'")
print(message)
except BaseException as exc:
exception = exc
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 None
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 not exit_on_failure:
return exception
sys.exit(1)
def ensure_addons_are_process_ready(
addons_manager: Optional[AddonsManager] = None,
exit_on_failure: bool = True,
**kwargs,
) -> Optional[Exception]:
"""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:
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:
Optional[Exception]: The exception that occurred during the
preparation, if any.
"""
context: ProcessContext = ProcessContext(**kwargs)
return ensure_addons_are_process_context_ready(
context, addons_manager, exit_on_failure
)

View file

@ -179,7 +179,7 @@ def clean_envs_for_ayon_process(env=None):
return env
def run_ayon_launcher_process(*args, **kwargs):
def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs):
"""Execute AYON process with passed arguments and wait.
Wrapper for 'run_process' which prepends AYON executable arguments
@ -209,6 +209,15 @@ def run_ayon_launcher_process(*args, **kwargs):
# - fill more if you find more
env = clean_envs_for_ayon_process(os.environ)
if add_sys_paths:
new_pythonpath = list(sys.path)
lookup_set = set(new_pythonpath)
for path in (env.get("PYTHONPATH") or "").split(os.pathsep):
if path and path not in lookup_set:
new_pythonpath.append(path)
lookup_set.add(path)
env["PYTHONPATH"] = os.pathsep.join(new_pythonpath)
return run_subprocess(args, env=env, **kwargs)

View file

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