Merge branch 'develop' into enhancement/python-2-deprecation

This commit is contained in:
Jakub Trllo 2024-08-09 10:08:05 +02:00 committed by GitHub
commit f528c81ee3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2374 additions and 830 deletions

View file

@ -86,7 +86,3 @@ AYON addons should contain separated logic of specific kind of implementation, s
"inventory": []
}
```
### TrayAddonsManager
- inherits from `AddonsManager`
- has specific implementation for AYON Tray and handle `ITrayAddon` methods

View file

@ -9,12 +9,18 @@ from .interfaces import (
)
from .base import (
ProcessPreparationError,
ProcessContext,
AYONAddon,
AddonsManager,
TrayAddonsManager,
load_addons,
)
from .utils import (
ensure_addons_are_process_context_ready,
ensure_addons_are_process_ready,
)
__all__ = (
"click_wrap",
@ -25,8 +31,12 @@ __all__ = (
"ITrayService",
"IHostAddon",
"ProcessPreparationError",
"ProcessContext",
"AYONAddon",
"AddonsManager",
"TrayAddonsManager",
"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
@ -22,8 +23,6 @@ from ayon_core.settings import get_studio_settings
from .interfaces import (
IPluginPaths,
IHostAddon,
ITrayAddon,
ITrayService
)
# Files that will be always ignored on addons import
@ -66,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.
@ -237,10 +286,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((
@ -586,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):
@ -923,20 +994,20 @@ class AddonsManager:
report = {}
time_start = time.time()
prev_start_time = time_start
enabled_modules = self.get_enabled_addons()
self.log.debug("Has {} enabled modules.".format(len(enabled_modules)))
for module in enabled_modules:
enabled_addons = self.get_enabled_addons()
self.log.debug("Has {} enabled addons.".format(len(enabled_addons)))
for addon in enabled_addons:
try:
if not is_func_marked(module.connect_with_addons):
module.connect_with_addons(enabled_modules)
if not is_func_marked(addon.connect_with_addons):
addon.connect_with_addons(enabled_addons)
elif hasattr(module, "connect_with_modules"):
elif hasattr(addon, "connect_with_modules"):
self.log.warning((
"DEPRECATION WARNING: Addon '{}' still uses"
" 'connect_with_modules' method. Please switch to use"
" 'connect_with_addons' method."
).format(module.name))
module.connect_with_modules(enabled_modules)
).format(addon.name))
addon.connect_with_modules(enabled_addons)
except Exception:
self.log.error(
@ -945,7 +1016,7 @@ class AddonsManager:
)
now = time.time()
report[module.__class__.__name__] = now - prev_start_time
report[addon.__class__.__name__] = now - prev_start_time
prev_start_time = now
if self._report is not None:
@ -1338,185 +1409,3 @@ class AddonsManager:
" 'get_host_module' please use 'get_host_addon' instead."
)
return self.get_host_addon(host_name)
class TrayAddonsManager(AddonsManager):
# Define order of addons in menu
# TODO find better way how to define order
addons_menu_order = (
"user",
"ftrack",
"kitsu",
"launcher_tool",
"avalon",
"clockify",
"traypublish_tool",
"log_viewer",
)
def __init__(self, settings=None):
super(TrayAddonsManager, self).__init__(settings, initialize=False)
self.tray_manager = None
self.doubleclick_callbacks = {}
self.doubleclick_callback = None
def add_doubleclick_callback(self, addon, callback):
"""Register double-click callbacks on tray icon.
Currently, there is no way how to determine which is launched. Name of
callback can be defined with `doubleclick_callback` attribute.
Missing feature how to define default callback.
Args:
addon (AYONAddon): Addon object.
callback (FunctionType): Function callback.
"""
callback_name = "_".join([addon.name, callback.__name__])
if callback_name not in self.doubleclick_callbacks:
self.doubleclick_callbacks[callback_name] = callback
if self.doubleclick_callback is None:
self.doubleclick_callback = callback_name
return
self.log.warning((
"Callback with name \"{}\" is already registered."
).format(callback_name))
def initialize(self, tray_manager, tray_menu):
self.tray_manager = tray_manager
self.initialize_addons()
self.tray_init()
self.connect_addons()
self.tray_menu(tray_menu)
def get_enabled_tray_addons(self):
"""Enabled tray addons.
Returns:
list[AYONAddon]: Enabled addons that inherit from tray interface.
"""
return [
addon
for addon in self.get_enabled_addons()
if isinstance(addon, ITrayAddon)
]
def restart_tray(self):
if self.tray_manager:
self.tray_manager.restart()
def tray_init(self):
report = {}
time_start = time.time()
prev_start_time = time_start
for addon in self.get_enabled_tray_addons():
try:
addon._tray_manager = self.tray_manager
addon.tray_init()
addon.tray_initialized = True
except Exception:
self.log.warning(
"Addon \"{}\" crashed on `tray_init`.".format(
addon.name
),
exc_info=True
)
now = time.time()
report[addon.__class__.__name__] = now - prev_start_time
prev_start_time = now
if self._report is not None:
report[self._report_total_key] = time.time() - time_start
self._report["Tray init"] = report
def tray_menu(self, tray_menu):
ordered_addons = []
enabled_by_name = {
addon.name: addon
for addon in self.get_enabled_tray_addons()
}
for name in self.addons_menu_order:
addon_by_name = enabled_by_name.pop(name, None)
if addon_by_name:
ordered_addons.append(addon_by_name)
ordered_addons.extend(enabled_by_name.values())
report = {}
time_start = time.time()
prev_start_time = time_start
for addon in ordered_addons:
if not addon.tray_initialized:
continue
try:
addon.tray_menu(tray_menu)
except Exception:
# Unset initialized mark
addon.tray_initialized = False
self.log.warning(
"Addon \"{}\" crashed on `tray_menu`.".format(
addon.name
),
exc_info=True
)
now = time.time()
report[addon.__class__.__name__] = now - prev_start_time
prev_start_time = now
if self._report is not None:
report[self._report_total_key] = time.time() - time_start
self._report["Tray menu"] = report
def start_addons(self):
report = {}
time_start = time.time()
prev_start_time = time_start
for addon in self.get_enabled_tray_addons():
if not addon.tray_initialized:
if isinstance(addon, ITrayService):
addon.set_service_failed_icon()
continue
try:
addon.tray_start()
except Exception:
self.log.warning(
"Addon \"{}\" crashed on `tray_start`.".format(
addon.name
),
exc_info=True
)
now = time.time()
report[addon.__class__.__name__] = now - prev_start_time
prev_start_time = now
if self._report is not None:
report[self._report_total_key] = time.time() - time_start
self._report["Addons start"] = report
def on_exit(self):
for addon in self.get_enabled_tray_addons():
if addon.tray_initialized:
try:
addon.tray_exit()
except Exception:
self.log.warning(
"Addon \"{}\" crashed on `tray_exit`.".format(
addon.name
),
exc_info=True
)
# DEPRECATED
def get_enabled_tray_modules(self):
return self.get_enabled_tray_addons()
def start_modules(self):
self.start_addons()

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

@ -5,6 +5,7 @@ import sys
import code
import traceback
from pathlib import Path
import warnings
import click
import acre
@ -12,9 +13,12 @@ import acre
from ayon_core import AYON_CORE_ROOT
from ayon_core.addon import AddonsManager
from ayon_core.settings import get_general_environments
from ayon_core.lib import initialize_ayon_connection, is_running_from_build
from ayon_core.lib import (
initialize_ayon_connection,
is_running_from_build,
Logger,
)
from .cli_commands import Commands
class AliasedGroup(click.Group):
@ -39,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.
@ -51,20 +56,26 @@ 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)
@Commands.add_addons
@main_cli.group(help="Run command line arguments of AYON addons")
@click.pass_context
def addon(ctx):
@ -80,6 +91,7 @@ main_cli.set_alias("addon", "module")
@main_cli.command()
@click.pass_context
@click.argument("output_json_path")
@click.option("--project", help="Project name", default=None)
@click.option("--asset", help="Folder path", default=None)
@ -88,7 +100,9 @@ main_cli.set_alias("addon", "module")
@click.option(
"--envgroup", help="Environment group (e.g. \"farm\")", default=None
)
def extractenvironments(output_json_path, project, asset, task, app, envgroup):
def extractenvironments(
ctx, output_json_path, project, asset, task, app, envgroup
):
"""Extract environment variables for entered context to a json file.
Entered output filepath will be created if does not exists.
@ -102,24 +116,42 @@ def extractenvironments(output_json_path, project, asset, task, app, envgroup):
This function is deprecated and will be removed in future. Please use
'addon applications extractenvironments ...' instead.
"""
Commands.extractenvironments(
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
)
@main_cli.command()
@click.pass_context
@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(path, targets, gui):
def publish(ctx, path, targets):
"""Start CLI publishing.
Publish collects json from path provided as an argument.
S
"""
Commands.publish(path, targets, gui)
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})
@ -149,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(
@ -245,11 +275,9 @@ def _set_global_environments() -> None:
os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
def _set_addons_environments():
def _set_addons_environments(addons_manager):
"""Set global environments for AYON addons."""
addons_manager = AddonsManager()
# Merge environments with current environments and update values
if module_envs := addons_manager.collect_global_environments():
parsed_envs = acre.parse(module_envs)
@ -258,6 +286,21 @@ def _set_addons_environments():
os.environ.update(env)
def _add_addons(addons_manager):
"""Modules/Addons can add their cli commands dynamically."""
log = Logger.get_logger("CLI-AddAddons")
for addon_obj in addons_manager.addons:
try:
addon_obj.cli(addon)
except Exception:
log.warning(
"Failed to add cli command for module \"{}\"".format(
addon_obj.name
), exc_info=True
)
def main(*args, **kwargs):
initialize_ayon_connection()
python_path = os.getenv("PYTHONPATH", "")
@ -281,10 +324,14 @@ def main(*args, **kwargs):
print(" - global AYON ...")
_set_global_environments()
print(" - for addons ...")
_set_addons_environments()
addons_manager = AddonsManager()
_set_addons_environments(addons_manager)
_add_addons(addons_manager)
try:
main_cli(obj={}, prog_name="ayon")
main_cli(
prog_name="ayon",
obj={"addons_manager": addons_manager},
)
except Exception: # noqa
exc_info = sys.exc_info()
print("!!! AYON crashed:")

View file

@ -1,195 +0,0 @@
# -*- coding: utf-8 -*-
"""Implementation of AYON commands."""
import os
import sys
import warnings
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.lib import Logger
from ayon_core.tools import tray
Logger.set_process_name("Tray")
tray.main()
@staticmethod
def add_addons(click_func):
"""Modules/Addons can add their cli commands dynamically."""
from ayon_core.lib import Logger
from ayon_core.addon import AddonsManager
manager = AddonsManager()
log = Logger.get_logger("CLI-AddModules")
for addon in manager.addons:
try:
addon.cli(click_func)
except Exception:
log.warning(
"Failed to add cli command for module \"{}\"".format(
addon.name
), exc_info=True
)
return click_func
@staticmethod
def publish(path: str, targets: list=None, gui:bool=False) -> None:
"""Start headless publishing.
Publish use json from passed path argument.
Args:
path (str): Path to JSON.
targets (list of str): List of pyblish targets.
gui (bool): Show publish UI.
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()
manager = AddonsManager()
publish_paths = manager.collect_plugin_paths()["publish"]
for plugin_path in publish_paths:
pyblish.api.register_plugin_path(plugin_path)
applications_addon = 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
):
"""Produces json file with environment based on project and app.
Called by Deadline plugin to propagate environment into render jobs.
"""
from ayon_core.addon import AddonsManager
warnings.warn(
(
"Command 'extractenvironments' is deprecated and will be"
" removed in future. Please use "
"'addon applications extractenvironments ...' instead."
),
DeprecationWarning
)
addons_manager = AddonsManager()
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)

View 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)

View file

@ -109,6 +109,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 (
@ -209,6 +210,7 @@ __all__ = [
"convert_ffprobe_fps_value",
"convert_ffprobe_fps_to_float",
"get_rescaled_command_arguments",
"get_media_mime_type",
"compile_list_of_regexes",

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

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

View file

@ -17,7 +17,6 @@ from .base import (
load_modules,
ModulesManager,
TrayModulesManager,
)
@ -38,5 +37,4 @@ __all__ = (
"load_modules",
"ModulesManager",
"TrayModulesManager",
)

View file

@ -3,7 +3,6 @@
from ayon_core.addon import (
AYONAddon,
AddonsManager,
TrayAddonsManager,
load_addons,
)
from ayon_core.addon.base import (
@ -12,18 +11,15 @@ from ayon_core.addon.base import (
)
ModulesManager = AddonsManager
TrayModulesManager = TrayAddonsManager
load_modules = load_addons
__all__ = (
"AYONAddon",
"AddonsManager",
"TrayAddonsManager",
"load_addons",
"OpenPypeModule",
"OpenPypeAddOn",
"ModulesManager",
"TrayModulesManager",
"load_modules",
)

View file

@ -1,13 +0,0 @@
from .version import __version__
from .structures import HostMsgAction
from .webserver_module import (
WebServerAddon
)
__all__ = (
"__version__",
"HostMsgAction",
"WebServerAddon",
)

View file

@ -1 +0,0 @@
__version__ = "1.0.0"

View file

@ -1,212 +0,0 @@
"""WebServerAddon spawns aiohttp server in asyncio loop.
Main usage of the module is in AYON tray where make sense to add ability
of other modules to add theirs routes. Module which would want use that
option must have implemented method `webserver_initialization` which must
expect `WebServerManager` object where is possible to add routes or paths
with handlers.
WebServerManager is by default created only in tray.
It is possible to create server manager without using module logic at all
using `create_new_server_manager`. That can be handy for standalone scripts
with predefined host and port and separated routes and logic.
Running multiple servers in one process is not recommended and probably won't
work as expected. It is because of few limitations connected to asyncio module.
When module's `create_server_manager` is called it is also set environment
variable "AYON_WEBSERVER_URL". Which should lead to root access point
of server.
"""
import os
import socket
from ayon_core import resources
from ayon_core.addon import AYONAddon, ITrayService
from .version import __version__
class WebServerAddon(AYONAddon, ITrayService):
name = "webserver"
version = __version__
label = "WebServer"
webserver_url_env = "AYON_WEBSERVER_URL"
def initialize(self, settings):
self._server_manager = None
self._host_listener = None
self._port = self.find_free_port()
self._webserver_url = None
@property
def server_manager(self):
"""
Returns:
Union[WebServerManager, None]: Server manager instance.
"""
return self._server_manager
@property
def port(self):
"""
Returns:
int: Port on which is webserver running.
"""
return self._port
@property
def webserver_url(self):
"""
Returns:
str: URL to webserver.
"""
return self._webserver_url
def connect_with_addons(self, enabled_modules):
if not self._server_manager:
return
for module in enabled_modules:
if not hasattr(module, "webserver_initialization"):
continue
try:
module.webserver_initialization(self._server_manager)
except Exception:
self.log.warning(
(
"Failed to connect module \"{}\" to webserver."
).format(module.name),
exc_info=True
)
def tray_init(self):
self.create_server_manager()
self._add_resources_statics()
self._add_listeners()
def tray_start(self):
self.start_server()
def tray_exit(self):
self.stop_server()
def start_server(self):
if self._server_manager is not None:
self._server_manager.start_server()
def stop_server(self):
if self._server_manager is not None:
self._server_manager.stop_server()
@staticmethod
def create_new_server_manager(port=None, host=None):
"""Create webserver manager for passed port and host.
Args:
port(int): Port on which wil webserver listen.
host(str): Host name or IP address. Default is 'localhost'.
Returns:
WebServerManager: Prepared manager.
"""
from .server import WebServerManager
return WebServerManager(port, host)
def create_server_manager(self):
if self._server_manager is not None:
return
self._server_manager = self.create_new_server_manager(self._port)
self._server_manager.on_stop_callbacks.append(
self.set_service_failed_icon
)
webserver_url = self._server_manager.url
os.environ["OPENPYPE_WEBSERVER_URL"] = str(webserver_url)
os.environ[self.webserver_url_env] = str(webserver_url)
self._webserver_url = webserver_url
@staticmethod
def find_free_port(
port_from=None, port_to=None, exclude_ports=None, host=None
):
"""Find available socket port from entered range.
It is also possible to only check if entered port is available.
Args:
port_from (int): Port number which is checked as first.
port_to (int): Last port that is checked in sequence from entered
`port_from`. Only `port_from` is checked if is not entered.
Nothing is processed if is equeal to `port_from`!
exclude_ports (list, tuple, set): List of ports that won't be
checked form entered range.
host (str): Host where will check for free ports. Set to
"localhost" by default.
"""
if port_from is None:
port_from = 8079
if port_to is None:
port_to = 65535
# Excluded ports (e.g. reserved for other servers/clients)
if exclude_ports is None:
exclude_ports = []
# Default host is localhost but it is possible to look for other hosts
if host is None:
host = "localhost"
found_port = None
for port in range(port_from, port_to + 1):
if port in exclude_ports:
continue
sock = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind((host, port))
found_port = port
except socket.error:
continue
finally:
if sock:
sock.close()
if found_port is not None:
break
return found_port
def _add_resources_statics(self):
static_prefix = "/res"
self._server_manager.add_static(static_prefix, resources.RESOURCES_DIR)
statisc_url = "{}{}".format(
self._webserver_url, static_prefix
)
os.environ["AYON_STATICS_SERVER"] = statisc_url
os.environ["OPENPYPE_STATICS_SERVER"] = statisc_url
def _add_listeners(self):
from . import host_console_listener
self._host_listener = host_console_listener.HostListener(
self._server_manager, self
)

View file

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

View file

@ -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.")

View file

@ -114,18 +114,19 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
# the database even if not used by the destination template
db_representation_context_keys = [
"project",
"asset",
"hierarchy",
"folder",
"task",
"product",
"subset",
"family",
"version",
"representation",
"username",
"user",
"output"
"output",
# OpenPype keys - should be removed
"asset", # folder[name]
"subset", # product[name]
"family", # product[type]
]
def process(self, instance):

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -140,12 +140,6 @@ class VersionComboBox(QtWidgets.QComboBox):
self.value_changed.emit(self._product_id, value)
class EditorInfo:
def __init__(self, widget):
self.widget = widget
self.added = False
class VersionDelegate(QtWidgets.QStyledItemDelegate):
"""A delegate that display version integer formatted as version string."""
@ -154,7 +148,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._editor_by_id: Dict[str, EditorInfo] = {}
self._editor_by_id: Dict[str, VersionComboBox] = {}
self._statuses_filter = None
def displayText(self, value, locale):
@ -164,8 +158,8 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
def set_statuses_filter(self, status_names):
self._statuses_filter = set(status_names)
for info in self._editor_by_id.values():
info.widget.set_statuses_filter(status_names)
for widget in self._editor_by_id.values():
widget.set_statuses_filter(status_names)
def paint(self, painter, option, index):
fg_color = index.data(QtCore.Qt.ForegroundRole)
@ -229,11 +223,11 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
editor = VersionComboBox(product_id, parent)
editor.setProperty("itemId", item_id)
self._editor_by_id[item_id] = EditorInfo(editor)
editor.value_changed.connect(self._on_editor_change)
editor.destroyed.connect(self._on_destroy)
self._editor_by_id[item_id] = editor
return editor
def setEditorData(self, editor, index):
@ -242,12 +236,10 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
# Current value of the index
versions = index.data(VERSION_NAME_EDIT_ROLE) or []
version_id = index.data(VERSION_ID_ROLE)
editor.update_versions(versions, version_id)
editor.set_statuses_filter(self._statuses_filter)
item_id = editor.property("itemId")
self._editor_by_id[item_id].added = True
def setModelData(self, editor, model, index):
"""Apply the integer version back in the model"""

View file

@ -132,7 +132,7 @@ class ProductsModel(QtGui.QStandardItemModel):
def get_product_item_indexes(self):
return [
item.index()
self.indexFromItem(item)
for item in self._items_by_id.values()
]
@ -156,16 +156,18 @@ class ProductsModel(QtGui.QStandardItemModel):
if product_item is None:
return
self.setData(
product_item.index(), version_id, VERSION_NAME_EDIT_ROLE
)
index = self.indexFromItem(product_item)
self.setData(index, version_id, VERSION_NAME_EDIT_ROLE)
def set_enable_grouping(self, enable_grouping):
if enable_grouping is self._grouping_enabled:
return
self._grouping_enabled = enable_grouping
# Ignore change if groups are not available
self.refresh(self._last_project_name, self._last_folder_ids)
self.refresh(
self._last_project_name,
self._last_folder_ids
)
def flags(self, index):
# Make the version column editable
@ -454,7 +456,7 @@ class ProductsModel(QtGui.QStandardItemModel):
def get_last_project_name(self):
return self._last_project_name
def refresh(self, project_name, folder_ids, status_names):
def refresh(self, project_name, folder_ids):
self._clear()
self._last_project_name = project_name
@ -486,17 +488,9 @@ class ProductsModel(QtGui.QStandardItemModel):
}
last_version_by_product_id = {}
for product_item in product_items:
all_versions = list(product_item.version_items.values())
all_versions.sort()
versions = [
version_item
for version_item in all_versions
if status_names is None or version_item.status in status_names
]
if versions:
last_version = versions[-1]
else:
last_version = all_versions[-1]
versions = list(product_item.version_items.values())
versions.sort()
last_version = versions[-1]
last_version_by_product_id[product_item.product_id] = (
last_version
)
@ -543,10 +537,11 @@ class ProductsModel(QtGui.QStandardItemModel):
for product_name, product_items in groups.items():
group_product_types |= {p.product_type for p in product_items}
for product_item in product_items:
group_product_types |= {
group_status_names |= {
version_item.status
for version_item in product_item.version_items.values()
}
group_product_types.add(product_item.product_type)
if len(product_items) == 1:
top_items.append(product_items[0])
@ -589,13 +584,15 @@ class ProductsModel(QtGui.QStandardItemModel):
product_name, product_items = path_info
(merged_color_hex, merged_color_qt) = self._get_next_color()
merged_color = qtawesome.icon(
"fa.circle", color=merged_color_qt)
"fa.circle", color=merged_color_qt
)
merged_item = self._get_merged_model_item(
product_name, len(product_items), merged_color_hex)
merged_item.setData(merged_color, QtCore.Qt.DecorationRole)
new_items.append(merged_item)
merged_product_types = set()
merged_status_names = set()
new_merged_items = []
for product_item in product_items:
item = self._get_product_model_item(
@ -608,9 +605,21 @@ class ProductsModel(QtGui.QStandardItemModel):
)
new_merged_items.append(item)
merged_product_types.add(product_item.product_type)
merged_status_names |= {
version_item.status
for version_item in (
product_item.version_items.values()
)
}
merged_item.setData(
"|".join(merged_product_types), PRODUCT_TYPE_ROLE)
"|".join(merged_product_types),
PRODUCT_TYPE_ROLE
)
merged_item.setData(
"|".join(merged_status_names),
STATUS_NAME_FILTER_ROLE
)
if new_merged_items:
merged_item.appendRows(new_merged_items)

View file

@ -186,11 +186,12 @@ class ProductsWidget(QtWidgets.QWidget):
products_proxy_model.rowsInserted.connect(self._on_rows_inserted)
products_proxy_model.rowsMoved.connect(self._on_rows_moved)
products_model.refreshed.connect(self._on_refresh)
products_model.version_changed.connect(self._on_version_change)
products_view.customContextMenuRequested.connect(
self._on_context_menu)
products_view.selectionModel().selectionChanged.connect(
products_view_sel_model = products_view.selectionModel()
products_view_sel_model.selectionChanged.connect(
self._on_selection_change)
products_model.version_changed.connect(self._on_version_change)
version_delegate.version_changed.connect(
self._on_version_delegate_change
)
@ -321,8 +322,7 @@ class ProductsWidget(QtWidgets.QWidget):
def _refresh_model(self):
self._products_model.refresh(
self._selected_project_name,
self._selected_folder_ids,
self._products_proxy_model.get_statuses_filter()
self._selected_folder_ids
)
def _on_context_menu(self, point):

View file

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

View file

@ -217,10 +217,7 @@ class InventoryModel(QtGui.QStandardItemModel):
version_item = version_items[repre_info.version_id]
version_label = format_version(version_item.version)
is_hero = version_item.version < 0
is_latest = version_item.is_latest
# TODO maybe use different colors for last approved and last
# version? Or don't care about color at all?
if not is_latest and not version_item.is_last_approved:
if not version_item.is_latest:
version_color = self.OUTDATED_COLOR
status_name = version_item.status

View file

@ -8,7 +8,7 @@ from datetime import datetime
import websocket
from ayon_core.lib import Logger
from ayon_core.modules.webserver import HostMsgAction
from ayon_core.tools.tray import HostMsgAction
log = Logger.get_logger(__name__)

View file

@ -1,6 +1,21 @@
from .tray import main
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",
"TrayState",
"get_tray_state",
"is_tray_running",
"get_tray_server_url",
"make_sure_tray_is_running",
"main",
)

View file

@ -0,0 +1,658 @@
import os
import sys
import json
import hashlib
import platform
import subprocess
import csv
import time
import signal
import locale
from typing import Optional, Dict, Tuple, Any
import requests
from ayon_api.utils import get_default_settings_variant
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
class TrayState:
NOT_RUNNING = 0
STARTING = 1
RUNNING = 2
class TrayIsRunningError(Exception):
pass
def _get_default_server_url() -> str:
"""Get default AYON server url."""
return os.getenv("AYON_SERVER_URL")
def _get_default_variant() -> str:
"""Get default settings variant."""
return get_default_settings_variant()
def _get_server_and_variant(
server_url: Optional[str] = None,
variant: Optional[str] = None
) -> Tuple[str, str]:
if not server_url:
server_url = _get_default_server_url()
if not variant:
variant = _get_default_variant()
return server_url, variant
def _windows_pid_is_running(pid: int) -> bool:
args = ["tasklist.exe", "/fo", "csv", "/fi", f"PID eq {pid}"]
output = subprocess.check_output(args)
encoding = locale.getpreferredencoding()
csv_content = csv.DictReader(output.decode(encoding).splitlines())
# if "PID" not in csv_content.fieldnames:
# return False
for _ in csv_content:
return True
return False
def _is_process_running(pid: int) -> bool:
"""Check whether process with pid is running."""
if platform.system().lower() == "windows":
return _windows_pid_is_running(pid)
if pid == 0:
return True
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _kill_tray_process(pid: int):
if _is_process_running(pid):
os.kill(pid, signal.SIGTERM)
def _create_tray_hash(server_url: str, variant: str) -> str:
"""Create tray hash for metadata filename.
Args:
server_url (str): AYON server url.
variant (str): Settings variant.
Returns:
str: Hash for metadata filename.
"""
data = f"{server_url}|{variant}"
return hashlib.sha256(data.encode()).hexdigest()
def _wait_for_starting_tray(
server_url: Optional[str] = None,
variant: Optional[str] = None,
timeout: Optional[int] = None
) -> Optional[Dict[str, Any]]:
"""Wait for tray to start.
Args:
server_url (Optional[str]): AYON server url.
variant (Optional[str]): Settings variant.
timeout (Optional[int]): Timeout for tray validation.
Returns:
Optional[Dict[str, Any]]: Tray file information.
"""
if timeout is None:
timeout = 10
started_at = time.time()
while True:
data = get_tray_file_info(server_url, variant)
if data is None:
return None
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)
def get_tray_storage_dir() -> str:
"""Get tray storage directory.
Returns:
str: Tray storage directory where metadata files are stored.
"""
return get_ayon_appdirs("tray")
def _get_tray_info_filepath(
server_url: Optional[str] = None,
variant: Optional[str] = None
) -> str:
hash_dir = get_tray_storage_dir()
server_url, variant = _get_server_and_variant(server_url, variant)
filename = _create_tray_hash(server_url, variant)
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
) -> Optional[Dict[str, Any]]:
"""Get tray information from file.
Metadata information about running tray that should contain tray
server url.
Args:
server_url (Optional[str]): AYON server url.
variant (Optional[str]): Settings variant.
Returns:
Optional[Dict[str, Any]]: Tray information.
"""
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:
response = requests.get(f"{tray_url}/tray")
response.raise_for_status()
return response.json()
except (requests.HTTPError, requests.ConnectionError):
return None
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(
validate: Optional[bool] = False,
server_url: Optional[str] = None,
variant: Optional[str] = None,
timeout: Optional[int] = None
) -> Optional[str]:
"""Get tray server url.
Does not validate if tray is running.
Args:
server_url (Optional[str]): AYON server url.
variant (Optional[str]): Settings variant.
validate (Optional[bool]): Validate if tray is running.
By default, does not validate.
timeout (Optional[int]): Timeout for tray start-up.
Returns:
Optional[str]: Tray server url.
"""
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):
"""Add tray server information file.
Called from tray logic, do not use on your own.
Args:
tray_url (Optional[str]): Webserver url with port.
started (bool): If tray is started. When set to 'False' it means
that tray is starting up.
"""
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)
data = {
"url": tray_url,
"pid": os.getpid(),
"started": started
}
with open(filepath, "w") as stream:
json.dump(data, stream)
def remove_tray_server_url(force: Optional[bool] = False):
"""Remove tray information file.
Called from tray logic, do not use on your own.
Args:
force (Optional[bool]): Force remove tray information file.
"""
filepath = _get_tray_info_filepath()
if not os.path.exists(filepath):
return
try:
with open(filepath, "r") as stream:
data = json.load(stream)
except BaseException:
data = {}
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,
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:
TrayInfo: Tray information.
"""
return TrayInfo.new(server_url, variant, timeout)
def get_tray_state(
server_url: Optional[str] = None,
variant: Optional[str] = None
) -> int:
"""Get tray state for AYON server and variant.
Args:
server_url (Optional[str]): AYON server url.
variant (Optional[str]): Settings variant.
Returns:
int: Tray state.
"""
tray_info = get_tray_information(server_url, variant)
return tray_info.state
def is_tray_running(
server_url: Optional[str] = None,
variant: Optional[str] = None
) -> bool:
"""Check if tray is running.
Args:
server_url (Optional[str]): AYON server url.
variant (Optional[str]): Settings variant.
Returns:
bool: True if tray is running
"""
state = get_tray_state(server_url, variant)
return state != TrayState.NOT_RUNNING
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")
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)
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.")
tray_info.wait_to_start()
file_state = tray_info.get_file_state()
if file_state == TrayState.RUNNING:
print("Tray started. Exiting.")
return
if file_state == TrayState.STARTING:
print(
"Tray did not start in expected time."
" Killing the process and starting new."
)
pid = tray_info.pid
if pid is not None:
_kill_tray_process(pid)
remove_tray_server_url(force=True)
# Prepare the file with 'pid' information as soon as possible
try:
set_tray_server_url(None, False)
except TrayIsRunningError:
print("Tray is running")
sys.exit(1)
main()

View file

@ -0,0 +1,6 @@
from .tray import main
__all__ = (
"main",
)

View file

@ -0,0 +1,247 @@
import os
import time
from typing import Callable
from ayon_core.addon import AddonsManager, ITrayAddon, ITrayService
from ayon_core.tools.tray.webserver import (
find_free_port,
WebServerManager,
)
class TrayAddonsManager(AddonsManager):
# TODO do not use env variable
webserver_url_env = "AYON_WEBSERVER_URL"
# Define order of addons in menu
# TODO find better way how to define order
addons_menu_order = (
"ftrack",
"kitsu",
"launcher_tool",
"clockify",
)
def __init__(self, tray_manager):
super().__init__(initialize=False)
self._tray_manager = tray_manager
self._webserver_manager = WebServerManager(find_free_port(), None)
self.doubleclick_callbacks = {}
self.doubleclick_callback = None
@property
def webserver_url(self):
return self._webserver_manager.url
def get_doubleclick_callback(self):
callback_name = self.doubleclick_callback
return self.doubleclick_callbacks.get(callback_name)
def add_doubleclick_callback(self, addon, callback):
"""Register double-click callbacks on tray icon.
Currently, there is no way how to determine which is launched. Name of
callback can be defined with `doubleclick_callback` attribute.
Missing feature how to define default callback.
Args:
addon (AYONAddon): Addon object.
callback (FunctionType): Function callback.
"""
callback_name = "_".join([addon.name, callback.__name__])
if callback_name not in self.doubleclick_callbacks:
self.doubleclick_callbacks[callback_name] = callback
if self.doubleclick_callback is None:
self.doubleclick_callback = callback_name
return
self.log.warning((
"Callback with name \"{}\" is already registered."
).format(callback_name))
def initialize(self, tray_menu):
self.initialize_addons()
self.tray_init()
self.connect_addons()
self.tray_menu(tray_menu)
def add_route(self, request_method: str, path: str, handler: Callable):
self._webserver_manager.add_route(request_method, path, handler)
def add_static(self, prefix: str, path: str):
self._webserver_manager.add_static(prefix, path)
def add_addon_route(
self,
addon_name: str,
path: str,
request_method: str,
handler: Callable
) -> str:
return self._webserver_manager.add_addon_route(
addon_name,
path,
request_method,
handler
)
def add_addon_static(
self, addon_name: str, prefix: str, path: str
) -> str:
return self._webserver_manager.add_addon_static(
addon_name,
prefix,
path
)
def get_enabled_tray_addons(self):
"""Enabled tray addons.
Returns:
list[AYONAddon]: Enabled addons that inherit from tray interface.
"""
return [
addon
for addon in self.get_enabled_addons()
if isinstance(addon, ITrayAddon)
]
def restart_tray(self):
if self._tray_manager:
self._tray_manager.restart()
def tray_init(self):
self._init_tray_webserver()
report = {}
time_start = time.time()
prev_start_time = time_start
for addon in self.get_enabled_tray_addons():
try:
addon._tray_manager = self._tray_manager
addon.tray_init()
addon.tray_initialized = True
except Exception:
self.log.warning(
"Addon \"{}\" crashed on `tray_init`.".format(
addon.name
),
exc_info=True
)
now = time.time()
report[addon.__class__.__name__] = now - prev_start_time
prev_start_time = now
if self._report is not None:
report[self._report_total_key] = time.time() - time_start
self._report["Tray init"] = report
def connect_addons(self):
self._webserver_manager.connect_with_addons(
self.get_enabled_addons()
)
super().connect_addons()
def tray_menu(self, tray_menu):
ordered_addons = []
enabled_by_name = {
addon.name: addon
for addon in self.get_enabled_tray_addons()
}
for name in self.addons_menu_order:
addon_by_name = enabled_by_name.pop(name, None)
if addon_by_name:
ordered_addons.append(addon_by_name)
ordered_addons.extend(enabled_by_name.values())
report = {}
time_start = time.time()
prev_start_time = time_start
for addon in ordered_addons:
if not addon.tray_initialized:
continue
try:
addon.tray_menu(tray_menu)
except Exception:
# Unset initialized mark
addon.tray_initialized = False
self.log.warning(
"Addon \"{}\" crashed on `tray_menu`.".format(
addon.name
),
exc_info=True
)
now = time.time()
report[addon.__class__.__name__] = now - prev_start_time
prev_start_time = now
if self._report is not None:
report[self._report_total_key] = time.time() - time_start
self._report["Tray menu"] = report
def start_addons(self):
self._webserver_manager.start_server()
report = {}
time_start = time.time()
prev_start_time = time_start
for addon in self.get_enabled_tray_addons():
if not addon.tray_initialized:
if isinstance(addon, ITrayService):
addon.set_service_failed_icon()
continue
try:
addon.tray_start()
except Exception:
self.log.warning(
"Addon \"{}\" crashed on `tray_start`.".format(
addon.name
),
exc_info=True
)
now = time.time()
report[addon.__class__.__name__] = now - prev_start_time
prev_start_time = now
if self._report is not None:
report[self._report_total_key] = time.time() - time_start
self._report["Addons start"] = report
def on_exit(self):
self._webserver_manager.stop_server()
for addon in self.get_enabled_tray_addons():
if addon.tray_initialized:
try:
addon.tray_exit()
except Exception:
self.log.warning(
"Addon \"{}\" crashed on `tray_exit`.".format(
addon.name
),
exc_info=True
)
def get_tray_webserver(self):
# TODO rename/remove method
return self._webserver_manager
def _init_tray_webserver(self):
webserver_url = self.webserver_url
statics_url = f"{webserver_url}/res"
# TODO stop using these env variables
# - function 'get_tray_server_url' should be used instead
os.environ[self.webserver_url_env] = webserver_url
os.environ["AYON_STATICS_SERVER"] = statics_url
# Deprecated
os.environ["OPENPYPE_WEBSERVER_URL"] = webserver_url
os.environ["OPENPYPE_STATICS_SERVER"] = statics_url

View file

@ -9,7 +9,7 @@ from qtpy import QtWidgets
from ayon_core.addon import ITrayService
from ayon_core.tools.stdout_broker.window import ConsoleDialog
from .structures import HostMsgAction
from ayon_core.tools.tray import HostMsgAction
log = logging.getLogger(__name__)
@ -22,18 +22,19 @@ class IconType:
class HostListener:
def __init__(self, webserver, module):
self._window_per_id = {}
self.module = module
self.webserver = webserver
def __init__(self, addons_manager, tray_manager):
self._tray_manager = tray_manager
self._window_per_id = {} # dialogs per host name
self._action_per_id = {} # QAction per host name
webserver.add_route('*', "/ws/host_listener", self.websocket_handler)
addons_manager.add_route(
"*", "/ws/host_listener", self.websocket_handler
)
def _host_is_connecting(self, host_name, label):
""" Initialize dialog, adds to submenu. """
services_submenu = self.module._services_submenu
""" Initialize dialog, adds to submenu."""
ITrayService.services_submenu(self._tray_manager)
services_submenu = self._tray_manager.get_services_submenu()
action = QtWidgets.QAction(label, services_submenu)
action.triggered.connect(lambda: self.show_widget(host_name))
@ -73,8 +74,9 @@ class HostListener:
Dialog get initialized when 'host_name' is connecting.
"""
self.module.execute_in_main_thread(
lambda: self._show_widget(host_name))
self._tray_manager.execute_in_main_thread(
self._show_widget, host_name
)
def _show_widget(self, host_name):
widget = self._window_per_id[host_name]
@ -95,21 +97,23 @@ class HostListener:
if action == HostMsgAction.CONNECTING:
self._action_per_id[host_name] = None
# must be sent to main thread, or action wont trigger
self.module.execute_in_main_thread(
lambda: self._host_is_connecting(host_name, text))
self._tray_manager.execute_in_main_thread(
self._host_is_connecting, host_name, text
)
elif action == HostMsgAction.CLOSE:
# clean close
self._close(host_name)
await ws.close()
elif action == HostMsgAction.INITIALIZED:
self.module.execute_in_main_thread(
self._tray_manager.execute_in_main_thread(
# must be queued as _host_is_connecting might not
# be triggered/finished yet
lambda: self._set_host_icon(host_name,
IconType.RUNNING))
self._set_host_icon, host_name, IconType.RUNNING
)
elif action == HostMsgAction.ADD:
self.module.execute_in_main_thread(
lambda: self._add_text(host_name, text))
self._tray_manager.execute_in_main_thread(
self._add_text, host_name, text
)
elif msg.type == aiohttp.WSMsgType.ERROR:
print('ws connection closed with exception %s' %
ws.exception())
@ -131,7 +135,7 @@ class HostListener:
def _close(self, host_name):
""" Clean close - remove from menu, delete widget."""
services_submenu = self.module._services_submenu
services_submenu = self._tray_manager.get_services_submenu()
action = self._action_per_id.pop(host_name)
services_submenu.removeAction(action)
widget = self._window_per_id.pop(host_name)

View file

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Before After
Before After

View file

@ -1,12 +1,13 @@
import os
import sys
import time
import collections
import atexit
import platform
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 (
@ -21,13 +22,19 @@ from ayon_core.settings import get_studio_settings
from ayon_core.addon import (
ITrayAction,
ITrayService,
TrayAddonsManager,
)
from ayon_core.tools.utils import (
WrappedCallbackItem,
get_ayon_qt_app,
)
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 (
UpdateDialog,
@ -54,25 +61,55 @@ class TrayManager:
)
if update_check_interval is None:
update_check_interval = 5
self._update_check_interval = update_check_interval * 60 * 1000
self._addons_manager = TrayAddonsManager()
update_check_interval = update_check_interval * 60 * 1000
# create timer loop to check callback functions
main_thread_timer = QtCore.QTimer()
main_thread_timer.setInterval(300)
update_check_timer = QtCore.QTimer()
if update_check_interval > 0:
update_check_timer.setInterval(update_check_interval)
main_thread_timer.timeout.connect(self._main_thread_execution)
update_check_timer.timeout.connect(self._on_update_check_timer)
self._addons_manager = TrayAddonsManager(self)
self._host_listener = HostListener(self._addons_manager, self)
self.errors = []
self._update_check_timer = None
self._outdated_dialog = None
self._main_thread_timer = None
self._update_check_timer = update_check_timer
self._update_check_interval = update_check_interval
self._main_thread_timer = main_thread_timer
self._main_thread_callbacks = collections.deque()
self._execution_in_progress = None
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(
self._addons_manager.webserver_url, False
)
except TrayIsRunningError:
self.log.error("Tray is already running.")
self._closing = True
def is_closing(self):
return self._closing
@property
def doubleclick_callback(self):
"""Double-click callback for Tray icon."""
callback_name = self._addons_manager.doubleclick_callback
return self._addons_manager.doubleclick_callbacks.get(callback_name)
return self._addons_manager.get_doubleclick_callback()
def execute_doubleclick(self):
"""Execute double click callback in main thread."""
@ -99,53 +136,71 @@ 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."""
if self._closing:
return
self._addons_manager.initialize(self, self.tray_widget.menu)
tray_menu = self.tray_widget.menu
self._addons_manager.initialize(tray_menu)
admin_submenu = ITrayAction.admin_submenu(self.tray_widget.menu)
self.tray_widget.menu.addMenu(admin_submenu)
self._addons_manager.add_route(
"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)
tray_menu.addMenu(admin_submenu)
# Add services if they are
services_submenu = ITrayService.services_submenu(
self.tray_widget.menu
)
self.tray_widget.menu.addMenu(services_submenu)
services_submenu = ITrayService.services_submenu(tray_menu)
self._services_submenu = services_submenu
tray_menu.addMenu(services_submenu)
# Add separator
self.tray_widget.menu.addSeparator()
tray_menu.addSeparator()
self._add_version_item()
# Add Exit action to menu
exit_action = QtWidgets.QAction("Exit", self.tray_widget)
exit_action.triggered.connect(self.tray_widget.exit)
self.tray_widget.menu.addAction(exit_action)
tray_menu.addAction(exit_action)
# Tell each addon which addons were imported
self._addons_manager.start_addons()
# TODO Capture only webserver issues (the only thing that can crash).
try:
self._addons_manager.start_addons()
except Exception:
self.log.error(
"Failed to start addons.",
exc_info=True
)
return self.exit()
# Print time report
self._addons_manager.print_report()
# create timer loop to check callback functions
main_thread_timer = QtCore.QTimer()
main_thread_timer.setInterval(300)
main_thread_timer.timeout.connect(self._main_thread_execution)
main_thread_timer.start()
self._main_thread_timer.start()
self._main_thread_timer = main_thread_timer
update_check_timer = QtCore.QTimer()
if self._update_check_interval > 0:
update_check_timer.timeout.connect(self._on_update_check_timer)
update_check_timer.setInterval(self._update_check_interval)
update_check_timer.start()
self._update_check_timer = update_check_timer
self._update_check_timer.start()
self.execute_in_main_thread(self._startup_validations)
try:
set_tray_server_url(
self._addons_manager.webserver_url, True
)
except TrayIsRunningError:
self.log.warning("Other tray started meanwhile. Exiting.")
self.exit()
def get_services_submenu(self):
return self._services_submenu
def restart(self):
"""Restart Tray tool.
@ -207,9 +262,13 @@ class TrayManager:
def exit(self):
self._closing = True
self.tray_widget.exit()
if self._main_thread_timer.isActive():
self.execute_in_main_thread(self.tray_widget.exit)
else:
self.tray_widget.exit()
def on_exit(self):
remove_tray_server_url()
self._addons_manager.on_exit()
def execute_in_main_thread(self, callback, *args, **kwargs):
@ -222,6 +281,53 @@ class TrayManager:
return item
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(),
"addons": {
addon.name: addon.version
for addon in self._addons_manager.get_enabled_addons()
},
"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:
bundles = ayon_api.get_bundles()
@ -298,20 +404,24 @@ class TrayManager:
)
def _main_thread_execution(self):
if self._execution_in_progress:
return
self._execution_in_progress = True
for _ in range(len(self._main_thread_callbacks)):
if self._main_thread_callbacks:
item = self._main_thread_callbacks.popleft()
try:
item.execute()
except BaseException:
self.log.erorr(
"Main thread execution failed", exc_info=True
)
try:
if self._execution_in_progress:
return
self._execution_in_progress = True
for _ in range(len(self._main_thread_callbacks)):
if self._main_thread_callbacks:
item = self._main_thread_callbacks.popleft()
try:
item.execute()
except BaseException:
self.log.erorr(
"Main thread execution failed", exc_info=True
)
self._execution_in_progress = False
self._execution_in_progress = False
except KeyboardInterrupt:
self.execute_in_main_thread(self.exit)
def _startup_validations(self):
"""Run possible startup validations."""
@ -319,9 +429,10 @@ class TrayManager:
self._update_check_timer.timeout.emit()
def _add_version_item(self):
tray_menu = self.tray_widget.menu
login_action = QtWidgets.QAction("Login", self.tray_widget)
login_action.triggered.connect(self._on_ayon_login)
self.tray_widget.menu.addAction(login_action)
tray_menu.addAction(login_action)
version_string = os.getenv("AYON_VERSION", "AYON Info")
version_action = QtWidgets.QAction(version_string, self.tray_widget)
@ -333,9 +444,9 @@ class TrayManager:
restart_action.triggered.connect(self._on_restart_action)
restart_action.setVisible(False)
self.tray_widget.menu.addAction(version_action)
self.tray_widget.menu.addAction(restart_action)
self.tray_widget.menu.addSeparator()
tray_menu.addAction(version_action)
tray_menu.addAction(restart_action)
tray_menu.addSeparator()
self._restart_action = restart_action
@ -424,19 +535,23 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon):
def __init__(self, parent):
icon = QtGui.QIcon(resources.get_ayon_icon_filepath())
super(SystemTrayIcon, self).__init__(icon, parent)
super().__init__(icon, parent)
self._exited = False
self._doubleclick = False
self._click_pos = None
self._initializing_addons = False
# Store parent - QtWidgets.QMainWindow()
self.parent = parent
self._parent = parent
# Setup menu in Tray
self.menu = QtWidgets.QMenu()
self.menu.setStyleSheet(style.load_stylesheet())
# Set addons
self.tray_man = TrayManager(self, self.parent)
self._tray_manager = TrayManager(self, parent)
# Add menu to Context of SystemTrayIcon
self.setContextMenu(self.menu)
@ -456,10 +571,9 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon):
click_timer.timeout.connect(self._click_timer_timeout)
self._click_timer = click_timer
self._doubleclick = False
self._click_pos = None
self._initializing_addons = False
def is_closing(self) -> bool:
return self._tray_manager.is_closing()
@property
def initializing_addons(self):
@ -468,7 +582,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon):
def initialize_addons(self):
self._initializing_addons = True
try:
self.tray_man.initialize_addons()
self._tray_manager.initialize_addons()
finally:
self._initializing_addons = False
@ -478,7 +592,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon):
# Reset bool value
self._doubleclick = False
if doubleclick:
self.tray_man.execute_doubleclick()
self._tray_manager.execute_doubleclick()
else:
self._show_context_menu()
@ -492,7 +606,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon):
def on_systray_activated(self, reason):
# show contextMenu if left click
if reason == QtWidgets.QSystemTrayIcon.Trigger:
if self.tray_man.doubleclick_callback:
if self._tray_manager.doubleclick_callback:
self._click_pos = QtGui.QCursor().pos()
self._click_timer.start()
else:
@ -511,7 +625,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon):
self._exited = True
self.hide()
self.tray_man.on_exit()
self._tray_manager.on_exit()
QtCore.QCoreApplication.exit()
@ -536,6 +650,11 @@ class TrayStarter(QtCore.QObject):
self._start_timer = start_timer
def _on_start_timer(self):
if self._tray_widget.is_closing():
self._start_timer.stop()
self._tray_widget.exit()
return
if self._timer_counter == 0:
self._timer_counter += 1
splash = self._get_splash()

View file

@ -0,0 +1,9 @@
from .base_routes import RestApiEndpoint
from .server import find_free_port, WebServerManager
__all__ = (
"RestApiEndpoint",
"find_free_port",
"WebServerManager",
)

View file

@ -1,7 +1,6 @@
"""Helper functions or classes for Webserver module.
These must not be imported in module itself to not break Python 2
applications.
These must not be imported in module itself to not break in-DCC process.
"""
import inspect

View file

@ -1,24 +1,85 @@
import re
import threading
import asyncio
import socket
import random
from typing import Callable, Optional
from aiohttp import web
from ayon_core.lib import Logger
from ayon_core.resources import RESOURCES_DIR
from .cors_middleware import cors_middleware
def find_free_port(
port_from=None, port_to=None, exclude_ports=None, host=None
):
"""Find available socket port from entered range.
It is also possible to only check if entered port is available.
Args:
port_from (int): Port number which is checked as first.
port_to (int): Last port that is checked in sequence from entered
`port_from`. Only `port_from` is checked if is not entered.
Nothing is processed if is equeal to `port_from`!
exclude_ports (list, tuple, set): List of ports that won't be
checked form entered range.
host (str): Host where will check for free ports. Set to
"localhost" by default.
"""
if port_from is None:
port_from = 8079
if port_to is None:
port_to = 65535
# Excluded ports (e.g. reserved for other servers/clients)
if exclude_ports is None:
exclude_ports = []
# Default host is localhost but it is possible to look for other hosts
if host is None:
host = "localhost"
found_port = None
while True:
port = random.randint(port_from, port_to)
if port in exclude_ports:
continue
sock = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind((host, port))
found_port = port
except socket.error:
continue
finally:
if sock:
sock.close()
if found_port is not None:
break
return found_port
class WebServerManager:
"""Manger that care about web server thread."""
def __init__(self, port=None, host=None):
def __init__(
self, port: Optional[int] = None, host: Optional[str] = None
):
self._log = None
self.port = port or 8079
self.host = host or "localhost"
self.client = None
self.handlers = {}
self.on_stop_callbacks = []
self.app = web.Application(
@ -30,9 +91,10 @@ class WebServerManager:
)
# add route with multiple methods for single "external app"
self.webserver_thread = WebServerThread(self)
self.add_static("/res", RESOURCES_DIR)
@property
def log(self):
if self._log is None:
@ -40,14 +102,46 @@ class WebServerManager:
return self._log
@property
def url(self):
return "http://{}:{}".format(self.host, self.port)
def url(self) -> str:
return f"http://{self.host}:{self.port}"
def add_route(self, *args, **kwargs):
self.app.router.add_route(*args, **kwargs)
def add_route(self, request_method: str, path: str, handler: Callable):
self.app.router.add_route(request_method, path, handler)
def add_static(self, *args, **kwargs):
self.app.router.add_static(*args, **kwargs)
def add_static(self, prefix: str, path: str):
self.app.router.add_static(prefix, path)
def add_addon_route(
self,
addon_name: str,
path: str,
request_method: str,
handler: Callable
) -> str:
path = path.lstrip("/")
full_path = f"/addons/{addon_name}/{path}"
self.app.router.add_route(request_method, full_path, handler)
return full_path
def add_addon_static(
self, addon_name: str, prefix: str, path: str
) -> str:
full_path = f"/addons/{addon_name}/{prefix}"
self.app.router.add_static(full_path, path)
return full_path
def connect_with_addons(self, addons):
for addon in addons:
if not hasattr(addon, "webserver_initialization"):
continue
try:
addon.webserver_initialization(self)
except Exception:
self.log.warning(
f"Failed to connect addon \"{addon.name}\" to webserver.",
exc_info=True
)
def start_server(self):
if self.webserver_thread and not self.webserver_thread.is_alive():
@ -68,7 +162,7 @@ class WebServerManager:
)
@property
def is_running(self):
def is_running(self) -> bool:
if not self.webserver_thread:
return False
return self.webserver_thread.is_running

View file

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

View file

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

View file

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

View file

@ -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": [],
}

View file

@ -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 acc",
"-b:a 192k",
"-g 1",
"-movflags faststart"
]
},
"filter": {

View file

@ -195,6 +195,7 @@ def _product_types_enum():
"editorial",
"gizmo",
"image",
"imagesequence",
"layout",
"look",
"matchmove",
@ -212,7 +213,6 @@ def _product_types_enum():
"setdress",
"take",
"usd",
"usdShade",
"vdbcache",
"vrayproxy",
"workfile",
@ -222,6 +222,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 +238,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 +512,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": [