Merge branch 'develop' into enhancement/usd_workflow_use_entity_uri

This commit is contained in:
Roy Nieterau 2024-07-25 14:17:04 +02:00 committed by GitHub
commit 0ccb7d6541
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
213 changed files with 2682 additions and 18444 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

@ -11,7 +11,6 @@ from .interfaces import (
from .base import (
AYONAddon,
AddonsManager,
TrayAddonsManager,
load_addons,
)
@ -27,6 +26,5 @@ __all__ = (
"AYONAddon",
"AddonsManager",
"TrayAddonsManager",
"load_addons",
)

View file

@ -9,9 +9,8 @@ import logging
import threading
import collections
from uuid import uuid4
from abc import ABCMeta, abstractmethod
from abc import ABC, abstractmethod
import six
import appdirs
import ayon_api
from semver import VersionInfo
@ -23,8 +22,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
@ -499,8 +496,7 @@ def is_func_marked(func):
return getattr(func, _MARKING_ATTR, False)
@six.add_metaclass(ABCMeta)
class AYONAddon(object):
class AYONAddon(ABC):
"""Base class of AYON addon.
Attributes:
@ -925,20 +921,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(
@ -947,7 +943,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:
@ -1340,185 +1336,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

@ -1,7 +1,5 @@
from abc import ABCMeta, abstractmethod
import six
from ayon_core import resources
@ -15,8 +13,7 @@ class _AYONInterfaceMeta(ABCMeta):
return str(self)
@six.add_metaclass(_AYONInterfaceMeta)
class AYONInterface:
class AYONInterface(metaclass=_AYONInterfaceMeta):
"""Base class of Interface that can be used as Mixin with abstract parts.
This is way how AYON addon can define that contains specific predefined

View file

@ -12,7 +12,11 @@ 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
@ -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.
@ -103,23 +117,30 @@ def extractenvironments(output_json_path, project, asset, task, app, envgroup):
'addon applications extractenvironments ...' instead.
"""
Commands.extractenvironments(
output_json_path, project, asset, task, app, envgroup
output_json_path,
project,
asset,
task,
app,
envgroup,
ctx.obj["addons_manager"]
)
@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, gui):
"""Start CLI publishing.
Publish collects json from path provided as an argument.
S
"""
Commands.publish(path, targets, gui)
Commands.publish(path, targets, gui, ctx.obj["addons_manager"])
@main_cli.command(context_settings={"ignore_unknown_options": True})
@ -245,11 +266,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 +277,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 +315,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

@ -3,6 +3,9 @@
import os
import sys
import warnings
from typing import Optional, List
from ayon_core.addon import AddonsManager
class Commands:
@ -11,45 +14,21 @@ class Commands:
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:
def publish(
path: str,
targets: Optional[List[str]] = None,
gui: Optional[bool] = False,
addons_manager: Optional[AddonsManager] = None,
) -> None:
"""Start headless publishing.
Publish use json from passed path argument.
Args:
path (str): Path to JSON.
targets (list of str): List of pyblish targets.
gui (bool): Show publish UI.
targets (Optional[List[str]]): List of pyblish targets.
gui (Optional[bool]): Show publish UI.
addons_manager (Optional[AddonsManager]): Addons manager instance.
Raises:
RuntimeError: When there is no path to process.
@ -102,14 +81,15 @@ class Commands:
install_ayon_plugins()
manager = AddonsManager()
if addons_manager is None:
addons_manager = AddonsManager()
publish_paths = manager.collect_plugin_paths()["publish"]
publish_paths = addons_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")
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(
@ -158,15 +138,12 @@ class Commands:
@staticmethod
def extractenvironments(
output_json_path, project, asset, task, app, env_group
output_json_path, project, asset, task, app, env_group, addons_manager
):
"""Produces json file with environment based on project and app.
Called by Deadline plugin to propagate environment into render jobs.
"""
from ayon_core.addon import AddonsManager
warnings.warn(
(
"Command 'extractenvironments' is deprecated and will be"
@ -176,7 +153,6 @@ class Commands:
DeprecationWarning
)
addons_manager = AddonsManager()
applications_addon = addons_manager.get_enabled_addon("applications")
if applications_addon is None:
raise RuntimeError(

View file

@ -7,18 +7,15 @@ exists is used.
"""
import os
from abc import ABCMeta, abstractmethod
from abc import ABC, abstractmethod
import platform
import six
from ayon_core.lib import Logger
from ayon_core.addon import AddonsManager
from ayon_core.settings import get_project_settings
@six.add_metaclass(ABCMeta)
class HostDirmap(object):
class HostDirmap(ABC):
"""Abstract class for running dirmap on a workfile in a host.
Dirmap is used to translate paths inside of host workfile from one
@ -181,25 +178,23 @@ class HostDirmap(object):
cached=False)
# overrides for roots set in `Site Settings`
active_roots = sitesync_addon.get_site_root_overrides(
project_name, active_site)
remote_roots = sitesync_addon.get_site_root_overrides(
project_name, remote_site)
active_roots_overrides = self._get_site_root_overrides(
sitesync_addon, project_name, active_site)
self.log.debug("active roots overrides {}".format(active_roots))
self.log.debug("remote roots overrides {}".format(remote_roots))
remote_roots_overrides = self._get_site_root_overrides(
sitesync_addon, project_name, remote_site)
current_platform = platform.system().lower()
remote_provider = sitesync_addon.get_provider_for_site(
project_name, remote_site
)
# dirmap has sense only with regular disk provider, in the workfile
# won't be root on cloud or sftp provider
# won't be root on cloud or sftp provider so fallback to studio
if remote_provider != "local_drive":
remote_site = "studio"
for root_name, active_site_dir in active_roots.items():
for root_name, active_site_dir in active_roots_overrides.items():
remote_site_dir = (
remote_roots.get(root_name)
remote_roots_overrides.get(root_name)
or sync_settings["sites"][remote_site]["root"][root_name]
)
@ -220,3 +215,22 @@ class HostDirmap(object):
self.log.debug("local sync mapping:: {}".format(mapping))
return mapping
def _get_site_root_overrides(
self, sitesync_addon, project_name, site_name
):
"""Safely handle root overrides.
SiteSync raises ValueError for non local or studio sites.
"""
# TODO: could be removed when `get_site_root_overrides` is not raising
# an Error but just returns {}
try:
site_roots_overrides = sitesync_addon.get_site_root_overrides(
project_name, site_name)
except ValueError:
site_roots_overrides = {}
self.log.debug("{} roots overrides {}".format(
site_name, site_roots_overrides))
return site_roots_overrides

View file

@ -1,15 +1,13 @@
import os
import logging
import contextlib
from abc import ABCMeta, abstractproperty
import six
from abc import ABC, abstractproperty
# NOTE can't import 'typing' because of issues in Maya 2020
# - shiboken crashes on 'typing' module import
@six.add_metaclass(ABCMeta)
class HostBase(object):
class HostBase(ABC):
"""Base of host implementation class.
Host is pipeline implementation of DCC application. This class should help

View file

@ -1,5 +1,4 @@
from abc import ABCMeta, abstractmethod
import six
from abc import ABC, abstractmethod
class MissingMethodsError(ValueError):
@ -106,8 +105,7 @@ class ILoadHost:
return self.get_containers()
@six.add_metaclass(ABCMeta)
class IWorkfileHost:
class IWorkfileHost(ABC):
"""Implementation requirements to be able use workfile utils and tool."""
@staticmethod

View file

@ -6,7 +6,6 @@ import json
import copy
from abc import ABCMeta, abstractmethod, abstractproperty
import six
import clique
# Global variable which store attribute definitions by type
@ -91,8 +90,7 @@ class AbstractAttrDefMeta(ABCMeta):
return obj
@six.add_metaclass(AbstractAttrDefMeta)
class AbstractAttrDef(object):
class AbstractAttrDef(metaclass=AbstractAttrDefMeta):
"""Abstraction of attribute definition.
Each attribute definition must have implemented validation and
@ -349,7 +347,7 @@ class NumberDef(AbstractAttrDef):
)
def convert_value(self, value):
if isinstance(value, six.string_types):
if isinstance(value, str):
try:
value = float(value)
except Exception:
@ -396,12 +394,12 @@ class TextDef(AbstractAttrDef):
if multiline is None:
multiline = False
elif not isinstance(default, six.string_types):
elif not isinstance(default, str):
raise TypeError((
"'default' argument must be a {}, not '{}'"
).format(six.string_types, type(default)))
f"'default' argument must be a str, not '{type(default)}'"
))
if isinstance(regex, six.string_types):
if isinstance(regex, str):
regex = re.compile(regex)
self.multiline = multiline
@ -418,7 +416,7 @@ class TextDef(AbstractAttrDef):
)
def convert_value(self, value):
if isinstance(value, six.string_types):
if isinstance(value, str):
return value
return self.default
@ -736,7 +734,7 @@ class FileDefItem(object):
else:
output.append(item)
elif isinstance(item, six.string_types):
elif isinstance(item, str):
str_filepaths.append(item)
else:
raise TypeError(
@ -844,7 +842,7 @@ class FileDef(AbstractAttrDef):
if isinstance(default, dict):
FileDefItem.from_dict(default)
elif isinstance(default, six.string_types):
elif isinstance(default, str):
default = FileDefItem.from_paths([default.strip()])[0]
else:
@ -883,14 +881,14 @@ class FileDef(AbstractAttrDef):
)
def convert_value(self, value):
if isinstance(value, six.string_types) or isinstance(value, dict):
if isinstance(value, (str, dict)):
value = [value]
if isinstance(value, (tuple, list, set)):
string_paths = []
dict_items = []
for item in value:
if isinstance(item, six.string_types):
if isinstance(item, str):
string_paths.append(item.strip())
elif isinstance(item, dict):
try:

View file

@ -2,7 +2,6 @@ import os
import logging
import sys
import errno
import six
from ayon_core.lib import create_hard_link
@ -158,11 +157,13 @@ class FileTransaction(object):
def rollback(self):
errors = 0
last_exc = None
# Rollback any transferred files
for path in self._transferred:
try:
os.remove(path)
except OSError:
except OSError as exc:
last_exc = exc
errors += 1
self.log.error(
"Failed to rollback created file: {}".format(path),
@ -172,7 +173,8 @@ class FileTransaction(object):
for backup, original in self._backup_to_original.items():
try:
os.rename(backup, original)
except OSError:
except OSError as exc:
last_exc = exc
errors += 1
self.log.error(
"Failed to restore original file: {} -> {}".format(
@ -183,7 +185,7 @@ class FileTransaction(object):
self.log.error(
"{} errors occurred during rollback.".format(errors),
exc_info=True)
six.reraise(*sys.exc_info())
raise last_exc
@property
def transferred(self):
@ -200,11 +202,9 @@ class FileTransaction(object):
try:
os.makedirs(dirname)
except OSError as e:
if e.errno == errno.EEXIST:
pass
else:
if e.errno != errno.EEXIST:
self.log.critical("An unexpected error occurred.")
six.reraise(*sys.exc_info())
raise e
def _same_paths(self, src, dst):
# handles same paths but with C:/project vs c:/project

View file

@ -4,7 +4,7 @@ import os
import json
import platform
from datetime import datetime
from abc import ABCMeta, abstractmethod
from abc import ABC, abstractmethod
# disable lru cache in Python 2
try:
@ -24,7 +24,6 @@ try:
except ImportError:
import ConfigParser as configparser
import six
import appdirs
import ayon_api
@ -133,8 +132,7 @@ class AYONSecureRegistry:
keyring.delete_password(self._name, name)
@six.add_metaclass(ABCMeta)
class ASettingRegistry():
class ASettingRegistry(ABC):
"""Abstract class defining structure of **SettingRegistry** class.
It is implementing methods to store secure items into keyring, otherwise

View file

@ -2,8 +2,6 @@ import os
import re
import numbers
import six
KEY_PATTERN = re.compile(r"(\{.*?[^{0]*\})")
KEY_PADDING_PATTERN = re.compile(r"([^:]+)\S+[><]\S+")
SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)")
@ -14,7 +12,7 @@ class TemplateUnsolved(Exception):
"""Exception for unsolved template when strict is set to True."""
msg = "Template \"{0}\" is unsolved.{1}{2}"
invalid_types_msg = " Keys with invalid DataType: `{0}`."
invalid_types_msg = " Keys with invalid data type: `{0}`."
missing_keys_msg = " Missing keys: \"{0}\"."
def __init__(self, template, missing_keys, invalid_types):
@ -43,7 +41,7 @@ class TemplateUnsolved(Exception):
class StringTemplate(object):
"""String that can be formatted."""
def __init__(self, template):
if not isinstance(template, six.string_types):
if not isinstance(template, str):
raise TypeError("<{}> argument must be a string, not {}.".format(
self.__class__.__name__, str(type(template))
))
@ -63,7 +61,7 @@ class StringTemplate(object):
new_parts = []
for part in parts:
if not isinstance(part, six.string_types):
if not isinstance(part, str):
new_parts.append(part)
continue
@ -113,7 +111,7 @@ class StringTemplate(object):
"""
result = TemplatePartResult()
for part in self._parts:
if isinstance(part, six.string_types):
if isinstance(part, str):
result.add_output(part)
else:
part.format(data, result)
@ -176,7 +174,7 @@ class StringTemplate(object):
value = "<>"
elif (
len(parts) == 1
and isinstance(parts[0], six.string_types)
and isinstance(parts[0], str)
):
value = "<{}>".format(parts[0])
else:
@ -200,8 +198,9 @@ class StringTemplate(object):
new_parts.extend(tmp_parts[idx])
return new_parts
class TemplateResult(str):
"""Result of template format with most of information in.
"""Result of template format with most of the information in.
Args:
used_values (dict): Dictionary of template filling data with
@ -299,7 +298,7 @@ class TemplatePartResult:
self._optional = True
def add_output(self, other):
if isinstance(other, six.string_types):
if isinstance(other, str):
self._output += other
elif isinstance(other, TemplatePartResult):
@ -457,7 +456,7 @@ class FormattingPart:
return True
for inh_class in type(value).mro():
if inh_class in six.string_types:
if inh_class is str:
return True
return False
@ -568,7 +567,7 @@ class OptionalPart:
def format(self, data, result):
new_result = TemplatePartResult(True)
for part in self._parts:
if isinstance(part, six.string_types):
if isinstance(part, str):
new_result.add_output(part)
else:
part.format(data, new_result)

View file

@ -978,7 +978,7 @@ def _ffmpeg_h264_codec_args(stream_data, source_ffmpeg_cmd):
if pix_fmt:
output.extend(["-pix_fmt", pix_fmt])
output.extend(["-intra", "-g", "1"])
output.extend(["-g", "1"])
return output

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

@ -7,12 +7,14 @@ import collections
import inspect
from uuid import uuid4
from contextlib import contextmanager
from typing import Optional
import pyblish.logic
import pyblish.api
import ayon_api
from ayon_core.settings import get_project_settings
from ayon_core.lib import is_func_signature_supported
from ayon_core.lib.attribute_definitions import (
UnknownDef,
serialize_attr_defs,
@ -46,7 +48,7 @@ class UnavailableSharedData(Exception):
class ImmutableKeyError(TypeError):
"""Accessed key is immutable so does not allow changes or removements."""
"""Accessed key is immutable so does not allow changes or removals."""
def __init__(self, key, msg=None):
self.immutable_key = key
@ -1404,6 +1406,7 @@ class CreateContext:
self._current_workfile_path = None
self._current_project_settings = None
self._current_project_entity = _NOT_SET
self._current_folder_entity = _NOT_SET
self._current_task_entity = _NOT_SET
self._current_task_type = _NOT_SET
@ -1431,7 +1434,7 @@ class CreateContext:
self.convertors_plugins = {}
self.convertor_items_by_id = {}
self.publish_discover_result = None
self.publish_discover_result: Optional[DiscoverResult] = None
self.publish_plugins_mismatch_targets = []
self.publish_plugins = []
self.plugins_with_defs = []
@ -1592,6 +1595,22 @@ class CreateContext:
self._current_task_type = task_type
return self._current_task_type
def get_current_project_entity(self):
"""Project entity for current context project.
Returns:
Union[dict[str, Any], None]: Folder entity.
"""
if self._current_project_entity is not _NOT_SET:
return copy.deepcopy(self._current_project_entity)
project_entity = None
project_name = self.get_current_project_name()
if project_name:
project_entity = ayon_api.get_project(project_name)
self._current_project_entity = project_entity
return copy.deepcopy(self._current_project_entity)
def get_current_folder_entity(self):
"""Folder entity for current context folder.
@ -1788,6 +1807,7 @@ class CreateContext:
self._current_task_name = task_name
self._current_workfile_path = workfile_path
self._current_project_entity = _NOT_SET
self._current_folder_entity = _NOT_SET
self._current_task_entity = _NOT_SET
self._current_task_type = _NOT_SET
@ -2023,7 +2043,8 @@ class CreateContext:
variant,
folder_entity=None,
task_entity=None,
pre_create_data=None
pre_create_data=None,
active=None
):
"""Trigger create of plugins with standartized arguments.
@ -2041,6 +2062,8 @@ class CreateContext:
of creation (possible context of created instance/s).
task_entity (Dict[str, Any]): Task entity.
pre_create_data (Dict[str, Any]): Pre-create attribute values.
active (Optional[bool]): Whether the created instance defaults
to be active or not.
Returns:
Any: Output of triggered creator's 'create' method.
@ -2083,13 +2106,22 @@ class CreateContext:
# TODO validate types
_pre_create_data.update(pre_create_data)
product_name = creator.get_product_name(
project_entity = self.get_current_project_entity()
args = (
project_name,
folder_entity,
task_entity,
variant,
self.host_name,
)
kwargs = {"project_entity": project_entity}
# Backwards compatibility for 'project_entity' argument
# - 'get_product_name' signature changed 24/07/08
if not is_func_signature_supported(
creator.get_product_name, *args, **kwargs
):
kwargs.pop("project_entity")
product_name = creator.get_product_name(*args, **kwargs)
instance_data = {
"folderPath": folder_entity["path"],
@ -2097,6 +2129,14 @@ class CreateContext:
"productType": creator.product_type,
"variant": variant
}
if active is not None:
if not isinstance(active, bool):
self.log.warning(
"CreateContext.create 'active' argument is not a bool. "
f"Converting {active} {type(active)} to bool.")
active = bool(active)
instance_data["active"] = active
return creator.create(
product_name,
instance_data,
@ -2576,7 +2616,7 @@ class CreateContext:
def collection_shared_data(self):
"""Access to shared data that can be used during creator's collection.
Retruns:
Returns:
Dict[str, Any]: Shared data.
Raises:

View file

@ -1,10 +1,9 @@
# -*- coding: utf-8 -*-
import copy
import collections
from typing import TYPE_CHECKING, Optional
from abc import ABCMeta, abstractmethod
import six
from abc import ABC, abstractmethod
from ayon_core.settings import get_project_settings
from ayon_core.lib import Logger
@ -21,6 +20,11 @@ from .product_name import get_product_name
from .utils import get_next_versions_for_instances
from .legacy_create import LegacyCreator
if TYPE_CHECKING:
from ayon_core.lib import AbstractAttrDef
# Avoid cyclic imports
from .context import CreateContext, CreatedInstance, UpdateData # noqa: F401
class CreatorError(Exception):
"""Should be raised when creator failed because of known issue.
@ -32,19 +36,18 @@ class CreatorError(Exception):
super(CreatorError, self).__init__(message)
@six.add_metaclass(ABCMeta)
class ProductConvertorPlugin(object):
class ProductConvertorPlugin(ABC):
"""Helper for conversion of instances created using legacy creators.
Conversion from legacy creators would mean to loose legacy instances,
Conversion from legacy creators would mean to lose legacy instances,
convert them automatically or write a script which must user run. All of
these solutions are workign but will happen without asking or user must
these solutions are working but will happen without asking or user must
know about them. This plugin can be used to show legacy instances in
Publisher and give user ability to run conversion script.
Convertor logic should be very simple. Method 'find_instances' is to
look for legacy instances in scene a possibly call
pre-implemented 'add_convertor_item'.
look for legacy instances in scene and possibly call pre-implemented
'add_convertor_item'.
User will have ability to trigger conversion which is executed by calling
'convert' which should call 'remove_convertor_item' when is done.
@ -57,7 +60,7 @@ class ProductConvertorPlugin(object):
can store any information to it's object for conversion purposes.
Args:
create_context
create_context (CreateContext): Context which initialized the plugin.
"""
_log = None
@ -122,8 +125,8 @@ class ProductConvertorPlugin(object):
def collection_shared_data(self):
"""Access to shared data that can be used during 'find_instances'.
Retruns:
Dict[str, Any]: Shared data.
Returns:
dict[str, Any]: Shared data.
Raises:
UnavailableSharedData: When called out of collection phase.
@ -146,11 +149,10 @@ class ProductConvertorPlugin(object):
self._create_context.remove_convertor_item(self.identifier)
@six.add_metaclass(ABCMeta)
class BaseCreator:
class BaseCreator(ABC):
"""Plugin that create and modify instance data before publishing process.
We should maybe find better name as creation is only one part of it's logic
We should maybe find better name as creation is only one part of its logic
and to avoid expectations that it is the same as `avalon.api.Creator`.
Single object should be used for multiple instances instead of single
@ -158,7 +160,7 @@ class BaseCreator:
to `self` if it's not Plugin specific.
Args:
project_settings (Dict[str, Any]): Project settings.
project_settings (dict[str, Any]): Project settings.
create_context (CreateContext): Context which initialized creator.
headless (bool): Running in headless mode.
"""
@ -185,20 +187,20 @@ class BaseCreator:
# Instance attribute definitions that can be changed per instance
# - returns list of attribute definitions from
# `ayon_core.pipeline.attribute_definitions`
instance_attr_defs = []
# `ayon_core.lib.attribute_definitions`
instance_attr_defs: "list[AbstractAttrDef]" = []
# Filtering by host name - can be used to be filtered by host name
# - used on all hosts when set to 'None' for Backwards compatibility
# - was added afterwards
# QUESTION make this required?
host_name = None
host_name: Optional[str] = None
# Settings auto-apply helpers
# Root key in project settings (mandatory for auto-apply to work)
settings_category = None
settings_category: Optional[str] = None
# Name of plugin in create settings > class name is used if not set
settings_name = None
settings_name: Optional[str] = None
def __init__(
self, project_settings, create_context, headless=False
@ -207,7 +209,7 @@ class BaseCreator:
self.create_context = create_context
self.project_settings = project_settings
# Creator is running in headless mode (without UI elemets)
# Creator is running in headless mode (without UI elements)
# - we may use UI inside processing this attribute should be checked
self.headless = headless
@ -223,7 +225,7 @@ class BaseCreator:
plugin_name (str): Name of settings.
Returns:
Union[dict[str, Any], None]: Settings values or None.
Optional[dict[str, Any]]: Settings values or None.
"""
settings = project_settings.get(category_name)
@ -297,7 +299,6 @@ class BaseCreator:
))
setattr(self, key, value)
@property
def identifier(self):
"""Identifier of creator (must be unique).
@ -389,7 +390,7 @@ class BaseCreator:
"""Helper method to remove instance from create context.
Instances must be removed from DCC workfile metadat aand from create
context in which plugin is existing at the moment of removement to
context in which plugin is existing at the moment of removal to
propagate the change without restarting create context.
Args:
@ -441,7 +442,7 @@ class BaseCreator:
"""Store changes of existing instances so they can be recollected.
Args:
update_list(List[UpdateData]): Gets list of tuples. Each item
update_list (list[UpdateData]): Gets list of tuples. Each item
contain changed instance and it's changes.
"""
@ -449,13 +450,13 @@ class BaseCreator:
@abstractmethod
def remove_instances(self, instances):
"""Method called on instance removement.
"""Method called on instance removal.
Can also remove instance metadata from context but should return
'True' if did so.
Args:
instance(List[CreatedInstance]): Instance objects which should be
instances (list[CreatedInstance]): Instance objects which should be
removed.
"""
@ -480,8 +481,7 @@ class BaseCreator:
):
"""Dynamic data for product name filling.
These may be get dynamically created based on current context of
workfile.
These may be dynamically created based on current context of workfile.
"""
return {}
@ -493,7 +493,8 @@ class BaseCreator:
task_entity,
variant,
host_name=None,
instance=None
instance=None,
project_entity=None,
):
"""Return product name for passed context.
@ -510,8 +511,9 @@ class BaseCreator:
instance (Optional[CreatedInstance]): Object of 'CreatedInstance'
for which is product name updated. Passed only on product name
update.
"""
project_entity (Optional[dict[str, Any]]): Project entity.
"""
if host_name is None:
host_name = self.create_context.host_name
@ -537,7 +539,8 @@ class BaseCreator:
self.product_type,
variant,
dynamic_data=dynamic_data,
project_settings=self.project_settings
project_settings=self.project_settings,
project_entity=project_entity,
)
def get_instance_attr_defs(self):
@ -552,7 +555,7 @@ class BaseCreator:
keys/values when plugin attributes change.
Returns:
List[AbstractAttrDef]: Attribute definitions that can be tweaked
list[AbstractAttrDef]: Attribute definitions that can be tweaked
for created instance.
"""
@ -562,8 +565,8 @@ class BaseCreator:
def collection_shared_data(self):
"""Access to shared data that can be used during creator's collection.
Retruns:
Dict[str, Any]: Shared data.
Returns:
dict[str, Any]: Shared data.
Raises:
UnavailableSharedData: When called out of collection phase.
@ -592,7 +595,7 @@ class BaseCreator:
versions.
Returns:
Dict[str, int]: Next versions by instance id.
dict[str, int]: Next versions by instance id.
"""
return get_next_versions_for_instances(
@ -711,7 +714,7 @@ class Creator(BaseCreator):
By default, returns `default_variants` value.
Returns:
List[str]: Whisper variants for user input.
list[str]: Whisper variants for user input.
"""
return copy.deepcopy(self.default_variants)
@ -784,7 +787,7 @@ class Creator(BaseCreator):
updating keys/values when plugin attributes change.
Returns:
List[AbstractAttrDef]: Attribute definitions that can be tweaked
list[AbstractAttrDef]: Attribute definitions that can be tweaked
for created instance.
"""
return self.pre_create_attr_defs
@ -803,7 +806,7 @@ class AutoCreator(BaseCreator):
"""
def remove_instances(self, instances):
"""Skip removement."""
"""Skip removal."""
pass
@ -916,7 +919,7 @@ def cache_and_get_instances(creator, shared_key, list_instances_func):
if data were not yet stored under 'shared_key'.
Returns:
Dict[str, Dict[str, Any]]: Cached instances by creator identifier from
dict[str, dict[str, Any]]: Cached instances by creator identifier from
result of passed function.
"""

View file

@ -112,6 +112,13 @@ class LegacyCreator(object):
This method can be modified to prefill some values just keep in mind it
is class method.
Args:
project_name (str): Context's project name.
folder_entity (dict[str, Any]): Folder entity.
task_entity (dict[str, Any]): Task entity.
variant (str): What is entered by user in creator tool.
host_name (str): Name of host.
Returns:
dict: Fill data for product name template.
"""

View file

@ -1,3 +1,5 @@
import ayon_api
from ayon_core.settings import get_project_settings
from ayon_core.lib import filter_profiles, prepare_template_data
@ -37,7 +39,7 @@ def get_product_name_template(
task_name (str): Name of task in which context the product is created.
task_type (str): Type of task in which context the product is created.
default_template (Union[str, None]): Default template which is used if
settings won't find any matching possitibility. Constant
settings won't find any matching possibility. Constant
'DEFAULT_PRODUCT_TEMPLATE' is used if not defined.
project_settings (Union[Dict[str, Any], None]): Prepared settings for
project. Settings are queried if not passed.
@ -88,6 +90,7 @@ def get_product_name(
dynamic_data=None,
project_settings=None,
product_type_filter=None,
project_entity=None,
):
"""Calculate product name based on passed context and AYON settings.
@ -120,12 +123,18 @@ def get_product_name(
product_type_filter (Optional[str]): Use different product type for
product template filtering. Value of `product_type` is used when
not passed.
project_entity (Optional[Dict[str, Any]]): Project entity used when
task short name is required by template.
Returns:
str: Product name.
Raises:
TaskNotSetError: If template requires task which is not provided.
TemplateFillError: If filled template contains placeholder key which
is not collected.
"""
"""
if not product_type:
return ""
@ -150,6 +159,16 @@ def get_product_name(
if "{task}" in template.lower():
task_value = task_name
elif "{task[short]}" in template.lower():
if project_entity is None:
project_entity = ayon_api.get_project(project_name)
task_types_by_name = {
task["name"]: task for task in
project_entity["taskTypes"]
}
task_short = task_types_by_name.get(task_type, {}).get("shortName")
task_value["short"] = task_short
fill_pairs = {
"variant": variant,
"family": product_type,

View file

@ -587,6 +587,21 @@ def switch_container(container, representation, loader_plugin=None):
return loader.switch(container, context)
def _fix_representation_context_compatibility(repre_context):
"""Helper function to fix representation context compatibility.
Args:
repre_context (dict): Representation context.
"""
# Auto-fix 'udim' being list of integers
# - This is a legacy issue for old representation entities,
# added 24/07/10
udim = repre_context.get("udim")
if isinstance(udim, list):
repre_context["udim"] = udim[0]
def get_representation_path_from_context(context):
"""Preparation wrapper using only context as a argument"""
from ayon_core.pipeline import get_current_project_name
@ -638,7 +653,9 @@ def get_representation_path_with_anatomy(repre_entity, anatomy):
try:
context = repre_entity["context"]
_fix_representation_context_compatibility(context)
context["root"] = anatomy.roots
path = StringTemplate.format_strict_template(template, context)
except TemplateUnsolved as exc:
@ -681,6 +698,9 @@ def get_representation_path(representation, root=None):
try:
context = representation["context"]
_fix_representation_context_compatibility(context)
context["root"] = root
path = StringTemplate.format_strict_template(
template, context

View file

@ -2,8 +2,6 @@ import os
import re
import json
import six
from ayon_core.settings import get_project_settings
from ayon_core.lib import Logger
@ -109,6 +107,6 @@ def get_project_basic_paths(project_name):
if not folder_structure:
return []
if isinstance(folder_structure, six.string_types):
if isinstance(folder_structure, str):
folder_structure = json.loads(folder_structure)
return _list_path_items(folder_structure)

View file

@ -7,8 +7,6 @@ TODO: use @dataclass when times come.
from abc import abstractmethod
import attr
import six
import pyblish.api
from .publish_plugins import AbstractMetaContextPlugin
@ -122,8 +120,9 @@ class RenderInstance(object):
raise ValueError("both tiles X a Y sizes are set to 1")
@six.add_metaclass(AbstractMetaContextPlugin)
class AbstractCollectRender(pyblish.api.ContextPlugin):
class AbstractCollectRender(
pyblish.api.ContextPlugin, metaclass=AbstractMetaContextPlugin
):
"""Gather all publishable render layers from renderSetup."""
order = pyblish.api.CollectorOrder + 0.01

View file

@ -1,11 +1,9 @@
# -*- coding: utf-8 -*-
"""Abstract ExpectedFile class definition."""
from abc import ABCMeta, abstractmethod
import six
from abc import ABC, abstractmethod
@six.add_metaclass(ABCMeta)
class ExpectedFiles:
class ExpectedFiles(ABC):
"""Class grouping functionality for all supported renderers.
Attributes:

View file

@ -17,7 +17,6 @@ import json
import logging
import jsonschema
import six
log_ = logging.getLogger(__name__)
@ -44,7 +43,7 @@ def validate(data, schema=None):
root, schema = data["schema"].rsplit(":", 1)
if isinstance(schema, six.string_types):
if isinstance(schema, str):
schema = _cache[schema + ".json"]
resolver = jsonschema.RefResolver(

View file

@ -3,7 +3,7 @@
Build templates are manually prepared using plugin definitions which create
placeholders inside the template which are populated on import.
This approach is very explicit to achive very specific build logic that can be
This approach is very explicit to achieve very specific build logic that can be
targeted by task types and names.
Placeholders are created using placeholder plugins which should care about
@ -15,9 +15,8 @@ import os
import re
import collections
import copy
from abc import ABCMeta, abstractmethod
from abc import ABC, abstractmethod
import six
from ayon_api import (
get_folders,
get_folder_by_path,
@ -82,12 +81,11 @@ class TemplateLoadFailed(Exception):
pass
@six.add_metaclass(ABCMeta)
class AbstractTemplateBuilder(object):
class AbstractTemplateBuilder(ABC):
"""Abstraction of Template Builder.
Builder cares about context, shared data, cache, discovery of plugins
and trigger logic. Provides public api for host workfile build systen.
and trigger logic. Provides public api for host workfile build system.
Rest of logic is based on plugins that care about collection and creation
of placeholder items.
@ -806,7 +804,7 @@ class AbstractTemplateBuilder(object):
)
def get_template_preset(self):
"""Unified way how template preset is received usign settings.
"""Unified way how template preset is received using settings.
Method is dependent on '_get_build_profiles' which should return filter
profiles to resolve path to a template. Default implementation looks
@ -941,8 +939,7 @@ class AbstractTemplateBuilder(object):
)
@six.add_metaclass(ABCMeta)
class PlaceholderPlugin(object):
class PlaceholderPlugin(ABC):
"""Plugin which care about handling of placeholder items logic.
Plugin create and update placeholders in scene and populate them on
@ -1427,7 +1424,7 @@ class PlaceholderLoadMixin(object):
placeholder='{"camera":"persp", "lights":True}',
tooltip=(
"Loader"
"\nDefines a dictionnary of arguments used to load assets."
"\nDefines a dictionary of arguments used to load assets."
"\nUseable arguments depend on current placeholder Loader."
"\nField should be a valid python dict."
" Anything else will be ignored."
@ -1472,7 +1469,7 @@ class PlaceholderLoadMixin(object):
]
def parse_loader_args(self, loader_args):
"""Helper function to parse string of loader arugments.
"""Helper function to parse string of loader arguments.
Empty dictionary is returned if conversion fails.
@ -1797,6 +1794,16 @@ class PlaceholderCreateMixin(object):
"\ncompiling of product name."
)
),
attribute_definitions.BoolDef(
"active",
label="Active",
default=options.get("active", True),
tooltip=(
"Active"
"\nDefines whether the created instance will default to "
"active or not."
)
),
attribute_definitions.UISeparatorDef(),
attribute_definitions.NumberDef(
"order",
@ -1826,6 +1833,7 @@ class PlaceholderCreateMixin(object):
legacy_create = self.builder.use_legacy_creators
creator_name = placeholder.data["creator"]
create_variant = placeholder.data["create_variant"]
active = placeholder.data.get("active")
creator_plugin = self.builder.get_creators_by_name()[creator_name]
@ -1872,8 +1880,9 @@ class PlaceholderCreateMixin(object):
creator_plugin.identifier,
create_variant,
folder_entity,
task_name=task_name,
pre_create_data=pre_create_data
task_entity,
pre_create_data=pre_create_data,
active=active
)
except: # noqa: E722

View file

@ -1,6 +1,7 @@
import collections
import os
import uuid
from typing import List, Dict, Any
import clique
import ayon_api
@ -41,11 +42,13 @@ class DeleteOldVersions(load.ProductLoaderPlugin):
)
]
requires_confirmation = True
def delete_whole_dir_paths(self, dir_paths, delete=True):
size = 0
for dir_path in dir_paths:
# Delete all files and fodlers in dir path
# Delete all files and folders in dir path
for root, dirs, files in os.walk(dir_path, topdown=False):
for name in files:
file_path = os.path.join(root, name)
@ -192,6 +195,42 @@ class DeleteOldVersions(load.ProductLoaderPlugin):
)
msgBox.exec_()
def _confirm_delete(self,
contexts: List[Dict[str, Any]],
versions_to_keep: int) -> bool:
"""Prompt user for a deletion confirmation"""
contexts_list = "\n".join(sorted(
"- {folder[name]} > {product[name]}".format_map(context)
for context in contexts
))
num_contexts = len(contexts)
s = "s" if num_contexts > 1 else ""
text = (
"Are you sure you want to delete versions?\n\n"
f"This will keep only the last {versions_to_keep} "
f"versions for the {num_contexts} selected product{s}."
)
informative_text="Warning: This will delete files from disk"
detailed_text = (
f"Keep only {versions_to_keep} versions for:\n{contexts_list}"
)
messagebox = QtWidgets.QMessageBox()
messagebox.setIcon(QtWidgets.QMessageBox.Warning)
messagebox.setWindowTitle("Delete Old Versions")
messagebox.setText(text)
messagebox.setInformativeText(informative_text)
messagebox.setDetailedText(detailed_text)
messagebox.setStandardButtons(
QtWidgets.QMessageBox.Yes
| QtWidgets.QMessageBox.Cancel
)
messagebox.setDefaultButton(QtWidgets.QMessageBox.Cancel)
messagebox.setStyleSheet(style.load_stylesheet())
messagebox.setAttribute(QtCore.Qt.WA_DeleteOnClose, True)
return messagebox.exec_() == QtWidgets.QMessageBox.Yes
def get_data(self, context, versions_count):
product_entity = context["product"]
folder_entity = context["folder"]
@ -365,19 +404,29 @@ class DeleteOldVersions(load.ProductLoaderPlugin):
return size
def load(self, contexts, name=None, namespace=None, options=None):
# Get user options
versions_to_keep = 2
remove_publish_folder = False
if options:
versions_to_keep = options.get(
"versions_to_keep", versions_to_keep
)
remove_publish_folder = options.get(
"remove_publish_folder", remove_publish_folder
)
# Because we do not want this run by accident we will add an extra
# user confirmation
if (
self.requires_confirmation
and not self._confirm_delete(contexts, versions_to_keep)
):
return
try:
size = 0
for count, context in enumerate(contexts):
versions_to_keep = 2
remove_publish_folder = False
if options:
versions_to_keep = options.get(
"versions_to_keep", versions_to_keep
)
remove_publish_folder = options.get(
"remove_publish_folder", remove_publish_folder
)
data = self.get_data(context, versions_to_keep)
if not data:
continue
@ -408,6 +457,8 @@ class CalculateOldVersions(DeleteOldVersions):
)
]
requires_confirmation = False
def main(self, project_name, data, remove_publish_folder):
size = 0

View file

@ -1,4 +1,5 @@
import ayon_api
import ayon_api.utils
import pyblish.api
@ -23,6 +24,12 @@ class CollectInputRepresentationsToVersions(pyblish.api.ContextPlugin):
if inst_repre:
representations.update(inst_repre)
# Ignore representation ids that are not valid
representations = {
representation_id for representation_id in representations
if ayon_api.utils.convert_entity_id(representation_id)
}
repre_entities = ayon_api.get_representations(
project_name=context.data["projectName"],
representation_ids=representations,

View file

@ -65,7 +65,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin):
"xgen",
"yeticacheUE",
"tycache",
"usd"
"usd",
"oxrig"
]
def process(self, instance):

View file

@ -1,7 +1,8 @@
import ayon_api
import pyblish.api
import ayon_api.utils
from ayon_core.pipeline import registered_host
import pyblish.api
class CollectSceneLoadedVersions(pyblish.api.ContextPlugin):
@ -41,6 +42,12 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin):
for container in containers
}
# Ignore representation ids that are not valid
repre_ids = {
representation_id for representation_id in repre_ids
if ayon_api.utils.convert_entity_id(representation_id)
}
project_name = context.data["projectName"]
repre_entities = ayon_api.get_representations(
project_name,
@ -65,7 +72,7 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin):
continue
# NOTE:
# may have more then one representation that are same version
# may have more than one representation that are same version
version = {
"container_name": con["name"],
"representation_id": repre_entity["id"],

View file

@ -6,7 +6,6 @@ import platform
import shutil
import clique
import six
import pyblish.api
from ayon_core import resources, AYON_CORE_ROOT
@ -456,7 +455,7 @@ class ExtractBurnin(publish.Extractor):
sys_name = platform.system().lower()
font_filepath = font_filepath.get(sys_name)
if font_filepath and isinstance(font_filepath, six.string_types):
if font_filepath and isinstance(font_filepath, str):
font_filepath = font_filepath.format(**os.environ)
if not os.path.exists(font_filepath):
font_filepath = None

View file

@ -4,9 +4,8 @@ import copy
import json
import shutil
import subprocess
from abc import ABCMeta, abstractmethod
from abc import ABC, abstractmethod
import six
import clique
import speedcopy
import pyblish.api
@ -1661,8 +1660,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
return vf_back
@six.add_metaclass(ABCMeta)
class _OverscanValue:
class _OverscanValue(ABC):
def __repr__(self):
return "<{}> {}".format(self.__class__.__name__, str(self))

View file

@ -4,7 +4,6 @@ import sys
import copy
import clique
import six
import pyblish.api
from ayon_api import (
get_attributes_for_type,
@ -160,15 +159,14 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
# Raise DuplicateDestinationError as KnownPublishError
# and rollback the transactions
file_transactions.rollback()
six.reraise(KnownPublishError,
KnownPublishError(exc),
sys.exc_info()[2])
except Exception:
raise KnownPublishError(exc).with_traceback(sys.exc_info()[2])
except Exception as exc:
# clean destination
# todo: preferably we'd also rollback *any* changes to the database
file_transactions.rollback()
self.log.critical("Error when registering", exc_info=True)
six.reraise(*sys.exc_info())
raise exc
# Finalizing can't rollback safely so no use for moving it to
# the try, except.
@ -789,11 +787,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
if value is not None:
repre_context[key] = value
# Explicitly store the full list even though template data might
# have a different value because it uses just a single udim tile
if repre.get("udim"):
repre_context["udim"] = repre.get("udim") # store list
# Use previous representation's id if there is a name match
existing = existing_repres_by_name.get(repre["name"].lower())
repre_id = None

View file

@ -2,7 +2,6 @@ import os
import copy
import json
import collections
import six
from ayon_core import resources
@ -75,7 +74,7 @@ def _convert_color_values_to_objects(value):
output[_key] = _convert_color_values_to_objects(_value)
return output
if not isinstance(value, six.string_types):
if not isinstance(value, str):
raise TypeError((
"Unexpected type in colors data '{}'. Expected 'str' or 'dict'."
).format(str(type(value))))

View file

@ -1,18 +1,16 @@
import time
import collections
import contextlib
from abc import ABCMeta, abstractmethod
from abc import ABC, abstractmethod
import ayon_api
import six
from ayon_core.lib import NestedCacheItem
HIERARCHY_MODEL_SENDER = "hierarchy.model"
@six.add_metaclass(ABCMeta)
class AbstractHierarchyController:
class AbstractHierarchyController(ABC):
@abstractmethod
def emit_event(self, topic, data, source):
pass

View file

@ -1,10 +1,7 @@
from abc import ABCMeta, abstractmethod
import six
from abc import ABC, abstractmethod
@six.add_metaclass(ABCMeta)
class AbstractLauncherCommon(object):
class AbstractLauncherCommon(ABC):
@abstractmethod
def register_event_callback(self, topic, callback):
"""Register event callback.

View file

@ -1,5 +1,4 @@
from abc import ABCMeta, abstractmethod
import six
from abc import ABC, abstractmethod
from ayon_core.lib.attribute_definitions import (
AbstractAttrDef,
@ -347,8 +346,7 @@ class ActionItem:
return cls(**data)
@six.add_metaclass(ABCMeta)
class _BaseLoaderController(object):
class _BaseLoaderController(ABC):
"""Base loader controller abstraction.
Abstract base class that is required for both frontend and backed.

View file

@ -0,0 +1,658 @@
from typing import List, Tuple, Optional, Iterable, Any
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils.lib import (
checkstate_int_to_enum,
checkstate_enum_to_int,
)
from ayon_core.tools.utils.constants import (
CHECKED_INT,
UNCHECKED_INT,
ITEM_IS_USER_TRISTATE,
)
VALUE_ITEM_TYPE = 0
STANDARD_ITEM_TYPE = 1
SEPARATOR_ITEM_TYPE = 2
class CustomPaintDelegate(QtWidgets.QStyledItemDelegate):
"""Delegate showing status name and short name."""
_checked_value = checkstate_enum_to_int(QtCore.Qt.Checked)
_checked_bg_color = QtGui.QColor("#2C3B4C")
def __init__(
self,
text_role: int,
short_text_role: int,
text_color_role: int,
icon_role: int,
item_type_role: Optional[int] = None,
parent=None
):
super().__init__(parent)
self._text_role = text_role
self._text_color_role = text_color_role
self._short_text_role = short_text_role
self._icon_role = icon_role
self._item_type_role = item_type_role
def paint(self, painter, option, index):
item_type = None
if self._item_type_role is not None:
item_type = index.data(self._item_type_role)
if item_type is None:
item_type = VALUE_ITEM_TYPE
if item_type == STANDARD_ITEM_TYPE:
super().paint(painter, option, index)
return
elif item_type == SEPARATOR_ITEM_TYPE:
self._paint_separator(painter, option, index)
return
if option.widget:
style = option.widget.style()
else:
style = QtWidgets.QApplication.style()
self.initStyleOption(option, index)
mode = QtGui.QIcon.Normal
if not (option.state & QtWidgets.QStyle.State_Enabled):
mode = QtGui.QIcon.Disabled
elif option.state & QtWidgets.QStyle.State_Selected:
mode = QtGui.QIcon.Selected
state = QtGui.QIcon.Off
if option.state & QtWidgets.QStyle.State_Open:
state = QtGui.QIcon.On
icon = self._get_index_icon(index)
option.features |= QtWidgets.QStyleOptionViewItem.HasDecoration
# Disable visible check indicator
# - checkstate is displayed by background color
option.features &= (
~QtWidgets.QStyleOptionViewItem.HasCheckIndicator
)
option.icon = icon
act_size = icon.actualSize(option.decorationSize, mode, state)
option.decorationSize = QtCore.QSize(
min(option.decorationSize.width(), act_size.width()),
min(option.decorationSize.height(), act_size.height())
)
text = self._get_index_name(index)
if text:
option.features |= QtWidgets.QStyleOptionViewItem.HasDisplay
option.text = text
painter.save()
painter.setClipRect(option.rect)
is_checked = (
index.data(QtCore.Qt.CheckStateRole) == self._checked_value
)
if is_checked:
painter.fillRect(option.rect, self._checked_bg_color)
icon_rect = style.subElementRect(
QtWidgets.QCommonStyle.SE_ItemViewItemDecoration,
option,
option.widget
)
text_rect = style.subElementRect(
QtWidgets.QCommonStyle.SE_ItemViewItemText,
option,
option.widget
)
# Draw background
style.drawPrimitive(
QtWidgets.QCommonStyle.PE_PanelItemViewItem,
option,
painter,
option.widget
)
# Draw icon
option.icon.paint(
painter,
icon_rect,
option.decorationAlignment,
mode,
state
)
fm = QtGui.QFontMetrics(option.font)
if text_rect.width() < fm.width(text):
text = self._get_index_short_name(index)
if not text or text_rect.width() < fm.width(text):
text = ""
fg_color = self._get_index_text_color(index)
pen = painter.pen()
pen.setColor(fg_color)
painter.setPen(pen)
painter.drawText(
text_rect,
option.displayAlignment,
text
)
if option.state & QtWidgets.QStyle.State_HasFocus:
focus_opt = QtWidgets.QStyleOptionFocusRect()
focus_opt.state = option.state
focus_opt.direction = option.direction
focus_opt.rect = option.rect
focus_opt.fontMetrics = option.fontMetrics
focus_opt.palette = option.palette
focus_opt.rect = style.subElementRect(
QtWidgets.QCommonStyle.SE_ItemViewItemFocusRect,
option,
option.widget
)
focus_opt.state |= (
QtWidgets.QStyle.State_KeyboardFocusChange
| QtWidgets.QStyle.State_Item
)
focus_opt.backgroundColor = option.palette.color(
(
QtGui.QPalette.Normal
if option.state & QtWidgets.QStyle.State_Enabled
else QtGui.QPalette.Disabled
),
(
QtGui.QPalette.Highlight
if option.state & QtWidgets.QStyle.State_Selected
else QtGui.QPalette.Window
)
)
style.drawPrimitive(
QtWidgets.QCommonStyle.PE_FrameFocusRect,
focus_opt,
painter,
option.widget
)
painter.restore()
def _paint_separator(self, painter, option, index):
painter.save()
painter.setClipRect(option.rect)
style = option.widget.style()
style.drawPrimitive(
QtWidgets.QCommonStyle.PE_PanelItemViewItem,
option,
painter,
option.widget
)
pen = painter.pen()
pen.setWidth(2)
painter.setPen(pen)
mid_y = (option.rect.top() + option.rect.bottom()) * 0.5
painter.drawLine(
QtCore.QPointF(option.rect.left(), mid_y),
QtCore.QPointF(option.rect.right(), mid_y)
)
painter.restore()
def _get_index_name(self, index):
return index.data(self._text_role)
def _get_index_short_name(self, index):
if self._short_text_role is None:
return None
return index.data(self._short_text_role)
def _get_index_text_color(self, index):
color = None
if self._text_color_role is not None:
color = index.data(self._text_color_role)
if color is not None:
return QtGui.QColor(color)
return QtGui.QColor(QtCore.Qt.white)
def _get_index_icon(self, index):
icon = None
if self._icon_role is not None:
icon = index.data(self._icon_role)
if icon is None:
return QtGui.QIcon()
return icon
class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
value_changed = QtCore.Signal()
focused_in = QtCore.Signal()
ignored_keys = {
QtCore.Qt.Key_Up,
QtCore.Qt.Key_Down,
QtCore.Qt.Key_PageDown,
QtCore.Qt.Key_PageUp,
QtCore.Qt.Key_Home,
QtCore.Qt.Key_End,
}
def __init__(
self,
text_role,
short_text_role,
text_color_role,
icon_role,
value_role=None,
item_type_role=None,
model=None,
placeholder=None,
parent=None,
):
super().__init__(parent=parent)
self.setFocusPolicy(QtCore.Qt.StrongFocus)
if model is not None:
self.setModel(model)
combo_view = QtWidgets.QListView(self)
self.setView(combo_view)
item_delegate = CustomPaintDelegate(
text_role=text_role,
short_text_role=short_text_role,
text_color_role=text_color_role,
icon_role=icon_role,
item_type_role=item_type_role,
parent=combo_view,
)
combo_view.setItemDelegateForColumn(0, item_delegate)
if value_role is None:
value_role = text_role
self._combo_view = combo_view
self._item_delegate = item_delegate
self._value_role = value_role
self._text_role = text_role
self._short_text_role = short_text_role
self._text_color_role = text_color_role
self._icon_role = icon_role
self._item_type_role = item_type_role
self._popup_is_shown = False
self._block_mouse_release_timer = QtCore.QTimer(self, singleShot=True)
self._initial_mouse_pos = None
self._placeholder_text = placeholder
self._custom_text = None
self._all_unchecked_as_checked = True
def all_unchecked_as_checked(self) -> bool:
return self._all_unchecked_as_checked
def set_all_unchecked_as_checked(self, value: bool):
"""Set if all unchecked items should be treated as checked.
Args:
value (bool): If True, all unchecked items will be treated
as checked.
"""
self._all_unchecked_as_checked = value
def get_placeholder_text(self) -> Optional[str]:
return self._placeholder_text
def set_placeholder_text(self, text: Optional[str]):
"""Set the placeholder text.
Text shown when nothing is selected.
Args:
text (str | None): The placeholder text.
"""
if text == self._placeholder_text:
return
self._placeholder_text = text
self.repaint()
def set_custom_text(self, text: Optional[str]):
"""Set the placeholder text.
Text always shown in combobox field.
Args:
text (str | None): The text. Use 'None' to reset to default.
"""
if text == self._custom_text:
return
self._custom_text = text
self.repaint()
def focusInEvent(self, event):
self.focused_in.emit()
return super().focusInEvent(event)
def mousePressEvent(self, event):
"""Reimplemented."""
self._popup_is_shown = False
super().mousePressEvent(event)
if self._popup_is_shown:
self._initial_mouse_pos = self.mapToGlobal(event.pos())
self._block_mouse_release_timer.start(
QtWidgets.QApplication.doubleClickInterval()
)
def showPopup(self):
"""Reimplemented."""
super().showPopup()
view = self.view()
view.installEventFilter(self)
view.viewport().installEventFilter(self)
self._popup_is_shown = True
def hidePopup(self):
"""Reimplemented."""
self.view().removeEventFilter(self)
self.view().viewport().removeEventFilter(self)
self._popup_is_shown = False
self._initial_mouse_pos = None
super().hidePopup()
self.view().clearFocus()
def _event_popup_shown(self, obj, event):
if not self._popup_is_shown:
return
current_index = self.view().currentIndex()
model = self.model()
if event.type() == QtCore.QEvent.MouseMove:
if (
self.view().isVisible()
and self._initial_mouse_pos is not None
and self._block_mouse_release_timer.isActive()
):
diff = obj.mapToGlobal(event.pos()) - self._initial_mouse_pos
if diff.manhattanLength() > 9:
self._block_mouse_release_timer.stop()
return
index_flags = current_index.flags()
state = checkstate_int_to_enum(
current_index.data(QtCore.Qt.CheckStateRole)
)
new_state = None
if event.type() == QtCore.QEvent.MouseButtonRelease:
new_state = self._mouse_released_event_handle(
event, current_index, index_flags, state
)
elif event.type() == QtCore.QEvent.KeyPress:
new_state = self._key_press_event_handler(
event, current_index, index_flags, state
)
if new_state is not None:
model.setData(current_index, new_state, QtCore.Qt.CheckStateRole)
self.view().update(current_index)
self.repaint()
self.value_changed.emit()
return True
def eventFilter(self, obj, event):
"""Reimplemented."""
result = self._event_popup_shown(obj, event)
if result is not None:
return result
return super().eventFilter(obj, event)
def addItem(self, *args, **kwargs):
idx = self.count()
super().addItem(*args, **kwargs)
self.model().item(idx).setCheckable(True)
def paintEvent(self, event):
"""Reimplemented."""
painter = QtWidgets.QStylePainter(self)
option = QtWidgets.QStyleOptionComboBox()
self.initStyleOption(option)
painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option)
idxs = self._get_checked_idx()
# draw the icon and text
draw_text = True
combotext = None
if self._custom_text is not None:
combotext = self._custom_text
elif not idxs:
combotext = self._placeholder_text
else:
draw_text = False
content_field_rect = self.style().subControlRect(
QtWidgets.QStyle.CC_ComboBox,
option,
QtWidgets.QStyle.SC_ComboBoxEditField
).adjusted(1, 0, -1, 0)
if draw_text:
color = option.palette.color(QtGui.QPalette.Text)
color.setAlpha(67)
pen = painter.pen()
pen.setColor(color)
painter.setPen(pen)
painter.drawText(
content_field_rect,
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
combotext
)
else:
self._paint_items(painter, idxs, content_field_rect)
painter.end()
def _paint_items(self, painter, indexes, content_rect):
origin_rect = QtCore.QRect(content_rect)
metrics = self.fontMetrics()
model = self.model()
available_width = content_rect.width()
total_used_width = 0
painter.save()
spacing = 2
for idx in indexes:
index = model.index(idx, 0)
if not index.isValid():
continue
icon = index.data(self._icon_role)
# TODO handle this case
if icon is None or icon.isNull():
continue
icon_rect = QtCore.QRect(content_rect)
diff = icon_rect.height() - metrics.height()
if diff < 0:
diff = 0
top_offset = diff // 2
bottom_offset = diff - top_offset
icon_rect.adjust(0, top_offset, 0, -bottom_offset)
icon_rect.setWidth(metrics.height())
icon.paint(
painter,
icon_rect,
QtCore.Qt.AlignCenter,
QtGui.QIcon.Normal,
QtGui.QIcon.On
)
content_rect.setLeft(icon_rect.right() + spacing)
if total_used_width > 0:
total_used_width += spacing
total_used_width += icon_rect.width()
if total_used_width > available_width:
break
painter.restore()
if total_used_width > available_width:
ellide_dots = chr(0x2026)
painter.drawText(origin_rect, QtCore.Qt.AlignRight, ellide_dots)
def setItemCheckState(self, index, state):
self.setItemData(index, state, QtCore.Qt.CheckStateRole)
def set_value(self, values: Optional[Iterable[Any]], role: Optional[int] = None):
if role is None:
role = self._value_role
for idx in range(self.count()):
value = self.itemData(idx, role=role)
check_state = CHECKED_INT
if values is None or value not in values:
check_state = UNCHECKED_INT
self.setItemData(idx, check_state, QtCore.Qt.CheckStateRole)
self.repaint()
def get_value_info(
self,
role: Optional[int] = None,
propagate_all_unchecked_as_checked: bool = None
) -> List[Tuple[Any, bool]]:
"""Get the values and their checked state.
Args:
role (int | None): The role to get the values from.
If None, the default value role is used.
propagate_all_unchecked_as_checked (bool | None): If True,
all unchecked items will be treated as checked.
If None, the current value of
'propagate_all_unchecked_as_checked' is used.
Returns:
List[Tuple[Any, bool]]: The values and their checked state.
"""
if role is None:
role = self._value_role
if propagate_all_unchecked_as_checked is None:
propagate_all_unchecked_as_checked = (
self._all_unchecked_as_checked
)
items = []
all_unchecked = True
for idx in range(self.count()):
item_type = self.itemData(idx, role=self._item_type_role)
if item_type is not None and item_type != VALUE_ITEM_TYPE:
continue
state = checkstate_int_to_enum(
self.itemData(idx, role=QtCore.Qt.CheckStateRole)
)
checked = state == QtCore.Qt.Checked
if checked:
all_unchecked = False
items.append(
(self.itemData(idx, role=role), checked)
)
if propagate_all_unchecked_as_checked and all_unchecked:
items = [
(value, True)
for value, checked in items
]
return items
def get_value(self, role=None):
if role is None:
role = self._value_role
return [
value
for value, checked in self.get_value_info(role)
if checked
]
def wheelEvent(self, event):
event.ignore()
def keyPressEvent(self, event):
if (
event.key() == QtCore.Qt.Key_Down
and event.modifiers() & QtCore.Qt.AltModifier
):
return self.showPopup()
if event.key() in self.ignored_keys:
return event.ignore()
return super().keyPressEvent(event)
def _get_checked_idx(self) -> List[int]:
checked_indexes = []
for idx in range(self.count()):
item_type = self.itemData(idx, role=self._item_type_role)
if item_type is not None and item_type != VALUE_ITEM_TYPE:
continue
state = checkstate_int_to_enum(
self.itemData(idx, role=QtCore.Qt.CheckStateRole)
)
if state == QtCore.Qt.Checked:
checked_indexes.append(idx)
return checked_indexes
def _mouse_released_event_handle(
self, event, current_index, index_flags, state
):
if (
self._block_mouse_release_timer.isActive()
or not current_index.isValid()
or not self.view().isVisible()
or not self.view().rect().contains(event.pos())
or not index_flags & QtCore.Qt.ItemIsSelectable
or not index_flags & QtCore.Qt.ItemIsEnabled
or not index_flags & QtCore.Qt.ItemIsUserCheckable
):
return None
if state == QtCore.Qt.Checked:
return UNCHECKED_INT
return CHECKED_INT
def _key_press_event_handler(
self, event, current_index, index_flags, state
):
# TODO: handle QtCore.Qt.Key_Enter, Key_Return?
if event.key() != QtCore.Qt.Key_Space:
return None
if (
index_flags & QtCore.Qt.ItemIsUserCheckable
and index_flags & ITEM_IS_USER_TRISTATE
):
return (checkstate_enum_to_int(state) + 1) % 3
if index_flags & QtCore.Qt.ItemIsUserCheckable:
# toggle the current items check state
if state != QtCore.Qt.Checked:
return CHECKED_INT
return UNCHECKED_INT
return None

View file

@ -1,4 +1,7 @@
import numbers
import uuid
from typing import Dict
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils.lib import format_version
@ -15,31 +18,21 @@ from .products_model import (
SYNC_REMOTE_SITE_AVAILABILITY,
)
STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1
class VersionComboBox(QtWidgets.QComboBox):
value_changed = QtCore.Signal(str)
def __init__(self, product_id, parent):
super(VersionComboBox, self).__init__(parent)
self._product_id = product_id
class VersionsModel(QtGui.QStandardItemModel):
def __init__(self):
super().__init__()
self._items_by_id = {}
self._current_id = None
self.currentIndexChanged.connect(self._on_index_change)
def update_versions(self, version_items, current_version_id):
model = self.model()
root_item = model.invisibleRootItem()
version_items = list(reversed(version_items))
version_ids = [
def update_versions(self, version_items):
version_ids = {
version_item.version_id
for version_item in version_items
]
if current_version_id not in version_ids and version_ids:
current_version_id = version_ids[0]
self._current_id = current_version_id
}
root_item = self.invisibleRootItem()
to_remove = set(self._items_by_id.keys()) - set(version_ids)
for item_id in to_remove:
item = self._items_by_id.pop(item_id)
@ -54,13 +47,89 @@ class VersionComboBox(QtWidgets.QComboBox):
item = QtGui.QStandardItem(label)
item.setData(version_id, QtCore.Qt.UserRole)
self._items_by_id[version_id] = item
item.setData(version_item.status, STATUS_NAME_ROLE)
if item.row() != idx:
root_item.insertRow(idx, item)
class VersionsFilterModel(QtCore.QSortFilterProxyModel):
def __init__(self):
super().__init__()
self._status_filter = None
def filterAcceptsRow(self, row, parent):
if self._status_filter is None:
return True
if not self._status_filter:
return False
index = self.sourceModel().index(row, 0, parent)
status = index.data(STATUS_NAME_ROLE)
return status in self._status_filter
def set_statuses_filter(self, status_names):
if self._status_filter == status_names:
return
self._status_filter = status_names
self.invalidateFilter()
class VersionComboBox(QtWidgets.QComboBox):
value_changed = QtCore.Signal(str, str)
def __init__(self, product_id, parent):
super().__init__(parent)
versions_model = VersionsModel()
proxy_model = VersionsFilterModel()
proxy_model.setSourceModel(versions_model)
self.setModel(proxy_model)
self._product_id = product_id
self._items_by_id = {}
self._current_id = None
self._versions_model = versions_model
self._proxy_model = proxy_model
self.currentIndexChanged.connect(self._on_index_change)
def get_product_id(self):
return self._product_id
def set_statuses_filter(self, status_names):
self._proxy_model.set_statuses_filter(status_names)
if self.count() == 0:
return
if self.currentIndex() != 0:
self.setCurrentIndex(0)
def all_versions_filtered_out(self):
if self._items_by_id:
return self.count() == 0
return False
def update_versions(self, version_items, current_version_id):
self.blockSignals(True)
version_items = list(version_items)
version_ids = [
version_item.version_id
for version_item in version_items
]
if current_version_id not in version_ids and version_ids:
current_version_id = version_ids[0]
self._current_id = current_version_id
self._versions_model.update_versions(version_items)
index = version_ids.index(current_version_id)
if self.currentIndex() != index:
self.setCurrentIndex(index)
self.blockSignals(False)
def _on_index_change(self):
idx = self.currentIndex()
@ -68,23 +137,30 @@ class VersionComboBox(QtWidgets.QComboBox):
if value == self._current_id:
return
self._current_id = value
self.value_changed.emit(self._product_id)
self.value_changed.emit(self._product_id, value)
class VersionDelegate(QtWidgets.QStyledItemDelegate):
"""A delegate that display version integer formatted as version string."""
version_changed = QtCore.Signal()
version_changed = QtCore.Signal(str, str)
def __init__(self, *args, **kwargs):
super(VersionDelegate, self).__init__(*args, **kwargs)
self._editor_by_product_id = {}
super().__init__(*args, **kwargs)
self._editor_by_id: Dict[str, VersionComboBox] = {}
self._statuses_filter = None
def displayText(self, value, locale):
if not isinstance(value, numbers.Integral):
return "N/A"
return format_version(value)
def set_statuses_filter(self, status_names):
self._statuses_filter = set(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)
if fg_color:
@ -142,27 +218,27 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
if not product_id:
return
item_id = uuid.uuid4().hex
editor = VersionComboBox(product_id, parent)
self._editor_by_product_id[product_id] = editor
editor.setProperty("itemId", item_id)
editor.value_changed.connect(self._on_editor_change)
editor.destroyed.connect(self._on_destroy)
self._editor_by_id[item_id] = editor
return editor
def _on_editor_change(self, product_id):
editor = self._editor_by_product_id[product_id]
# Update model data
self.commitData.emit(editor)
# Display model data
self.version_changed.emit()
def setEditorData(self, editor, index):
editor.clear()
# 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)
def setModelData(self, editor, model, index):
"""Apply the integer version back in the model"""
@ -170,6 +246,13 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
version_id = editor.itemData(editor.currentIndex())
model.setData(index, version_id, VERSION_NAME_EDIT_ROLE)
def _on_editor_change(self, product_id, version_id):
self.version_changed.emit(product_id, version_id)
def _on_destroy(self, obj):
item_id = obj.property("itemId")
self._editor_by_id.pop(item_id, None)
class LoadedInSceneDelegate(QtWidgets.QStyledItemDelegate):
"""Delegate for Loaded in Scene state columns.

View file

@ -39,6 +39,8 @@ REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 28
SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 29
SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30
STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 31
class ProductsModel(QtGui.QStandardItemModel):
refreshed = QtCore.Signal()
@ -105,7 +107,7 @@ class ProductsModel(QtGui.QStandardItemModel):
}
def __init__(self, controller):
super(ProductsModel, self).__init__()
super().__init__()
self.setColumnCount(len(self.column_labels))
for idx, label in enumerate(self.column_labels):
self.setHeaderData(idx, QtCore.Qt.Horizontal, label)
@ -130,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()
]
@ -146,12 +148,26 @@ class ProductsModel(QtGui.QStandardItemModel):
return self._product_items_by_id.get(product_id)
def set_product_version(self, product_id, version_id):
if version_id is None:
return
product_item = self._items_by_id.get(product_id)
if product_item is None:
return
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
@ -163,7 +179,7 @@ class ProductsModel(QtGui.QStandardItemModel):
)
if index.column() != 0:
index = self.index(index.row(), 0, index.parent())
return super(ProductsModel, self).flags(index)
return super().flags(index)
def data(self, index, role=None):
if role is None:
@ -190,7 +206,7 @@ class ProductsModel(QtGui.QStandardItemModel):
return self._get_status_icon(status_name)
if col == 0:
return super(ProductsModel, self).data(index, role)
return super().data(index, role)
if role == QtCore.Qt.DecorationRole:
if col == 1:
@ -223,7 +239,7 @@ class ProductsModel(QtGui.QStandardItemModel):
index = self.index(index.row(), 0, index.parent())
return super(ProductsModel, self).data(index, role)
return super().data(index, role)
def setData(self, index, value, role=None):
if not index.isValid():
@ -255,7 +271,7 @@ class ProductsModel(QtGui.QStandardItemModel):
self._set_version_data_to_product_item(item, final_version_item)
self.version_changed.emit()
return True
return super(ProductsModel, self).setData(index, value, role)
return super().setData(index, value, role)
def _get_next_color(self):
return next(self._color_iterator)
@ -349,11 +365,10 @@ class ProductsModel(QtGui.QStandardItemModel):
representation count by version id.
sync_availability_by_version_id (Optional[str, Tuple[int, int]]):
Mapping of sync availability by version id.
"""
"""
model_item.setData(version_item.version_id, VERSION_ID_ROLE)
model_item.setData(version_item.version, VERSION_NAME_ROLE)
model_item.setData(version_item.version_id, VERSION_ID_ROLE)
model_item.setData(version_item.is_hero, VERSION_HERO_ROLE)
model_item.setData(
version_item.published_time, VERSION_PUBLISH_TIME_ROLE
@ -396,11 +411,15 @@ class ProductsModel(QtGui.QStandardItemModel):
remote_site_icon,
repre_count_by_version_id,
sync_availability_by_version_id,
last_version_by_product_id,
):
model_item = self._items_by_id.get(product_item.product_id)
versions = list(product_item.version_items.values())
versions.sort()
last_version = versions[-1]
last_version = last_version_by_product_id[product_item.product_id]
statuses = {
version_item.status
for version_item in product_item.version_items.values()
}
if model_item is None:
product_id = product_item.product_id
model_item = QtGui.QStandardItem(product_item.product_name)
@ -418,6 +437,7 @@ class ProductsModel(QtGui.QStandardItemModel):
self._product_items_by_id[product_id] = product_item
self._items_by_id[product_id] = model_item
model_item.setData("|".join(statuses), STATUS_NAME_FILTER_ROLE)
model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE)
in_scene = 1 if product_item.product_in_scene else 0
model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE)
@ -466,16 +486,19 @@ class ProductsModel(QtGui.QStandardItemModel):
product_item.product_id: product_item
for product_item in product_items
}
last_version_id_by_product_id = {}
last_version_by_product_id = {}
for product_item in product_items:
versions = list(product_item.version_items.values())
versions.sort()
last_version = versions[-1]
last_version_id_by_product_id[product_item.product_id] = (
last_version.version_id
last_version_by_product_id[product_item.product_id] = (
last_version
)
version_ids = set(last_version_id_by_product_id.values())
version_ids = {
version_item.version_id
for version_item in last_version_by_product_id.values()
}
repre_count_by_version_id = self._controller.get_versions_representation_count(
project_name, version_ids
)
@ -494,10 +517,7 @@ class ProductsModel(QtGui.QStandardItemModel):
product_name = product_item.product_name
group = product_name_matches_by_group[group_name]
if product_name not in group:
group[product_name] = [product_item]
continue
group[product_name].append(product_item)
group.setdefault(product_name, []).append(product_item)
group_names = set(product_name_matches_by_group.keys())
@ -513,8 +533,16 @@ class ProductsModel(QtGui.QStandardItemModel):
merged_product_items = {}
top_items = []
group_product_types = set()
group_status_names = set()
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_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])
else:
@ -529,7 +557,13 @@ class ProductsModel(QtGui.QStandardItemModel):
if group_name:
parent_item = self._get_group_model_item(group_name)
parent_item.setData(
"|".join(group_product_types), PRODUCT_TYPE_ROLE)
"|".join(group_product_types),
PRODUCT_TYPE_ROLE
)
parent_item.setData(
"|".join(group_status_names),
STATUS_NAME_FILTER_ROLE
)
new_items = []
if parent_item is not None and parent_item.row() < 0:
@ -542,6 +576,7 @@ class ProductsModel(QtGui.QStandardItemModel):
remote_site_icon,
repre_count_by_version_id,
sync_availability_by_version_id,
last_version_by_product_id,
)
new_items.append(item)
@ -549,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(
@ -564,12 +601,25 @@ class ProductsModel(QtGui.QStandardItemModel):
remote_site_icon,
repre_count_by_version_id,
sync_availability_by_version_id,
last_version_by_product_id,
)
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

@ -22,6 +22,7 @@ from .products_model import (
VERSION_STATUS_COLOR_ROLE,
VERSION_STATUS_ICON_ROLE,
VERSION_THUMBNAIL_ID_ROLE,
STATUS_NAME_FILTER_ROLE,
)
from .products_delegates import (
VersionDelegate,
@ -33,18 +34,31 @@ from .actions_utils import show_actions_menu
class ProductsProxyModel(RecursiveSortFilterProxyModel):
def __init__(self, parent=None):
super(ProductsProxyModel, self).__init__(parent)
super().__init__(parent)
self._product_type_filters = {}
self._statuses_filter = None
self._ascending_sort = True
def get_statuses_filter(self):
if self._statuses_filter is None:
return None
return set(self._statuses_filter)
def set_product_type_filters(self, product_type_filters):
self._product_type_filters = product_type_filters
self.invalidateFilter()
def set_statuses_filter(self, statuses_filter):
if self._statuses_filter == statuses_filter:
return
self._statuses_filter = statuses_filter
self.invalidateFilter()
def filterAcceptsRow(self, source_row, source_parent):
source_model = self.sourceModel()
index = source_model.index(source_row, 0, source_parent)
product_types_s = source_model.data(index, PRODUCT_TYPE_ROLE)
product_types = []
if product_types_s:
@ -53,8 +67,22 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel):
for product_type in product_types:
if not self._product_type_filters.get(product_type, True):
return False
return super(ProductsProxyModel, self).filterAcceptsRow(
source_row, source_parent)
if not self._accept_row_by_statuses(index):
return False
return super().filterAcceptsRow(source_row, source_parent)
def _accept_row_by_statuses(self, index):
if self._statuses_filter is None:
return True
if not self._statuses_filter:
return False
status_s = index.data(STATUS_NAME_FILTER_ROLE)
for status in status_s.split("|"):
if status in self._statuses_filter:
return True
return False
def lessThan(self, left, right):
l_model = left.model()
@ -74,20 +102,19 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel):
if not self._ascending_sort:
output = not output
return output
return super(ProductsProxyModel, self).lessThan(left, right)
return super().lessThan(left, right)
def sort(self, column, order=None):
if order is None:
order = QtCore.Qt.AscendingOrder
self._ascending_sort = order == QtCore.Qt.AscendingOrder
super(ProductsProxyModel, self).sort(column, order)
super().sort(column, order)
class ProductsWidget(QtWidgets.QWidget):
refreshed = QtCore.Signal()
merged_products_selection_changed = QtCore.Signal()
selection_changed = QtCore.Signal()
version_changed = QtCore.Signal()
default_widths = (
200, # Product name
90, # Product type
@ -159,11 +186,15 @@ 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
)
controller.register_event_callback(
"selection.folders.changed",
@ -211,10 +242,20 @@ class ProductsWidget(QtWidgets.QWidget):
Args:
name (str): The string filter.
"""
"""
self._products_proxy_model.setFilterFixedString(name)
def set_statuses_filter(self, status_names):
"""Set filter of version statuses.
Args:
status_names (list[str]): The list of status names.
"""
self._version_delegate.set_statuses_filter(status_names)
self._products_proxy_model.set_statuses_filter(status_names)
def set_product_type_filter(self, product_type_filters):
"""
@ -403,6 +444,9 @@ class ProductsWidget(QtWidgets.QWidget):
def _on_version_change(self):
self._on_selection_change()
def _on_version_delegate_change(self, product_id, version_id):
self._products_model.set_product_version(product_id, version_id)
def _on_folders_selection_change(self, event):
project_name = event["project_name"]
sitesync_enabled = self._controller.is_sitesync_enabled(

View file

@ -0,0 +1,331 @@
from typing import List, Dict
from qtpy import QtCore, QtGui
from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.common_models import StatusItem
from ._multicombobox import (
CustomPaintMultiselectComboBox,
STANDARD_ITEM_TYPE,
)
STATUS_ITEM_TYPE = 0
SELECT_ALL_TYPE = 1
DESELECT_ALL_TYPE = 2
SWAP_STATE_TYPE = 3
STATUSES_FILTER_SENDER = "loader.statuses_filter"
STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1
STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 2
STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 3
STATUS_ICON_ROLE = QtCore.Qt.UserRole + 4
ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5
ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 6
class StatusesQtModel(QtGui.QStandardItemModel):
def __init__(self, controller):
self._controller = controller
self._items_by_name: Dict[str, QtGui.QStandardItem] = {}
self._icons_by_name_n_color: Dict[str, QtGui.QIcon] = {}
self._last_project = None
self._select_project_item = None
self._empty_statuses_item = None
self._select_all_item = None
self._deselect_all_item = None
self._swap_states_item = None
super().__init__()
self.refresh(None)
def get_placeholder_text(self):
return self._placeholder
def refresh(self, project_name):
# New project was selected
# status filter is reset to show all statuses
uncheck_all = False
if project_name != self._last_project:
self._last_project = project_name
uncheck_all = True
if project_name is None:
self._add_select_project_item()
return
status_items: List[StatusItem] = (
self._controller.get_project_status_items(
project_name, sender=STATUSES_FILTER_SENDER
)
)
if not status_items:
self._add_empty_statuses_item()
return
self._remove_empty_items()
items_to_remove = set(self._items_by_name)
root_item = self.invisibleRootItem()
for row_idx, status_item in enumerate(status_items):
name = status_item.name
if name in self._items_by_name:
is_new = False
item = self._items_by_name[name]
if uncheck_all:
item.setCheckState(QtCore.Qt.Unchecked)
items_to_remove.discard(name)
else:
is_new = True
item = QtGui.QStandardItem()
item.setData(ITEM_SUBTYPE_ROLE, STATUS_ITEM_TYPE)
item.setCheckState(QtCore.Qt.Unchecked)
item.setFlags(
QtCore.Qt.ItemIsEnabled
| QtCore.Qt.ItemIsSelectable
| QtCore.Qt.ItemIsUserCheckable
)
self._items_by_name[name] = item
icon = self._get_icon(status_item)
for role, value in (
(STATUS_NAME_ROLE, status_item.name),
(STATUS_SHORT_ROLE, status_item.short),
(STATUS_COLOR_ROLE, status_item.color),
(STATUS_ICON_ROLE, icon),
):
if item.data(role) != value:
item.setData(value, role)
if is_new:
root_item.insertRow(row_idx, item)
for name in items_to_remove:
item = self._items_by_name.pop(name)
root_item.removeRow(item.row())
self._add_selection_items()
def setData(self, index, value, role):
if role == QtCore.Qt.CheckStateRole and index.isValid():
item_type = index.data(ITEM_SUBTYPE_ROLE)
if item_type == SELECT_ALL_TYPE:
for item in self._items_by_name.values():
item.setCheckState(QtCore.Qt.Checked)
return True
if item_type == DESELECT_ALL_TYPE:
for item in self._items_by_name.values():
item.setCheckState(QtCore.Qt.Unchecked)
return True
if item_type == SWAP_STATE_TYPE:
for item in self._items_by_name.values():
current_state = item.checkState()
item.setCheckState(
QtCore.Qt.Checked
if current_state == QtCore.Qt.Unchecked
else QtCore.Qt.Unchecked
)
return True
return super().setData(index, value, role)
def _get_icon(self, status_item: StatusItem) -> QtGui.QIcon:
name = status_item.name
color = status_item.color
unique_id = "|".join([name or "", color or ""])
icon = self._icons_by_name_n_color.get(unique_id)
if icon is not None:
return icon
icon: QtGui.QIcon = get_qt_icon({
"type": "material-symbols",
"name": status_item.icon,
"color": status_item.color
})
self._icons_by_name_n_color[unique_id] = icon
return icon
def _init_default_items(self):
if self._empty_statuses_item is not None:
return
empty_statuses_item = QtGui.QStandardItem("No statuses...")
select_project_item = QtGui.QStandardItem("Select project...")
select_all_item = QtGui.QStandardItem("Select all")
deselect_all_item = QtGui.QStandardItem("Deselect all")
swap_states_item = QtGui.QStandardItem("Swap")
for item in (
empty_statuses_item,
select_project_item,
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setData(STANDARD_ITEM_TYPE, ITEM_TYPE_ROLE)
select_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "done_all",
"color": "white"
}))
deselect_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "remove_done",
"color": "white"
}))
swap_states_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "swap_horiz",
"color": "white"
}))
for item in (
empty_statuses_item,
select_project_item,
):
item.setFlags(QtCore.Qt.NoItemFlags)
for item, item_type in (
(select_all_item, SELECT_ALL_TYPE),
(deselect_all_item, DESELECT_ALL_TYPE),
(swap_states_item, SWAP_STATE_TYPE),
):
item.setData(item_type, ITEM_SUBTYPE_ROLE)
for item in (
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setFlags(
QtCore.Qt.ItemIsEnabled
| QtCore.Qt.ItemIsSelectable
| QtCore.Qt.ItemIsUserCheckable
)
self._empty_statuses_item = empty_statuses_item
self._select_project_item = select_project_item
self._select_all_item = select_all_item
self._deselect_all_item = deselect_all_item
self._swap_states_item = swap_states_item
def _get_empty_statuses_item(self):
self._init_default_items()
return self._empty_statuses_item
def _get_select_project_item(self):
self._init_default_items()
return self._select_project_item
def _get_empty_items(self):
self._init_default_items()
return [
self._empty_statuses_item,
self._select_project_item,
]
def _get_selection_items(self):
self._init_default_items()
return [
self._select_all_item,
self._deselect_all_item,
self._swap_states_item,
]
def _get_default_items(self):
return self._get_empty_items() + self._get_selection_items()
def _add_select_project_item(self):
item = self._get_select_project_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_empty_statuses_item(self):
item = self._get_empty_statuses_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_selection_items(self):
root_item = self.invisibleRootItem()
items = self._get_selection_items()
for item in self._get_selection_items():
row = item.row()
if row >= 0:
root_item.takeRow(row)
root_item.appendRows(items)
def _remove_items(self):
root_item = self.invisibleRootItem()
for item in self._get_default_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
root_item.removeRows(0, root_item.rowCount())
self._items_by_name.clear()
def _remove_empty_items(self):
root_item = self.invisibleRootItem()
for item in self._get_empty_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
class StatusesCombobox(CustomPaintMultiselectComboBox):
def __init__(self, controller, parent):
self._controller = controller
model = StatusesQtModel(controller)
super().__init__(
STATUS_NAME_ROLE,
STATUS_SHORT_ROLE,
STATUS_COLOR_ROLE,
STATUS_ICON_ROLE,
item_type_role=ITEM_TYPE_ROLE,
model=model,
parent=parent
)
self.set_placeholder_text("Version status filter...")
self._model = model
self._last_project_name = None
self._fully_disabled_filter = False
controller.register_event_callback(
"selection.project.changed",
self._on_project_change
)
controller.register_event_callback(
"projects.refresh.finished",
self._on_projects_refresh
)
self.setToolTip("Statuses filter")
self.value_changed.connect(
self._on_status_filter_change
)
def _on_status_filter_change(self):
lines = ["Statuses filter"]
for item in self.get_value_info():
status_name, enabled = item
lines.append(f"{'' if enabled else ''} {status_name}")
self.setToolTip("\n".join(lines))
def _on_project_change(self, event):
project_name = event["project_name"]
self._last_project_name = project_name
self._model.refresh(project_name)
def _on_projects_refresh(self):
if self._last_project_name:
self._model.refresh(self._last_project_name)
self._on_status_filter_change()

View file

@ -19,6 +19,7 @@ from .product_types_widget import ProductTypesView
from .product_group_dialog import ProductGroupDialog
from .info_widget import InfoWidget
from .repres_widget import RepresentationsWidget
from .statuses_combo import StatusesCombobox
class LoadErrorMessageBox(ErrorMessageBox):
@ -183,6 +184,9 @@ class LoaderWindow(QtWidgets.QWidget):
products_filter_input = PlaceholderLineEdit(products_inputs_widget)
products_filter_input.setPlaceholderText("Product name filter...")
product_status_filter_combo = StatusesCombobox(controller, self)
product_group_checkbox = QtWidgets.QCheckBox(
"Enable grouping", products_inputs_widget)
product_group_checkbox.setChecked(True)
@ -192,6 +196,7 @@ class LoaderWindow(QtWidgets.QWidget):
products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget)
products_inputs_layout.setContentsMargins(0, 0, 0, 0)
products_inputs_layout.addWidget(products_filter_input, 1)
products_inputs_layout.addWidget(product_status_filter_combo, 1)
products_inputs_layout.addWidget(product_group_checkbox, 0)
products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget)
@ -245,6 +250,9 @@ class LoaderWindow(QtWidgets.QWidget):
products_filter_input.textChanged.connect(
self._on_product_filter_change
)
product_status_filter_combo.value_changed.connect(
self._on_status_filter_change
)
product_group_checkbox.stateChanged.connect(
self._on_product_group_change
)
@ -299,6 +307,7 @@ class LoaderWindow(QtWidgets.QWidget):
self._product_types_widget = product_types_widget
self._products_filter_input = products_filter_input
self._product_status_filter_combo = product_status_filter_combo
self._product_group_checkbox = product_group_checkbox
self._products_widget = products_widget
@ -412,6 +421,10 @@ class LoaderWindow(QtWidgets.QWidget):
def _on_product_filter_change(self, text):
self._products_widget.set_name_filter(text)
def _on_status_filter_change(self):
status_names = self._product_status_filter_combo.get_value()
self._products_widget.set_statuses_filter(status_names)
def _on_product_type_filter_change(self):
self._products_widget.set_product_type_filter(
self._product_types_widget.get_filter_info()

View file

@ -166,6 +166,12 @@ class AbstractPublisherBackend(AbstractPublisherCommon):
) -> Union[TaskItem, None]:
pass
@abstractmethod
def get_project_entity(
self, project_name: str
) -> Union[Dict[str, Any], None]:
pass
@abstractmethod
def get_folder_entity(
self, project_name: str, folder_id: str

View file

@ -193,6 +193,9 @@ class PublisherController(
def get_convertor_items(self):
return self._create_model.get_convertor_items()
def get_project_entity(self, project_name):
return self._projects_model.get_project_entity(project_name)
def get_folder_type_items(self, project_name, sender=None):
return self._projects_model.get_folder_type_items(
project_name, sender

View file

@ -9,6 +9,7 @@ from ayon_core.lib.attribute_definitions import (
)
from ayon_core.lib.profiles_filtering import filter_profiles
from ayon_core.lib.attribute_definitions import UIDef
from ayon_core.lib import is_func_signature_supported
from ayon_core.pipeline.create import (
BaseCreator,
AutoCreator,
@ -26,6 +27,7 @@ from ayon_core.tools.publisher.abstract import (
AbstractPublisherBackend,
CardMessageTypes,
)
CREATE_EVENT_SOURCE = "publisher.create.model"
@ -356,13 +358,24 @@ class CreateModel:
project_name, task_item.task_id
)
return creator.get_product_name(
project_entity = self._controller.get_project_entity(project_name)
args = (
project_name,
folder_entity,
task_entity,
variant,
instance=instance
variant
)
kwargs = {
"instance": instance,
"project_entity": project_entity,
}
# Backwards compatibility for 'project_entity' argument
# - 'get_product_name' signature changed 24/07/08
if not is_func_signature_supported(
creator.get_product_name, *args, **kwargs
):
kwargs.pop("project_entity")
return creator.get_product_name(*args, **kwargs)
def create(
self,

View file

@ -1,6 +1,5 @@
import os
import json
import six
import uuid
import appdirs
@ -387,7 +386,7 @@ class LoadedFilesModel(QtGui.QStandardItemModel):
if not filepaths:
return
if isinstance(filepaths, six.string_types):
if isinstance(filepaths, str):
filepaths = [filepaths]
filtered_paths = []

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,445 @@
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 ayon_api
import requests
from ayon_core.lib import Logger, get_ayon_launcher_args, run_detached_process
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 ayon_api.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_information(tray_url: str) -> Optional[Dict[str, Any]]:
if not tray_url:
return None
try:
response = requests.get(f"{tray_url}/tray")
response.raise_for_status()
return response.json()
except (requests.HTTPError, requests.ConnectionError):
return None
def _get_tray_info_filepath(
server_url: Optional[str] = None,
variant: Optional[str] = None
) -> 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
) -> 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.
"""
filepath = _get_tray_info_filepath(server_url, variant)
if not os.path.exists(filepath):
return None
try:
with open(filepath, "r") as stream:
data = json.load(stream)
except Exception:
return None
return data
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.
"""
data = get_tray_file_info(server_url, variant)
if data is None:
return None
if data.get("started") is False:
data = _wait_for_starting_tray(server_url, variant, timeout)
if data is None:
return None
url = data.get("url")
if not url:
return None
if not validate:
return url
if _get_tray_information(url):
return url
return None
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.
"""
file_info = get_tray_file_info()
if file_info and file_info["pid"] != os.getpid():
if not file_info["started"] or _get_tray_information(file_info["url"]):
raise TrayIsRunningError("Tray is already running.")
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
) -> Optional[Dict[str, Any]]:
"""Get information about tray.
Args:
server_url (Optional[str]): AYON server url.
variant (Optional[str]): Settings variant.
Returns:
Optional[Dict[str, Any]]: Tray information.
"""
tray_url = get_tray_server_url(server_url, variant)
return _get_tray_information(tray_url)
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.
"""
file_info = get_tray_file_info(server_url, variant)
if file_info is None:
return TrayState.NOT_RUNNING
if file_info.get("started") is False:
return TrayState.STARTING
tray_url = file_info.get("url")
info = _get_tray_information(tray_url)
if not info:
# Remove the information as the tray is not running
remove_tray_server_url(force=True)
return TrayState.NOT_RUNNING
return TrayState.RUNNING
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 make_sure_tray_is_running(
ayon_url: Optional[str] = None,
variant: 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.
env (Optional[Dict[str, str]]): Environment variables for the process.
"""
state = get_tray_state(ayon_url, variant)
if state == TrayState.RUNNING:
return
if state == TrayState.STARTING:
_wait_for_starting_tray(ayon_url, variant)
state = get_tray_state(ayon_url, variant)
if state == TrayState.RUNNING:
return
args = get_ayon_launcher_args("tray", "--force")
if env is None:
env = os.environ.copy()
# Make sure 'QT_API' is not set
env.pop("QT_API", None)
if ayon_url:
env["AYON_SERVER_URL"] = ayon_url
# TODO maybe handle variant in a better way
if variant:
if variant == "staging":
args.append("--use-staging")
run_detached_process(args, env=env)
def main(force=False):
from ayon_core.tools.tray.ui import main
Logger.set_process_name("Tray")
state = get_tray_state()
if force and state in (TrayState.RUNNING, TrayState.STARTING):
file_info = get_tray_file_info() or {}
pid = file_info.get("pid")
if pid is not None:
_kill_tray_process(pid)
remove_tray_server_url(force=True)
state = TrayState.NOT_RUNNING
if state == TrayState.RUNNING:
print("Tray is already running.")
return
if state == TrayState.STARTING:
print("Tray is starting. Waiting for it to start.")
_wait_for_starting_tray()
state = get_tray_state()
if state == TrayState.RUNNING:
print("Tray started. Exiting.")
return
if state == TrayState.STARTING:
print(
"Tray did not start in expected time."
" Killing the process and starting new."
)
file_info = get_tray_file_info() or {}
pid = file_info.get("pid")
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

@ -83,7 +83,7 @@ class UpdateDialog(QtWidgets.QDialog):
top_layout.addWidget(label_widget, 1)
ignore_btn = QtWidgets.QPushButton("Ignore", self)
restart_btn = QtWidgets.QPushButton("Restart && Change", self)
restart_btn = QtWidgets.QPushButton("Restart && Update", self)
restart_btn.setObjectName("TrayRestartButton")
btns_layout = QtWidgets.QHBoxLayout()

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,10 +1,12 @@
import os
import sys
import time
import collections
import atexit
import json
import platform
from aiohttp.web_response import Response
import ayon_api
from qtpy import QtCore, QtGui, QtWidgets
@ -21,13 +23,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 +62,51 @@ 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()
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."""
@ -102,50 +136,64 @@ class TrayManager:
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._get_web_tray_info
)
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 +255,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 +274,19 @@ class TrayManager:
return item
async def _get_web_tray_info(self, request):
return Response(text=json.dumps({
"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,
}))
def _on_update_check_timer(self):
try:
bundles = ayon_api.get_bundles()
@ -298,20 +363,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 +388,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 +403,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 +494,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 +530,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 +541,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 +551,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 +565,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 +584,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 +609,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

@ -17,7 +17,7 @@ from ayon_core.style import (
from ayon_core.resources import get_image_path
from ayon_core.lib import Logger
from .constants import CHECKED_INT, UNCHECKED_INT
from .constants import CHECKED_INT, UNCHECKED_INT, PARTIALLY_CHECKED_INT
log = Logger.get_logger(__name__)
@ -37,10 +37,10 @@ def checkstate_enum_to_int(state):
if isinstance(state, int):
return state
if state == QtCore.Qt.Checked:
return 0
return CHECKED_INT
if state == QtCore.Qt.PartiallyChecked:
return 1
return 2
return PARTIALLY_CHECKED_INT
return UNCHECKED_INT
def center_window(window):
@ -485,7 +485,10 @@ class _IconsCache:
parts = [icon_type, icon_def["path"]]
elif icon_type in {"awesome-font", "material-symbols"}:
parts = [icon_type, icon_def["name"], icon_def["color"]]
color = icon_def["color"] or ""
if isinstance(color, QtGui.QColor):
color = color.name()
parts = [icon_type, icon_def["name"] or "", color]
return "|".join(parts)
@classmethod

View file

@ -770,7 +770,7 @@ class SeparatorWidget(QtWidgets.QFrame):
if self._orientation == orientation:
return
# Reset min/max sizes in opossite direction
# Reset min/max sizes in opposite direction
if self._orientation == QtCore.Qt.Vertical:
self.setMinimumHeight(0)
self.setMaximumHeight(self._maximum_height)

View file

@ -1,7 +1,6 @@
import os
from abc import ABCMeta, abstractmethod
from abc import ABC, abstractmethod
import six
from ayon_core.style import get_default_entity_icon_color
@ -335,8 +334,7 @@ class WorkareaFilepathResult:
self.filepath = filepath
@six.add_metaclass(ABCMeta)
class AbstractWorkfilesCommon(object):
class AbstractWorkfilesCommon(ABC):
@abstractmethod
def is_host_valid(self):
"""Host is valid for workfiles tool work.

View file

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