mirror of
https://github.com/ynput/ayon-core.git
synced 2026-01-02 00:44:52 +01:00
Merge branch 'develop' into enhancement/usd_workflow_use_entity_uri
This commit is contained in:
commit
0ccb7d6541
213 changed files with 2682 additions and 18444 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ from .base import (
|
|||
load_modules,
|
||||
|
||||
ModulesManager,
|
||||
TrayModulesManager,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -38,5 +37,4 @@ __all__ = (
|
|||
"load_modules",
|
||||
|
||||
"ModulesManager",
|
||||
"TrayModulesManager",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
from .version import __version__
|
||||
from .structures import HostMsgAction
|
||||
from .webserver_module import (
|
||||
WebServerAddon
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"__version__",
|
||||
|
||||
"HostMsgAction",
|
||||
"WebServerAddon",
|
||||
)
|
||||
|
|
@ -1 +0,0 @@
|
|||
__version__ = "1.0.0"
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -65,7 +65,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin):
|
|||
"xgen",
|
||||
"yeticacheUE",
|
||||
"tycache",
|
||||
"usd"
|
||||
"usd",
|
||||
"oxrig"
|
||||
]
|
||||
|
||||
def process(self, instance):
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
658
client/ayon_core/tools/loader/ui/_multicombobox.py
Normal file
658
client/ayon_core/tools/loader/ui/_multicombobox.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
331
client/ayon_core/tools/loader/ui/statuses_combo.py
Normal file
331
client/ayon_core/tools/loader/ui/statuses_combo.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
445
client/ayon_core/tools/tray/lib.py
Normal file
445
client/ayon_core/tools/tray/lib.py
Normal 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()
|
||||
|
||||
6
client/ayon_core/tools/tray/ui/__init__.py
Normal file
6
client/ayon_core/tools/tray/ui/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .tray import main
|
||||
|
||||
|
||||
__all__ = (
|
||||
"main",
|
||||
)
|
||||
247
client/ayon_core/tools/tray/ui/addons_manager.py
Normal file
247
client/ayon_core/tools/tray/ui/addons_manager.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
|
@ -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()
|
||||
9
client/ayon_core/tools/tray/webserver/__init__.py
Normal file
9
client/ayon_core/tools/tray/webserver/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from .base_routes import RestApiEndpoint
|
||||
from .server import find_free_port, WebServerManager
|
||||
|
||||
|
||||
__all__ = (
|
||||
"RestApiEndpoint",
|
||||
"find_free_port",
|
||||
"WebServerManager",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue