diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c0ab04abef..2cef7d13b0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,20 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.3.2 + - 1.3.1 + - 1.3.0 + - 1.2.0 + - 1.1.9 + - 1.1.8 + - 1.1.7 + - 1.1.6 + - 1.1.5 + - 1.1.4 + - 1.1.3 + - 1.1.2 + - 1.1.1 + - 1.1.0 - 1.0.14 - 1.0.13 - 1.0.12 diff --git a/.github/workflows/pr_linting.yml b/.github/workflows/pr_linting.yml index 896d5b7f4d..d41596fb4a 100644 --- a/.github/workflows/pr_linting.yml +++ b/.github/workflows/pr_linting.yml @@ -21,6 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: astral-sh/ruff-action@v1 + - uses: astral-sh/ruff-action@v3 with: changed-files: "true" + version-file: "pyproject.toml" diff --git a/.github/workflows/update_bug_report.yml b/.github/workflows/update_bug_report.yml index 1e5da414bb..98a8454e4b 100644 --- a/.github/workflows/update_bug_report.yml +++ b/.github/workflows/update_bug_report.yml @@ -1,10 +1,11 @@ name: 🐞 Update Bug Report on: + workflow_run: + workflows: ["🚀 Release Trigger"] + types: + - completed workflow_dispatch: - release: - # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release - types: [published] jobs: update-bug-report: diff --git a/.gitignore b/.gitignore index 41389755f1..72c4204dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -77,9 +77,13 @@ dump.sql # Poetry ######## .poetry/ +poetry.lock .python-version .editorconfig .pre-commit-config.yaml mypy.ini .github_changelog_generator + +# ignore mkdocs build +site/ diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index f983e37d3c..bb365f42e1 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -37,7 +37,7 @@ def _handle_error( if process_context.headless: if detail: print(detail) - print(f"{10*'*'}\n{message}\n{10*'*'}") + print(f"{10 * '*'}\n{message}\n{10 * '*'}") return current_dir = os.path.dirname(os.path.abspath(__file__)) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 8f2abbaeab..13573b52eb 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -186,7 +186,6 @@ def contextselection( main(output_path, project, folder, strict) - @main_cli.command( context_settings=dict( ignore_unknown_options=True, diff --git a/client/ayon_core/hooks/pre_global_host_data.py b/client/ayon_core/hooks/pre_global_host_data.py index 12da6f12f8..23f725901c 100644 --- a/client/ayon_core/hooks/pre_global_host_data.py +++ b/client/ayon_core/hooks/pre_global_host_data.py @@ -1,12 +1,15 @@ from ayon_api import get_project, get_folder_by_path, get_task_by_name +from ayon_core.pipeline import Anatomy +from ayon_core.pipeline.anatomy import RootMissingEnv + from ayon_applications import PreLaunchHook +from ayon_applications.exceptions import ApplicationLaunchFailed from ayon_applications.utils import ( EnvironmentPrepData, prepare_app_environments, prepare_context_environments ) -from ayon_core.pipeline import Anatomy class GlobalHostDataHook(PreLaunchHook): @@ -67,9 +70,12 @@ class GlobalHostDataHook(PreLaunchHook): self.data["project_entity"] = project_entity # Anatomy - self.data["anatomy"] = Anatomy( - project_name, project_entity=project_entity - ) + try: + self.data["anatomy"] = Anatomy( + project_name, project_entity=project_entity + ) + except RootMissingEnv as exc: + raise ApplicationLaunchFailed(str(exc)) folder_path = self.data.get("folder_path") if not folder_path: diff --git a/client/ayon_core/hooks/pre_ocio_hook.py b/client/ayon_core/hooks/pre_ocio_hook.py index 9f5c8c7339..d1a02e613d 100644 --- a/client/ayon_core/hooks/pre_ocio_hook.py +++ b/client/ayon_core/hooks/pre_ocio_hook.py @@ -29,6 +29,15 @@ class OCIOEnvHook(PreLaunchHook): def execute(self): """Hook entry method.""" + task_entity = self.data.get("task_entity") + + if not task_entity: + self.log.info( + "Skipping OCIO Environment preparation." + "Task Entity is not available." + ) + return + folder_entity = self.data["folder_entity"] template_data = get_template_data( diff --git a/client/ayon_core/hooks/pre_remove_launcher_paths.py b/client/ayon_core/hooks/pre_remove_launcher_paths.py new file mode 100644 index 0000000000..df27e512d0 --- /dev/null +++ b/client/ayon_core/hooks/pre_remove_launcher_paths.py @@ -0,0 +1,30 @@ +""""Pre launch hook to remove launcher paths from the system.""" +import os + +from ayon_applications import PreLaunchHook + + +class PreRemoveLauncherPaths(PreLaunchHook): + """Remove launcher paths from the system. + + This hook is used to remove launcher paths from the system before launching + an application. It is used to ensure that the application is launched with + the correct environment variables. Especially for Windows, where + paths in `PATH` are used to load DLLs. This is important to avoid + conflicts with other applications that may have the same DLLs in their + paths. + """ + order = 1 + + def execute(self) -> None: + """Execute the hook.""" + # Remove launcher paths from the system + ayon_root = os.path.normpath(os.environ["AYON_ROOT"]) + + paths = [ + path + for path in self.launch_context.env.get( + "PATH", "").split(os.pathsep) + if not os.path.normpath(path).startswith(ayon_root) + ] + self.launch_context.env["PATH"] = os.pathsep.join(paths) diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 92c3966e77..477eb29c28 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -62,6 +62,7 @@ from .execute import ( run_subprocess, run_detached_process, run_ayon_launcher_process, + run_detached_ayon_launcher_process, path_to_subprocess_arg, CREATE_NO_WINDOW ) @@ -98,7 +99,6 @@ from .profiles_filtering import ( from .transcoding import ( get_transcode_temp_directory, should_convert_for_ffmpeg, - convert_for_ffmpeg, convert_input_paths_for_ffmpeg, get_ffprobe_data, get_ffprobe_streams, @@ -132,6 +132,7 @@ from .ayon_info import ( is_staging_enabled, is_dev_mode_enabled, is_in_tests, + get_settings_variant, ) terminal = Terminal @@ -161,6 +162,7 @@ __all__ = [ "run_subprocess", "run_detached_process", "run_ayon_launcher_process", + "run_detached_ayon_launcher_process", "path_to_subprocess_arg", "CREATE_NO_WINDOW", @@ -198,7 +200,6 @@ __all__ = [ "get_transcode_temp_directory", "should_convert_for_ffmpeg", - "convert_for_ffmpeg", "convert_input_paths_for_ffmpeg", "get_ffprobe_data", "get_ffprobe_streams", @@ -242,4 +243,5 @@ __all__ = [ "is_staging_enabled", "is_dev_mode_enabled", "is_in_tests", + "get_settings_variant", ] diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 6b334aa16a..cb74fea0f1 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -22,12 +22,10 @@ import clique if typing.TYPE_CHECKING: from typing import Self, Tuple, Union, TypedDict, Pattern - class EnumItemDict(TypedDict): label: str value: Any - EnumItemsInputType = Union[ Dict[Any, str], List[Tuple[Any, str]], @@ -35,7 +33,6 @@ if typing.TYPE_CHECKING: List[EnumItemDict] ] - class FileDefItemDict(TypedDict): directory: str filenames: List[str] @@ -289,6 +286,7 @@ AttrDefType = TypeVar("AttrDefType", bound=AbstractAttrDef) # UI attribute definitions won't hold value # ----------------------------------------- + class UIDef(AbstractAttrDef): is_value_def = False diff --git a/client/ayon_core/lib/ayon_connection.py b/client/ayon_core/lib/ayon_connection.py index 1132d77aaa..32aa5ad629 100644 --- a/client/ayon_core/lib/ayon_connection.py +++ b/client/ayon_core/lib/ayon_connection.py @@ -177,10 +177,12 @@ def initialize_ayon_connection(force=False): return _new_get_last_versions( con, *args, **kwargs ) + def _lv_by_pi_wrapper(*args, **kwargs): return _new_get_last_version_by_product_id( con, *args, **kwargs ) + def _lv_by_pn_wrapper(*args, **kwargs): return _new_get_last_version_by_product_name( con, *args, **kwargs diff --git a/client/ayon_core/lib/ayon_info.py b/client/ayon_core/lib/ayon_info.py index 7e194a824e..1a7e4cca76 100644 --- a/client/ayon_core/lib/ayon_info.py +++ b/client/ayon_core/lib/ayon_info.py @@ -78,15 +78,15 @@ def is_using_ayon_console(): return "ayon_console" in executable_filename -def is_headless_mode_enabled(): +def is_headless_mode_enabled() -> bool: return os.getenv("AYON_HEADLESS_MODE") == "1" -def is_staging_enabled(): +def is_staging_enabled() -> bool: return os.getenv("AYON_USE_STAGING") == "1" -def is_in_tests(): +def is_in_tests() -> bool: """Process is running in automatic tests mode. Returns: @@ -96,7 +96,7 @@ def is_in_tests(): return os.environ.get("AYON_IN_TESTS") == "1" -def is_dev_mode_enabled(): +def is_dev_mode_enabled() -> bool: """Dev mode is enabled in AYON. Returns: @@ -106,6 +106,22 @@ def is_dev_mode_enabled(): return os.getenv("AYON_USE_DEV") == "1" +def get_settings_variant() -> str: + """Get AYON settings variant. + + Returns: + str: Settings variant. + + """ + if is_dev_mode_enabled(): + return os.environ["AYON_BUNDLE_NAME"] + + if is_staging_enabled(): + return "staging" + + return "production" + + def get_ayon_info(): executable_args = get_ayon_launcher_args() if is_running_from_build(): diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index 516ea958f5..7c6efde35c 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import sys import subprocess @@ -201,29 +202,9 @@ def clean_envs_for_ayon_process(env=None): return env -def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): - """Execute AYON process with passed arguments and wait. - - Wrapper for 'run_process' which prepends AYON executable arguments - before passed arguments and define environments if are not passed. - - Values from 'os.environ' are used for environments if are not passed. - They are cleaned using 'clean_envs_for_ayon_process' function. - - Example: - ``` - run_ayon_process("run", "") - ``` - - Args: - *args (str): ayon-launcher cli arguments. - **kwargs (Any): Keyword arguments for subprocess.Popen. - - Returns: - str: Full output of subprocess concatenated stdout and stderr. - - """ - args = get_ayon_launcher_args(*args) +def _prepare_ayon_launcher_env( + add_sys_paths: bool, kwargs: dict +) -> dict[str, str]: env = kwargs.pop("env", None) # Keep env untouched if are passed and not empty if not env: @@ -239,8 +220,7 @@ def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): new_pythonpath.append(path) lookup_set.add(path) env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) - - return run_subprocess(args, env=env, **kwargs) + return env def run_detached_process(args, **kwargs): @@ -314,6 +294,67 @@ def run_detached_process(args, **kwargs): return process +def run_ayon_launcher_process( + *args, add_sys_paths: bool = False, **kwargs +) -> str: + """Execute AYON process with passed arguments and wait. + + Wrapper for 'run_process' which prepends AYON executable arguments + before passed arguments and define environments if are not passed. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_ayon_process' function. + + Example: + ``` + run_ayon_launcher_process("run", "") + ``` + + Args: + *args (str): ayon-launcher cli arguments. + add_sys_paths (bool): Add system paths to PYTHONPATH. + **kwargs (Any): Keyword arguments for subprocess.Popen. + + Returns: + str: Full output of subprocess concatenated stdout and stderr. + + """ + args = get_ayon_launcher_args(*args) + env = _prepare_ayon_launcher_env(add_sys_paths, kwargs) + return run_subprocess(args, env=env, **kwargs) + + +def run_detached_ayon_launcher_process( + *args, add_sys_paths: bool = False, **kwargs +) -> subprocess.Popen: + """Execute AYON process with passed arguments and wait. + + Wrapper for 'run_process' which prepends AYON executable arguments + before passed arguments and define environments if are not passed. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_ayon_process' function. + + Example: + ``` + run_detached_ayon_launcher_process("run", "") + ``` + + Args: + *args (str): ayon-launcher cli arguments. + add_sys_paths (bool): Add system paths to PYTHONPATH. + **kwargs (Any): Keyword arguments for subprocess.Popen. + + Returns: + subprocess.Popen: Pointer to launched process but it is possible that + launched process is already killed (on linux). + + """ + args = get_ayon_launcher_args(*args) + env = _prepare_ayon_launcher_env(add_sys_paths, kwargs) + return run_detached_process(args, env=env, **kwargs) + + def path_to_subprocess_arg(path): """Prepare path for subprocess arguments. diff --git a/client/ayon_core/lib/file_transaction.py b/client/ayon_core/lib/file_transaction.py index a502403958..d720ff8d30 100644 --- a/client/ayon_core/lib/file_transaction.py +++ b/client/ayon_core/lib/file_transaction.py @@ -1,15 +1,13 @@ +import concurrent.futures import os import logging -import sys import errno +from concurrent.futures import ThreadPoolExecutor, Future +from typing import List, Optional from ayon_core.lib import create_hard_link -# this is needed until speedcopy for linux is fixed -if sys.platform == "win32": - from speedcopy import copyfile -else: - from shutil import copyfile +from speedcopy import copyfile class DuplicateDestinationError(ValueError): @@ -109,41 +107,52 @@ class FileTransaction: self._transfers[dst] = (src, opts) def process(self): - # Backup any existing files - for dst, (src, _) in self._transfers.items(): - self.log.debug("Checking file ... {} -> {}".format(src, dst)) - path_same = self._same_paths(src, dst) - if path_same or not os.path.exists(dst): - continue + with ThreadPoolExecutor(max_workers=8) as executor: + # Submit backup tasks + backup_futures = [ + executor.submit(self._backup_file, dst, src) + for dst, (src, _) in self._transfers.items() + ] + wait_for_future_errors( + executor, backup_futures, logger=self.log) - # Backup original file - # todo: add timestamp or uuid to ensure unique - backup = dst + ".bak" - self._backup_to_original[backup] = dst + # Submit transfer tasks + transfer_futures = [ + executor.submit(self._transfer_file, dst, src, opts) + for dst, (src, opts) in self._transfers.items() + ] + wait_for_future_errors( + executor, transfer_futures, logger=self.log) + + def _backup_file(self, dst, src): + self.log.debug(f"Checking file ... {src} -> {dst}") + path_same = self._same_paths(src, dst) + if path_same or not os.path.exists(dst): + return + + # Backup original file + backup = dst + ".bak" + self._backup_to_original[backup] = dst + self.log.debug(f"Backup existing file: {dst} -> {backup}") + os.rename(dst, backup) + + def _transfer_file(self, dst, src, opts): + path_same = self._same_paths(src, dst) + if path_same: self.log.debug( - "Backup existing file: {} -> {}".format(dst, backup)) - os.rename(dst, backup) + f"Source and destination are same files {src} -> {dst}") + return - # Copy the files to transfer - for dst, (src, opts) in self._transfers.items(): - path_same = self._same_paths(src, dst) - if path_same: - self.log.debug( - "Source and destination are same files {} -> {}".format( - src, dst)) - continue + self._create_folder_for_file(dst) - self._create_folder_for_file(dst) + if opts["mode"] == self.MODE_COPY: + self.log.debug(f"Copying file ... {src} -> {dst}") + copyfile(src, dst) + elif opts["mode"] == self.MODE_HARDLINK: + self.log.debug(f"Hardlinking file ... {src} -> {dst}") + create_hard_link(src, dst) - if opts["mode"] == self.MODE_COPY: - self.log.debug("Copying file ... {} -> {}".format(src, dst)) - copyfile(src, dst) - elif opts["mode"] == self.MODE_HARDLINK: - self.log.debug("Hardlinking file ... {} -> {}".format( - src, dst)) - create_hard_link(src, dst) - - self._transferred.append(dst) + self._transferred.append(dst) def finalize(self): # Delete any backed up files @@ -212,3 +221,46 @@ class FileTransaction: return os.stat(src) == os.stat(dst) return src == dst + + +def wait_for_future_errors( + executor: ThreadPoolExecutor, + futures: List[Future], + logger: Optional[logging.Logger] = None): + """For the ThreadPoolExecutor shutdown and cancel futures as soon one of + the workers raises an error as they complete. + + The ThreadPoolExecutor only cancels pending futures on exception but will + still complete those that are running - each which also themselves could + fail. We log all exceptions but re-raise the last exception only. + """ + if logger is None: + logger = logging.getLogger(__name__) + + for future in concurrent.futures.as_completed(futures): + exception = future.exception() + if exception: + # As soon as an error occurs, stop executing more futures. + # Running workers, however, will still be complete, so we also want + # to log those errors if any occurred on them. + executor.shutdown(wait=True, cancel_futures=True) + break + else: + # Futures are completed, no exceptions occurred + return + + # An exception occurred in at least one future. Get exceptions from + # all futures that are done and ended up failing until that point. + exceptions = [] + for future in futures: + if not future.cancelled() and future.done(): + exception = future.exception() + if exception: + exceptions.append(exception) + + # Log any exceptions that occurred in all workers + for exception in exceptions: + logger.error("Error occurred in worker", exc_info=exception) + + # Raise the last exception + raise exceptions[-1] diff --git a/client/ayon_core/lib/terminal.py b/client/ayon_core/lib/terminal.py index 10fcc79a27..ea23feeb95 100644 --- a/client/ayon_core/lib/terminal.py +++ b/client/ayon_core/lib/terminal.py @@ -39,6 +39,7 @@ class Terminal: """ from ayon_core.lib import env_value_to_bool + log_no_colors = env_value_to_bool( "AYON_LOG_NO_COLORS", default=None ) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 1fda014bd8..8c84e1c4dc 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -526,137 +526,6 @@ def should_convert_for_ffmpeg(src_filepath): return False -# Deprecated since 2022 4 20 -# - Reason - Doesn't convert sequences right way: Can't handle gaps, reuse -# first frame for all frames and changes filenames when input -# is sequence. -# - use 'convert_input_paths_for_ffmpeg' instead -def convert_for_ffmpeg( - first_input_path, - output_dir, - input_frame_start=None, - input_frame_end=None, - logger=None -): - """Convert source file to format supported in ffmpeg. - - Currently can convert only exrs. - - Args: - first_input_path (str): Path to first file of a sequence or a single - file path for non-sequential input. - output_dir (str): Path to directory where output will be rendered. - Must not be same as input's directory. - input_frame_start (int): Frame start of input. - input_frame_end (int): Frame end of input. - logger (logging.Logger): Logger used for logging. - - Raises: - ValueError: If input filepath has extension not supported by function. - Currently is supported only ".exr" extension. - """ - if logger is None: - logger = logging.getLogger(__name__) - - logger.warning(( - "DEPRECATED: 'ayon_core.lib.transcoding.convert_for_ffmpeg' is" - " deprecated function of conversion for FFMpeg. Please replace usage" - " with 'ayon_core.lib.transcoding.convert_input_paths_for_ffmpeg'" - )) - - ext = os.path.splitext(first_input_path)[1].lower() - if ext != ".exr": - raise ValueError(( - "Function 'convert_for_ffmpeg' currently support only" - " \".exr\" extension. Got \"{}\"." - ).format(ext)) - - is_sequence = False - if input_frame_start is not None and input_frame_end is not None: - is_sequence = int(input_frame_end) != int(input_frame_start) - - input_info = get_oiio_info_for_input(first_input_path, logger=logger) - - # Change compression only if source compression is "dwaa" or "dwab" - # - they're not supported in ffmpeg - compression = input_info["attribs"].get("compression") - if compression in ("dwaa", "dwab"): - compression = "none" - - # Prepare subprocess arguments - oiio_cmd = get_oiio_tool_args( - "oiiotool", - # Don't add any additional attributes - "--nosoftwareattrib", - ) - # Add input compression if available - if compression: - oiio_cmd.extend(["--compression", compression]) - - # Collect channels to export - input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) - - oiio_cmd.extend([ - input_arg, first_input_path, - # Tell oiiotool which channels should be put to top stack (and output) - "--ch", channels_arg, - # Use first subimage - "--subimage", "0" - ]) - - # Add frame definitions to arguments - if is_sequence: - oiio_cmd.extend([ - "--frames", "{}-{}".format(input_frame_start, input_frame_end) - ]) - - for attr_name, attr_value in input_info["attribs"].items(): - if not isinstance(attr_value, str): - continue - - # Remove attributes that have string value longer than allowed length - # for ffmpeg or when contain prohibited symbols - erase_reason = "Missing reason" - erase_attribute = False - if len(attr_value) > MAX_FFMPEG_STRING_LEN: - erase_reason = "has too long value ({} chars).".format( - len(attr_value) - ) - erase_attribute = True - - if not erase_attribute: - for char in NOT_ALLOWED_FFMPEG_CHARS: - if char in attr_value: - erase_attribute = True - erase_reason = ( - "contains unsupported character \"{}\"." - ).format(char) - break - - if erase_attribute: - # Set attribute to empty string - logger.info(( - "Removed attribute \"{}\" from metadata because {}." - ).format(attr_name, erase_reason)) - oiio_cmd.extend(["--eraseattrib", attr_name]) - - # Add last argument - path to output - if is_sequence: - ext = os.path.splitext(first_input_path)[1] - base_filename = "tmp.%{:0>2}d{}".format( - len(str(input_frame_end)), ext - ) - else: - base_filename = os.path.basename(first_input_path) - output_path = os.path.join(output_dir, base_filename) - oiio_cmd.extend([ - "-o", output_path - ]) - - logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) - run_subprocess(oiio_cmd, logger=logger) - - def convert_input_paths_for_ffmpeg( input_paths, output_dir, @@ -664,7 +533,7 @@ def convert_input_paths_for_ffmpeg( ): """Convert source file to format supported in ffmpeg. - Currently can convert only exrs. The input filepaths should be files + Can currently convert only EXRs. The input filepaths should be files with same type. Information about input is loaded only from first found file. @@ -691,10 +560,10 @@ def convert_input_paths_for_ffmpeg( ext = os.path.splitext(first_input_path)[1].lower() if ext != ".exr": - raise ValueError(( - "Function 'convert_for_ffmpeg' currently support only" - " \".exr\" extension. Got \"{}\"." - ).format(ext)) + raise ValueError( + "Function 'convert_input_paths_for_ffmpeg' currently supports" + f" only \".exr\" extension. Got \"{ext}\"." + ) input_info = get_oiio_info_for_input(first_input_path, logger=logger) diff --git a/client/ayon_core/lib/vendor_bin_utils.py b/client/ayon_core/lib/vendor_bin_utils.py index 41654476c2..412a9292cc 100644 --- a/client/ayon_core/lib/vendor_bin_utils.py +++ b/client/ayon_core/lib/vendor_bin_utils.py @@ -162,7 +162,7 @@ def find_tool_in_custom_paths(paths, tool, validation_func=None): # Handle cases when path is just an executable # - it allows to use executable from PATH # - basename must match 'tool' value (without extension) - extless_path, ext = os.path.splitext(path) + extless_path, _ext = os.path.splitext(path) if extless_path == tool: executable_path = find_executable(tool) if executable_path and ( @@ -181,7 +181,7 @@ def find_tool_in_custom_paths(paths, tool, validation_func=None): # If path is a file validate it if os.path.isfile(normalized): - basename, ext = os.path.splitext(os.path.basename(path)) + basename, _ext = os.path.splitext(os.path.basename(path)) # Check if the filename has actually the sane bane as 'tool' if basename == tool: executable_path = find_executable(normalized) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 41bcd0dbd1..137736c302 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -100,6 +100,10 @@ from .context_tools import ( get_current_task_name ) +from .compatibility import ( + is_product_base_type_supported, +) + from .workfile import ( discover_workfile_build_plugins, register_workfile_build_plugin, @@ -223,4 +227,7 @@ __all__ = ( # Backwards compatible function names "install", "uninstall", + + # Feature detection + "is_product_base_type_supported", ) diff --git a/client/ayon_core/pipeline/anatomy/__init__.py b/client/ayon_core/pipeline/anatomy/__init__.py index 336d09ccaa..7000f51495 100644 --- a/client/ayon_core/pipeline/anatomy/__init__.py +++ b/client/ayon_core/pipeline/anatomy/__init__.py @@ -1,5 +1,6 @@ from .exceptions import ( ProjectNotSet, + RootMissingEnv, RootCombinationError, TemplateMissingKey, AnatomyTemplateUnsolved, @@ -9,6 +10,7 @@ from .anatomy import Anatomy __all__ = ( "ProjectNotSet", + "RootMissingEnv", "RootCombinationError", "TemplateMissingKey", "AnatomyTemplateUnsolved", diff --git a/client/ayon_core/pipeline/anatomy/anatomy.py b/client/ayon_core/pipeline/anatomy/anatomy.py index 98bbaa9bdc..9885e383b7 100644 --- a/client/ayon_core/pipeline/anatomy/anatomy.py +++ b/client/ayon_core/pipeline/anatomy/anatomy.py @@ -462,8 +462,8 @@ class Anatomy(BaseAnatomy): Union[Dict[str, str], None]): Local root overrides. """ if not project_name: - return - return ayon_api.get_project_roots_for_site( + return None + return ayon_api.get_project_root_overrides_by_site_id( project_name, get_local_site_id() ) diff --git a/client/ayon_core/pipeline/anatomy/exceptions.py b/client/ayon_core/pipeline/anatomy/exceptions.py index 39f116baf0..24df0e3046 100644 --- a/client/ayon_core/pipeline/anatomy/exceptions.py +++ b/client/ayon_core/pipeline/anatomy/exceptions.py @@ -5,6 +5,11 @@ class ProjectNotSet(Exception): """Exception raised when is created Anatomy without project name.""" +class RootMissingEnv(KeyError): + """Raised when root requires environment variables which is not filled.""" + pass + + class RootCombinationError(Exception): """This exception is raised when templates has combined root types.""" diff --git a/client/ayon_core/pipeline/anatomy/roots.py b/client/ayon_core/pipeline/anatomy/roots.py index 2773559d49..bd09a9fe51 100644 --- a/client/ayon_core/pipeline/anatomy/roots.py +++ b/client/ayon_core/pipeline/anatomy/roots.py @@ -2,9 +2,11 @@ import os import platform import numbers -from ayon_core.lib import Logger +from ayon_core.lib import Logger, StringTemplate from ayon_core.lib.path_templates import FormatObject +from .exceptions import RootMissingEnv + class RootItem(FormatObject): """Represents one item or roots. @@ -21,18 +23,36 @@ class RootItem(FormatObject): multi root setup otherwise None value is expected. """ def __init__(self, parent, root_raw_data, name): - super(RootItem, self).__init__() + super().__init__() self._log = None - lowered_platform_keys = {} - for key, value in root_raw_data.items(): - lowered_platform_keys[key.lower()] = value + lowered_platform_keys = { + key.lower(): value + for key, value in root_raw_data.items() + } self.raw_data = lowered_platform_keys self.cleaned_data = self._clean_roots(lowered_platform_keys) self.name = name self.parent = parent self.available_platforms = set(lowered_platform_keys.keys()) - self.value = lowered_platform_keys.get(platform.system().lower()) + + current_platform = platform.system().lower() + # WARNING: Using environment variables in roots is not considered + # as production safe. Some features may not work as expected, for + # example USD resolver or site sync. + try: + self.value = lowered_platform_keys[current_platform].format_map( + os.environ + ) + except KeyError: + result = StringTemplate(self.value).format(os.environ.copy()) + is_are = "is" if len(result.missing_keys) == 1 else "are" + missing_keys = ", ".join(result.missing_keys) + raise RootMissingEnv( + f"Root \"{name}\" requires environment variable/s" + f" {missing_keys} which {is_are} not available." + ) + self.clean_value = self._clean_root(self.value) def __format__(self, *args, **kwargs): @@ -105,10 +125,10 @@ class RootItem(FormatObject): def _clean_roots(self, raw_data): """Clean all values of raw root item values.""" - cleaned = {} - for key, value in raw_data.items(): - cleaned[key] = self._clean_root(value) - return cleaned + return { + key: self._clean_root(value) + for key, value in raw_data.items() + } def path_remapper(self, path, dst_platform=None, src_platform=None): """Remap path for specific platform. diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 8c4f97ab1c..4b1d14d570 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -834,7 +834,7 @@ def _get_global_config_data( if not product_entities_by_name: # in case no product was found we need to use fallback - fallback_type = fallback_data["type"] + fallback_type = fallback_data["fallback_type"] return _get_config_path_from_profile_data( fallback_data, fallback_type, template_data ) diff --git a/client/ayon_core/pipeline/compatibility.py b/client/ayon_core/pipeline/compatibility.py new file mode 100644 index 0000000000..f7d48526b7 --- /dev/null +++ b/client/ayon_core/pipeline/compatibility.py @@ -0,0 +1,16 @@ +"""Package to handle compatibility checks for pipeline components.""" + + +def is_product_base_type_supported() -> bool: + """Check support for product base types. + + This function checks if the current pipeline supports product base types. + Once this feature is implemented, it will return True. This should be used + in places where some kind of backward compatibility is needed to avoid + breaking existing functionality that relies on the current behavior. + + Returns: + bool: True if product base types are supported, False otherwise. + + """ + return False diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index b9ae906ab4..66556bbb35 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -27,7 +27,8 @@ from .workfile import ( get_workdir, get_custom_workfile_template_by_string_context, get_workfile_template_key_from_context, - get_last_workfile + get_last_workfile, + MissingWorkdirError, ) from . import ( register_loader_plugin_path, @@ -251,7 +252,7 @@ def uninstall_host(): pyblish.api.deregister_discovery_filter(filter_pyblish_plugins) deregister_loader_plugin_path(LOAD_PATH) deregister_inventory_action_path(INVENTORY_PATH) - log.info("Global plug-ins unregistred") + log.info("Global plug-ins unregistered") deregister_host() @@ -617,7 +618,18 @@ def version_up_current_workfile(): last_workfile_path = get_last_workfile( work_root, file_template, data, extensions, True ) - new_workfile_path = version_up(last_workfile_path) + # `get_last_workfile` will return the first expected file version + # if no files exist yet. In that case, if they do not exist we will + # want to save v001 + new_workfile_path = last_workfile_path if os.path.exists(new_workfile_path): new_workfile_path = version_up(new_workfile_path) + + # Raise an error if the parent folder doesn't exist as `host.save_workfile` + # is not supposed/able to create missing folders. + parent_folder = os.path.dirname(new_workfile_path) + if not os.path.exists(parent_folder): + raise MissingWorkdirError( + f"Work area directory '{parent_folder}' does not exist.") + host.save_workfile(new_workfile_path) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 26b04ed3ed..f0d9fa8927 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -872,7 +872,7 @@ class CreateContext: """ return self._event_hub.add_callback(INSTANCE_ADDED_TOPIC, callback) - def add_instances_removed_callback (self, callback): + def add_instances_removed_callback(self, callback): """Register callback for removed instances. Event is triggered when instances are already removed from context. @@ -933,7 +933,7 @@ class CreateContext: """ self._event_hub.add_callback(VALUE_CHANGED_TOPIC, callback) - def add_pre_create_attr_defs_change_callback (self, callback): + def add_pre_create_attr_defs_change_callback(self, callback): """Register callback to listen pre-create attribute changes. Create plugin can trigger refresh of pre-create attributes. Usage of @@ -961,7 +961,7 @@ class CreateContext: PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC, callback ) - def add_create_attr_defs_change_callback (self, callback): + def add_create_attr_defs_change_callback(self, callback): """Register callback to listen create attribute changes. Create plugin changed attribute definitions of instance. @@ -986,7 +986,7 @@ class CreateContext: """ self._event_hub.add_callback(CREATE_ATTR_DEFS_CHANGED_TOPIC, callback) - def add_publish_attr_defs_change_callback (self, callback): + def add_publish_attr_defs_change_callback(self, callback): """Register callback to listen publish attribute changes. Publish plugin changed attribute definitions of instance of context. @@ -2303,10 +2303,16 @@ class CreateContext: for plugin_name, plugin_value in item_changes.pop( "publish_attributes" ).items(): + if plugin_value is None: + current_publish[plugin_name] = None + continue plugin_changes = current_publish.setdefault( plugin_name, {} ) - plugin_changes.update(plugin_value) + if plugin_changes is None: + current_publish[plugin_name] = plugin_value + else: + plugin_changes.update(plugin_value) item_values.update(item_changes) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 0daec8a7ad..ecffa4a340 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -52,15 +52,15 @@ def get_product_name_template( # TODO remove formatting keys replacement template = ( matching_profile["template"] - .replace("{task[name]}", "{task}") - .replace("{Task[name]}", "{Task}") - .replace("{TASK[NAME]}", "{TASK}") - .replace("{product[type]}", "{family}") - .replace("{Product[type]}", "{Family}") - .replace("{PRODUCT[TYPE]}", "{FAMILY}") - .replace("{folder[name]}", "{asset}") - .replace("{Folder[name]}", "{Asset}") - .replace("{FOLDER[NAME]}", "{ASSET}") + .replace("{task}", "{task[name]}") + .replace("{Task}", "{Task[name]}") + .replace("{TASK}", "{TASK[NAME]}") + .replace("{family}", "{product[type]}") + .replace("{Family}", "{Product[type]}") + .replace("{FAMILY}", "{PRODUCT[TYPE]}") + .replace("{asset}", "{folder[name]}") + .replace("{Asset}", "{Folder[name]}") + .replace("{ASSET}", "{FOLDER[NAME]}") ) # Make sure template is set (matching may have empty string) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 17bb85b720..d7ba6b9c24 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -160,29 +160,26 @@ class AttributeValues: return self._attr_defs_by_key.get(key, default) def update(self, value): - changes = {} - for _key, _value in dict(value).items(): - if _key in self._data and self._data.get(_key) == _value: - continue - self._data[_key] = _value - changes[_key] = _value - + changes = self._update(value) if changes: self._parent.attribute_value_changed(self._key, changes) def pop(self, key, default=None): - has_key = key in self._data - value = self._data.pop(key, default) - # Remove attribute definition if is 'UnknownDef' - # - gives option to get rid of unknown values - attr_def = self._attr_defs_by_key.get(key) - if isinstance(attr_def, UnknownDef): - self._attr_defs_by_key.pop(key) - self._attr_defs.remove(attr_def) - elif has_key: - self._parent.attribute_value_changed(self._key, {key: None}) + value, changes = self._pop(key, default) + if changes: + self._parent.attribute_value_changed(self._key, changes) return value + def set_value(self, value): + pop_keys = set(value.keys()) - set(self._data.keys()) + changes = self._update(value) + for key in pop_keys: + _, key_changes = self._pop(key, None) + changes.update(key_changes) + + if changes: + self._parent.attribute_value_changed(self._key, changes) + def reset_values(self): self._data = {} @@ -228,6 +225,29 @@ class AttributeValues: return serialize_attr_defs(self._attr_defs) + def _update(self, value): + changes = {} + for key, value in dict(value).items(): + if key in self._data and self._data.get(key) == value: + continue + self._data[key] = value + changes[key] = value + return changes + + def _pop(self, key, default): + has_key = key in self._data + value = self._data.pop(key, default) + # Remove attribute definition if is 'UnknownDef' + # - gives option to get rid of unknown values + attr_def = self._attr_defs_by_key.get(key) + changes = {} + if isinstance(attr_def, UnknownDef): + self._attr_defs_by_key.pop(key) + self._attr_defs.remove(attr_def) + elif has_key: + changes[key] = None + return value, changes + class CreatorAttributeValues(AttributeValues): """Creator specific attribute values of an instance.""" @@ -270,6 +290,23 @@ class PublishAttributes: def __getitem__(self, key): return self._data[key] + def __setitem__(self, key, value): + """Set value for plugin. + + Args: + key (str): Plugin name. + value (dict[str, Any]): Value to set. + + """ + current_value = self._data.get(key) + if isinstance(current_value, PublishAttributeValues): + current_value.set_value(value) + else: + self._data[key] = value + + def __delitem__(self, key): + self.pop(key) + def __contains__(self, key): return key in self._data @@ -332,7 +369,7 @@ class PublishAttributes: return copy.deepcopy(self._origin_data) def attribute_value_changed(self, key, changes): - self._parent.publish_attribute_value_changed(key, changes) + self._parent.publish_attribute_value_changed(key, changes) def set_publish_plugin_attr_defs( self, diff --git a/client/ayon_core/pipeline/delivery.py b/client/ayon_core/pipeline/delivery.py index 55c840f3a5..e686b739ae 100644 --- a/client/ayon_core/pipeline/delivery.py +++ b/client/ayon_core/pipeline/delivery.py @@ -255,7 +255,7 @@ def deliver_sequence( report_items[""].append(msg) return report_items, 0 - dir_path, file_name = os.path.split(str(src_path)) + dir_path, _file_name = os.path.split(str(src_path)) context = repre["context"] ext = context.get("ext", context.get("representation")) @@ -270,7 +270,7 @@ def deliver_sequence( # context.representation could be .psd ext = ext.replace("..", ".") - src_collections, remainder = clique.assemble(os.listdir(dir_path)) + src_collections, _remainder = clique.assemble(os.listdir(dir_path)) src_collection = None for col in src_collections: if col.tail != ext: diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index c6f3ae7115..0d8e70f9d2 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations import copy import os import re @@ -660,14 +660,6 @@ def _get_legacy_product_name_and_group( warnings.warn("Using legacy product name for renders", DeprecationWarning) - if not source_product_name.startswith(product_type): - resulting_group_name = '{}{}{}{}{}'.format( - product_type, - task_name[0].upper(), task_name[1:], - source_product_name[0].upper(), source_product_name[1:]) - else: - resulting_group_name = source_product_name - # create product name `` if not source_product_name.startswith(product_type): resulting_group_name = '{}{}{}{}{}'.format( @@ -1168,7 +1160,7 @@ def prepare_cache_representations(skeleton_data, exp_files, anatomy): """ representations = [] - collections, remainders = clique.assemble(exp_files) + collections, _remainders = clique.assemble(exp_files) log = Logger.get_logger("farm_publishing") diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index b601914acd..4a11b929cc 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -221,19 +221,6 @@ class LoaderPlugin(list): """ return cls.options or [] - @property - def fname(self): - """Backwards compatibility with deprecation warning""" - - self.log.warning(( - "DEPRECATION WARNING: Source - Loader plugin {}." - " The 'fname' property on the Loader plugin will be removed in" - " future versions of OpenPype. Planned version to drop the support" - " is 3.16.6 or 3.17.0." - ).format(self.__class__.__name__)) - if hasattr(self, "_fname"): - return self._fname - @classmethod def get_representation_name_aliases(cls, representation_name: str): """Return representation names to which switching is allowed from diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index de8e1676e7..b130161190 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -316,12 +316,6 @@ def load_with_repre_context( ) loader = Loader() - - # Backwards compatibility: Originally the loader's __init__ required the - # representation context to set `fname` attribute to the filename to load - # Deprecated - to be removed in OpenPype 3.16.6 or 3.17.0. - loader._fname = get_representation_path_from_context(repre_context) - return loader.load(repre_context, name, namespace, options) diff --git a/client/ayon_core/pipeline/schema/__init__.py b/client/ayon_core/pipeline/schema/__init__.py index d16755696d..5e4e8a668d 100644 --- a/client/ayon_core/pipeline/schema/__init__.py +++ b/client/ayon_core/pipeline/schema/__init__.py @@ -41,7 +41,7 @@ def validate(data, schema=None): if not _CACHED: _precache() - root, schema = data["schema"].rsplit(":", 1) + _root, schema = data["schema"].rsplit(":", 1) if isinstance(schema, str): schema = _cache[schema + ".json"] diff --git a/client/ayon_core/pipeline/staging_dir.py b/client/ayon_core/pipeline/staging_dir.py index 1cb2979415..a172c177fd 100644 --- a/client/ayon_core/pipeline/staging_dir.py +++ b/client/ayon_core/pipeline/staging_dir.py @@ -209,7 +209,7 @@ def get_staging_dir_info( staging_dir_config = get_staging_dir_config( project_entity["name"], task_type, - task_name , + task_name, product_type, product_name, host_name, diff --git a/client/ayon_core/pipeline/thumbnails.py b/client/ayon_core/pipeline/thumbnails.py index 401d95f273..658372888f 100644 --- a/client/ayon_core/pipeline/thumbnails.py +++ b/client/ayon_core/pipeline/thumbnails.py @@ -226,11 +226,26 @@ class _CacheItems: thumbnails_cache = ThumbnailsCache() -def get_thumbnail_path(project_name, thumbnail_id): +def get_thumbnail_path( + project_name: str, + entity_type: str, + entity_id: str, + thumbnail_id: str +): """Get path to thumbnail image. + Thumbnail is cached by thumbnail id but is received using entity type and + entity id. + + Notes: + Function 'get_thumbnail_by_id' can't be used because does not work + for artists. The endpoint can't validate artist permissions. + Args: project_name (str): Project where thumbnail belongs to. + entity_type (str): Entity type "folder", "task", "version" + and "workfile". + entity_id (str): Entity id. thumbnail_id (Union[str, None]): Thumbnail id. Returns: @@ -251,7 +266,7 @@ def get_thumbnail_path(project_name, thumbnail_id): # 'get_thumbnail_by_id' did not return output of # 'ServerAPI' method. con = ayon_api.get_server_api_connection() - result = con.get_thumbnail_by_id(project_name, thumbnail_id) + result = con.get_thumbnail(project_name, entity_type, entity_id) if result is not None and result.is_valid: return _CacheItems.thumbnails_cache.store_thumbnail( diff --git a/client/ayon_core/pipeline/workfile/__init__.py b/client/ayon_core/pipeline/workfile/__init__.py index 05f939024c..aa7e150bca 100644 --- a/client/ayon_core/pipeline/workfile/__init__.py +++ b/client/ayon_core/pipeline/workfile/__init__.py @@ -16,6 +16,7 @@ from .path_resolving import ( from .utils import ( should_use_last_workfile_on_launch, should_open_workfiles_tool_on_launch, + MissingWorkdirError, ) from .build_workfile import BuildWorkfile @@ -46,6 +47,7 @@ __all__ = ( "should_use_last_workfile_on_launch", "should_open_workfiles_tool_on_launch", + "MissingWorkdirError", "BuildWorkfile", diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 61c6e5b876..9b2fe25199 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -329,9 +329,9 @@ def get_last_workfile( Returns: str: Last or first workfile as filename of full path to filename. - """ - filename, version = get_last_workfile_with_version( + """ + filename, _version = get_last_workfile_with_version( workdir, file_template, fill_data, extensions ) if filename is None: diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 53de3269b2..25be061dec 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -2,6 +2,11 @@ from ayon_core.lib import filter_profiles from ayon_core.settings import get_project_settings +class MissingWorkdirError(Exception): + """Raised when accessing a work directory not found on disk.""" + pass + + def should_use_last_workfile_on_launch( project_name, host_name, diff --git a/client/ayon_core/plugins/load/delete_old_versions.py b/client/ayon_core/plugins/load/delete_old_versions.py index f8c45baff6..3a42ccba7e 100644 --- a/client/ayon_core/plugins/load/delete_old_versions.py +++ b/client/ayon_core/plugins/load/delete_old_versions.py @@ -211,7 +211,7 @@ class DeleteOldVersions(load.ProductLoaderPlugin): 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" + informative_text = "Warning: This will delete files from disk" detailed_text = ( f"Keep only {versions_to_keep} versions for:\n{contexts_list}" ) diff --git a/client/ayon_core/plugins/load/export_otio.py b/client/ayon_core/plugins/load/export_otio.py index e7a844aed3..8094490246 100644 --- a/client/ayon_core/plugins/load/export_otio.py +++ b/client/ayon_core/plugins/load/export_otio.py @@ -22,6 +22,7 @@ from ayon_core.tools.utils import show_message_dialog OTIO = None FRAME_SPLITTER = "__frame_splitter__" + def _import_otio(): global OTIO if OTIO is None: diff --git a/client/ayon_core/plugins/publish/cleanup.py b/client/ayon_core/plugins/publish/cleanup.py index 57ef803352..681fe700a3 100644 --- a/client/ayon_core/plugins/publish/cleanup.py +++ b/client/ayon_core/plugins/publish/cleanup.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- """Cleanup leftover files from publish.""" import os -import shutil -import pyblish.api import re +import shutil +import tempfile + +import pyblish.api from ayon_core.lib import is_in_tests +from ayon_core.pipeline import PublishError class CleanUp(pyblish.api.InstancePlugin): @@ -48,17 +51,15 @@ class CleanUp(pyblish.api.InstancePlugin): if is_in_tests(): # let automatic test process clean up temporary data return - # Get the errored instances - failed = [] + + # If instance has errors, do not clean up for result in instance.context.data["results"]: - if (result["error"] is not None and result["instance"] is not None - and result["instance"] not in failed): - failed.append(result["instance"]) - assert instance not in failed, ( - "Result of '{}' instance were not success".format( - instance.data["name"] - ) - ) + if result["error"] is not None and result["instance"] is instance: + raise PublishError( + "Result of '{}' instance were not success".format( + instance.data["name"] + ) + ) _skip_cleanup_filepaths = instance.context.data.get( "skipCleanupFilepaths" @@ -71,10 +72,17 @@ class CleanUp(pyblish.api.InstancePlugin): self.log.debug("Cleaning renders new...") self.clean_renders(instance, skip_cleanup_filepaths) - if [ef for ef in self.exclude_families - if instance.data["productType"] in ef]: + # TODO: Figure out whether this could be refactored to just a + # product_type in self.exclude_families check. + product_type = instance.data["productType"] + if any( + product_type in exclude_family + for exclude_family in self.exclude_families + ): + self.log.debug( + "Skipping cleanup for instance because product " + f"type is excluded from cleanup: {product_type}") return - import tempfile temp_root = tempfile.gettempdir() staging_dir = instance.data.get("stagingDir", None) diff --git a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py index 677ebb04a2..2fcf562dd0 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py @@ -394,7 +394,6 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): if aov: anatomy_data["aov"] = aov - def _fill_folder_data(self, instance, project_entity, anatomy_data): # QUESTION: should we make sure that all folder data are popped if # folder data cannot be found? diff --git a/client/ayon_core/plugins/publish/collect_audio.py b/client/ayon_core/plugins/publish/collect_audio.py index c1633e414e..57c69ef2b2 100644 --- a/client/ayon_core/plugins/publish/collect_audio.py +++ b/client/ayon_core/plugins/publish/collect_audio.py @@ -39,6 +39,7 @@ class CollectAudio(pyblish.api.ContextPlugin): "blender", "houdini", "max", + "circuit", ] audio_product_name = "audioMain" diff --git a/client/ayon_core/plugins/publish/collect_explicit_resolution.py b/client/ayon_core/plugins/publish/collect_explicit_resolution.py new file mode 100644 index 0000000000..3ea3d42102 --- /dev/null +++ b/client/ayon_core/plugins/publish/collect_explicit_resolution.py @@ -0,0 +1,106 @@ +import pyblish.api +from ayon_core.lib import EnumDef +from ayon_core.pipeline import publish +from ayon_core.pipeline.publish import PublishError + + +class CollectExplicitResolution( + pyblish.api.InstancePlugin, + publish.AYONPyblishPluginMixin, +): + """Collect explicit user defined resolution attributes for instances""" + + label = "Choose Explicit Resolution" + order = pyblish.api.CollectorOrder - 0.091 + settings_category = "core" + + enabled = False + + default_resolution_item = (None, "Don't override") + # Settings + product_types = [] + options = [] + + # caching resoluton items + resolution_items = None + + def process(self, instance): + """Process the instance and collect explicit resolution attributes""" + + # Get the values from the instance data + values = self.get_attr_values_from_data(instance.data) + resolution_value = values.get("explicit_resolution", None) + if resolution_value is None: + return + + # Get the width, height and pixel_aspect from the resolution value + resolution_data = self._get_resolution_values(resolution_value) + + # Set the values to the instance data + instance.data.update(resolution_data) + + def _get_resolution_values(self, resolution_value): + """ + Returns width, height and pixel_aspect from the resolution value + + Arguments: + resolution_value (str): resolution value + + Returns: + dict: dictionary with width, height and pixel_aspect + """ + resolution_items = self._get_resolution_items() + # ensure resolution_value is part of expected items + item_values = resolution_items.get(resolution_value) + + # if the item is in the cache, get the values from it + if item_values: + return { + "resolutionWidth": item_values["width"], + "resolutionHeight": item_values["height"], + "pixelAspect": item_values["pixel_aspect"], + } + + raise PublishError( + f"Invalid resolution value: {resolution_value} " + f"expected choices: {resolution_items}" + ) + + @classmethod + def _get_resolution_items(cls): + if cls.resolution_items is None: + resolution_items = {} + for item in cls.options: + item_text = ( + f"{item['width']}x{item['height']} " + f"({item['pixel_aspect']})" + ) + resolution_items[item_text] = item + + cls.resolution_items = resolution_items + + return cls.resolution_items + + @classmethod + def get_attr_defs_for_instance( + cls, create_context, instance, + ): + if instance.product_type not in cls.product_types: + return [] + + # Get the resolution items + resolution_items = cls._get_resolution_items() + + items = [cls.default_resolution_item] + # Add all cached resolution items to the dropdown options + for item_text in resolution_items: + items.append((item_text, item_text)) + + return [ + EnumDef( + "explicit_resolution", + items, + default="Don't override", + label="Force product resolution", + ), + ] diff --git a/client/ayon_core/plugins/publish/collect_farm_env_variables.py b/client/ayon_core/plugins/publish/collect_farm_env_variables.py index 2782ea86ac..39c421381d 100644 --- a/client/ayon_core/plugins/publish/collect_farm_env_variables.py +++ b/client/ayon_core/plugins/publish/collect_farm_env_variables.py @@ -43,4 +43,3 @@ class CollectCoreJobEnvVars(pyblish.api.ContextPlugin): if value: self.log.debug(f"Setting job env: {key}: {value}") env[key] = value - diff --git a/client/ayon_core/plugins/publish/collect_hierarchy.py b/client/ayon_core/plugins/publish/collect_hierarchy.py index 266c2e1458..56b48c37f6 100644 --- a/client/ayon_core/plugins/publish/collect_hierarchy.py +++ b/client/ayon_core/plugins/publish/collect_hierarchy.py @@ -50,7 +50,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): "comments": instance.data.get("comments", []), } - shot_data["attributes"] = {} + shot_data["attributes"] = {} SHOT_ATTRS = ( "handleStart", "handleEnd", diff --git a/client/ayon_core/plugins/publish/collect_managed_staging_dir.py b/client/ayon_core/plugins/publish/collect_managed_staging_dir.py index 1034b9a716..62b007461a 100644 --- a/client/ayon_core/plugins/publish/collect_managed_staging_dir.py +++ b/client/ayon_core/plugins/publish/collect_managed_staging_dir.py @@ -32,16 +32,16 @@ class CollectManagedStagingDir(pyblish.api.InstancePlugin): label = "Collect Managed Staging Directory" order = pyblish.api.CollectorOrder + 0.4990 - def process(self, instance): + def process(self, instance: pyblish.api.Instance): """ Collect the staging data and stores it to the instance. Args: instance (object): The instance to inspect. """ staging_dir_path = get_instance_staging_dir(instance) - persistance = instance.data.get("stagingDir_persistent", False) + persistence: bool = instance.data.get("stagingDir_persistent", False) - self.log.info(( + self.log.debug( f"Instance staging dir was set to `{staging_dir_path}` " - f"and persistence is set to `{persistance}`" - )) + f"and persistence is set to `{persistence}`" + ) diff --git a/client/ayon_core/plugins/publish/collect_otio_subset_resources.py b/client/ayon_core/plugins/publish/collect_otio_subset_resources.py index f1fa6a817d..275b8a7f55 100644 --- a/client/ayon_core/plugins/publish/collect_otio_subset_resources.py +++ b/client/ayon_core/plugins/publish/collect_otio_subset_resources.py @@ -194,7 +194,6 @@ class CollectOtioSubsetResources( repre = self._create_representation( frame_start, frame_end, file=filename) - else: _trim = False dirname, filename = os.path.split(media_ref.target_url) @@ -209,7 +208,6 @@ class CollectOtioSubsetResources( repre = self._create_representation( frame_start, frame_end, file=filename, trim=_trim) - instance.data["originalDirname"] = self.staging_dir # add representation to instance data @@ -221,7 +219,6 @@ class CollectOtioSubsetResources( instance.data["representations"].append(repre) - self.log.debug(instance.data) def _create_representation(self, start, end, **kwargs): diff --git a/client/ayon_core/plugins/publish/collect_rendered_files.py b/client/ayon_core/plugins/publish/collect_rendered_files.py index deecf7ba24..5c68af888f 100644 --- a/client/ayon_core/plugins/publish/collect_rendered_files.py +++ b/client/ayon_core/plugins/publish/collect_rendered_files.py @@ -31,6 +31,9 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): # Keep "filesequence" for backwards compatibility of older jobs targets = ["filesequence", "farm"] label = "Collect rendered frames" + settings_category = "core" + + remove_files = False _context = None @@ -120,7 +123,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): self._fill_staging_dir(repre_data, anatomy) representations.append(repre_data) - if not staging_dir_persistent: + if self.remove_files and not staging_dir_persistent: add_repre_files_for_cleanup(instance, repre_data) instance.data["representations"] = representations @@ -170,7 +173,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): os.environ.update(session_data) staging_dir_persistent = self._process_path(data, anatomy) - if not staging_dir_persistent: + if self.remove_files and not staging_dir_persistent: context.data["cleanupFullPaths"].append(path) context.data["cleanupEmptyDirs"].append( os.path.dirname(path) diff --git a/client/ayon_core/plugins/publish/extract_burnin.py b/client/ayon_core/plugins/publish/extract_burnin.py index 8e8764fc33..3f7c2f4cba 100644 --- a/client/ayon_core/plugins/publish/extract_burnin.py +++ b/client/ayon_core/plugins/publish/extract_burnin.py @@ -54,7 +54,8 @@ class ExtractBurnin(publish.Extractor): "houdini", "max", "blender", - "unreal" + "unreal", + "circuit", ] optional = True diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 1f2c2a89af..1e86b91484 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -280,10 +280,14 @@ class ExtractOIIOTranscode(publish.Extractor): collection = collections[0] frames = list(collection.indexes) - if collection.holes(): + if collection.holes().indexes: return files_to_convert - frame_str = "{}-{}#".format(frames[0], frames[-1]) + # Get the padding from the collection + # This is the number of digits used in the frame numbers + padding = collection.padding + + frame_str = "{}-{}%0{}d".format(frames[0], frames[-1], padding) file_name = "{}{}{}".format(collection.head, frame_str, collection.tail) diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 7a9a020ff0..f217be551c 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -54,7 +54,7 @@ class ExtractOTIOReview( # plugin default attributes to_width = 1280 to_height = 720 - output_ext = ".jpg" + output_ext = ".png" def process(self, instance): # Not all hosts can import these modules. @@ -510,6 +510,12 @@ class ExtractOTIOReview( "-tune", "stillimage" ]) + if video or sequence: + command.extend([ + "-vf", f"scale={self.to_width}:{self.to_height}:flags=lanczos", + "-compression_level", "5", + ]) + # add output attributes command.extend([ "-start_number", str(out_frame_start) @@ -520,9 +526,10 @@ class ExtractOTIOReview( input_extension and self.output_ext == input_extension ): - command.extend([ - "-c", "copy" - ]) + command.extend(["-c", "copy"]) + else: + # For lossy formats, force re-encode + command.extend(["-pix_fmt", "rgba"]) # add output path at the end command.append(output_path) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 7c38b0453b..3fc2185d1a 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import re import copy @@ -5,11 +6,16 @@ import json import shutil import subprocess from abc import ABC, abstractmethod +from typing import Any, Optional +from dataclasses import dataclass, field +import tempfile import clique import speedcopy import pyblish.api +from ayon_api import get_last_version_by_product_name, get_representations + from ayon_core.lib import ( get_ffmpeg_tool_args, filter_profiles, @@ -31,6 +37,39 @@ from ayon_core.pipeline.publish import ( from ayon_core.pipeline.publish.lib import add_repre_files_for_cleanup +@dataclass +class TempData: + """Temporary data used across extractor's process.""" + fps: float + frame_start: int + frame_end: int + handle_start: int + handle_end: int + frame_start_handle: int + frame_end_handle: int + output_frame_start: int + output_frame_end: int + pixel_aspect: float + resolution_width: int + resolution_height: int + origin_repre: dict[str, Any] + input_is_sequence: bool + first_sequence_frame: int + input_allow_bg: bool + with_audio: bool + without_handles: bool + handles_are_set: bool + input_ext: str + explicit_input_paths: list[str] + paths_to_remove: list[str] + + # Set later + full_output_path: str = "" + filled_files: dict[int, str] = field(default_factory=dict) + output_ext_is_image: bool = True + output_is_sequence: bool = True + + def frame_to_timecode(frame: int, fps: float) -> str: """Convert a frame number and FPS to editorial timecode (HH:MM:SS:FF). @@ -91,7 +130,8 @@ class ExtractReview(pyblish.api.InstancePlugin): "webpublisher", "aftereffects", "flame", - "unreal" + "unreal", + "circuit", ] # Supported extensions @@ -196,7 +236,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ).format(repre_name)) continue - input_ext = repre["ext"] + input_ext = repre["ext"].lower() if input_ext.startswith("."): input_ext = input_ext[1:] @@ -399,15 +439,73 @@ class ExtractReview(pyblish.api.InstancePlugin): ) temp_data = self.prepare_temp_data(instance, repre, output_def) - files_to_clean = [] - if temp_data["input_is_sequence"]: + new_frame_files = {} + if temp_data.input_is_sequence: self.log.debug("Checking sequence to fill gaps in sequence..") - files_to_clean = self.fill_sequence_gaps( - files=temp_data["origin_repre"]["files"], - staging_dir=new_repre["stagingDir"], - start_frame=temp_data["frame_start"], - end_frame=temp_data["frame_end"] - ) + + files = temp_data.origin_repre["files"] + collections = clique.assemble( + files, + )[0] + if len(collections) != 1: + raise KnownPublishError( + "Multiple collections {} found.".format(collections)) + + collection = collections[0] + + fill_missing_frames = _output_def["fill_missing_frames"] + if fill_missing_frames == "closest_existing": + new_frame_files = self.fill_sequence_gaps_from_existing( + collection=collection, + staging_dir=new_repre["stagingDir"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, + ) + elif fill_missing_frames == "blank": + new_frame_files = self.fill_sequence_gaps_with_blanks( + collection=collection, + staging_dir=new_repre["stagingDir"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, + resolution_width=temp_data.resolution_width, + resolution_height=temp_data.resolution_height, + extension=temp_data.input_ext, + temp_data=temp_data + ) + elif fill_missing_frames == "previous_version": + new_frame_files = self.fill_sequence_gaps_with_previous( + collection=collection, + staging_dir=new_repre["stagingDir"], + instance=instance, + current_repre_name=repre["name"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, + ) + # fallback to original workflow + if new_frame_files is None: + new_frame_files = ( + self.fill_sequence_gaps_from_existing( + collection=collection, + staging_dir=new_repre["stagingDir"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, + )) + elif fill_missing_frames == "only_rendered": + temp_data.explicit_input_paths = [ + os.path.join( + new_repre["stagingDir"], file + ).replace("\\", "/") + for file in files + ] + frame_start = min(collection.indexes) + frame_end = max(collection.indexes) + # modify range for burnins + instance.data["frameStart"] = frame_start + instance.data["frameEnd"] = frame_end + temp_data.frame_start = frame_start + temp_data.frame_end = frame_end + + temp_data.filled_files = new_frame_files # create or update outputName output_name = new_repre.get("outputName", "") @@ -415,7 +513,7 @@ class ExtractReview(pyblish.api.InstancePlugin): if output_name: output_name += "_" output_name += output_def["filename_suffix"] - if temp_data["without_handles"]: + if temp_data.without_handles: output_name += "_noHandles" # add outputName to anatomy format fill_data @@ -428,7 +526,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # like Resolve or Premiere can detect the start frame for e.g. # review output files "timecode": frame_to_timecode( - frame=temp_data["frame_start_handle"], + frame=temp_data.frame_start_handle, fps=float(instance.data["fps"]) ) }) @@ -445,7 +543,7 @@ class ExtractReview(pyblish.api.InstancePlugin): except ZeroDivisionError: # TODO recalculate width and height using OIIO before # conversion - if 'exr' in temp_data["origin_repre"]["ext"]: + if 'exr' in temp_data.origin_repre["ext"]: self.log.warning( ( "Unsupported compression on input files." @@ -464,17 +562,20 @@ class ExtractReview(pyblish.api.InstancePlugin): run_subprocess(subprcs_cmd, shell=True, logger=self.log) # delete files added to fill gaps - if files_to_clean: - for f in files_to_clean: - os.unlink(f) + if new_frame_files: + for filepath in new_frame_files.values(): + os.unlink(filepath) + + for filepath in temp_data.paths_to_remove: + os.unlink(filepath) new_repre.update({ - "fps": temp_data["fps"], + "fps": temp_data.fps, "name": "{}_{}".format(output_name, output_ext), "outputName": output_name, "outputDef": output_def, - "frameStartFtrack": temp_data["output_frame_start"], - "frameEndFtrack": temp_data["output_frame_end"], + "frameStartFtrack": temp_data.output_frame_start, + "frameEndFtrack": temp_data.output_frame_end, "ffmpeg_cmd": subprcs_cmd }) @@ -500,7 +601,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # - there can be more than one collection return isinstance(repre["files"], (list, tuple)) - def prepare_temp_data(self, instance, repre, output_def): + def prepare_temp_data(self, instance, repre, output_def) -> TempData: """Prepare dictionary with values used across extractor's process. All data are collected from instance, context, origin representation @@ -516,7 +617,7 @@ class ExtractReview(pyblish.api.InstancePlugin): output_def (dict): Definition of output of this plugin. Returns: - dict: All data which are used across methods during process. + TempData: All data which are used across methods during process. Their values should not change during process but new keys with values may be added. """ @@ -559,6 +660,7 @@ class ExtractReview(pyblish.api.InstancePlugin): input_is_sequence = self.input_is_sequence(repre) input_allow_bg = False first_sequence_frame = None + if input_is_sequence and repre["files"]: # Calculate first frame that should be used cols, _ = clique.assemble(repre["files"]) @@ -577,28 +679,33 @@ class ExtractReview(pyblish.api.InstancePlugin): ext = os.path.splitext(repre["files"][0])[1].replace(".", "") if ext.lower() in self.alpha_exts: input_allow_bg = True + else: + ext = os.path.splitext(repre["files"])[1].replace(".", "") - return { - "fps": float(instance.data["fps"]), - "frame_start": frame_start, - "frame_end": frame_end, - "handle_start": handle_start, - "handle_end": handle_end, - "frame_start_handle": frame_start_handle, - "frame_end_handle": frame_end_handle, - "output_frame_start": int(output_frame_start), - "output_frame_end": int(output_frame_end), - "pixel_aspect": instance.data.get("pixelAspect", 1), - "resolution_width": instance.data.get("resolutionWidth"), - "resolution_height": instance.data.get("resolutionHeight"), - "origin_repre": repre, - "input_is_sequence": input_is_sequence, - "first_sequence_frame": first_sequence_frame, - "input_allow_bg": input_allow_bg, - "with_audio": with_audio, - "without_handles": without_handles, - "handles_are_set": handles_are_set - } + return TempData( + fps=float(instance.data["fps"]), + frame_start=frame_start, + frame_end=frame_end, + handle_start=handle_start, + handle_end=handle_end, + frame_start_handle=frame_start_handle, + frame_end_handle=frame_end_handle, + output_frame_start=int(output_frame_start), + output_frame_end=int(output_frame_end), + pixel_aspect=instance.data.get("pixelAspect", 1), + resolution_width=instance.data.get("resolutionWidth"), + resolution_height=instance.data.get("resolutionHeight"), + origin_repre=repre, + input_is_sequence=input_is_sequence, + first_sequence_frame=first_sequence_frame, + input_allow_bg=input_allow_bg, + with_audio=with_audio, + without_handles=without_handles, + handles_are_set=handles_are_set, + input_ext=ext, + explicit_input_paths=[], # absolute paths to rendered files + paths_to_remove=[] + ) def _ffmpeg_arguments( self, @@ -619,7 +726,7 @@ class ExtractReview(pyblish.api.InstancePlugin): instance (Instance): Currently processed instance. new_repre (dict): Representation representing output of this process. - temp_data (dict): Base data for successful process. + temp_data (TempData): Base data for successful process. """ # Get FFmpeg arguments from profile presets @@ -661,31 +768,32 @@ class ExtractReview(pyblish.api.InstancePlugin): # Set output frames len to 1 when output is single image if ( - temp_data["output_ext_is_image"] - and not temp_data["output_is_sequence"] + temp_data.output_ext_is_image + and not temp_data.output_is_sequence ): output_frames_len = 1 else: output_frames_len = ( - temp_data["output_frame_end"] - - temp_data["output_frame_start"] + temp_data.output_frame_end + - temp_data.output_frame_start + 1 ) - duration_seconds = float(output_frames_len / temp_data["fps"]) + duration_seconds = float(output_frames_len / temp_data.fps) # Define which layer should be used if layer_name: ffmpeg_input_args.extend(["-layer", layer_name]) - if temp_data["input_is_sequence"]: + explicit_input_paths = temp_data.explicit_input_paths + if temp_data.input_is_sequence and not explicit_input_paths: # Set start frame of input sequence (just frame in filename) # - definition of input filepath # - add handle start if output should be without handles - start_number = temp_data["first_sequence_frame"] - if temp_data["without_handles"] and temp_data["handles_are_set"]: - start_number += temp_data["handle_start"] + start_number = temp_data.first_sequence_frame + if temp_data.without_handles and temp_data.handles_are_set: + start_number += temp_data.handle_start ffmpeg_input_args.extend([ "-start_number", str(start_number) ]) @@ -698,32 +806,32 @@ class ExtractReview(pyblish.api.InstancePlugin): # } # Add framerate to input when input is sequence ffmpeg_input_args.extend([ - "-framerate", str(temp_data["fps"]) + "-framerate", str(temp_data.fps) ]) # Add duration of an input sequence if output is video - if not temp_data["output_is_sequence"]: + if not temp_data.output_is_sequence: ffmpeg_input_args.extend([ "-to", "{:0.10f}".format(duration_seconds) ]) - if temp_data["output_is_sequence"]: + if temp_data.output_is_sequence and not explicit_input_paths: # Set start frame of output sequence (just frame in filename) # - this is definition of an output ffmpeg_output_args.extend([ - "-start_number", str(temp_data["output_frame_start"]) + "-start_number", str(temp_data.output_frame_start) ]) # Change output's duration and start point if should not contain # handles - if temp_data["without_handles"] and temp_data["handles_are_set"]: + if temp_data.without_handles and temp_data.handles_are_set: # Set output duration in seconds ffmpeg_output_args.extend([ "-t", "{:0.10}".format(duration_seconds) ]) # Add -ss (start offset in seconds) if input is not sequence - if not temp_data["input_is_sequence"]: - start_sec = float(temp_data["handle_start"]) / temp_data["fps"] + if not temp_data.input_is_sequence: + start_sec = float(temp_data.handle_start) / temp_data.fps # Set start time without handles # - Skip if start sec is 0.0 if start_sec > 0.0: @@ -732,18 +840,42 @@ class ExtractReview(pyblish.api.InstancePlugin): ]) # Set frame range of output when input or output is sequence - elif temp_data["output_is_sequence"]: + elif temp_data.output_is_sequence: ffmpeg_output_args.extend([ "-frames:v", str(output_frames_len) ]) - # Add video/image input path - ffmpeg_input_args.extend([ - "-i", path_to_subprocess_arg(temp_data["full_input_path"]) - ]) + if not explicit_input_paths: + # Add video/image input path + ffmpeg_input_args.extend([ + "-i", path_to_subprocess_arg(temp_data.full_input_path) + ]) + else: + frame_duration = 1 / temp_data.fps + + explicit_frames_meta = tempfile.NamedTemporaryFile( + mode="w", prefix="explicit_frames", suffix=".txt", delete=False + ) + explicit_frames_meta.close() + explicit_frames_path = explicit_frames_meta.name + with open(explicit_frames_path, "w") as fp: + lines = [ + f"file '{path}'{os.linesep}duration {frame_duration}" + for path in temp_data.explicit_input_paths + ] + fp.write("\n".join(lines)) + temp_data.paths_to_remove.append(explicit_frames_path) + + # let ffmpeg use only rendered files, might have gaps + ffmpeg_input_args.extend([ + "-f", "concat", + "-safe", "0", + "-i", path_to_subprocess_arg(explicit_frames_path), + "-r", str(temp_data.fps) + ]) # Add audio arguments if there are any. Skipped when output are images. - if not temp_data["output_ext_is_image"] and temp_data["with_audio"]: + if not temp_data.output_ext_is_image and temp_data.with_audio: audio_in_args, audio_filters, audio_out_args = self.audio_args( instance, temp_data, duration_seconds ) @@ -765,7 +897,7 @@ class ExtractReview(pyblish.api.InstancePlugin): bg_red, bg_green, bg_blue, bg_alpha = bg_color if bg_alpha > 0.0: - if not temp_data["input_allow_bg"]: + if not temp_data.input_allow_bg: self.log.info(( "Output definition has defined BG color input was" " resolved as does not support adding BG." @@ -796,7 +928,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # NOTE This must be latest added item to output arguments. ffmpeg_output_args.append( - path_to_subprocess_arg(temp_data["full_output_path"]) + path_to_subprocess_arg(temp_data.full_output_path) ) return self.ffmpeg_full_args( @@ -880,8 +1012,159 @@ class ExtractReview(pyblish.api.InstancePlugin): return all_args - def fill_sequence_gaps(self, files, staging_dir, start_frame, end_frame): - # type: (list, str, int, int) -> list + def fill_sequence_gaps_with_previous( + self, + collection: str, + staging_dir: str, + instance: pyblish.plugin.Instance, + current_repre_name: str, + start_frame: int, + end_frame: int + ) -> Optional[dict[int, str]]: + """Tries to replace missing frames from ones from last version""" + repre_file_paths = self._get_last_version_files( + instance, current_repre_name) + if repre_file_paths is None: + # issues in getting last version files, falling back + return None + + prev_collection = clique.assemble( + repre_file_paths, + patterns=[clique.PATTERNS["frames"]], + minimum_items=1 + )[0][0] + prev_col_format = prev_collection.format("{head}{padding}{tail}") + + added_files = {} + anatomy = instance.context.data["anatomy"] + col_format = collection.format("{head}{padding}{tail}") + for frame in range(start_frame, end_frame + 1): + if frame in collection.indexes: + continue + hole_fpath = os.path.join(staging_dir, col_format % frame) + + previous_version_path = prev_col_format % frame + previous_version_path = anatomy.fill_root(previous_version_path) + if not os.path.exists(previous_version_path): + self.log.warning( + "Missing frame should be replaced from " + f"'{previous_version_path}' but that doesn't exist. " + "Falling back to filling from currently last rendered." + ) + return None + + self.log.warning( + f"Replacing missing '{hole_fpath}' with " + f"'{previous_version_path}'" + ) + speedcopy.copyfile(previous_version_path, hole_fpath) + added_files[frame] = hole_fpath + + return added_files + + def _get_last_version_files( + self, + instance: pyblish.plugin.Instance, + current_repre_name: str, + ): + product_name = instance.data["productName"] + project_name = instance.data["projectEntity"]["name"] + folder_entity = instance.data["folderEntity"] + + version_entity = get_last_version_by_product_name( + project_name, + product_name, + folder_entity["id"], + fields={"id"} + ) + if not version_entity: + return None + + matching_repres = get_representations( + project_name, + version_ids=[version_entity["id"]], + representation_names=[current_repre_name], + fields={"files"} + ) + + if not matching_repres: + return None + matching_repre = list(matching_repres)[0] + + repre_file_paths = [ + file_info["path"] + for file_info in matching_repre["files"] + ] + + return repre_file_paths + + def fill_sequence_gaps_with_blanks( + self, + collection: str, + staging_dir: str, + start_frame: int, + end_frame: int, + resolution_width: int, + resolution_height: int, + extension: str, + temp_data: TempData + ) -> Optional[dict[int, str]]: + """Fills missing files by blank frame.""" + + blank_frame_path = None + + added_files = {} + + col_format = collection.format("{head}{padding}{tail}") + for frame in range(start_frame, end_frame + 1): + if frame in collection.indexes: + continue + hole_fpath = os.path.join(staging_dir, col_format % frame) + if blank_frame_path is None: + blank_frame_path = self._create_blank_frame( + staging_dir, extension, resolution_width, resolution_height + ) + temp_data.paths_to_remove.append(blank_frame_path) + speedcopy.copyfile(blank_frame_path, hole_fpath) + added_files[frame] = hole_fpath + + return added_files + + def _create_blank_frame( + self, + staging_dir, + extension, + resolution_width, + resolution_height + ): + blank_frame_path = os.path.join(staging_dir, f"blank.{extension}") + + command = get_ffmpeg_tool_args( + "ffmpeg", + "-f", "lavfi", + "-i", "color=c=black:s={}x{}:d=1".format( + resolution_width, resolution_height + ), + "-tune", "stillimage", + "-frames:v", "1", + blank_frame_path + ) + + self.log.debug("Executing: {}".format(" ".join(command))) + output = run_subprocess( + command, logger=self.log + ) + self.log.debug("Output: {}".format(output)) + + return blank_frame_path + + def fill_sequence_gaps_from_existing( + self, + collection, + staging_dir: str, + start_frame: int, + end_frame: int + ) -> dict[int, str]: """Fill missing files in sequence by duplicating existing ones. This will take nearest frame file and copy it with so as to fill @@ -889,40 +1172,33 @@ class ExtractReview(pyblish.api.InstancePlugin): hole ahead. Args: - files (list): List of representation files. + collection (clique.collection) staging_dir (str): Path to staging directory. start_frame (int): Sequence start (no matter what files are there) end_frame (int): Sequence end (no matter what files are there) Returns: - list of added files. Those should be cleaned after work + dict[int, str] of added files. Those should be cleaned after work is done. Raises: KnownPublishError: if more than one collection is obtained. """ - collections = clique.assemble(files)[0] - if len(collections) != 1: - raise KnownPublishError( - "Multiple collections {} found.".format(collections)) - - col = collections[0] - # Prepare which hole is filled with what frame # - the frame is filled only with already existing frames - prev_frame = next(iter(col.indexes)) + prev_frame = next(iter(collection.indexes)) hole_frame_to_nearest = {} for frame in range(int(start_frame), int(end_frame) + 1): - if frame in col.indexes: + if frame in collection.indexes: prev_frame = frame else: # Use previous frame as source for hole hole_frame_to_nearest[frame] = prev_frame # Calculate paths - added_files = [] - col_format = col.format("{head}{padding}{tail}") + added_files = {} + col_format = collection.format("{head}{padding}{tail}") for hole_frame, src_frame in hole_frame_to_nearest.items(): hole_fpath = os.path.join(staging_dir, col_format % hole_frame) src_fpath = os.path.join(staging_dir, col_format % src_frame) @@ -931,11 +1207,11 @@ class ExtractReview(pyblish.api.InstancePlugin): "Missing previously detected file: {}".format(src_fpath)) speedcopy.copyfile(src_fpath, hole_fpath) - added_files.append(hole_fpath) + added_files[hole_frame] = hole_fpath return added_files - def input_output_paths(self, new_repre, output_def, temp_data): + def input_output_paths(self, new_repre, output_def, temp_data: TempData): """Deduce input nad output file paths based on entered data. Input may be sequence of images, video file or single image file and @@ -948,11 +1224,11 @@ class ExtractReview(pyblish.api.InstancePlugin): "sequence_file" (if output is sequence) keys to new representation. """ - repre = temp_data["origin_repre"] + repre = temp_data.origin_repre src_staging_dir = repre["stagingDir"] dst_staging_dir = new_repre["stagingDir"] - if temp_data["input_is_sequence"]: + if temp_data.input_is_sequence: collections = clique.assemble(repre["files"])[0] full_input_path = os.path.join( src_staging_dir, @@ -977,6 +1253,14 @@ class ExtractReview(pyblish.api.InstancePlugin): # Make sure to have full path to one input file full_input_path_single_file = full_input_path + filled_files = temp_data.filled_files + if filled_files: + first_frame, first_file = next(iter(filled_files.items())) + if first_file < full_input_path_single_file: + self.log.warning(f"Using filled frame: '{first_file}'") + full_input_path_single_file = first_file + temp_data.first_sequence_frame = first_frame + filename_suffix = output_def["filename_suffix"] output_ext = output_def.get("ext") @@ -1003,8 +1287,8 @@ class ExtractReview(pyblish.api.InstancePlugin): ) if output_is_sequence: new_repre_files = [] - frame_start = temp_data["output_frame_start"] - frame_end = temp_data["output_frame_end"] + frame_start = temp_data.output_frame_start + frame_end = temp_data.output_frame_end filename_base = "{}_{}".format(filename, filename_suffix) # Temporary template for frame filling. Example output: @@ -1041,18 +1325,18 @@ class ExtractReview(pyblish.api.InstancePlugin): new_repre["stagingDir"] = dst_staging_dir # Store paths to temp data - temp_data["full_input_path"] = full_input_path - temp_data["full_input_path_single_file"] = full_input_path_single_file - temp_data["full_output_path"] = full_output_path + temp_data.full_input_path = full_input_path + temp_data.full_input_path_single_file = full_input_path_single_file + temp_data.full_output_path = full_output_path # Store information about output - temp_data["output_ext_is_image"] = output_ext_is_image - temp_data["output_is_sequence"] = output_is_sequence + temp_data.output_ext_is_image = output_ext_is_image + temp_data.output_is_sequence = output_is_sequence self.log.debug("Input path {}".format(full_input_path)) self.log.debug("Output path {}".format(full_output_path)) - def audio_args(self, instance, temp_data, duration_seconds): + def audio_args(self, instance, temp_data: TempData, duration_seconds): """Prepares FFMpeg arguments for audio inputs.""" audio_in_args = [] audio_filters = [] @@ -1069,7 +1353,7 @@ class ExtractReview(pyblish.api.InstancePlugin): frame_start_ftrack = instance.data.get("frameStartFtrack") if frame_start_ftrack is not None: offset_frames = frame_start_ftrack - audio["offset"] - offset_seconds = offset_frames / temp_data["fps"] + offset_seconds = offset_frames / temp_data.fps if offset_seconds > 0: audio_in_args.append( @@ -1253,7 +1537,7 @@ class ExtractReview(pyblish.api.InstancePlugin): return output - def rescaling_filters(self, temp_data, output_def, new_repre): + def rescaling_filters(self, temp_data: TempData, output_def, new_repre): """Prepare vieo filters based on tags in new representation. It is possible to add letterboxes to output video or rescale to @@ -1273,7 +1557,7 @@ class ExtractReview(pyblish.api.InstancePlugin): self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking)) # NOTE Skipped using instance's resolution - full_input_path_single_file = temp_data["full_input_path_single_file"] + full_input_path_single_file = temp_data.full_input_path_single_file try: streams = get_ffprobe_streams( full_input_path_single_file, self.log @@ -1298,7 +1582,7 @@ class ExtractReview(pyblish.api.InstancePlugin): break # Get instance data - pixel_aspect = temp_data["pixel_aspect"] + pixel_aspect = temp_data.pixel_aspect if reformat_in_baking: self.log.debug(( "Using resolution from input. It is already " @@ -1332,7 +1616,7 @@ class ExtractReview(pyblish.api.InstancePlugin): bg_red, bg_green, bg_blue = overscan_color else: # Backwards compatibility - bg_red, bg_green, bg_blue, _ = overscan_color + bg_red, bg_green, bg_blue, _ = overscan_color overscan_color_value = "#{0:0>2X}{1:0>2X}{2:0>2X}".format( bg_red, bg_green, bg_blue @@ -1393,8 +1677,8 @@ class ExtractReview(pyblish.api.InstancePlugin): # - use instance resolution only if there were not scale changes # that may massivelly affect output 'use_input_res' if not use_input_res and output_width is None or output_height is None: - output_width = temp_data["resolution_width"] - output_height = temp_data["resolution_height"] + output_width = temp_data.resolution_width + output_height = temp_data.resolution_height # Use source's input resolution instance does not have set it. if output_width is None or output_height is None: diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index da429c1cd2..69bb9007f9 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -17,7 +17,7 @@ from ayon_core.lib import ( ) from ayon_core.lib.transcoding import convert_colorspace -from ayon_core.lib.transcoding import VIDEO_EXTENSIONS +from ayon_core.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS class ExtractThumbnail(pyblish.api.InstancePlugin): @@ -39,7 +39,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): "nuke", "aftereffects", "unreal", - "houdini" + "houdini", + "circuit", ] enabled = False @@ -162,9 +163,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # Store new staging to cleanup paths instance.context.data["cleanupFullPaths"].append(dst_staging) - thumbnail_created = False oiio_supported = is_oiio_supported() + thumbnail_created = False for repre in filtered_repres: + # Reset for each iteration to handle cases where multiple + # reviewable thumbnails are needed + repre_thumb_created = False repre_files = repre["files"] src_staging = os.path.normpath(repre["stagingDir"]) if not isinstance(repre_files, (list, tuple)): @@ -213,7 +217,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) # If the input can read by OIIO then use OIIO method for # conversion otherwise use ffmpeg - thumbnail_created = self._create_thumbnail_oiio( + repre_thumb_created = self._create_thumbnail_oiio( full_input_path, full_output_path, colorspace_data @@ -222,21 +226,22 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # Try to use FFMPEG if OIIO is not supported or for cases when # oiiotool isn't available or representation is not having # colorspace data - if not thumbnail_created: + if not repre_thumb_created: if oiio_supported: self.log.debug( "Converting with FFMPEG because input" " can't be read by OIIO." ) - thumbnail_created = self._create_thumbnail_ffmpeg( + repre_thumb_created = self._create_thumbnail_ffmpeg( full_input_path, full_output_path ) # Skip representation and try next one if wasn't created - if not thumbnail_created: + if not repre_thumb_created: continue + thumbnail_created = True if len(explicit_repres) > 1: repre_name = "thumbnail_{}".format(repre["outputName"]) else: @@ -331,7 +336,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return need_thumb_repres def _get_filtered_repres(self, instance): - filtered_repres = [] + review_repres = [] + other_repres = [] src_repres = instance.data.get("representations") or [] for repre in src_repres: @@ -343,17 +349,36 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # to be published locally continue - if "review" not in tags: - continue - if not repre.get("files"): self.log.debug(( "Representation \"{}\" doesn't have files. Skipping" ).format(repre["name"])) continue - filtered_repres.append(repre) - return filtered_repres + if "review" in tags: + review_repres.append(repre) + elif self._is_valid_images_repre(repre): + other_repres.append(repre) + + return review_repres + other_repres + + def _is_valid_images_repre(self, repre): + """Check if representation contains valid image files + + Args: + repre (dict): representation + + Returns: + bool: whether the representation has the valid image content + """ + # Get first file's extension + first_file = repre["files"] + if isinstance(first_file, (list, tuple)): + first_file = first_file[0] + + ext = os.path.splitext(first_file)[1].lower() + + return ext in IMAGE_EXTENSIONS or ext in VIDEO_EXTENSIONS def _create_thumbnail_oiio( self, @@ -449,7 +474,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # output arguments from presets jpeg_items.extend(ffmpeg_args.get("output") or []) # we just want one frame from movie files - jpeg_items.extend(["-vframes", "1"]) + jpeg_items.extend(["-frames:v", "1"]) if resolution_arg: jpeg_items.extend(resolution_arg) @@ -481,27 +506,36 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # Set video input attributes max_int = str(2147483647) video_data = get_ffprobe_data(video_file_path, logger=self.log) - # Use duration of the individual streams since it is returned with - # higher decimal precision than 'format.duration'. We need this - # more precise value for calculating the correct amount of frames - # for higher FPS ranges or decimal ranges, e.g. 29.97 FPS - duration = max( - float(stream.get("duration", 0)) - for stream in video_data["streams"] - if stream.get("codec_type") == "video" - ) - cmd_args = [ - "-y", - "-ss", str(duration * self.duration_split), + # Get duration or use a safe default (single frame) + duration = 0 + for stream in video_data["streams"]: + if stream.get("codec_type") == "video": + stream_duration = float(stream.get("duration", 0)) + if stream_duration > duration: + duration = stream_duration + + # For very short videos, just use the first frame + # Calculate seek position safely + seek_position = 0.0 + # Only use timestamp calculation for videos longer than 0.1 seconds + if duration > 0.1: + seek_position = duration * self.duration_split + + # Build command args + cmd_args = [] + if seek_position > 0.0: + cmd_args.extend(["-ss", str(seek_position)]) + + # Add generic ffmpeg commands + cmd_args.extend([ "-i", video_file_path, "-analyzeduration", max_int, "-probesize", max_int, - "-vframes", "1" - ] - - # add output file path - cmd_args.append(output_thumb_file_path) + "-y", + "-frames:v", "1", + output_thumb_file_path + ]) # create ffmpeg command cmd = get_ffmpeg_tool_args( @@ -512,15 +546,53 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # run subprocess self.log.debug("Executing: {}".format(" ".join(cmd))) run_subprocess(cmd, logger=self.log) - self.log.debug( - "Thumbnail created: {}".format(output_thumb_file_path)) - return output_thumb_file_path + + # Verify the output file was created + if ( + os.path.exists(output_thumb_file_path) + and os.path.getsize(output_thumb_file_path) > 0 + ): + self.log.debug( + "Thumbnail created: {}".format(output_thumb_file_path)) + return output_thumb_file_path + self.log.warning("Output file was not created or is empty") + + # Try to create thumbnail without offset + # - skip if offset did not happen + if "-ss" not in cmd_args: + return None + + self.log.debug("Trying fallback without offset") + # Remove -ss and its value + ss_index = cmd_args.index("-ss") + cmd_args.pop(ss_index) # Remove -ss + cmd_args.pop(ss_index) # Remove the timestamp value + + # Create new command and try again + cmd = get_ffmpeg_tool_args("ffmpeg", *cmd_args) + self.log.debug("Fallback command: {}".format(" ".join(cmd))) + run_subprocess(cmd, logger=self.log) + + if ( + os.path.exists(output_thumb_file_path) + and os.path.getsize(output_thumb_file_path) > 0 + ): + self.log.debug("Fallback thumbnail created") + return output_thumb_file_path + return None except RuntimeError as error: self.log.warning( "Failed intermediate thumb source using ffmpeg: {}".format( error) ) return None + finally: + # Remove output file if is empty + if ( + os.path.exists(output_thumb_file_path) + and os.path.getsize(output_thumb_file_path) == 0 + ): + os.remove(output_thumb_file_path) def _get_resolution_arg( self, diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 7751d73335..59a62b1d7b 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -170,7 +170,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): "-analyzeduration", max_int, "-probesize", max_int, "-i", src_path, - "-vframes", "1", + "-frames:v", "1", dst_path ) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index ae043a10a9..f1e066018c 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -619,8 +619,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # used for all represe # from temp to final original_directory = ( - instance.data.get("originalDirname") or instance_stagingdir) - + instance.data.get("originalDirname") or stagingdir) _rootless = self.get_rootless_path(anatomy, original_directory) if _rootless == original_directory: raise KnownPublishError(( @@ -684,7 +683,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): elif is_sequence_representation: # Collection of files (sequence) - src_collections, remainders = clique.assemble(files) + src_collections, _remainders = clique.assemble(files) src_collection = src_collections[0] destination_indexes = list(src_collection.indexes) diff --git a/client/ayon_core/plugins/publish/integrate_attach_reviewable.py b/client/ayon_core/plugins/publish/integrate_attach_reviewable.py new file mode 100644 index 0000000000..b98d8d28fe --- /dev/null +++ b/client/ayon_core/plugins/publish/integrate_attach_reviewable.py @@ -0,0 +1,138 @@ +import copy +import pyblish.api +from typing import List + +from ayon_core.lib import EnumDef +from ayon_core.pipeline import OptionalPyblishPluginMixin + + +class AttachReviewables( + pyblish.api.InstancePlugin, OptionalPyblishPluginMixin +): + """Attach reviewable to other instances + + This pre-integrator plugin allows instances to be 'attached to' other + instances by moving all its representations over to the other instance. + Even though this technically could work for any representation the current + intent is to use for reviewables only, like e.g. `review` or `render` + product type. + + When the reviewable is attached to another instance, the instance itself + will not be published as a separate entity. Instead, the representations + will be copied/moved to the instances it is attached to. + """ + + families = ["render", "review"] + order = pyblish.api.IntegratorOrder - 0.499 + label = "Attach reviewables" + + settings_category = "core" + + def process(self, instance): + # TODO: Support farm. + # If instance is being submitted to the farm we should pass through + # the 'attached reviewables' metadata to the farm job + # TODO: Reviewable frame range and resolutions + # Because we are attaching the data to another instance, how do we + # correctly propagate the resolution + frame rate to the other + # instance? Do we even need to? + # TODO: If this were to attach 'renders' to another instance that would + # mean there wouldn't necessarily be a render publish separate as a + # result. Is that correct expected behavior? + attr_values = self.get_attr_values_from_data(instance.data) + attach_to = attr_values.get("attach", []) + if not attach_to: + self.log.debug( + "Reviewable is not set to attach to another instance." + ) + return + + attach_instances: List[pyblish.api.Instance] = [] + for attach_instance_id in attach_to: + # Find the `pyblish.api.Instance` matching the `CreatedInstance.id` + # in the `attach_to` list + attach_instance = next( + ( + _inst + for _inst in instance.context + if _inst.data.get("instance_id") == attach_instance_id + ), + None, + ) + if attach_instance is None: + continue + + # Skip inactive instances + if not attach_instance.data.get("active", True): + continue + + # For now do not support attaching to 'farm' instances until we + # can pass the 'attaching' on to the farm jobs. + if attach_instance.data.get("farm"): + self.log.warning( + "Attaching to farm instances is not supported yet." + ) + continue + + attach_instances.append(attach_instance) + + instances_names = ", ".join( + instance.name for instance in attach_instances + ) + self.log.info( + f"Attaching reviewable to other instances: {instances_names}" + ) + + # Copy the representations of this reviewable instance to the other + # instance + representations = instance.data.get("representations", []) + for attach_instance in attach_instances: + self.log.info(f"Attaching to {attach_instance.name}") + attach_instance.data.setdefault("representations", []).extend( + copy.deepcopy(representations) + ) + + # Delete representations on the reviewable instance itself + for repre in representations: + self.log.debug( + "Marking representation as deleted because it was " + f"attached to other instances instead: {repre}" + ) + repre.setdefault("tags", []).append("delete") + + # Stop integrator from trying to integrate this instance + if attach_to: + instance.data["integrate"] = False + + @classmethod + def get_attr_defs_for_instance(cls, create_context, instance): + # TODO: Check if instance is actually a 'reviewable' + # Filtering of instance, if needed, can be customized + if not cls.instance_matches_plugin_families(instance): + return [] + + items = [] + for other_instance in create_context.instances: + if other_instance == instance: + continue + + # Do not allow attaching to other reviewable instances + if other_instance.data["productType"] in cls.families: + continue + + items.append( + { + "label": other_instance.label, + "value": str(other_instance.id), + } + ) + + return [ + EnumDef( + "attach", + label="Attach reviewable", + multiselection=True, + items=items, + tooltip="Attach this reviewable to another instance", + ) + ] diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 2163596864..43f93da293 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -1,7 +1,11 @@ import os import copy import errno +import itertools import shutil +from concurrent.futures import ThreadPoolExecutor + +from speedcopy import copyfile import clique import pyblish.api @@ -13,6 +17,7 @@ from ayon_api.operations import ( from ayon_api.utils import create_entity_id from ayon_core.lib import create_hard_link, source_hash +from ayon_core.lib.file_transaction import wait_for_future_errors from ayon_core.pipeline.publish import ( get_publish_template_name, OptionalPyblishPluginMixin, @@ -415,11 +420,14 @@ class IntegrateHeroVersion( # Copy(hardlink) paths of source and destination files # TODO should we *only* create hardlinks? # TODO should we keep files for deletion until this is successful? - for src_path, dst_path in src_to_dst_file_paths: - self.copy_file(src_path, dst_path) - - for src_path, dst_path in other_file_paths_mapping: - self.copy_file(src_path, dst_path) + with ThreadPoolExecutor(max_workers=8) as executor: + futures = [ + executor.submit(self.copy_file, src_path, dst_path) + for src_path, dst_path in itertools.chain( + src_to_dst_file_paths, other_file_paths_mapping + ) + ] + wait_for_future_errors(executor, futures) # Update prepared representation etity data with files # and integrate it to server. @@ -648,7 +656,7 @@ class IntegrateHeroVersion( src_path, dst_path )) - shutil.copy(src_path, dst_path) + copyfile(src_path, dst_path) def version_from_representations(self, project_name, repres): for repre in repres: diff --git a/client/ayon_core/plugins/publish/integrate_resources_path.py b/client/ayon_core/plugins/publish/integrate_resources_path.py index 56dc0e5ef7..b518f7f6f1 100644 --- a/client/ayon_core/plugins/publish/integrate_resources_path.py +++ b/client/ayon_core/plugins/publish/integrate_resources_path.py @@ -7,7 +7,7 @@ class IntegrateResourcesPath(pyblish.api.InstancePlugin): label = "Integrate Resources Path" order = pyblish.api.IntegratorOrder - 0.05 - families = ["clip", "projectfile", "plate"] + families = ["clip", "projectfile", "plate"] def process(self, instance): resources = instance.data.get("resources") or [] diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index ca32e60cc2..067c3470e8 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -27,8 +27,10 @@ import collections import pyblish.api import ayon_api +from ayon_api import RequestTypes from ayon_api.operations import OperationsSession + InstanceFilterResult = collections.namedtuple( "InstanceFilterResult", ["instance", "thumbnail_path", "version_id"] @@ -161,6 +163,30 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): return None return os.path.normpath(filled_path) + def _create_thumbnail(self, project_name: str, src_filepath: str) -> str: + """Upload thumbnail to AYON and return its id. + + This is temporary fix of 'create_thumbnail' function in ayon_api to + fix jpeg mime type. + + """ + mime_type = None + with open(src_filepath, "rb") as stream: + if b"\xff\xd8\xff" == stream.read(3): + mime_type = "image/jpeg" + + if mime_type is None: + return ayon_api.create_thumbnail(project_name, src_filepath) + + response = ayon_api.upload_file( + f"projects/{project_name}/thumbnails", + src_filepath, + request_type=RequestTypes.post, + headers={"Content-Type": mime_type}, + ) + response.raise_for_status() + return response.json()["id"] + def _integrate_thumbnails( self, filtered_instance_items, @@ -179,7 +205,7 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): ).format(instance_label)) continue - thumbnail_id = ayon_api.create_thumbnail( + thumbnail_id = self._create_thumbnail( project_name, thumbnail_path ) diff --git a/client/ayon_core/scripts/otio_burnin.py b/client/ayon_core/scripts/otio_burnin.py index cb72606222..77eeecaff6 100644 --- a/client/ayon_core/scripts/otio_burnin.py +++ b/client/ayon_core/scripts/otio_burnin.py @@ -173,7 +173,6 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): if frame_end is not None: options["frame_end"] = frame_end - options["label"] = align self._add_burnin(text, align, options, DRAWTEXT) diff --git a/client/ayon_core/scripts/slates/slate_base/base.py b/client/ayon_core/scripts/slates/slate_base/base.py index e1648c916a..a4427bbb86 100644 --- a/client/ayon_core/scripts/slates/slate_base/base.py +++ b/client/ayon_core/scripts/slates/slate_base/base.py @@ -175,7 +175,7 @@ class BaseObj: self.log.warning("Invalid range '{}'".format(part)) continue - for idx in range(sub_parts[0], sub_parts[1]+1): + for idx in range(sub_parts[0], sub_parts[1] + 1): indexes.append(idx) return indexes @@ -353,7 +353,6 @@ class BaseObj: self.items[item.id] = item item.fill_data_format() - def reset(self): for item in self.items.values(): item.reset() diff --git a/client/ayon_core/scripts/slates/slate_base/items.py b/client/ayon_core/scripts/slates/slate_base/items.py index ec3358ed5e..eb7859a6e1 100644 --- a/client/ayon_core/scripts/slates/slate_base/items.py +++ b/client/ayon_core/scripts/slates/slate_base/items.py @@ -282,7 +282,7 @@ class ItemTable(BaseItem): value.draw(image, drawer) def value_width(self): - row_heights, col_widths = self.size_values + _row_heights, col_widths = self.size_values width = 0 for _width in col_widths: width += _width @@ -292,7 +292,7 @@ class ItemTable(BaseItem): return width def value_height(self): - row_heights, col_widths = self.size_values + row_heights, _col_widths = self.size_values height = 0 for _height in row_heights: height += _height @@ -569,21 +569,21 @@ class TableField(BaseItem): @property def item_pos_x(self): - pos_x, pos_y, width, height = ( + pos_x, _pos_y, _width, _height = ( self.parent.content_pos_info_by_cord(self.row_idx, self.col_idx) ) return pos_x @property def item_pos_y(self): - pos_x, pos_y, width, height = ( + _pos_x, pos_y, _width, _height = ( self.parent.content_pos_info_by_cord(self.row_idx, self.col_idx) ) return pos_y @property def value_pos_x(self): - pos_x, pos_y, width, height = ( + pos_x, _pos_y, width, _height = ( self.parent.content_pos_info_by_cord(self.row_idx, self.col_idx) ) alignment_hor = self.style["alignment-horizontal"].lower() @@ -605,7 +605,7 @@ class TableField(BaseItem): @property def value_pos_y(self): - pos_x, pos_y, width, height = ( + _pos_x, pos_y, _width, height = ( self.parent.content_pos_info_by_cord(self.row_idx, self.col_idx) ) diff --git a/client/ayon_core/settings/lib.py b/client/ayon_core/settings/lib.py index cd219a153b..47d74144ea 100644 --- a/client/ayon_core/settings/lib.py +++ b/client/ayon_core/settings/lib.py @@ -88,14 +88,9 @@ class _AyonSettingsCache: @classmethod def _get_variant(cls): if _AyonSettingsCache.variant is None: - from ayon_core.lib import is_staging_enabled, is_dev_mode_enabled - - variant = "production" - if is_dev_mode_enabled(): - variant = cls._get_studio_bundle_name() - elif is_staging_enabled(): - variant = "staging" + from ayon_core.lib import get_settings_variant + variant = get_settings_variant() # Cache variant _AyonSettingsCache.variant = variant diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 0e19702d53..4ef903540e 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -829,6 +829,37 @@ HintedLineEditButton { } /* Launcher specific stylesheets */ +ActionsView[mode="icon"] { + /* font size can't be set on items */ + font-size: 9pt; + border: 0px; + padding: 0px; + margin: 0px; +} + +ActionsView[mode="icon"]::item { + padding-top: 8px; + padding-bottom: 4px; + border: 0px; + border-radius: 0.3em; +} + +ActionsView[mode="icon"]::item:hover { + color: {color:font-hover}; + background: #424A57; +} + +ActionsView[mode="icon"]::icon {} + +ActionMenuPopup #Wrapper { + border-radius: 0.3em; + background: #353B46; +} +ActionMenuPopup ActionsView[mode="icon"] { + background: transparent; + border: none; +} + #IconView[mode="icon"] { /* font size can't be set on items */ font-size: 9pt; diff --git a/client/ayon_core/tools/attribute_defs/dialog.py b/client/ayon_core/tools/attribute_defs/dialog.py index ef717d576a..7423d58475 100644 --- a/client/ayon_core/tools/attribute_defs/dialog.py +++ b/client/ayon_core/tools/attribute_defs/dialog.py @@ -1,22 +1,58 @@ -from qtpy import QtWidgets +from __future__ import annotations + +from typing import Optional + +from qtpy import QtWidgets, QtGui + +from ayon_core.style import load_stylesheet +from ayon_core.resources import get_ayon_icon_filepath +from ayon_core.lib import AbstractAttrDef from .widgets import AttributeDefinitionsWidget class AttributeDefinitionsDialog(QtWidgets.QDialog): - def __init__(self, attr_defs, parent=None): - super(AttributeDefinitionsDialog, self).__init__(parent) + def __init__( + self, + attr_defs: list[AbstractAttrDef], + title: Optional[str] = None, + submit_label: Optional[str] = None, + cancel_label: Optional[str] = None, + submit_icon: Optional[QtGui.QIcon] = None, + cancel_icon: Optional[QtGui.QIcon] = None, + parent: Optional[QtWidgets.QWidget] = None, + ): + super().__init__(parent) + + if title: + self.setWindowTitle(title) + + icon = QtGui.QIcon(get_ayon_icon_filepath()) + self.setWindowIcon(icon) + self.setStyleSheet(load_stylesheet()) attrs_widget = AttributeDefinitionsWidget(attr_defs, self) + if submit_label is None: + submit_label = "OK" + + if cancel_label is None: + cancel_label = "Cancel" + btns_widget = QtWidgets.QWidget(self) - ok_btn = QtWidgets.QPushButton("OK", btns_widget) - cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget) + cancel_btn = QtWidgets.QPushButton(cancel_label, btns_widget) + submit_btn = QtWidgets.QPushButton(submit_label, btns_widget) + + if submit_icon is not None: + submit_btn.setIcon(submit_icon) + + if cancel_icon is not None: + cancel_btn.setIcon(cancel_icon) btns_layout = QtWidgets.QHBoxLayout(btns_widget) btns_layout.setContentsMargins(0, 0, 0, 0) btns_layout.addStretch(1) - btns_layout.addWidget(ok_btn, 0) + btns_layout.addWidget(submit_btn, 0) btns_layout.addWidget(cancel_btn, 0) main_layout = QtWidgets.QVBoxLayout(self) @@ -24,10 +60,33 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog): main_layout.addStretch(1) main_layout.addWidget(btns_widget, 0) - ok_btn.clicked.connect(self.accept) + submit_btn.clicked.connect(self.accept) cancel_btn.clicked.connect(self.reject) self._attrs_widget = attrs_widget + self._submit_btn = submit_btn + self._cancel_btn = cancel_btn def get_values(self): return self._attrs_widget.current_value() + + def set_values(self, values): + self._attrs_widget.set_value(values) + + def set_submit_label(self, text: str): + self._submit_btn.setText(text) + + def set_submit_icon(self, icon: QtGui.QIcon): + self._submit_btn.setIcon(icon) + + def set_submit_visible(self, visible: bool): + self._submit_btn.setVisible(visible) + + def set_cancel_label(self, text: str): + self._cancel_btn.setText(text) + + def set_cancel_icon(self, icon: QtGui.QIcon): + self._cancel_btn.setIcon(icon) + + def set_cancel_visible(self, visible: bool): + self._cancel_btn.setVisible(visible) diff --git a/client/ayon_core/tools/attribute_defs/widgets.py b/client/ayon_core/tools/attribute_defs/widgets.py index dbd65fd215..1e948b2d28 100644 --- a/client/ayon_core/tools/attribute_defs/widgets.py +++ b/client/ayon_core/tools/attribute_defs/widgets.py @@ -22,6 +22,7 @@ from ayon_core.tools.utils import ( FocusSpinBox, FocusDoubleSpinBox, MultiSelectionComboBox, + MarkdownLabel, PlaceholderLineEdit, PlaceholderPlainTextEdit, set_style_property, @@ -247,12 +248,10 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): def set_value(self, value): new_value = copy.deepcopy(value) - unused_keys = set(new_value.keys()) for widget in self._widgets_by_id.values(): attr_def = widget.attr_def if attr_def.key not in new_value: continue - unused_keys.remove(attr_def.key) widget_value = new_value[attr_def.key] if widget_value is None: @@ -350,7 +349,7 @@ class SeparatorAttrWidget(_BaseAttrDefWidget): class LabelAttrWidget(_BaseAttrDefWidget): def _ui_init(self): - input_widget = QtWidgets.QLabel(self) + input_widget = MarkdownLabel(self) label = self.attr_def.label if label: input_widget.setText(str(label)) diff --git a/client/ayon_core/tools/common_models/hierarchy.py b/client/ayon_core/tools/common_models/hierarchy.py index edff8471b0..891eb80960 100644 --- a/client/ayon_core/tools/common_models/hierarchy.py +++ b/client/ayon_core/tools/common_models/hierarchy.py @@ -227,6 +227,9 @@ class HierarchyModel(object): self._tasks_by_id = NestedCacheItem( levels=2, default_factory=dict, lifetime=self.lifetime) + self._entity_ids_by_assignee = NestedCacheItem( + levels=2, default_factory=dict, lifetime=self.lifetime) + self._folders_refreshing = set() self._tasks_refreshing = set() self._controller = controller @@ -238,6 +241,8 @@ class HierarchyModel(object): self._task_items.reset() self._tasks_by_id.reset() + self._entity_ids_by_assignee.reset() + def refresh_project(self, project_name): """Force to refresh folder items for a project. @@ -461,6 +466,54 @@ class HierarchyModel(object): output = self.get_task_entities(project_name, {task_id}) return output[task_id] + def get_entity_ids_for_assignees( + self, project_name: str, assignees: list[str] + ): + folder_ids = set() + task_ids = set() + output = { + "folder_ids": folder_ids, + "task_ids": task_ids, + } + assignees = set(assignees) + for assignee in tuple(assignees): + cache = self._entity_ids_by_assignee[project_name][assignee] + if cache.is_valid: + assignees.discard(assignee) + assignee_data = cache.get_data() + folder_ids.update(assignee_data["folder_ids"]) + task_ids.update(assignee_data["task_ids"]) + + if not assignees: + return output + + tasks = ayon_api.get_tasks( + project_name, + assignees_all=assignees, + fields={"id", "folderId", "assignees"}, + ) + tasks_assignee = {} + for task in tasks: + folder_ids.add(task["folderId"]) + task_ids.add(task["id"]) + for assignee in task["assignees"]: + tasks_assignee.setdefault(assignee, []).append(task) + + for assignee, tasks in tasks_assignee.items(): + cache = self._entity_ids_by_assignee[project_name][assignee] + assignee_folder_ids = set() + assignee_task_ids = set() + assignee_data = { + "folder_ids": assignee_folder_ids, + "task_ids": assignee_task_ids, + } + for task in tasks: + assignee_folder_ids.add(task["folderId"]) + assignee_task_ids.add(task["id"]) + cache.update_data(assignee_data) + + return output + @contextlib.contextmanager def _folder_refresh_event_manager(self, project_name, sender): self._folders_refreshing.add(project_name) diff --git a/client/ayon_core/tools/common_models/thumbnails.py b/client/ayon_core/tools/common_models/thumbnails.py index 2fa1e36e5c..d25c6a1ecd 100644 --- a/client/ayon_core/tools/common_models/thumbnails.py +++ b/client/ayon_core/tools/common_models/thumbnails.py @@ -21,8 +21,49 @@ class ThumbnailsModel: self._folders_cache.reset() self._versions_cache.reset() - def get_thumbnail_path(self, project_name, thumbnail_id): - return self._get_thumbnail_path(project_name, thumbnail_id) + def get_thumbnail_paths( + self, + project_name, + entity_type, + entity_ids, + ): + output = { + entity_id: None + for entity_id in entity_ids + } + if not project_name or not entity_type or not entity_ids: + return output + + thumbnail_id_by_entity_id = {} + if entity_type == "folder": + thumbnail_id_by_entity_id = self.get_folder_thumbnail_ids( + project_name, entity_ids + ) + + elif entity_type == "version": + thumbnail_id_by_entity_id = self.get_version_thumbnail_ids( + project_name, entity_ids + ) + + if not thumbnail_id_by_entity_id: + return output + + entity_ids_by_thumbnail_id = collections.defaultdict(set) + for entity_id, thumbnail_id in thumbnail_id_by_entity_id.items(): + if not thumbnail_id: + continue + entity_ids_by_thumbnail_id[thumbnail_id].add(entity_id) + + for thumbnail_id, entity_ids in entity_ids_by_thumbnail_id.items(): + thumbnail_path = self._get_thumbnail_path( + project_name, entity_type, next(iter(entity_ids)), thumbnail_id + ) + if not thumbnail_path: + continue + for entity_id in entity_ids: + output[entity_id] = thumbnail_path + + return output def get_folder_thumbnail_ids(self, project_name, folder_ids): project_cache = self._folders_cache[project_name] @@ -56,7 +97,13 @@ class ThumbnailsModel: output[version_id] = cache.get_data() return output - def _get_thumbnail_path(self, project_name, thumbnail_id): + def _get_thumbnail_path( + self, + project_name, + entity_type, + entity_id, + thumbnail_id + ): if not thumbnail_id: return None @@ -64,7 +111,12 @@ class ThumbnailsModel: if thumbnail_id in project_cache: return project_cache[thumbnail_id] - filepath = get_thumbnail_path(project_name, thumbnail_id) + filepath = get_thumbnail_path( + project_name, + entity_type, + entity_id, + thumbnail_id + ) project_cache[thumbnail_id] = filepath return filepath diff --git a/client/ayon_core/tools/console_interpreter/ui/widgets.py b/client/ayon_core/tools/console_interpreter/ui/widgets.py index 2b9361666e..3dc55b081c 100644 --- a/client/ayon_core/tools/console_interpreter/ui/widgets.py +++ b/client/ayon_core/tools/console_interpreter/ui/widgets.py @@ -248,4 +248,3 @@ class EnhancedTabBar(QtWidgets.QTabBar): else: super().mouseReleaseEvent(event) - diff --git a/client/ayon_core/tools/creator/window.py b/client/ayon_core/tools/creator/window.py index 5bdc6da9b6..5d1c0a272a 100644 --- a/client/ayon_core/tools/creator/window.py +++ b/client/ayon_core/tools/creator/window.py @@ -492,7 +492,7 @@ def show(parent=None): try: module.window.close() - del(module.window) + del module.window except (AttributeError, RuntimeError): pass diff --git a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py index 33de4bf036..a485c682a1 100644 --- a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py +++ b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py @@ -32,7 +32,7 @@ from qtpy import QtWidgets, QtCore, QtGui import pyblish.api from ayon_core import style -TAB = 4* " " +TAB = 4 * " " HEADER_SIZE = "15px" KEY_COLOR = QtGui.QColor("#ffffff") @@ -243,7 +243,7 @@ class DebugUI(QtWidgets.QDialog): self._set_window_title(plugin=result["plugin"]) - print(10*"<", result["plugin"].__name__, 10*">") + print(10 * "<", result["plugin"].__name__, 10 * ">") plugin_order = result["plugin"].order plugin_name = result["plugin"].__name__ diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index 63ba4cd717..1d7dafd62f 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -1,4 +1,59 @@ +from __future__ import annotations + from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional, Any + +from ayon_core.tools.common_models import ( + ProjectItem, + FolderItem, + FolderTypeItem, + TaskItem, + TaskTypeItem, +) + + +@dataclass +class WebactionContext: + """Context used for methods related to webactions.""" + identifier: str + project_name: str + folder_id: str + task_id: str + addon_name: str + addon_version: str + + +@dataclass +class ActionItem: + """Item representing single action to trigger. + + Attributes: + action_type (Literal["webaction", "local"]): Type of action. + identifier (str): Unique identifier of action item. + order (int): Action ordering. + label (str): Action label. + variant_label (Union[str, None]): Variant label, full label is + concatenated with space. Actions are grouped under single + action if it has same 'label' and have set 'variant_label'. + full_label (str): Full label, if not set it is generated + from 'label' and 'variant_label'. + icon (dict[str, str]): Icon definition. + addon_name (Optional[str]): Addon name. + addon_version (Optional[str]): Addon version. + config_fields (list[dict]): Config fields for webaction. + + """ + action_type: str + identifier: str + order: int + label: str + variant_label: Optional[str] + full_label: str + icon: Optional[dict[str, str]] + config_fields: list[dict] + addon_name: Optional[str] = None + addon_version: Optional[str] = None class AbstractLauncherCommon(ABC): @@ -88,7 +143,9 @@ class AbstractLauncherBackend(AbstractLauncherCommon): class AbstractLauncherFrontEnd(AbstractLauncherCommon): # Entity items for UI @abstractmethod - def get_project_items(self, sender=None): + def get_project_items( + self, sender: Optional[str] = None + ) -> list[ProjectItem]: """Project items for all projects. This function may trigger events 'projects.refresh.started' and @@ -106,7 +163,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_folder_type_items(self, project_name, sender=None): + def get_folder_type_items( + self, project_name: str, sender: Optional[str] = None + ) -> list[FolderTypeItem]: """Folder type items for a project. This function may trigger events with topics @@ -126,7 +185,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_task_type_items(self, project_name, sender=None): + def get_task_type_items( + self, project_name: str, sender: Optional[str] = None + ) -> list[TaskTypeItem]: """Task type items for a project. This function may trigger events with topics @@ -146,7 +207,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_folder_items(self, project_name, sender=None): + def get_folder_items( + self, project_name: str, sender: Optional[str] = None + ) -> list[FolderItem]: """Folder items to visualize project hierarchy. This function may trigger events 'folders.refresh.started' and @@ -160,12 +223,14 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): Returns: list[FolderItem]: Minimum possible information needed for visualisation of folder hierarchy. - """ + """ pass @abstractmethod - def get_task_items(self, project_name, folder_id, sender=None): + def get_task_items( + self, project_name: str, folder_id: str, sender: Optional[str] = None + ) -> list[TaskItem]: """Task items. This function may trigger events 'tasks.refresh.started' and @@ -180,52 +245,52 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): Returns: list[TaskItem]: Minimum possible information needed for visualisation of tasks. - """ + """ pass @abstractmethod - def get_selected_project_name(self): + def get_selected_project_name(self) -> Optional[str]: """Selected project name. Returns: Union[str, None]: Selected project name. - """ + """ pass @abstractmethod - def get_selected_folder_id(self): + def get_selected_folder_id(self) -> Optional[str]: """Selected folder id. Returns: Union[str, None]: Selected folder id. - """ + """ pass @abstractmethod - def get_selected_task_id(self): + def get_selected_task_id(self) -> Optional[str]: """Selected task id. Returns: Union[str, None]: Selected task id. - """ + """ pass @abstractmethod - def get_selected_task_name(self): + def get_selected_task_name(self) -> Optional[str]: """Selected task name. Returns: Union[str, None]: Selected task name. - """ + """ pass @abstractmethod - def get_selected_context(self): + def get_selected_context(self) -> dict[str, Optional[str]]: """Get whole selected context. Example: @@ -238,34 +303,36 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): Returns: dict[str, Union[str, None]]: Selected context. - """ + """ pass @abstractmethod - def set_selected_project(self, project_name): + def set_selected_project(self, project_name: Optional[str]): """Change selected folder. Args: project_name (Union[str, None]): Project nameor None if no project is selected. - """ + """ pass @abstractmethod - def set_selected_folder(self, folder_id): + def set_selected_folder(self, folder_id: Optional[str]): """Change selected folder. Args: folder_id (Union[str, None]): Folder id or None if no folder is selected. - """ + """ pass @abstractmethod - def set_selected_task(self, task_id, task_name): + def set_selected_task( + self, task_id: Optional[str], task_name: Optional[str] + ): """Change selected task. Args: @@ -273,13 +340,18 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): is selected. task_name (Union[str, None]): Task name or None if no task is selected. - """ + """ pass # Actions @abstractmethod - def get_action_items(self, project_name, folder_id, task_id): + def get_action_items( + self, + project_name: Optional[str], + folder_id: Optional[str], + task_id: Optional[str], + ) -> list[ActionItem]: """Get action items for given context. Args: @@ -290,37 +362,74 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): Returns: list[ActionItem]: List of action items that should be shown for given context. - """ + """ pass @abstractmethod - def trigger_action(self, project_name, folder_id, task_id, action_id): + def trigger_action( + self, + action_id: str, + project_name: Optional[str], + folder_id: Optional[str], + task_id: Optional[str], + ): """Trigger action on given context. Args: + action_id (str): Action identifier. project_name (Union[str, None]): Project name. folder_id (Union[str, None]): Folder id. task_id (Union[str, None]): Task id. - action_id (str): Action identifier. - """ + """ pass @abstractmethod - def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_ids, enabled + def trigger_webaction( + self, + context: WebactionContext, + action_label: str, + form_data: Optional[dict[str, Any]] = None, ): - """This is application action related to force not open last workfile. + """Trigger action on the given context. Args: - project_name (Union[str, None]): Project name. - folder_id (Union[str, None]): Folder id. - task_id (Union[str, None]): Task id. - action_id (Iterable[str]): Action identifiers. - enabled (bool): New value of force not open workfile. - """ + context (WebactionContext): Webaction context. + action_label (str): Action label. + form_data (Optional[dict[str, Any]]): Form values of action. + """ + pass + + @abstractmethod + def get_action_config_values( + self, context: WebactionContext + ) -> dict[str, Any]: + """Get action config values. + + Args: + context (WebactionContext): Webaction context. + + Returns: + dict[str, Any]: Action config values. + + """ + pass + + @abstractmethod + def set_action_config_values( + self, + context: WebactionContext, + values: dict[str, Any], + ): + """Set action config values. + + Args: + context (WebactionContext): Webaction context. + values (dict[str, Any]): Action config values. + + """ pass @abstractmethod @@ -340,5 +449,19 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): Triggers 'controller.refresh.actions.started' event at the beginning and 'controller.refresh.actions.finished' at the end. """ - + pass + + @abstractmethod + def get_my_tasks_entity_ids( + self, project_name: str + ) -> dict[str, list[str]]: + """Get entity ids for my tasks. + + Args: + project_name (str): Project name. + + Returns: + dict[str, list[str]]: Folder and task ids. + + """ pass diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index d60e4747e3..fb9f950bd1 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -1,4 +1,4 @@ -from ayon_core.lib import Logger +from ayon_core.lib import Logger, get_ayon_username from ayon_core.lib.events import QueuedEventSystem from ayon_core.settings import get_project_settings from ayon_core.tools.common_models import ProjectsModel, HierarchyModel @@ -6,6 +6,8 @@ from ayon_core.tools.common_models import ProjectsModel, HierarchyModel from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend from .models import LauncherSelectionModel, ActionsModel +NOT_SET = object() + class BaseLauncherController( AbstractLauncherFrontEnd, AbstractLauncherBackend @@ -15,6 +17,8 @@ class BaseLauncherController( self._event_system = None self._log = None + self._username = NOT_SET + self._selection_model = LauncherSelectionModel(self) self._projects_model = ProjectsModel(self) self._hierarchy_model = HierarchyModel(self) @@ -28,7 +32,7 @@ class BaseLauncherController( @property def event_system(self): - """Inner event system for workfiles tool controller. + """Inner event system for launcher tool controller. Is used for communication with UI. Event system is created on demand. @@ -131,16 +135,30 @@ class BaseLauncherController( return self._actions_model.get_action_items( project_name, folder_id, task_id) - def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_ids, enabled + def trigger_action( + self, + identifier, + project_name, + folder_id, + task_id, ): - self._actions_model.set_application_force_not_open_workfile( - project_name, folder_id, task_id, action_ids, enabled + self._actions_model.trigger_action( + identifier, + project_name, + folder_id, + task_id, ) - def trigger_action(self, project_name, folder_id, task_id, identifier): - self._actions_model.trigger_action( - project_name, folder_id, task_id, identifier) + def trigger_webaction(self, context, action_label, form_data=None): + self._actions_model.trigger_webaction( + context, action_label, form_data + ) + + def get_action_config_values(self, context): + return self._actions_model.get_action_config_values(context) + + def set_action_config_values(self, context, values): + return self._actions_model.set_action_config_values(context, values) # General methods def refresh(self): @@ -168,5 +186,19 @@ class BaseLauncherController( self._emit_event("controller.refresh.actions.finished") + def get_my_tasks_entity_ids(self, project_name: str): + username = self._get_my_username() + assignees = [] + if username: + assignees.append(username) + return self._hierarchy_model.get_entity_ids_for_assignees( + project_name, assignees + ) + + def _get_my_username(self): + if self._username is NOT_SET: + self._username = get_ayon_username() + return self._username + def _emit_event(self, topic, data=None): self.emit_event(topic, data, "controller") diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index e1612e2b9f..0ed4bdad3a 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,219 +1,47 @@ import os +import uuid +from dataclasses import dataclass, asdict +from urllib.parse import urlencode, urlparse +from typing import Any, Optional +import webbrowser + +import ayon_api from ayon_core import resources -from ayon_core.lib import Logger, AYONSettingsRegistry +from ayon_core.lib import ( + Logger, + NestedCacheItem, + CacheItem, + get_settings_variant, + run_detached_ayon_launcher_process, +) from ayon_core.addon import AddonsManager from ayon_core.pipeline.actions import ( discover_launcher_actions, - LauncherAction, LauncherActionSelection, register_launcher_action_path, ) -from ayon_core.pipeline.workfile import should_use_last_workfile_on_launch - -try: - # Available since applications addon 0.2.4 - from ayon_applications.action import ApplicationAction -except ImportError: - # Backwards compatibility from 0.3.3 (24/06/10) - # TODO: Remove in future releases - class ApplicationAction(LauncherAction): - """Action to launch an application. - - Application action based on 'ApplicationManager' system. - - Handling of applications in launcher is not ideal and should be - completely redone from scratch. This is just a temporary solution - to keep backwards compatibility with AYON launcher. - - Todos: - Move handling of errors to frontend. - """ - - # Application object - application = None - # Action attributes - name = None - label = None - label_variant = None - group = None - icon = None - color = None - order = 0 - data = {} - project_settings = {} - project_entities = {} - - _log = None - - @property - def log(self): - if self._log is None: - self._log = Logger.get_logger(self.__class__.__name__) - return self._log - - def is_compatible(self, selection): - if not selection.is_task_selected: - return False - - project_entity = self.project_entities[selection.project_name] - apps = project_entity["attrib"].get("applications") - if not apps or self.application.full_name not in apps: - return False - - project_settings = self.project_settings[selection.project_name] - only_available = project_settings["applications"]["only_available"] - if only_available and not self.application.find_executable(): - return False - return True - - def _show_message_box(self, title, message, details=None): - from qtpy import QtWidgets, QtGui - from ayon_core import style - - dialog = QtWidgets.QMessageBox() - icon = QtGui.QIcon(resources.get_ayon_icon_filepath()) - dialog.setWindowIcon(icon) - dialog.setStyleSheet(style.load_stylesheet()) - dialog.setWindowTitle(title) - dialog.setText(message) - if details: - dialog.setDetailedText(details) - dialog.exec_() - - def process(self, selection, **kwargs): - """Process the full Application action""" - - from ayon_applications import ( - ApplicationExecutableNotFound, - ApplicationLaunchFailed, - ) - - try: - self.application.launch( - project_name=selection.project_name, - folder_path=selection.folder_path, - task_name=selection.task_name, - **self.data - ) - - except ApplicationExecutableNotFound as exc: - details = exc.details - msg = exc.msg - log_msg = str(msg) - if details: - log_msg += "\n" + details - self.log.warning(log_msg) - self._show_message_box( - "Application executable not found", msg, details - ) - - except ApplicationLaunchFailed as exc: - msg = str(exc) - self.log.warning(msg, exc_info=True) - self._show_message_box("Application launch failed", msg) +from ayon_core.tools.launcher.abstract import ActionItem, WebactionContext -# class Action: -# def __init__(self, label, icon=None, identifier=None): -# self._label = label -# self._icon = icon -# self._callbacks = [] -# self._identifier = identifier or uuid.uuid4().hex -# self._checked = True -# self._checkable = False -# -# def set_checked(self, checked): -# self._checked = checked -# -# def set_checkable(self, checkable): -# self._checkable = checkable -# -# def set_label(self, label): -# self._label = label -# -# def add_callback(self, callback): -# self._callbacks = callback -# -# -# class Menu: -# def __init__(self, label, icon=None): -# self.label = label -# self.icon = icon -# self._actions = [] -# -# def add_action(self, action): -# self._actions.append(action) +@dataclass +class WebactionForm: + fields: list[dict[str, Any]] + title: str + submit_label: str + submit_icon: str + cancel_label: str + cancel_icon: str -class ActionItem: - """Item representing single action to trigger. - - Todos: - Get rid of application specific logic. - - Args: - identifier (str): Unique identifier of action item. - label (str): Action label. - variant_label (Union[str, None]): Variant label, full label is - concatenated with space. Actions are grouped under single - action if it has same 'label' and have set 'variant_label'. - icon (dict[str, str]): Icon definition. - order (int): Action ordering. - is_application (bool): Is action application action. - force_not_open_workfile (bool): Force not open workfile. Application - related. - full_label (Optional[str]): Full label, if not set it is generated - from 'label' and 'variant_label'. - """ - - def __init__( - self, - identifier, - label, - variant_label, - icon, - order, - is_application, - force_not_open_workfile, - full_label=None - ): - self.identifier = identifier - self.label = label - self.variant_label = variant_label - self.icon = icon - self.order = order - self.is_application = is_application - self.force_not_open_workfile = force_not_open_workfile - self._full_label = full_label - - def copy(self): - return self.from_data(self.to_data()) - - @property - def full_label(self): - if self._full_label is None: - if self.variant_label: - self._full_label = " ".join([self.label, self.variant_label]) - else: - self._full_label = self.label - return self._full_label - - def to_data(self): - return { - "identifier": self.identifier, - "label": self.label, - "variant_label": self.variant_label, - "icon": self.icon, - "order": self.order, - "is_application": self.is_application, - "force_not_open_workfile": self.force_not_open_workfile, - "full_label": self._full_label, - } - - @classmethod - def from_data(cls, data): - return cls(**data) +@dataclass +class WebactionResponse: + response_type: str + success: bool + message: Optional[str] = None + clipboard_text: Optional[str] = None + form: Optional[WebactionForm] = None + error_message: Optional[str] = None def get_action_icon(action): @@ -264,8 +92,6 @@ class ActionsModel: controller (AbstractLauncherBackend): Controller instance. """ - _not_open_workfile_reg_key = "force_not_open_workfile" - def __init__(self, controller): self._controller = controller @@ -274,11 +100,21 @@ class ActionsModel: self._discovered_actions = None self._actions = None self._action_items = {} - - self._launcher_tool_reg = AYONSettingsRegistry("launcher_tool") + self._webaction_items = NestedCacheItem( + levels=2, default_factory=list, lifetime=20, + ) self._addons_manager = None + self._variant = get_settings_variant() + + @staticmethod + def calculate_full_label(label: str, variant_label: Optional[str]) -> str: + """Calculate full label from label and variant_label.""" + if variant_label: + return " ".join([label, variant_label]) + return label + @property def log(self): if self._log is None: @@ -289,39 +125,12 @@ class ActionsModel: self._discovered_actions = None self._actions = None self._action_items = {} + self._webaction_items.reset() self._controller.emit_event("actions.refresh.started") self._get_action_objects() self._controller.emit_event("actions.refresh.finished") - def _should_start_last_workfile( - self, - project_name, - task_id, - identifier, - host_name, - not_open_workfile_actions - ): - if identifier in not_open_workfile_actions: - return not not_open_workfile_actions[identifier] - - task_name = None - task_type = None - if task_id is not None: - task_entity = self._controller.get_task_entity( - project_name, task_id - ) - task_name = task_entity["name"] - task_type = task_entity["taskType"] - - output = should_use_last_workfile_on_launch( - project_name, - host_name, - task_name, - task_type - ) - return output - def get_action_items(self, project_name, folder_id, task_id): """Get actions for project. @@ -332,53 +141,31 @@ class ActionsModel: Returns: list[ActionItem]: List of actions. + """ - not_open_workfile_actions = self._get_no_last_workfile_for_context( - project_name, folder_id, task_id) selection = self._prepare_selection(project_name, folder_id, task_id) output = [] action_items = self._get_action_items(project_name) for identifier, action in self._get_action_objects().items(): - if not action.is_compatible(selection): - continue + if action.is_compatible(selection): + output.append(action_items[identifier]) + output.extend(self._get_webactions(selection)) - action_item = action_items[identifier] - # Handling of 'force_not_open_workfile' for applications - if action_item.is_application: - action_item = action_item.copy() - start_last_workfile = self._should_start_last_workfile( - project_name, - task_id, - identifier, - action.application.host_name, - not_open_workfile_actions - ) - action_item.force_not_open_workfile = ( - not start_last_workfile - ) - - output.append(action_item) return output - def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_ids, enabled + def trigger_action( + self, + identifier, + project_name, + folder_id, + task_id, ): - no_workfile_reg_data = self._get_no_last_workfile_reg_data() - project_data = no_workfile_reg_data.setdefault(project_name, {}) - folder_data = project_data.setdefault(folder_id, {}) - task_data = folder_data.setdefault(task_id, {}) - for action_id in action_ids: - task_data[action_id] = enabled - self._launcher_tool_reg.set_item( - self._not_open_workfile_reg_key, no_workfile_reg_data - ) - - def trigger_action(self, project_name, folder_id, task_id, identifier): selection = self._prepare_selection(project_name, folder_id, task_id) failed = False error_message = None action_label = identifier action_items = self._get_action_items(project_name) + trigger_id = uuid.uuid4().hex try: action = self._actions[identifier] action_item = action_items[identifier] @@ -386,22 +173,11 @@ class ActionsModel: self._controller.emit_event( "action.trigger.started", { + "trigger_id": trigger_id, "identifier": identifier, "full_label": action_label, } ) - if isinstance(action, ApplicationAction): - per_action = self._get_no_last_workfile_for_context( - project_name, folder_id, task_id - ) - start_last_workfile = self._should_start_last_workfile( - project_name, - task_id, - identifier, - action.application.host_name, - per_action - ) - action.data["start_last_workfile"] = start_last_workfile action.process(selection) except Exception as exc: @@ -412,6 +188,7 @@ class ActionsModel: self._controller.emit_event( "action.trigger.finished", { + "trigger_id": trigger_id, "identifier": identifier, "failed": failed, "error_message": error_message, @@ -419,32 +196,148 @@ class ActionsModel: } ) + def trigger_webaction(self, context, action_label, form_data): + entity_type = None + entity_ids = [] + identifier = context.identifier + folder_id = context.folder_id + task_id = context.task_id + project_name = context.project_name + addon_name = context.addon_name + addon_version = context.addon_version + + if task_id: + entity_type = "task" + entity_ids.append(task_id) + elif folder_id: + entity_type = "folder" + entity_ids.append(folder_id) + + query = { + "addonName": addon_name, + "addonVersion": addon_version, + "identifier": identifier, + "variant": self._variant, + } + url = f"actions/execute?{urlencode(query)}" + request_data = { + "projectName": project_name, + "entityType": entity_type, + "entityIds": entity_ids, + } + if form_data is not None: + request_data["formData"] = form_data + + trigger_id = uuid.uuid4().hex + failed = False + try: + self._controller.emit_event( + "webaction.trigger.started", + { + "trigger_id": trigger_id, + "identifier": identifier, + "full_label": action_label, + } + ) + + conn = ayon_api.get_server_api_connection() + # Add 'referer' header to the request + # - ayon-api 1.1.1 adds the value to the header automatically + headers = conn.get_headers() + if "referer" in headers: + headers = None + else: + headers["referer"] = conn.get_base_url() + response = ayon_api.raw_post( + url, headers=headers, json=request_data + ) + response.raise_for_status() + handle_response = self._handle_webaction_response(response.data) + + except Exception: + failed = True + self.log.warning("Action trigger failed.", exc_info=True) + handle_response = WebactionResponse( + "unknown", + False, + error_message="Failed to trigger webaction.", + ) + + data = asdict(handle_response) + data.update({ + "trigger_failed": failed, + "trigger_id": trigger_id, + "identifier": identifier, + "full_label": action_label, + "project_name": project_name, + "folder_id": folder_id, + "task_id": task_id, + "addon_name": addon_name, + "addon_version": addon_version, + }) + self._controller.emit_event( + "webaction.trigger.finished", + data, + ) + + def get_action_config_values(self, context: WebactionContext): + selection = self._prepare_selection( + context.project_name, context.folder_id, context.task_id + ) + if not selection.is_project_selected: + return {} + + request_data = self._get_webaction_request_data(selection) + + query = { + "addonName": context.addon_name, + "addonVersion": context.addon_version, + "identifier": context.identifier, + "variant": self._variant, + } + url = f"actions/config?{urlencode(query)}" + try: + response = ayon_api.post(url, **request_data) + response.raise_for_status() + except Exception: + self.log.warning( + "Failed to collect webaction config values.", + exc_info=True + ) + return {} + return response.data + + def set_action_config_values(self, context, values): + selection = self._prepare_selection( + context.project_name, context.folder_id, context.task_id + ) + if not selection.is_project_selected: + return {} + + request_data = self._get_webaction_request_data(selection) + request_data["value"] = values + + query = { + "addonName": context.addon_name, + "addonVersion": context.addon_version, + "identifier": context.identifier, + "variant": self._variant, + } + url = f"actions/config?{urlencode(query)}" + try: + response = ayon_api.post(url, **request_data) + response.raise_for_status() + except Exception: + self.log.warning( + "Failed to store webaction config values.", + exc_info=True + ) + def _get_addons_manager(self): if self._addons_manager is None: self._addons_manager = AddonsManager() return self._addons_manager - def _get_no_last_workfile_reg_data(self): - try: - no_workfile_reg_data = self._launcher_tool_reg.get_item( - self._not_open_workfile_reg_key) - except ValueError: - no_workfile_reg_data = {} - self._launcher_tool_reg.set_item( - self._not_open_workfile_reg_key, no_workfile_reg_data) - return no_workfile_reg_data - - def _get_no_last_workfile_for_context( - self, project_name, folder_id, task_id - ): - not_open_workfile_reg_data = self._get_no_last_workfile_reg_data() - return ( - not_open_workfile_reg_data - .get(project_name, {}) - .get(folder_id, {}) - .get(task_id, {}) - ) - def _prepare_selection(self, project_name, folder_id, task_id): project_entity = None if project_name: @@ -458,6 +351,179 @@ class ActionsModel: project_settings=project_settings, ) + def _get_webaction_request_data(self, selection: LauncherActionSelection): + if not selection.is_project_selected: + return None + + entity_type = None + entity_id = None + entity_subtypes = [] + if selection.is_task_selected: + entity_type = "task" + entity_id = selection.task_entity["id"] + entity_subtypes = [selection.task_entity["taskType"]] + + elif selection.is_folder_selected: + entity_type = "folder" + entity_id = selection.folder_entity["id"] + entity_subtypes = [selection.folder_entity["folderType"]] + + entity_ids = [] + if entity_id: + entity_ids.append(entity_id) + + project_name = selection.project_name + return { + "projectName": project_name, + "entityType": entity_type, + "entitySubtypes": entity_subtypes, + "entityIds": entity_ids, + } + + def _get_webactions(self, selection: LauncherActionSelection): + if not selection.is_project_selected: + return [] + + request_data = self._get_webaction_request_data(selection) + project_name = selection.project_name + entity_id = None + if request_data["entityIds"]: + entity_id = request_data["entityIds"][0] + + cache: CacheItem = self._webaction_items[project_name][entity_id] + if cache.is_valid: + return cache.get_data() + + try: + response = ayon_api.post("actions/list", **request_data) + response.raise_for_status() + except Exception: + self.log.warning("Failed to collect webactions.", exc_info=True) + return [] + + action_items = [] + for action in response.data["actions"]: + # NOTE Settings variant may be important for triggering? + # - action["variant"] + icon = action.get("icon") + if icon and icon["type"] == "url": + if not urlparse(icon["url"]).scheme: + icon["type"] = "ayon_url" + + config_fields = action.get("configFields") or [] + variant_label = action["label"] + group_label = action.get("groupLabel") + if not group_label: + group_label = variant_label + variant_label = None + + full_label = self.calculate_full_label( + group_label, variant_label + ) + action_items.append(ActionItem( + action_type="webaction", + identifier=action["identifier"], + order=action["order"], + label=group_label, + variant_label=variant_label, + full_label=full_label, + icon=icon, + addon_name=action["addonName"], + addon_version=action["addonVersion"], + config_fields=config_fields, + # category=action["category"], + )) + + cache.update_data(action_items) + return cache.get_data() + + def _handle_webaction_response(self, data) -> WebactionResponse: + response_type = data["type"] + # Backwards compatibility -> 'server' type is not available since + # AYON backend 1.8.3 + if response_type == "server": + return WebactionResponse( + response_type, + False, + error_message="Please use AYON web UI to run the action.", + ) + + payload = data.get("payload") or {} + + download_uri = payload.get("extra_download") + if download_uri is not None: + # Find out if is relative or absolute URL + if not urlparse(download_uri).scheme: + ayon_url = ayon_api.get_base_url().rstrip("/") + path = download_uri.lstrip("/") + download_uri = f"{ayon_url}/{path}" + + # Use webbrowser to open file + webbrowser.open_new_tab(download_uri) + + response = WebactionResponse( + response_type, + data["success"], + data.get("message"), + payload.get("extra_clipboard"), + ) + if response_type == "simple": + pass + + elif response_type == "redirect": + # NOTE unused 'newTab' key because we always have to + # open new tab from desktop app. + if not webbrowser.open_new_tab(payload["uri"]): + payload.error_message = "Failed to open web browser." + + elif response_type == "form": + submit_icon = payload["submit_icon"] or None + cancel_icon = payload["cancel_icon"] or None + if submit_icon: + submit_icon = { + "type": "material-symbols", + "name": submit_icon, + } + + if cancel_icon: + cancel_icon = { + "type": "material-symbols", + "name": cancel_icon, + } + + response.form = WebactionForm( + fields=payload["fields"], + title=payload["title"], + submit_label=payload["submit_label"], + cancel_label=payload["cancel_label"], + submit_icon=submit_icon, + cancel_icon=cancel_icon, + ) + + elif response_type == "launcher": + # Run AYON launcher process with uri in arguments + # NOTE This does pass environment variables of current process + # to the subprocess. + # NOTE We could 'take action' directly and use the arguments here + if payload is not None: + uri = payload["uri"] + else: + uri = data["uri"] + run_detached_ayon_launcher_process(uri) + + elif response_type in ("query", "navigate"): + response.error_message = ( + "Please use AYON web UI to run the action." + ) + + else: + self.log.warning( + f"Unknown webaction response type '{response_type}'" + ) + response.error_message = "Unknown webaction response type." + + return response + def _get_discovered_action_classes(self): if self._discovered_actions is None: # NOTE We don't need to register the paths, but that would @@ -470,7 +536,6 @@ class ActionsModel: register_launcher_action_path(path) self._discovered_actions = ( discover_launcher_actions() - + self._get_applications_action_classes() ) return self._discovered_actions @@ -498,62 +563,29 @@ class ActionsModel: action_items = {} for identifier, action in self._get_action_objects().items(): - is_application = isinstance(action, ApplicationAction) # Backwards compatibility from 0.3.3 (24/06/10) # TODO: Remove in future releases - if is_application and hasattr(action, "project_settings"): + if hasattr(action, "project_settings"): action.project_entities[project_name] = project_entity action.project_settings[project_name] = project_settings label = action.label or identifier variant_label = getattr(action, "label_variant", None) + full_label = self.calculate_full_label( + label, variant_label + ) icon = get_action_icon(action) item = ActionItem( - identifier, - label, - variant_label, - icon, - action.order, - is_application, - False + action_type="local", + identifier=identifier, + order=action.order, + label=label, + variant_label=variant_label, + full_label=full_label, + icon=icon, + config_fields=[], ) action_items[identifier] = item self._action_items[project_name] = action_items return action_items - - def _get_applications_action_classes(self): - addons_manager = self._get_addons_manager() - applications_addon = addons_manager.get_enabled_addon("applications") - if hasattr(applications_addon, "get_applications_action_classes"): - return applications_addon.get_applications_action_classes() - - # Backwards compatibility from 0.3.3 (24/06/10) - # TODO: Remove in future releases - actions = [] - if applications_addon is None: - return actions - - manager = applications_addon.get_applications_manager() - for full_name, application in manager.applications.items(): - if not application.enabled: - continue - - action = type( - "app_{}".format(full_name), - (ApplicationAction,), - { - "identifier": "application.{}".format(full_name), - "application": application, - "name": application.name, - "label": application.group.label, - "label_variant": application.label, - "group": None, - "icon": application.icon, - "color": getattr(application, "color", None), - "order": getattr(application, "order", None) or 0, - "data": {} - } - ) - actions.append(action) - return actions diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index c64d718172..0459999958 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -1,22 +1,38 @@ import time +import uuid import collections from qtpy import QtWidgets, QtCore, QtGui +from ayon_core.lib import Logger +from ayon_core.lib.attribute_definitions import ( + UILabelDef, + EnumDef, + TextDef, + BoolDef, + NumberDef, + HiddenDef, +) from ayon_core.tools.flickcharm import FlickCharm -from ayon_core.tools.utils import get_qt_icon - -from .resources import get_options_image_path +from ayon_core.tools.utils import ( + get_qt_icon, + PixmapLabel, +) +from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog +from ayon_core.tools.launcher.abstract import WebactionContext ANIMATION_LEN = 7 ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 -ACTION_IS_APPLICATION_ROLE = QtCore.Qt.UserRole + 2 +ACTION_TYPE_ROLE = QtCore.Qt.UserRole + 2 ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 3 -ACTION_SORT_ROLE = QtCore.Qt.UserRole + 4 -ANIMATION_START_ROLE = QtCore.Qt.UserRole + 5 -ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 6 -FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 7 +ACTION_HAS_CONFIGS_ROLE = QtCore.Qt.UserRole + 4 +ACTION_SORT_ROLE = QtCore.Qt.UserRole + 5 +ACTION_ADDON_NAME_ROLE = QtCore.Qt.UserRole + 6 +ACTION_ADDON_VERSION_ROLE = QtCore.Qt.UserRole + 7 +PLACEHOLDER_ITEM_ROLE = QtCore.Qt.UserRole + 8 +ANIMATION_START_ROLE = QtCore.Qt.UserRole + 9 +ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 10 def _variant_label_sort_getter(action_item): @@ -34,6 +50,43 @@ def _variant_label_sort_getter(action_item): return action_item.variant_label or "" +# --- Replacement for QAction for action variants --- +class LauncherSettingsLabel(PixmapLabel): + _settings_icon = None + + def __init__(self, parent): + icon = self._get_settings_icon() + super().__init__(icon.pixmap(64, 64), parent) + + @classmethod + def _get_settings_icon(cls): + if cls._settings_icon is None: + cls._settings_icon = get_qt_icon({ + "type": "material-symbols", + "name": "settings", + }) + return cls._settings_icon + + +class ActionOverlayWidget(QtWidgets.QFrame): + config_requested = QtCore.Signal(str) + + def __init__(self, item_id, parent): + super().__init__(parent) + self._item_id = item_id + + settings_icon = LauncherSettingsLabel(self) + settings_icon.setToolTip("Right click for options") + + main_layout = QtWidgets.QGridLayout(self) + main_layout.setContentsMargins(5, 5, 0, 0) + main_layout.addWidget(settings_icon, 0, 0) + main_layout.setColumnStretch(1, 1) + main_layout.setRowStretch(1, 1) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + class ActionsQtModel(QtGui.QStandardItemModel): """Qt model for actions. @@ -44,7 +97,8 @@ class ActionsQtModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(ActionsQtModel, self).__init__() + self._log = Logger.get_logger(self.__class__.__name__) + super().__init__() controller.register_event_callback( "selection.project.changed", @@ -84,6 +138,17 @@ class ActionsQtModel(QtGui.QStandardItemModel): def get_item_by_id(self, action_id): return self._items_by_id.get(action_id) + def get_group_item_by_action_id(self, action_id): + item = self._items_by_id.get(action_id) + if item is not None: + return item + + for group_id, items in self._groups_by_id.items(): + for item in items: + if item.identifier == action_id: + return self._items_by_id[group_id] + return None + def get_action_item_by_id(self, action_id): return self._action_items_by_id.get(action_id) @@ -108,8 +173,10 @@ class ActionsQtModel(QtGui.QStandardItemModel): root_item = self.invisibleRootItem() all_action_items_info = [] + action_items_by_id = {} items_by_label = collections.defaultdict(list) for item in items: + action_items_by_id[item.identifier] = item if not item.variant_label: all_action_items_info.append((item, False)) else: @@ -122,16 +189,30 @@ class ActionsQtModel(QtGui.QStandardItemModel): all_action_items_info.append((first_item, len(action_items) > 1)) groups_by_id[first_item.identifier] = action_items + transparent_icon = {"type": "transparent", "size": 256} new_items = [] items_by_id = {} - action_items_by_id = {} for action_item_info in all_action_items_info: action_item, is_group = action_item_info - icon = get_qt_icon(action_item.icon) + icon_def = action_item.icon + if not icon_def: + icon_def = transparent_icon.copy() + + try: + icon = get_qt_icon(icon_def) + except Exception: + self._log.warning( + "Failed to parse icon definition", exc_info=True + ) + # Use empty icon if failed to parse definition + icon = get_qt_icon(transparent_icon.copy()) + if is_group: + has_configs = False label = action_item.label else: label = action_item.full_label + has_configs = bool(action_item.config_fields) item = self._items_by_id.get(action_item.identifier) if item is None: @@ -141,16 +222,15 @@ class ActionsQtModel(QtGui.QStandardItemModel): item.setFlags(QtCore.Qt.ItemIsEnabled) item.setData(label, QtCore.Qt.DisplayRole) + # item.setData(label, QtCore.Qt.ToolTipRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(is_group, ACTION_IS_GROUP_ROLE) + item.setData(has_configs, ACTION_HAS_CONFIGS_ROLE) + item.setData(action_item.action_type, ACTION_TYPE_ROLE) + item.setData(action_item.addon_name, ACTION_ADDON_NAME_ROLE) + item.setData(action_item.addon_version, ACTION_ADDON_VERSION_ROLE) item.setData(action_item.order, ACTION_SORT_ROLE) - item.setData( - action_item.is_application, ACTION_IS_APPLICATION_ROLE) - item.setData( - action_item.force_not_open_workfile, - FORCE_NOT_OPEN_WORKFILE_ROLE) items_by_id[action_item.identifier] = item - action_items_by_id[action_item.identifier] = action_item if new_items: root_item.appendRows(new_items) @@ -166,6 +246,12 @@ class ActionsQtModel(QtGui.QStandardItemModel): self._action_items_by_id = action_items_by_id self.refreshed.emit() + def get_action_config_fields(self, action_id: str): + action_item = self._action_items_by_id.get(action_id) + if action_item is not None: + return action_item.config_fields + return None + def _on_selection_project_changed(self, event): self._selected_project_name = event["project_name"] self._selected_folder_id = None @@ -185,14 +271,341 @@ class ActionsQtModel(QtGui.QStandardItemModel): self.refresh() +class ActionMenuPopupModel(QtGui.QStandardItemModel): + def set_action_items(self, action_items): + """Set action items for the popup.""" + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + transparent_icon = {"type": "transparent", "size": 256} + new_items = [] + for action_item in action_items: + icon_def = action_item.icon + if not icon_def: + icon_def = transparent_icon.copy() + + try: + icon = get_qt_icon(icon_def) + except Exception: + self._log.warning( + "Failed to parse icon definition", exc_info=True + ) + # Use empty icon if failed to parse definition + icon = get_qt_icon(transparent_icon.copy()) + + item = QtGui.QStandardItem() + item.setFlags(QtCore.Qt.ItemIsEnabled) + # item.setData(action_item.full_label, QtCore.Qt.ToolTipRole) + item.setData(action_item.full_label, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(action_item.identifier, ACTION_ID_ROLE) + item.setData( + bool(action_item.config_fields), + ACTION_HAS_CONFIGS_ROLE + ) + item.setData(action_item.order, ACTION_SORT_ROLE) + + new_items.append(item) + + if new_items: + root_item.appendRows(new_items) + + def fill_to_count(self, count: int): + """Fill up items to specifi counter. + + This is needed to visually organize structure or the viewed items. If + items are shown right to left then mouse would not hover over + last item i there are multiple rows that are uneven. This will + fill the "first items" with invisible items so visually it looks + correct. + + Visually it will cause this: + [ ] [ ] [ ] [A] + [A] [A] [A] [A] + + Instead of: + [A] [A] [A] [A] + [A] [ ] [ ] [ ] + + """ + remainders = count - self.rowCount() + if not remainders: + return + + items = [] + for _ in range(remainders): + item = QtGui.QStandardItem() + item.setFlags(QtCore.Qt.NoItemFlags) + item.setData(True, PLACEHOLDER_ITEM_ROLE) + items.append(item) + + root_item = self.invisibleRootItem() + root_item.appendRows(items) + + +class ActionMenuPopup(QtWidgets.QWidget): + action_triggered = QtCore.Signal(str) + config_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + + self.setWindowFlags(QtCore.Qt.Tool | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating, True) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + # Close widget if is not updated by event + close_timer = QtCore.QTimer() + close_timer.setSingleShot(True) + close_timer.setInterval(100) + + expand_anim = QtCore.QVariantAnimation() + expand_anim.setDuration(60) + expand_anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad) + + # View with actions + view = ActionsView(self) + view.setGridSize(QtCore.QSize(75, 80)) + view.setIconSize(QtCore.QSize(32, 32)) + view.move(QtCore.QPoint(3, 3)) + + # Background draw + wrapper = QtWidgets.QFrame(self) + wrapper.setObjectName("Wrapper") + wrapper.stackUnder(view) + + model = ActionMenuPopupModel() + proxy_model = ActionsProxyModel() + proxy_model.setSourceModel(model) + + view.setModel(proxy_model) + view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + + close_timer.timeout.connect(self.close) + expand_anim.valueChanged.connect(self._on_expand_anim) + expand_anim.finished.connect(self._on_expand_finish) + + view.clicked.connect(self._on_clicked) + view.config_requested.connect(self.config_requested) + + self._view = view + self._wrapper = wrapper + self._model = model + self._proxy_model = proxy_model + + self._close_timer = close_timer + self._expand_anim = expand_anim + + self._showed = False + self._current_id = None + self._right_to_left = False + + def showEvent(self, event): + self._showed = True + super().showEvent(event) + + def closeEvent(self, event): + self._showed = False + super().closeEvent(event) + + def enterEvent(self, event): + super().leaveEvent(event) + self._close_timer.stop() + + def leaveEvent(self, event): + super().leaveEvent(event) + self._close_timer.start() + + def show_items(self, action_id, action_items, pos): + if not action_items: + if self._showed: + self._close_timer.start() + self._current_id = None + return + + self._close_timer.stop() + + update_position = False + if action_id != self._current_id: + update_position = True + self._current_id = action_id + self._update_items(action_items) + + # Make sure is visible + if not self._showed: + update_position = True + self.show() + + if not update_position: + self.raise_() + return + + # Set geometry to position + # - first make sure widget changes from '_update_items' + # are recalculated + app = QtWidgets.QApplication.instance() + app.processEvents() + items_count, size, target_size = self._get_size_hint() + self._model.fill_to_count(items_count) + + window = self.screen() + window_geo = window.geometry() + right_to_left = ( + pos.x() + target_size.width() > window_geo.right() + or pos.y() + target_size.height() > window_geo.bottom() + ) + + pos_x = pos.x() - 5 + pos_y = pos.y() - 4 + + wrap_x = wrap_y = 0 + sort_order = QtCore.Qt.DescendingOrder + if right_to_left: + sort_order = QtCore.Qt.AscendingOrder + size_diff = target_size - size + pos_x -= size_diff.width() + pos_y -= size_diff.height() + wrap_x = size_diff.width() + wrap_y = size_diff.height() + + wrap_geo = QtCore.QRect( + wrap_x, wrap_y, size.width(), size.height() + ) + if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: + self._expand_anim.stop() + self._first_anim_frame = True + self._right_to_left = right_to_left + + self._proxy_model.sort(0, sort_order) + self.setUpdatesEnabled(False) + self._view.setMask(wrap_geo) + self._view.setMinimumWidth(target_size.width()) + self._view.setMaximumWidth(target_size.width()) + self._wrapper.setGeometry(wrap_geo) + self.setGeometry( + pos_x, pos_y, + target_size.width(), target_size.height() + ) + self.setUpdatesEnabled(True) + self._expand_anim.updateCurrentTime(0) + self._expand_anim.setStartValue(size) + self._expand_anim.setEndValue(target_size) + self._expand_anim.start() + + self.raise_() + + def _on_clicked(self, index): + if not index or not index.isValid(): + return + + if not index.data(ACTION_HAS_CONFIGS_ROLE): + return + + action_id = index.data(ACTION_ID_ROLE) + self.action_triggered.emit(action_id) + + def _on_expand_anim(self, value): + if not self._showed: + if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: + self._expand_anim.stop() + return + + wrapper_geo = self._wrapper.geometry() + wrapper_geo.setWidth(value.width()) + wrapper_geo.setHeight(value.height()) + + if self._right_to_left: + geo = self.geometry() + pos = QtCore.QPoint( + geo.width() - value.width(), + geo.height() - value.height(), + ) + wrapper_geo.setTopLeft(pos) + + self._view.setMask(wrapper_geo) + self._wrapper.setGeometry(wrapper_geo) + + def _on_expand_finish(self): + # Make sure that size is recalculated if src and targe size is same + _, _, size = self._get_size_hint() + self._on_expand_anim(size) + + def _get_size_hint(self): + grid_size = self._view.gridSize() + row_count = self._proxy_model.rowCount() + cols = 4 + rows = 1 + while True: + rows = row_count // cols + if row_count % cols: + rows += 1 + if rows <= cols: + break + cols += 1 + + if rows == 1: + cols = row_count + + m_l, m_t, m_r, m_b = (3, 3, 1, 1) + # QUESTION how to get the margins from Qt? + border = 2 * 1 + single_width = ( + grid_size.width() + + self._view.horizontalOffset() + border + m_l + m_r + 1 + ) + single_height = ( + grid_size.height() + + self._view.verticalOffset() + border + m_b + m_t + 1 + ) + total_width = single_width + total_height = single_height + if cols > 1: + total_width += ( + (cols - 1) * (self._view.spacing() + grid_size.width()) + ) + + if rows > 1: + total_height += ( + (rows - 1) * (grid_size.height() + self._view.spacing()) + ) + return ( + cols * rows, + QtCore.QSize(single_width, single_height), + QtCore.QSize(total_width, total_height) + ) + + def _update_items(self, action_items): + """Update items in the tooltip.""" + # This method can be used to update the content of the tooltip + # with new icon, text and settings button visibility. + self._model.set_action_items(action_items) + self._view.update_on_refresh() + + def _on_trigger(self, action_id): + self.action_triggered.emit(action_id) + self.close() + + def _on_configs_trigger(self, action_id): + self.config_requested.emit(action_id) + self.close() + + class ActionDelegate(QtWidgets.QStyledItemDelegate): _cached_extender = {} + _cached_extender_base_pix = None def __init__(self, *args, **kwargs): - super(ActionDelegate, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._anim_start_color = QtGui.QColor(178, 255, 246) self._anim_end_color = QtGui.QColor(5, 44, 50) + def sizeHint(self, option, index): + return option.widget.gridSize() + + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect) + def _draw_animation(self, painter, option, index): grid_size = option.widget.gridSize() x_offset = int( @@ -244,7 +657,17 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): pix = cls._cached_extender.get(size) if pix is not None: return pix - pix = QtGui.QPixmap(get_options_image_path()).scaled( + + base_pix = cls._cached_extender_base_pix + if base_pix is None: + icon = get_qt_icon({ + "type": "material-symbols", + "name": "more_horiz", + }) + base_pix = icon.pixmap(64, 64) + cls._cached_extender_base_pix = base_pix + + pix = base_pix.scaled( size, size, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation @@ -260,15 +683,8 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): if index.data(ANIMATION_STATE_ROLE): self._draw_animation(painter, option, index) - - super(ActionDelegate, self).paint(painter, option, index) - - if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): - rect = QtCore.QRectF( - option.rect.x(), option.rect.y() + option.rect.height(), 5, 5) - painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(QtGui.QColor(200, 0, 0)) - painter.drawEllipse(rect) + option.displayAlignment = QtCore.Qt.AlignHCenter | QtCore.Qt.AlignTop + super().paint(painter, option, index) if not index.data(ACTION_IS_GROUP_ROLE): return @@ -297,7 +713,11 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) def lessThan(self, left, right): - # Sort by action order and then by label + if left.data(PLACEHOLDER_ITEM_ROLE): + return True + if right.data(PLACEHOLDER_ITEM_ROLE): + return False + left_value = left.data(ACTION_SORT_ROLE) right_value = right.data(ACTION_SORT_ROLE) @@ -318,29 +738,129 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): return True +class ActionsView(QtWidgets.QListView): + action_triggered = QtCore.Signal(str) + config_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setProperty("mode", "icon") + self.setViewMode(QtWidgets.QListView.IconMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setSelectionMode(QtWidgets.QListView.NoSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.setContentsMargins(0, 0, 0, 0) + self.setViewportMargins(0, 0, 0, 0) + self.setWrapping(True) + self.setSpacing(0) + self.setWordWrap(True) + self.setMouseTracking(True) + + vertical_scroll = self.verticalScrollBar() + vertical_scroll.setSingleStep(8) + + delegate = ActionDelegate(self) + self.setItemDelegate(delegate) + + # Make view flickable + flick = FlickCharm(parent=self) + flick.activateOn(self) + + self.customContextMenuRequested.connect(self._on_context_menu) + + self._overlay_widgets = [] + self._flick = flick + self._delegate = delegate + self._popup_widget = None + + def mouseMoveEvent(self, event): + """Handle mouse move event.""" + super().mouseMoveEvent(event) + # Update hover state for the item under mouse + index = self.indexAt(event.pos()) + if index.isValid() and index.data(ACTION_IS_GROUP_ROLE): + self._show_group_popup(index) + + elif self._popup_widget is not None: + self._popup_widget.close() + + def _on_context_menu(self, point): + """Creates menu to force skip opening last workfile.""" + index = self.indexAt(point) + if not index.isValid(): + return + action_id = index.data(ACTION_ID_ROLE) + self.config_requested.emit(action_id) + + def _get_popup_widget(self): + if self._popup_widget is None: + popup_widget = ActionMenuPopup(self) + + popup_widget.action_triggered.connect(self.action_triggered) + popup_widget.config_requested.connect(self.config_requested) + self._popup_widget = popup_widget + return self._popup_widget + + def _show_group_popup(self, index): + action_id = index.data(ACTION_ID_ROLE) + model = self.model() + while hasattr(model, "sourceModel"): + model = model.sourceModel() + + if not hasattr(model, "get_group_items"): + return + + action_items = model.get_group_items(action_id) + rect = self.visualRect(index) + pos = self.mapToGlobal(rect.topLeft()) + + popup_widget = self._get_popup_widget() + popup_widget.show_items( + action_id, action_items, pos + ) + + def update_on_refresh(self): + viewport = self.viewport() + viewport.update() + self._add_overlay_widgets() + + def _add_overlay_widgets(self): + overlay_widgets = [] + viewport = self.viewport() + model = self.model() + for row in range(model.rowCount()): + index = model.index(row, 0) + has_configs = index.data(ACTION_HAS_CONFIGS_ROLE) + widget = None + if has_configs: + item_id = index.data(ACTION_ID_ROLE) + widget = ActionOverlayWidget(item_id, viewport) + widget.config_requested.connect( + self.config_requested + ) + overlay_widgets.append(widget) + self.setIndexWidget(index, widget) + + while self._overlay_widgets: + widget = self._overlay_widgets.pop(0) + widget.setVisible(False) + widget.setParent(None) + widget.deleteLater() + + self._overlay_widgets = overlay_widgets + + class ActionsWidget(QtWidgets.QWidget): def __init__(self, controller, parent): - super(ActionsWidget, self).__init__(parent) + super().__init__(parent) self._controller = controller - view = QtWidgets.QListView(self) - view.setProperty("mode", "icon") - view.setObjectName("IconView") - view.setViewMode(QtWidgets.QListView.IconMode) - view.setResizeMode(QtWidgets.QListView.Adjust) - view.setSelectionMode(QtWidgets.QListView.NoSelection) - view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) - view.setWrapping(True) + view = ActionsView(self) view.setGridSize(QtCore.QSize(70, 75)) view.setIconSize(QtCore.QSize(30, 30)) - view.setSpacing(0) - view.setWordWrap(True) - - # Make view flickable - flick = FlickCharm(parent=view) - flick.activateOn(view) model = ActionsQtModel(controller) @@ -348,9 +868,6 @@ class ActionsWidget(QtWidgets.QWidget): proxy_model.setSourceModel(model) view.setModel(proxy_model) - delegate = ActionDelegate(self) - view.setItemDelegate(delegate) - layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(view) @@ -360,15 +877,13 @@ class ActionsWidget(QtWidgets.QWidget): animation_timer.timeout.connect(self._on_animation) view.clicked.connect(self._on_clicked) - view.customContextMenuRequested.connect(self._on_context_menu) + view.action_triggered.connect(self._trigger_action) + view.config_requested.connect(self._on_config_request) model.refreshed.connect(self._on_model_refresh) self._animated_items = set() self._animation_timer = animation_timer - self._context_menu = None - - self._flick = flick self._view = view self._model = model self._proxy_model = proxy_model @@ -378,14 +893,52 @@ class ActionsWidget(QtWidgets.QWidget): def refresh(self): self._model.refresh() + def handle_webaction_form_event(self, event): + # NOTE The 'ActionsWidget' should be responsible for handling this + # but because we're showing messages to user it is handled by window + identifier = event["identifier"] + form = event["form"] + submit_icon = form["submit_icon"] + if submit_icon: + submit_icon = get_qt_icon(submit_icon) + + cancel_icon = form["cancel_icon"] + if cancel_icon: + cancel_icon = get_qt_icon(cancel_icon) + + dialog = self._create_attrs_dialog( + form["fields"], + form["title"], + form["submit_label"], + form["cancel_label"], + submit_icon, + cancel_icon, + ) + dialog.setMinimumSize(380, 180) + result = dialog.exec_() + if result != QtWidgets.QDialog.Accepted: + return + form_data = dialog.get_values() + self._controller.trigger_webaction( + WebactionContext( + identifier, + event["project_name"], + event["folder_id"], + event["task_id"], + event["addon_name"], + event["addon_version"], + ), + event["action_label"], + form_data, + ) + def _set_row_height(self, rows): self.setMinimumHeight(rows * 75) def _on_model_refresh(self): self._proxy_model.sort(0) # Force repaint all items - viewport = self._view.viewport() - viewport.update() + self._view.update_on_refresh() def _on_animation(self): time_now = time.time() @@ -416,89 +969,193 @@ class ActionsWidget(QtWidgets.QWidget): self._animated_items.add(action_id) self._animation_timer.start() - def _on_context_menu(self, point): - """Creates menu to force skip opening last workfile.""" - index = self._view.indexAt(point) - if not index.isValid(): - return - - if not index.data(ACTION_IS_APPLICATION_ROLE): - return - - menu = QtWidgets.QMenu(self._view) - checkbox = QtWidgets.QCheckBox( - "Skip opening last workfile.", menu) - if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): - checkbox.setChecked(True) - - action_id = index.data(ACTION_ID_ROLE) - is_group = index.data(ACTION_IS_GROUP_ROLE) - if is_group: - action_items = self._model.get_group_items(action_id) - else: - action_items = [self._model.get_action_item_by_id(action_id)] - action_ids = {action_item.identifier for action_item in action_items} - checkbox.stateChanged.connect( - lambda: self._on_checkbox_changed( - action_ids, checkbox.isChecked() - ) - ) - action = QtWidgets.QWidgetAction(menu) - action.setDefaultWidget(checkbox) - - menu.addAction(action) - - self._context_menu = menu - global_point = self.mapToGlobal(point) - menu.exec_(global_point) - self._context_menu = None - - def _on_checkbox_changed(self, action_ids, is_checked): - if self._context_menu is not None: - self._context_menu.close() - - project_name = self._model.get_selected_project_name() - folder_id = self._model.get_selected_folder_id() - task_id = self._model.get_selected_task_id() - self._controller.set_application_force_not_open_workfile( - project_name, folder_id, task_id, action_ids, is_checked) - self._model.refresh() - def _on_clicked(self, index): if not index or not index.isValid(): return is_group = index.data(ACTION_IS_GROUP_ROLE) + if is_group: + return action_id = index.data(ACTION_ID_ROLE) + self._trigger_action(action_id, index) + + def _trigger_action(self, action_id, index=None): + project_name = self._model.get_selected_project_name() + folder_id = self._model.get_selected_folder_id() + task_id = self._model.get_selected_task_id() + action_item = self._model.get_action_item_by_id(action_id) + + if action_item.action_type == "webaction": + action_item = self._model.get_action_item_by_id(action_id) + context = WebactionContext( + action_id, + project_name, + folder_id, + task_id, + action_item.addon_name, + action_item.addon_version + ) + self._controller.trigger_webaction( + context, action_item.full_label + ) + else: + self._controller.trigger_action( + action_id, project_name, folder_id, task_id + ) + + if index is None: + item = self._model.get_group_item_by_action_id(action_id) + if item is not None: + index = self._proxy_model.mapFromSource(item.index()) + + if index is not None: + self._start_animation(index) + + def _on_config_request(self, action_id): + self._show_config_dialog(action_id) + + def _show_config_dialog(self, action_id): + action_item = self._model.get_action_item_by_id(action_id) + config_fields = self._model.get_action_config_fields(action_id) + if not config_fields: + return project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() - - if not is_group: - self._controller.trigger_action( - project_name, folder_id, task_id, action_id - ) - self._start_animation(index) - return - - action_items = self._model.get_group_items(action_id) - - menu = QtWidgets.QMenu(self) - actions_mapping = {} - - for action_item in action_items: - menu_action = QtWidgets.QAction(action_item.full_label) - menu.addAction(menu_action) - actions_mapping[menu_action] = action_item - - result = menu.exec_(QtGui.QCursor.pos()) - if not result: - return - - action_item = actions_mapping[result] - - self._controller.trigger_action( - project_name, folder_id, task_id, action_item.identifier + context = WebactionContext( + action_id, + project_name=project_name, + folder_id=folder_id, + task_id=task_id, + addon_name=action_item.addon_name, + addon_version=action_item.addon_version, ) - self._start_animation(index) + values = self._controller.get_action_config_values(context) + + dialog = self._create_attrs_dialog( + config_fields, + "Action Config", + "Save", + "Cancel", + ) + dialog.set_values(values) + result = dialog.exec_() + if result == QtWidgets.QDialog.Accepted: + new_values = dialog.get_values() + self._controller.set_action_config_values(context, new_values) + + def _create_attrs_dialog( + self, + config_fields, + title, + submit_label, + cancel_label, + submit_icon=None, + cancel_icon=None, + ): + """Creates attribute definitions dialog. + + Types: + label - 'text' + text - 'label', 'value', 'placeholder', 'regex', + 'multiline', 'syntax' + boolean - 'label', 'value' + select - 'label', 'value', 'options' + multiselect - 'label', 'value', 'options' + hidden - 'value' + integer - 'label', 'value', 'placeholder', 'min', 'max' + float - 'label', 'value', 'placeholder', 'min', 'max' + + """ + attr_defs = [] + for config_field in config_fields: + field_type = config_field["type"] + attr_def = None + if field_type == "label": + label = config_field.get("value") + if label is None: + label = config_field.get("text") + attr_def = UILabelDef( + label, key=uuid.uuid4().hex + ) + elif field_type == "boolean": + value = config_field["value"] + if isinstance(value, str): + value = value.lower() == "true" + + attr_def = BoolDef( + config_field["name"], + default=value, + label=config_field.get("label"), + ) + elif field_type == "text": + attr_def = TextDef( + config_field["name"], + default=config_field.get("value"), + label=config_field.get("label"), + placeholder=config_field.get("placeholder"), + multiline=config_field.get("multiline", False), + regex=config_field.get("regex"), + # syntax=config_field["syntax"], + ) + elif field_type in ("integer", "float"): + value = config_field.get("value") + if isinstance(value, str): + if field_type == "integer": + value = int(value) + else: + value = float(value) + attr_def = NumberDef( + config_field["name"], + default=value, + label=config_field.get("label"), + decimals=0 if field_type == "integer" else 5, + # placeholder=config_field.get("placeholder"), + minimum=config_field.get("min"), + maximum=config_field.get("max"), + ) + elif field_type in ("select", "multiselect"): + attr_def = EnumDef( + config_field["name"], + items=config_field["options"], + default=config_field.get("value"), + label=config_field.get("label"), + multiselection=field_type == "multiselect", + ) + elif field_type == "hidden": + attr_def = HiddenDef( + config_field["name"], + default=config_field.get("value"), + ) + + if attr_def is None: + print(f"Unknown config field type: {field_type}") + attr_def = UILabelDef( + f"Unknown field type '{field_type}", + key=uuid.uuid4().hex + ) + attr_defs.append(attr_def) + + dialog = AttributeDefinitionsDialog( + attr_defs, + title=title, + parent=self, + ) + if submit_label: + dialog.set_submit_label(submit_label) + else: + dialog.set_submit_visible(False) + + if submit_icon: + dialog.set_submit_icon(submit_icon) + + if cancel_label: + dialog.set_cancel_label(cancel_label) + else: + dialog.set_cancel_visible(False) + + if cancel_icon: + dialog.set_cancel_icon(cancel_icon) + + return dialog diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index ad48e8ac77..7c34989947 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -5,17 +5,17 @@ from ayon_core.tools.utils import ( PlaceholderLineEdit, SquareButton, RefreshButton, -) -from ayon_core.tools.utils import ( ProjectsCombobox, FoldersWidget, TasksWidget, + NiceCheckbox, ) +from ayon_core.tools.utils.lib import checkstate_int_to_enum class HierarchyPage(QtWidgets.QWidget): def __init__(self, controller, parent): - super(HierarchyPage, self).__init__(parent) + super().__init__(parent) # Header header_widget = QtWidgets.QWidget(self) @@ -43,23 +43,36 @@ class HierarchyPage(QtWidgets.QWidget): ) content_body.setOrientation(QtCore.Qt.Horizontal) - # - Folders widget with filter - folders_wrapper = QtWidgets.QWidget(content_body) + # - filters + filters_widget = QtWidgets.QWidget(self) - folders_filter_text = PlaceholderLineEdit(folders_wrapper) + folders_filter_text = PlaceholderLineEdit(filters_widget) folders_filter_text.setPlaceholderText("Filter folders...") - folders_widget = FoldersWidget(controller, folders_wrapper) + my_tasks_tooltip = ( + "Filter folders and task to only those you are assigned to." + ) + my_tasks_label = QtWidgets.QLabel("My tasks", filters_widget) + my_tasks_label.setToolTip(my_tasks_tooltip) - folders_wrapper_layout = QtWidgets.QVBoxLayout(folders_wrapper) - folders_wrapper_layout.setContentsMargins(0, 0, 0, 0) - folders_wrapper_layout.addWidget(folders_filter_text, 0) - folders_wrapper_layout.addWidget(folders_widget, 1) + my_tasks_checkbox = NiceCheckbox(filters_widget) + my_tasks_checkbox.setChecked(False) + my_tasks_checkbox.setToolTip(my_tasks_tooltip) + + filters_layout = QtWidgets.QHBoxLayout(filters_widget) + filters_layout.setContentsMargins(0, 0, 0, 0) + filters_layout.addWidget(folders_filter_text, 1) + filters_layout.addWidget(my_tasks_label, 0) + filters_layout.addWidget(my_tasks_checkbox, 0) + + # - Folders widget + folders_widget = FoldersWidget(controller, content_body) + folders_widget.set_header_visible(True) # - Tasks widget tasks_widget = TasksWidget(controller, content_body) - content_body.addWidget(folders_wrapper) + content_body.addWidget(folders_widget) content_body.addWidget(tasks_widget) content_body.setStretchFactor(0, 100) content_body.setStretchFactor(1, 65) @@ -67,20 +80,27 @@ class HierarchyPage(QtWidgets.QWidget): main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(header_widget, 0) + main_layout.addWidget(filters_widget, 0) main_layout.addWidget(content_body, 1) btn_back.clicked.connect(self._on_back_clicked) refresh_btn.clicked.connect(self._on_refresh_clicked) folders_filter_text.textChanged.connect(self._on_filter_text_changed) + my_tasks_checkbox.stateChanged.connect( + self._on_my_tasks_checkbox_state_changed + ) self._is_visible = False self._controller = controller self._btn_back = btn_back self._projects_combobox = projects_combobox + self._my_tasks_checkbox = my_tasks_checkbox self._folders_widget = folders_widget self._tasks_widget = tasks_widget + self._project_name = None + # Post init projects_combobox.set_listen_to_selection_change(self._is_visible) @@ -91,10 +111,14 @@ class HierarchyPage(QtWidgets.QWidget): self._projects_combobox.set_listen_to_selection_change(visible) if visible and project_name: self._projects_combobox.set_selection(project_name) + self._project_name = project_name def refresh(self): self._folders_widget.refresh() self._tasks_widget.refresh() + self._on_my_tasks_checkbox_state_changed( + self._my_tasks_checkbox.checkState() + ) def _on_back_clicked(self): self._controller.set_selected_project(None) @@ -104,3 +128,16 @@ class HierarchyPage(QtWidgets.QWidget): def _on_filter_text_changed(self, text): self._folders_widget.set_name_filter(text) + + def _on_my_tasks_checkbox_state_changed(self, state): + folder_ids = None + task_ids = None + state = checkstate_int_to_enum(state) + if state == QtCore.Qt.Checked: + entity_ids = self._controller.get_my_tasks_entity_ids( + self._project_name + ) + folder_ids = entity_ids["folder_ids"] + task_ids = entity_ids["task_ids"] + self._folders_widget.set_folder_ids_filter(folder_ids) + self._tasks_widget.set_task_ids_filter(task_ids) diff --git a/client/ayon_core/tools/launcher/ui/resources/__init__.py b/client/ayon_core/tools/launcher/ui/resources/__init__.py deleted file mode 100644 index 27c59af2ba..0000000000 --- a/client/ayon_core/tools/launcher/ui/resources/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -import os - -RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) - - -def get_options_image_path(): - return os.path.join(RESOURCES_DIR, "options.png") diff --git a/client/ayon_core/tools/launcher/ui/resources/options.png b/client/ayon_core/tools/launcher/ui/resources/options.png deleted file mode 100644 index a9617d0d19..0000000000 Binary files a/client/ayon_core/tools/launcher/ui/resources/options.png and /dev/null differ diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index 2d52a73c38..7fde8518b0 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -1,9 +1,9 @@ from qtpy import QtWidgets, QtCore, QtGui -from ayon_core import style -from ayon_core import resources +from ayon_core import style, resources from ayon_core.tools.launcher.control import BaseLauncherController +from ayon_core.tools.utils import MessageOverlayObject from .projects_widget import ProjectsWidget from .hierarchy_page import HierarchyPage @@ -17,7 +17,7 @@ class LauncherWindow(QtWidgets.QWidget): page_side_anim_interval = 250 def __init__(self, controller=None, parent=None): - super(LauncherWindow, self).__init__(parent) + super().__init__(parent) if controller is None: controller = BaseLauncherController() @@ -41,6 +41,8 @@ class LauncherWindow(QtWidgets.QWidget): self._controller = controller + overlay_object = MessageOverlayObject(self) + # Main content - Pages & Actions content_body = QtWidgets.QSplitter(self) @@ -78,26 +80,18 @@ class LauncherWindow(QtWidgets.QWidget): content_body.setSizes([580, 160]) # Footer - footer_widget = QtWidgets.QWidget(self) - - # - Message label - message_label = QtWidgets.QLabel(footer_widget) - + # footer_widget = QtWidgets.QWidget(self) + # # action_history = ActionHistory(footer_widget) # action_history.setStatusTip("Show Action History") - - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - footer_layout.setContentsMargins(0, 0, 0, 0) - footer_layout.addWidget(message_label, 1) + # + # footer_layout = QtWidgets.QHBoxLayout(footer_widget) + # footer_layout.setContentsMargins(0, 0, 0, 0) # footer_layout.addWidget(action_history, 0) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(content_body, 1) - layout.addWidget(footer_widget, 0) - - message_timer = QtCore.QTimer() - message_timer.setInterval(self.message_interval) - message_timer.setSingleShot(True) + # layout.addWidget(footer_widget, 0) actions_refresh_timer = QtCore.QTimer() actions_refresh_timer.setInterval(self.refresh_interval) @@ -109,7 +103,6 @@ class LauncherWindow(QtWidgets.QWidget): page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) projects_page.refreshed.connect(self._on_projects_refresh) - message_timer.timeout.connect(self._on_message_timeout) actions_refresh_timer.timeout.connect( self._on_actions_refresh_timeout) page_slide_anim.valueChanged.connect( @@ -128,6 +121,16 @@ class LauncherWindow(QtWidgets.QWidget): "action.trigger.finished", self._on_action_trigger_finished, ) + controller.register_event_callback( + "webaction.trigger.started", + self._on_webaction_trigger_started, + ) + controller.register_event_callback( + "webaction.trigger.finished", + self._on_webaction_trigger_finished, + ) + + self._overlay_object = overlay_object self._controller = controller @@ -141,11 +144,8 @@ class LauncherWindow(QtWidgets.QWidget): self._projects_page = projects_page self._hierarchy_page = hierarchy_page self._actions_widget = actions_widget - - self._message_label = message_label # self._action_history = action_history - self._message_timer = message_timer self._actions_refresh_timer = actions_refresh_timer self._page_slide_anim = page_slide_anim @@ -153,14 +153,14 @@ class LauncherWindow(QtWidgets.QWidget): self.resize(520, 740) def showEvent(self, event): - super(LauncherWindow, self).showEvent(event) + super().showEvent(event) self._window_is_active = True if not self._actions_refresh_timer.isActive(): self._actions_refresh_timer.start() self._controller.refresh() def closeEvent(self, event): - super(LauncherWindow, self).closeEvent(event) + super().closeEvent(event) self._window_is_active = False self._actions_refresh_timer.stop() @@ -176,7 +176,7 @@ class LauncherWindow(QtWidgets.QWidget): self._on_actions_refresh_timeout() self._actions_refresh_timer.start() - super(LauncherWindow, self).changeEvent(event) + super().changeEvent(event) def _on_actions_refresh_timeout(self): # Stop timer if widget is not visible @@ -185,13 +185,6 @@ class LauncherWindow(QtWidgets.QWidget): else: self._refresh_on_activate = True - def _echo(self, message): - self._message_label.setText(str(message)) - self._message_timer.start() - - def _on_message_timeout(self): - self._message_label.setText("") - def _on_project_selection_change(self, event): project_name = event["project_name"] self._selected_project_name = project_name @@ -215,13 +208,69 @@ class LauncherWindow(QtWidgets.QWidget): self._hierarchy_page.refresh() self._actions_widget.refresh() + def _show_toast_message(self, message, success=True, message_id=None): + message_type = None + if not success: + message_type = "error" + + self._overlay_object.add_message( + message, message_type, message_id=message_id + ) + def _on_action_trigger_started(self, event): - self._echo("Running action: {}".format(event["full_label"])) + self._show_toast_message( + "Running: {}".format(event["full_label"]), + message_id=event["trigger_id"], + ) def _on_action_trigger_finished(self, event): - if not event["failed"]: + action_label = event["full_label"] + if event["failed"]: + message = f"Failed to run: {action_label}" + else: + message = f"Finished: {action_label}" + self._show_toast_message( + message, + not event["failed"], + message_id=event["trigger_id"], + ) + + def _on_webaction_trigger_started(self, event): + self._show_toast_message( + "Running: {}".format(event["full_label"]), + message_id=event["trigger_id"], + ) + + def _on_webaction_trigger_finished(self, event): + clipboard_text = event["clipboard_text"] + if clipboard_text: + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(clipboard_text) + + action_label = event["full_label"] + # Avoid to show exception message + if event["trigger_failed"]: + self._show_toast_message( + f"Failed to run: {action_label}", + message_id=event["trigger_id"] + ) return - self._echo("Failed: {}".format(event["error_message"])) + + # Failed to run webaction, e.g. because of missing webaction handling + # - not reported by server + if event["error_message"]: + self._show_toast_message( + event["error_message"], + success=False, + message_id=event["trigger_id"] + ) + return + + if event["message"]: + self._show_toast_message(event["message"], event["success"]) + + if event["form"]: + self._actions_widget.handle_webaction_form_event(event) def _is_page_slide_anim_running(self): return ( diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 26b476de1f..d0d7cd430b 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -733,7 +733,12 @@ class FrontendLoaderController(_BaseLoaderController): pass @abstractmethod - def get_thumbnail_path(self, project_name, thumbnail_id): + def get_thumbnail_paths( + self, + project_name, + entity_type, + entity_ids + ): """Get thumbnail path for thumbnail id. This method should get a path to a thumbnail based on thumbnail id. @@ -742,10 +747,11 @@ class FrontendLoaderController(_BaseLoaderController): Args: project_name (str): Project name. - thumbnail_id (str): Thumbnail id. + entity_type (str): Entity type. + entity_ids (set[str]): Entity ids. Returns: - Union[str, None]: Thumbnail path or None if not found. + dict[str, Union[str, None]]: Thumbnail path by entity id. """ pass diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 7959a63edb..b3a80b34d4 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -259,9 +259,14 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): project_name, version_ids ) - def get_thumbnail_path(self, project_name, thumbnail_id): - return self._thumbnails_model.get_thumbnail_path( - project_name, thumbnail_id + def get_thumbnail_paths( + self, + project_name, + entity_type, + entity_ids, + ): + return self._thumbnails_model.get_thumbnail_paths( + project_name, entity_type, entity_ids ) def change_products_group(self, project_name, product_ids, group_name): diff --git a/client/ayon_core/tools/loader/ui/actions_utils.py b/client/ayon_core/tools/loader/ui/actions_utils.py index 5a988ef4c2..b601cd95bd 100644 --- a/client/ayon_core/tools/loader/ui/actions_utils.py +++ b/client/ayon_core/tools/loader/ui/actions_utils.py @@ -84,15 +84,17 @@ def _get_options(action, action_item, parent): if not getattr(action, "optioned", False) or not options: return {} + dialog_title = action.label + " Options" if isinstance(options[0], AbstractAttrDef): qargparse_options = False - dialog = AttributeDefinitionsDialog(options, parent) + dialog = AttributeDefinitionsDialog( + options, title=dialog_title, parent=parent + ) else: qargparse_options = True dialog = OptionDialog(parent) dialog.create(options) - - dialog.setWindowTitle(action.label + " Options") + dialog.setWindowTitle(dialog_title) if not dialog.exec_(): return None diff --git a/client/ayon_core/tools/loader/ui/product_types_combo.py b/client/ayon_core/tools/loader/ui/product_types_combo.py index 91fa52b0e9..525f1cae1b 100644 --- a/client/ayon_core/tools/loader/ui/product_types_combo.py +++ b/client/ayon_core/tools/loader/ui/product_types_combo.py @@ -1,3 +1,4 @@ +from __future__ import annotations from qtpy import QtGui, QtCore from ._multicombobox import ( diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index b846484c39..b70f5554c7 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -501,38 +501,29 @@ class LoaderWindow(QtWidgets.QWidget): self._update_thumbnails() def _update_thumbnails(self): + # TODO make this threaded and show loading animation while running project_name = self._selected_project_name - thumbnail_ids = set() + entity_type = None + entity_ids = set() if self._selected_version_ids: - thumbnail_id_by_entity_id = ( - self._controller.get_version_thumbnail_ids( - project_name, - self._selected_version_ids - ) - ) - thumbnail_ids = set(thumbnail_id_by_entity_id.values()) + entity_ids = set(self._selected_version_ids) + entity_type = "version" elif self._selected_folder_ids: - thumbnail_id_by_entity_id = ( - self._controller.get_folder_thumbnail_ids( - project_name, - self._selected_folder_ids - ) - ) - thumbnail_ids = set(thumbnail_id_by_entity_id.values()) + entity_ids = set(self._selected_folder_ids) + entity_type = "folder" - thumbnail_ids.discard(None) - - if not thumbnail_ids: - self._thumbnails_widget.set_current_thumbnails(None) - return - - thumbnail_paths = set() - for thumbnail_id in thumbnail_ids: - thumbnail_path = self._controller.get_thumbnail_path( - project_name, thumbnail_id) - thumbnail_paths.add(thumbnail_path) + thumbnail_path_by_entity_id = self._controller.get_thumbnail_paths( + project_name, entity_type, entity_ids + ) + thumbnail_paths = set(thumbnail_path_by_entity_id.values()) thumbnail_paths.discard(None) - self._thumbnails_widget.set_current_thumbnail_paths(thumbnail_paths) + + if thumbnail_paths: + self._thumbnails_widget.set_current_thumbnail_paths( + thumbnail_paths + ) + else: + self._thumbnails_widget.set_current_thumbnails(None) def _on_projects_refresh(self): self._refresh_handler.set_project_refreshed() diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 4ed91813d3..6d0027d35d 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from dataclasses import dataclass, asdict from typing import ( Optional, Dict, @@ -28,6 +29,19 @@ if TYPE_CHECKING: from .models import CreatorItem, PublishErrorInfo, InstanceItem +@dataclass +class CommentDef: + """Comment attribute definition.""" + minimum_chars_required: int + + def to_data(self): + return asdict(self) + + @classmethod + def from_data(cls, data): + return cls(**data) + + class CardMessageTypes: standard = None info = "info" @@ -135,6 +149,17 @@ class AbstractPublisherCommon(ABC): pass + @abstractmethod + def get_comment_def(self) -> CommentDef: + """Get comment attribute definition. + + This can define how the Comment field should behave, like having + a minimum amount of required characters before being allowed to + publish. + + """ + pass + class AbstractPublisherBackend(AbstractPublisherCommon): @abstractmethod diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index 98fdda08cf..ef2e122692 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -20,7 +20,8 @@ from .models import ( from .abstract import ( AbstractPublisherBackend, AbstractPublisherFrontend, - CardMessageTypes + CardMessageTypes, + CommentDef, ) @@ -601,3 +602,17 @@ class PublisherController( def _start_publish(self, up_validation): self._publish_model.set_publish_up_validation(up_validation) self._publish_model.start_publish(wait=True) + + def get_comment_def(self) -> CommentDef: + # Take the cached settings from the Create Context + settings = self.get_create_context().get_current_project_settings() + comment_minimum_required_chars: int = ( + settings + .get("core", {}) + .get("tools", {}) + .get("publish", {}) + .get("comment_minimum_required_chars", 0) + ) + return CommentDef( + minimum_chars_required=comment_minimum_required_chars + ) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 9644af43e0..900168eaef 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -461,19 +461,19 @@ class CreateModel: self._create_context.add_instances_added_callback( self._cc_added_instance ) - self._create_context.add_instances_removed_callback ( + self._create_context.add_instances_removed_callback( self._cc_removed_instance ) self._create_context.add_value_changed_callback( self._cc_value_changed ) - self._create_context.add_pre_create_attr_defs_change_callback ( + self._create_context.add_pre_create_attr_defs_change_callback( self._cc_pre_create_attr_changed ) - self._create_context.add_create_attr_defs_change_callback ( + self._create_context.add_create_attr_defs_change_callback( self._cc_create_attr_changed ) - self._create_context.add_publish_attr_defs_change_callback ( + self._create_context.add_publish_attr_defs_change_callback( self._cc_publish_attr_changed ) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index 97a956b18f..97070d106f 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -358,7 +358,7 @@ class PublishReportMaker: exception = result.get("error") if exception: - fname, line_no, func, exc = exception.traceback + fname, line_no, func, _ = exception.traceback # Conversion of exception into string may crash try: diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index ed5b909a55..dc086a3b48 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -245,6 +245,13 @@ class PublisherWindow(QtWidgets.QDialog): show_timer.setInterval(1) show_timer.timeout.connect(self._on_show_timer) + comment_invalid_timer = QtCore.QTimer() + comment_invalid_timer.setSingleShot(True) + comment_invalid_timer.setInterval(2500) + comment_invalid_timer.timeout.connect( + self._on_comment_invalid_timeout + ) + errors_dialog_message_timer = QtCore.QTimer() errors_dialog_message_timer.setInterval(100) errors_dialog_message_timer.timeout.connect( @@ -395,6 +402,7 @@ class PublisherWindow(QtWidgets.QDialog): self._app_event_listener_installed = False self._show_timer = show_timer + self._comment_invalid_timer = comment_invalid_timer self._show_counter = 0 self._window_is_visible = False @@ -823,15 +831,45 @@ class PublisherWindow(QtWidgets.QDialog): self._controller.set_comment(self._comment_input.text()) def _on_validate_clicked(self): - if self._save_changes(False): + if self._validate_comment() and self._save_changes(False): self._set_publish_comment() self._controller.validate() def _on_publish_clicked(self): - if self._save_changes(False): + if self._validate_comment() and self._save_changes(False): self._set_publish_comment() self._controller.publish() + def _validate_comment(self) -> bool: + # Validate comment length + comment_def = self._controller.get_comment_def() + char_count = len(self._comment_input.text().strip()) + if ( + comment_def.minimum_chars_required + and char_count < comment_def.minimum_chars_required + ): + self._overlay_object.add_message( + "Please enter a comment of at least " + f"{comment_def.minimum_chars_required} characters", + message_type="error" + ) + self._invalidate_comment_field() + return False + return True + + def _invalidate_comment_field(self): + self._comment_invalid_timer.start() + self._comment_input.setStyleSheet("border-color: #DD2020") + # Set focus so user can start typing and is pointed towards the field + self._comment_input.setFocus() + self._comment_input.setCursorPosition( + len(self._comment_input.text()) + ) + + def _on_comment_invalid_timeout(self): + # Reset style + self._comment_input.setStyleSheet("") + def _set_footer_enabled(self, enabled): self._save_btn.setEnabled(True) self._reset_btn.setEnabled(True) diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index bb95e37d4e..fdd1bdbe75 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -959,11 +959,13 @@ class SceneInventoryView(QtWidgets.QTreeView): remove_container(container) self.data_changed.emit() - def _show_version_error_dialog(self, version, item_ids): + def _show_version_error_dialog(self, version, item_ids, exception): """Shows QMessageBox when version switch doesn't work Args: version: str or int or None + item_ids (Iterable[str]): List of item ids to run the + exception (Exception): Exception that occurred """ if version == -1: version_str = "latest" @@ -988,10 +990,11 @@ class SceneInventoryView(QtWidgets.QTreeView): dialog.addButton(QtWidgets.QMessageBox.Cancel) msg = ( - "Version update to '{}' failed as representation doesn't exist." + "Version update to '{}' failed with the following error:\n" + "{}." "\n\nPlease update to version with a valid representation" " OR \n use 'Switch Folder' button to change folder." - ).format(version_str) + ).format(version_str, exception) dialog.setText(msg) dialog.exec_() @@ -1105,10 +1108,10 @@ class SceneInventoryView(QtWidgets.QTreeView): container = containers_by_id[item_id] try: update_container(container, item_version) - except AssertionError: + except Exception as exc: log.warning("Update failed", exc_info=True) self._show_version_error_dialog( - item_version, [item_id] + item_version, [item_id], exc ) finally: # Always update the scene inventory view, even if errors occurred diff --git a/client/ayon_core/tools/stdout_broker/app.py b/client/ayon_core/tools/stdout_broker/app.py deleted file mode 100644 index ae73db1bb9..0000000000 --- a/client/ayon_core/tools/stdout_broker/app.py +++ /dev/null @@ -1,12 +0,0 @@ -import warnings -from .broker import StdOutBroker - -warnings.warn( - ( - "Import of 'StdOutBroker' from 'ayon_core.tools.stdout_broker.app'" - " is deprecated. Please use 'ayon_core.tools.stdout_broker' instead." - ), - DeprecationWarning -) - -__all__ = ("StdOutBroker", ) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 13ee1eea5c..deb49b9711 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -738,4 +738,3 @@ def main(force=False): sys.exit(1) main() - diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 9206af9beb..8688430c71 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -6,6 +6,7 @@ from .widgets import ( CustomTextComboBox, PlaceholderLineEdit, PlaceholderPlainTextEdit, + MarkdownLabel, ElideLabel, HintedLineEdit, ExpandingTextEdit, @@ -91,6 +92,7 @@ __all__ = ( "CustomTextComboBox", "PlaceholderLineEdit", "PlaceholderPlainTextEdit", + "MarkdownLabel", "ElideLabel", "HintedLineEdit", "ExpandingTextEdit", diff --git a/client/ayon_core/tools/utils/constants.py b/client/ayon_core/tools/utils/constants.py index 0c92e3ccc8..b590d1d778 100644 --- a/client/ayon_core/tools/utils/constants.py +++ b/client/ayon_core/tools/utils/constants.py @@ -14,3 +14,4 @@ except AttributeError: DEFAULT_PROJECT_LABEL = "< Default >" PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 101 PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 102 +DEFAULT_WEB_ICON_COLOR = "#f4f5f5" diff --git a/client/ayon_core/tools/utils/folders_widget.py b/client/ayon_core/tools/utils/folders_widget.py index b3b5cebb0e..7b71dd087c 100644 --- a/client/ayon_core/tools/utils/folders_widget.py +++ b/client/ayon_core/tools/utils/folders_widget.py @@ -1,4 +1,6 @@ +from __future__ import annotations import collections +from typing import Optional from qtpy import QtWidgets, QtGui, QtCore @@ -33,7 +35,10 @@ class FoldersQtModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(FoldersQtModel, self).__init__() + super().__init__() + + self.setColumnCount(1) + self.setHeaderData(0, QtCore.Qt.Horizontal, "Folders") self._controller = controller self._items_by_id = {} @@ -334,6 +339,29 @@ class FoldersQtModel(QtGui.QStandardItemModel): self.refreshed.emit() +class FoldersProxyModel(RecursiveSortFilterProxyModel): + def __init__(self): + super().__init__() + + self._folder_ids_filter = None + + def set_folder_ids_filter(self, folder_ids: Optional[list[str]]): + if self._folder_ids_filter == folder_ids: + return + self._folder_ids_filter = folder_ids + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent_index): + if self._folder_ids_filter is not None: + if not self._folder_ids_filter: + return False + source_index = self.sourceModel().index(row, 0, parent_index) + folder_id = source_index.data(FOLDER_ID_ROLE) + if folder_id not in self._folder_ids_filter: + return False + return super().filterAcceptsRow(row, parent_index) + + class FoldersWidget(QtWidgets.QWidget): """Folders widget. @@ -369,13 +397,13 @@ class FoldersWidget(QtWidgets.QWidget): refreshed = QtCore.Signal() def __init__(self, controller, parent, handle_expected_selection=False): - super(FoldersWidget, self).__init__(parent) + super().__init__(parent) folders_view = TreeView(self) folders_view.setHeaderHidden(True) folders_model = FoldersQtModel(controller) - folders_proxy_model = RecursiveSortFilterProxyModel() + folders_proxy_model = FoldersProxyModel() folders_proxy_model.setSourceModel(folders_model) folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) @@ -446,6 +474,18 @@ class FoldersWidget(QtWidgets.QWidget): if name: self._folders_view.expandAll() + def set_folder_ids_filter(self, folder_ids: Optional[list[str]]): + """Set filter of folder ids. + + Args: + folder_ids (list[str]): The list of folder ids. + + """ + self._folders_proxy_model.set_folder_ids_filter(folder_ids) + + def set_header_visible(self, visible: bool): + self._folders_view.setHeaderHidden(not visible) + def refresh(self): """Refresh folders model. diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index 4b303c0143..f7919a3317 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -1,11 +1,14 @@ import os import sys +import io import contextlib import collections import traceback +import urllib.request from functools import partial from typing import Union, Any +import ayon_api from qtpy import QtWidgets, QtCore, QtGui import qtawesome import qtmaterialsymbols @@ -17,7 +20,12 @@ 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, PARTIALLY_CHECKED_INT +from .constants import ( + CHECKED_INT, + UNCHECKED_INT, + PARTIALLY_CHECKED_INT, + DEFAULT_WEB_ICON_COLOR, +) log = Logger.get_logger(__name__) @@ -480,11 +488,27 @@ class _IconsCache: if icon_type == "path": parts = [icon_type, icon_def["path"]] - elif icon_type in {"awesome-font", "material-symbols"}: - color = icon_def["color"] or "" + elif icon_type == "awesome-font": + color = icon_def.get("color") or "" if isinstance(color, QtGui.QColor): color = color.name() parts = [icon_type, icon_def["name"] or "", color] + + elif icon_type == "material-symbols": + color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR + if isinstance(color, QtGui.QColor): + color = color.name() + parts = [icon_type, icon_def["name"] or "", color] + + elif icon_type in {"url", "ayon_url"}: + parts = [icon_type, icon_def["url"]] + + elif icon_type == "transparent": + size = icon_def.get("size") + if size is None: + size = 256 + parts = [icon_type, str(size)] + return "|".join(parts) @classmethod @@ -505,7 +529,7 @@ class _IconsCache: elif icon_type == "awesome-font": icon_name = icon_def["name"] - icon_color = icon_def["color"] + icon_color = icon_def.get("color") icon = cls.get_qta_icon_by_name_and_color(icon_name, icon_color) if icon is None: icon = cls.get_qta_icon_by_name_and_color( @@ -513,10 +537,40 @@ class _IconsCache: elif icon_type == "material-symbols": icon_name = icon_def["name"] - icon_color = icon_def["color"] + icon_color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR if qtmaterialsymbols.get_icon_name_char(icon_name) is not None: icon = qtmaterialsymbols.get_icon(icon_name, icon_color) + elif icon_type == "url": + url = icon_def["url"] + try: + content = urllib.request.urlopen(url).read() + pix = QtGui.QPixmap() + pix.loadFromData(content) + icon = QtGui.QIcon(pix) + except Exception: + log.warning( + "Failed to download image '%s'", url, exc_info=True + ) + icon = None + + elif icon_type == "ayon_url": + url = icon_def["url"].lstrip("/") + url = f"{ayon_api.get_base_url()}/{url}" + stream = io.BytesIO() + ayon_api.download_file_to_stream(url, stream) + pix = QtGui.QPixmap() + pix.loadFromData(stream.getvalue()) + icon = QtGui.QIcon(pix) + + elif icon_type == "transparent": + size = icon_def.get("size") + if size is None: + size = 256 + pix = QtGui.QPixmap(size, size) + pix.fill(QtCore.Qt.transparent) + icon = QtGui.QIcon(pix) + if icon is None: icon = cls.get_default() cls._cache[cache_key] = icon diff --git a/client/ayon_core/tools/utils/projects_widget.py b/client/ayon_core/tools/utils/projects_widget.py index 88d8a6c9f5..c340be2f83 100644 --- a/client/ayon_core/tools/utils/projects_widget.py +++ b/client/ayon_core/tools/utils/projects_widget.py @@ -350,21 +350,21 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): if project_name is None: return True - string_pattern = self.filterRegularExpression().pattern() - if string_pattern: - return string_pattern.lower() in project_name.lower() - - # Current project keep always visible - default = super(ProjectSortFilterProxy, self).filterAcceptsRow( - source_row, source_parent - ) - if not default: - return default - # Make sure current project is visible if index.data(PROJECT_IS_CURRENT_ROLE): return True + default = super().filterAcceptsRow(source_row, source_parent) + if not default: + return default + + string_pattern = self.filterRegularExpression().pattern() + if ( + string_pattern + and string_pattern.lower() not in project_name.lower() + ): + return False + if ( self._filter_inactive and not index.data(PROJECT_IS_ACTIVE_ROLE) diff --git a/client/ayon_core/tools/utils/tasks_widget.py b/client/ayon_core/tools/utils/tasks_widget.py index 30846e6cda..744eb6060a 100644 --- a/client/ayon_core/tools/utils/tasks_widget.py +++ b/client/ayon_core/tools/utils/tasks_widget.py @@ -1,3 +1,6 @@ +from __future__ import annotations +from typing import Optional + from qtpy import QtWidgets, QtGui, QtCore from ayon_core.style import ( @@ -343,6 +346,29 @@ class TasksQtModel(QtGui.QStandardItemModel): return self._has_content +class TasksProxyModel(QtCore.QSortFilterProxyModel): + def __init__(self): + super().__init__() + + self._task_ids_filter: Optional[set[str]] = None + + def set_task_ids_filter(self, task_ids: Optional[set[str]]): + if self._task_ids_filter == task_ids: + return + self._task_ids_filter = task_ids + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent_index): + if self._task_ids_filter is not None: + if not self._task_ids_filter: + return False + source_index = self.sourceModel().index(row, 0, parent_index) + task_id = source_index.data(ITEM_ID_ROLE) + if task_id is not None and task_id not in self._task_ids_filter: + return False + return super().filterAcceptsRow(row, parent_index) + + class TasksWidget(QtWidgets.QWidget): """Tasks widget. @@ -364,7 +390,7 @@ class TasksWidget(QtWidgets.QWidget): tasks_view.setIndentation(0) tasks_model = TasksQtModel(controller) - tasks_proxy_model = QtCore.QSortFilterProxyModel() + tasks_proxy_model = TasksProxyModel() tasks_proxy_model.setSourceModel(tasks_model) tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) @@ -490,6 +516,15 @@ class TasksWidget(QtWidgets.QWidget): ) return True + def set_task_ids_filter(self, task_ids: Optional[list[str]]): + """Set filter of folder ids. + + Args: + task_ids (list[str]): The list of folder ids. + + """ + self._tasks_proxy_model.set_task_ids_filter(task_ids) + def _on_tasks_refresh_finished(self, event): """Tasks were refreshed in controller. @@ -540,7 +575,7 @@ class TasksWidget(QtWidgets.QWidget): if self._tasks_model.is_refreshing: return - parent_id, task_id, task_name, _ = self._get_selected_item_ids() + _parent_id, task_id, task_name, _ = self._get_selected_item_ids() self._controller.set_selected_task(task_id, task_name) self.selection_changed.emit() diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 0cd6d68ab3..af0745af1f 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -6,6 +6,11 @@ from qtpy import QtWidgets, QtCore, QtGui import qargparse import qtawesome +try: + import markdown +except Exception: + markdown = None + from ayon_core.style import ( get_objected_colors, get_style_image_path, @@ -131,6 +136,37 @@ class PlaceholderPlainTextEdit(QtWidgets.QPlainTextEdit): viewport.setPalette(filter_palette) +class MarkdownLabel(QtWidgets.QLabel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Enable word wrap by default + self.setWordWrap(True) + + text_format_available = hasattr(QtCore.Qt, "MarkdownText") + if text_format_available: + self.setTextFormat(QtCore.Qt.MarkdownText) + + self._text_format_available = text_format_available + + self.setText(self.text()) + + def setText(self, text): + if not self._text_format_available: + text = self._md_to_html(text) + super().setText(text) + + @staticmethod + def _md_to_html(text): + if markdown is None: + # This does add style definition to the markdown which does not + # feel natural in the UI (but still better than raw MD). + doc = QtGui.QTextDocument() + doc.setMarkdown(text) + return doc.toHtml() + return markdown.markdown(text) + + class ElideLabel(QtWidgets.QLabel): """Label which elide text. @@ -459,15 +495,15 @@ class ClickableLabel(QtWidgets.QLabel): """Label that catch left mouse click and can trigger 'clicked' signal.""" clicked = QtCore.Signal() - def __init__(self, parent): - super(ClickableLabel, self).__init__(parent) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self._mouse_pressed = False def mousePressEvent(self, event): if event.button() == QtCore.Qt.LeftButton: self._mouse_pressed = True - super(ClickableLabel, self).mousePressEvent(event) + super().mousePressEvent(event) def mouseReleaseEvent(self, event): if self._mouse_pressed: @@ -475,7 +511,7 @@ class ClickableLabel(QtWidgets.QLabel): if self.rect().contains(event.pos()): self.clicked.emit() - super(ClickableLabel, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) class ExpandBtnLabel(QtWidgets.QLabel): @@ -704,7 +740,7 @@ class PixmapLabel(QtWidgets.QLabel): def resizeEvent(self, event): self._set_resized_pix() - super(PixmapLabel, self).resizeEvent(event) + super().resizeEvent(event) class PixmapButtonPainter(QtWidgets.QWidget): diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index c621a44937..cc034571f3 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -462,7 +462,7 @@ class WorkfileEntitiesModel: anatomy = self._controller.project_anatomy workdir, filename = os.path.split(filepath) - success, rootless_dir = anatomy.find_root_template_from_path(workdir) + _, rootless_dir = anatomy.find_root_template_from_path(workdir) return "/".join([ os.path.normpath(rootless_dir).replace("\\", "/"), filename diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.codepoints b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.codepoints similarity index 91% rename from client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.codepoints rename to client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.codepoints index a4451c793a..d5ede9bf32 100644 --- a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.codepoints +++ b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.codepoints @@ -19,6 +19,7 @@ 21mp e95f 22mp e960 23mp e961 +24fps_select f3f2 24mp e962 2d ef37 2k e963 @@ -27,6 +28,7 @@ 30fps efce 30fps_select efcf 360 e577 +3d ed38 3d_rotation e84d 3g_mobiledata efd0 3g_mobiledata_badge f7f0 @@ -71,6 +73,7 @@ accessibility e84e accessibility_new e92c accessible e914 accessible_forward e934 +accessible_menu f34e account_balance e84f account_balance_wallet e850 account_box e851 @@ -92,6 +95,7 @@ adaptive_audio_mic f4cc adaptive_audio_mic_off f4cb adb e60e add e145 +add_2 f3dd add_a_photo e439 add_ad e72a add_alarm e856 @@ -103,6 +107,8 @@ add_card eb86 add_chart ef3c add_circle e3ba add_circle_outline e3ba +add_column_left f425 +add_column_right f424 add_comment e266 add_diamond f49c add_home f8eb @@ -116,6 +122,8 @@ add_notes e091 add_photo_alternate e43e add_reaction e1d3 add_road ef3b +add_row_above f423 +add_row_below f422 add_shopping_cart e854 add_task f23a add_to_drive e65c @@ -156,6 +164,7 @@ alarm e855 alarm_add e856 alarm_off e857 alarm_on e858 +alarm_pause f35b alarm_smart_wake f6b0 album e019 align_center e356 @@ -230,6 +239,7 @@ area_chart e770 arming_countdown e78a arrow_and_edge f5d7 arrow_back e5c4 +arrow_back_2 f43a arrow_back_ios e5e0 arrow_back_ios_new e2ea arrow_circle_down f181 @@ -247,6 +257,8 @@ arrow_forward_ios e5e1 arrow_insert f837 arrow_left e5de arrow_left_alt ef7d +arrow_menu_close f3d3 +arrow_menu_open f3d2 arrow_or_edge f5d6 arrow_outward f8ce arrow_range f69b @@ -256,14 +268,19 @@ arrow_selector_tool f82f arrow_split ea04 arrow_top_left f72e arrow_top_right f72d +arrow_upload_progress f3f4 +arrow_upload_ready f3f5 arrow_upward e5d8 arrow_upward_alt e986 arrow_warm_up f4b5 +arrows_input f394 arrows_more_down f8ab arrows_more_up f8ac +arrows_output f393 arrows_outward f72c art_track e060 article ef42 +article_person f368 article_shortcut f587 artist e01a aspect_ratio e85b @@ -324,6 +341,7 @@ auto_towing e71e auto_transmission f53f auto_videocam f6c0 autofps_select efdc +automation f421 autopause f6b6 autopay f84b autoplay f6b5 @@ -358,6 +376,7 @@ balcony e58f ballot e172 bar_chart e26b bar_chart_4_bars f681 +bar_chart_off f411 barcode e70b barcode_reader f85c barcode_scanner e70c @@ -382,6 +401,20 @@ battery_6_bar f0a1 battery_80 f0a0 battery_90 f0a1 battery_alert e19c +battery_android_0 f30d +battery_android_1 f30c +battery_android_2 f30b +battery_android_3 f30a +battery_android_4 f309 +battery_android_5 f308 +battery_android_6 f307 +battery_android_alert f306 +battery_android_bolt f305 +battery_android_full f304 +battery_android_plus f303 +battery_android_question f302 +battery_android_share f301 +battery_android_shield f300 battery_change f7eb battery_charging_20 f0a2 battery_charging_30 f0a3 @@ -413,7 +446,7 @@ bed efdf bedroom_baby efe0 bedroom_child efe1 bedroom_parent efe2 -bedtime ef44 +bedtime f159 bedtime_off eb76 beenhere e52d bento f1f4 @@ -445,6 +478,8 @@ blur_medium e84c blur_off e3a4 blur_on e3a5 blur_short e8cf +boat_bus f36d +boat_railway f36c body_fat e098 body_system e099 bolt ea0b @@ -454,10 +489,13 @@ book_2 f53e book_3 f53d book_4 f53c book_5 f53b +book_6 f3df book_online f217 +book_ribbon f3e7 bookmark e8e7 bookmark_add e598 bookmark_added e599 +bookmark_bag f410 bookmark_border e8e7 bookmark_check f457 bookmark_flag f456 @@ -466,6 +504,7 @@ bookmark_manager f7b1 bookmark_remove e59a bookmark_star f454 bookmarks e98b +books_movies_and_music ef82 border_all e228 border_bottom e229 border_clear e22a @@ -478,6 +517,7 @@ border_right e230 border_style e231 border_top e232 border_vertical e233 +borg f40d bottom_app_bar e730 bottom_drawer e72d bottom_navigation e98c @@ -496,6 +536,7 @@ breakfast_dining ea54 breaking_news ea08 breaking_news_alt_1 f0ba breastfeeding f856 +brick f388 brightness_1 e3fa brightness_2 f036 brightness_3 e3a8 @@ -529,6 +570,7 @@ build_circle ef48 bungalow e591 burst_mode e43c bus_alert e98f +bus_railway f36b business e7ee business_center eb3f business_chip f84c @@ -579,9 +621,26 @@ cancel_presentation e0e9 cancel_schedule_send ea39 candle f588 candlestick_chart ead4 +cannabis f2f3 captive_portal f728 capture f727 car_crash ebf2 +car_defrost_left f344 +car_defrost_low_left f343 +car_defrost_low_right f342 +car_defrost_mid_low_left f341 +car_defrost_mid_right f340 +car_defrost_right f33f +car_fan_low_left f33e +car_fan_low_mid_left f33d +car_fan_low_right f33c +car_fan_mid_left f33b +car_fan_mid_low_right f33a +car_fan_mid_right f339 +car_fan_recirculate f338 +car_gear f337 +car_lock f336 +car_mirror_heat f335 car_rental ea55 car_repair ea56 car_tag f4e3 @@ -591,6 +650,7 @@ card_travel e8f8 cardio_load f4b9 cardiology e09c cards e991 +cards_star f375 carpenter f1f8 carry_on_bag eb08 carry_on_bag_checked eb0b @@ -605,6 +665,7 @@ cast_pause f5f0 cast_warning f5ef castle eab1 category e574 +category_search f437 celebration ea65 cell_merge f82e cell_tower ebba @@ -627,6 +688,7 @@ chat_bubble_outline e0cb chat_error f7ac chat_info f52b chat_paste_go f6bd +chat_paste_go_2 f3cb check e5ca check_box e834 check_box_outline_blank e835 @@ -643,7 +705,9 @@ checklist e6b1 checklist_rtl e6b3 checkroom f19e cheer f6a8 +chef_hat f357 chess f5e7 +chess_pawn f3b6 chevron_backward f46b chevron_forward f46a chevron_left e5cb @@ -674,6 +738,8 @@ clear_day f157 clear_night f159 climate_mini_split f8b5 clinical_notes e09e +clock_arrow_down f382 +clock_arrow_up f381 clock_loader_10 f726 clock_loader_20 f725 clock_loader_40 f724 @@ -688,9 +754,11 @@ closed_caption_add f4ae closed_caption_disabled f1dc closed_caption_off e996 cloud f15c +cloud_alert f3cc cloud_circle e2be cloud_done e2bf cloud_download e2c0 +cloud_lock f386 cloud_off e2c1 cloud_queue f15c cloud_sync eb5a @@ -706,6 +774,7 @@ code_off e4f3 coffee efef coffee_maker eff0 cognition e09f +cognition_2 f3b5 collapse_all e944 collapse_content f507 collections e3d3 @@ -713,6 +782,7 @@ collections_bookmark e431 color_lens e40a colorize e3b8 colors e997 +combine_columns f420 comedy_mask f4d6 comic_bubble f5dd comment e24c @@ -730,6 +800,8 @@ component_exchange f1e7 compost e761 compress e94d computer e31e +computer_arrow_up f2f7 +computer_cancel f2f6 concierge f561 conditions e0a0 confirmation_number e638 @@ -769,6 +841,7 @@ control_point_duplicate e3bb controller_gen e83d conversion_path f0c1 conversion_path_off f7b4 +convert_to_text f41f conveyor_belt f867 cookie eaac cookie_off f79a @@ -793,6 +866,7 @@ countertops f1f7 create f097 create_new_folder e2cc credit_card e8a1 +credit_card_clock f438 credit_card_gear f52d credit_card_heart f52c credit_card_off e4f4 @@ -814,6 +888,7 @@ crop_rotate e437 crop_square e3c6 crossword f5e5 crowdsource eb18 +crown ecb3 cruelty_free e799 css eb93 csv e6cf @@ -836,6 +911,7 @@ cyclone ebd5 dangerous e99a dark_mode e51c dashboard e871 +dashboard_2 f3ea dashboard_customize e99b data_alert f7f6 data_array ead1 @@ -850,6 +926,9 @@ data_table e99c data_thresholding eb9f data_usage eff2 database f20e +database_off f414 +database_search f38e +database_upload f3dc dataset f8ee dataset_linked f8ef date_range e916 @@ -864,6 +943,9 @@ delete_forever e92b delete_history f518 delete_outline e92e delete_sweep e16c +delivery_dining eb28 +delivery_truck_bolt f3a2 +delivery_truck_speed f3a1 demography e489 density_large eba9 density_medium eb9e @@ -882,7 +964,10 @@ design_services f10a desk f8f4 deskphone f7fa desktop_access_disabled e99d +desktop_cloud f3db +desktop_cloud_stack f3be desktop_landscape f45e +desktop_landscape_add f439 desktop_mac e30b desktop_portrait f45d desktop_windows e30c @@ -901,17 +986,20 @@ developer_board_off e4ff developer_guide e99e developer_mode e1b0 developer_mode_tv e874 +device_band f2f5 device_hub e335 device_reset e8b3 device_thermostat e1ff device_unknown e339 devices e326 devices_fold ebde +devices_fold_2 f406 devices_off f7a5 devices_other e337 devices_wearables f6ab dew_point f879 diagnosis e0a8 +diagonal_line f41e dialer_sip e0bb dialogs e99f dialpad e0bc @@ -973,9 +1061,11 @@ dock e30e dock_to_bottom f7e6 dock_to_left f7e5 dock_to_right f7e4 +docs ea7d docs_add_on f0c2 docs_apps_script f0c3 document_scanner e5fa +document_search f385 domain e7ee domain_add eb62 domain_disabled e0ef @@ -1015,6 +1105,7 @@ draw_collage f7f7 drawing_recognition eb00 dresser e210 drive_eta eff7 +drive_export f41d drive_file_move e9a1 drive_file_move_outline e9a1 drive_file_move_rtl e9a1 @@ -1022,6 +1113,7 @@ drive_file_rename_outline e9a2 drive_folder_upload e9a3 drive_fusiontable e678 dropdown e9a4 +dropper_eye f351 dry f1b3 dry_cleaning ea58 dual_screen f6cf @@ -1033,7 +1125,12 @@ e911_avatar f11a e911_emergency f119 e_mobiledata f002 e_mobiledata_badge f7e3 +ear_sound f356 +earbud_case f327 +earbud_left f326 +earbud_right f325 earbuds f003 +earbuds_2 f324 earbuds_battery f004 early_on e2ba earthquake f64f @@ -1045,7 +1142,10 @@ eda f6e8 edgesensor_high f005 edgesensor_low f006 edit f097 +edit_arrow_down f380 +edit_arrow_up f37f edit_attributes e578 +edit_audio f42d edit_calendar e742 edit_document f88c edit_location e568 @@ -1093,6 +1193,10 @@ emoticon e5f3 empty_dashboard f844 enable f188 encrypted e593 +encrypted_add f429 +encrypted_add_circle f42a +encrypted_minus_circle f428 +encrypted_off f427 endocrinology e0a9 energy e9a6 energy_program_saving f15f @@ -1105,6 +1209,11 @@ enterprise e70e enterprise_off eb4d equal f77b equalizer e01d +eraser_size_1 f3fc +eraser_size_2 f3fb +eraser_size_3 f3fa +eraser_size_4 f3f9 +eraser_size_5 f3f8 error f8b6 error_circle_rounded f8b6 error_med e49b @@ -1138,6 +1247,8 @@ expand_circle_up f5d2 expand_content f830 expand_less e5ce expand_more e5cf +expansion_panels ef90 +expension_panels ef90 experiment e686 explicit e01e explore e87a @@ -1161,9 +1272,15 @@ face_3 f8db face_4 f8dc face_5 f8dd face_6 f8de +face_down f402 +face_left f401 +face_nod f400 face_retouching_natural ef4e face_retouching_off f007 +face_right f3ff +face_shake f3fe face_unlock f008 +face_up f3fd fact_check f0c5 factory ebbc falling f60d @@ -1173,6 +1290,8 @@ family_home eb26 family_link eb19 family_restroom f1a2 family_star f527 +fan_focus f334 +fan_indirect f333 farsight_digital f559 fast_forward e01f fast_rewind e020 @@ -1203,13 +1322,18 @@ file_copy_off f4d8 file_download f090 file_download_done f091 file_download_off e4fe +file_export f3b2 +file_json f3bb file_map e2c5 +file_map_stack f3e2 file_open eaf3 +file_png f3bc file_present ea0e file_save f17f file_save_off e505 file_upload f09b file_upload_off f886 +files ea85 filter e3d3 filter_1 e3d0 filter_2 e3d1 @@ -1223,6 +1347,7 @@ filter_9 e3d9 filter_9_plus e3da filter_alt ef4f filter_alt_off eb32 +filter_arrow_right f3d1 filter_b_and_w e3db filter_center_focus e3dc filter_drama e3dd @@ -1248,11 +1373,15 @@ fire_truck f8f2 fireplace ea43 first_page e5dc fit_page f77a +fit_page_height f397 +fit_page_width f396 fit_screen ea10 fit_width f779 fitness_center eb43 fitness_tracker f463 flag f0c6 +flag_2 f40f +flag_check f3d8 flag_circle eaf8 flag_filled f0c6 flaky ef50 @@ -1283,6 +1412,7 @@ flood ebe6 floor f6e4 floor_lamp e21e flourescent f07d +flowchart f38d flowsheet e0ae fluid e483 fluid_balance f80d @@ -1296,11 +1426,17 @@ fmd_good f1db foggy e818 folded_hands f5ed folder e2c7 +folder_check f3d7 +folder_check_2 f3d6 +folder_code f3c8 folder_copy ebbd folder_data f586 folder_delete eb34 +folder_eye f3d5 +folder_info f395 folder_limited f4e4 folder_managed f775 +folder_match f3d4 folder_off eb83 folder_open e2c8 folder_shared e2c9 @@ -1317,6 +1453,7 @@ for_you e9ac forest ea99 fork_left eba0 fork_right ebac +fork_spoon f3e4 forklift f868 format_align_center e234 format_align_justify e235 @@ -1353,6 +1490,7 @@ format_overline eb65 format_paint e243 format_paragraph f865 format_quote e244 +format_quote_off f413 format_shapes e25e format_size e245 format_strikethrough e246 @@ -1376,6 +1514,7 @@ forward_circle f6f5 forward_media f6f4 forward_to_inbox f187 foundation f200 +fragrance f345 frame_inspect f772 frame_person f8a6 frame_person_mic f4d5 @@ -1417,12 +1556,15 @@ gesture e155 gesture_select f657 get_app f090 gif e908 +gif_2 f40e gif_box e7a3 girl eb68 gite e58b glass_cup f6e3 globe e64c globe_asia f799 +globe_book f3c9 +globe_location_pin f35d globe_uk f798 glucose e4a0 glyphs f8a3 @@ -1439,10 +1581,17 @@ gpp_maybe f014 gps_fixed e55c gps_not_fixed e1b7 gps_off e1b6 -grade e885 +grade f09a gradient e3e9 grading ea4f grain e3ea +graph_1 f3a0 +graph_2 f39f +graph_3 f39e +graph_4 f39d +graph_5 f39c +graph_6 f39b +graph_7 f346 graphic_eq e1b8 grass f205 grid_3x3 f015 @@ -1458,6 +1607,7 @@ group ea21 group_add e7f0 group_off e747 group_remove e7ad +group_search f3ce group_work e886 grouped_bar_chart f211 groups f233 @@ -1473,12 +1623,14 @@ hail e9b1 hallway e6f8 hand_bones f894 hand_gesture ef9c +hand_gesture_off f3f3 handheld_controller f4c6 handshake ebcb handwriting_recognition eb02 handyman f10b hangout_video e0c1 hangout_video_off e0c2 +hard_disk f3da hard_drive f80e hard_drive_2 f7a4 hardware ea59 @@ -1509,6 +1661,7 @@ heap_snapshot_multiple f76d heap_snapshot_thumbnail f76c hearing e023 hearing_aid f464 +hearing_aid_disabled f3b0 hearing_disabled f104 heart_broken eac2 heart_check f60a @@ -1533,6 +1686,7 @@ high_density f79c high_quality e024 high_res f54b highlight e25f +highlight_alt ef52 highlight_keyboard_focus f510 highlight_mouse_cursor f511 highlight_off e888 @@ -1544,6 +1698,7 @@ highlighter_size_4 f768 highlighter_size_5 f767 hiking e50a history e8b3 +history_2 f3e6 history_edu ea3e history_off f4da history_toggle_off f17d @@ -1569,14 +1724,18 @@ home_work f030 horizontal_distribute e014 horizontal_rule f108 horizontal_split e947 +host f3d9 hot_tub eb46 hotel e549 hotel_class e743 hourglass ebff +hourglass_arrow_down f37e +hourglass_arrow_up f37d hourglass_bottom ea5c hourglass_disabled ef53 hourglass_empty e88b hourglass_full e88c +hourglass_pause f38c hourglass_top ea5b house ea44 house_siding f202 @@ -1599,13 +1758,17 @@ humidity_low f164 humidity_mid f165 humidity_percentage f87e hvac f10e +hvac_max_defrost f332 ice_skating e50b icecream ea69 id_card f4ca +identity_aware_proxy e2dd +identity_platform ebb7 ifl e025 iframe f71b iframe_off f71c image e3f4 +image_arrow_up f317 image_aspect_ratio e3f5 image_not_supported f116 image_search e43f @@ -1619,6 +1782,10 @@ in_home_mode e833 inactive_order e0fc inbox e156 inbox_customize f859 +inbox_text f399 +inbox_text_asterisk f360 +inbox_text_person f35e +inbox_text_share f35c incomplete_circle e79b indeterminate_check_box e909 indeterminate_question_box f56d @@ -1631,6 +1798,7 @@ ink_highlighter e6d1 ink_highlighter_move f524 ink_marker e6d2 ink_pen e6d3 +ink_selection ef52 inpatient e0fe input e890 input_circle f71a @@ -1726,6 +1894,7 @@ labs e105 lan eb2f landscape e564 landscape_2 f4c4 +landscape_2_edit f310 landscape_2_off f4c3 landslide ebd7 language e894 @@ -1747,6 +1916,7 @@ language_us_colemak f75b language_us_dvorak f75a laps f6b9 laptop e31e +laptop_car f3cd laptop_chromebook e31f laptop_mac e320 laptop_windows e321 @@ -1778,6 +1948,7 @@ light_group e28b light_mode e518 light_off e9b8 lightbulb e90f +lightbulb_2 f3e3 lightbulb_circle ebfe lightbulb_outline e90f lightning_stand efa4 @@ -1806,6 +1977,7 @@ liquor ea60 list e896 list_alt e0ee list_alt_add f756 +list_alt_check f3de lists e9b9 live_help e0c6 live_tv e63a @@ -1855,6 +2027,7 @@ locator_tag f8c1 lock e899 lock_clock ef57 lock_open e898 +lock_open_circle f361 lock_open_right f656 lock_outline e899 lock_person f8f3 @@ -1906,6 +2079,7 @@ manage_search f02f manga f5e3 manufacturing e726 map e55b +map_search f3ca maps_home_work f030 maps_ugc ef58 margin e9bb @@ -1921,8 +2095,10 @@ markdown_paste f554 markunread e159 markunread_mailbox e89b masked_transitions e72e +masked_transitions_add f42b masks f218 match_case f6f1 +match_case_off f36f match_word f6f0 matter e907 maximize e930 @@ -1952,6 +2128,7 @@ metabolism e10b metro f474 mfg_nest_yale_lock f11d mic e31d +mic_alert f392 mic_double f5d1 mic_external_off ef59 mic_external_on ef5a @@ -1975,8 +2152,16 @@ mitre f547 mixture_med e4c8 mms e618 mobile_friendly e200 +mobile_hand f323 +mobile_hand_left f313 +mobile_hand_left_off f312 +mobile_hand_off f314 +mobile_loupe f322 mobile_off e201 mobile_screen_share e0e7 +mobile_screensaver f321 +mobile_sound_2 f318 +mobile_speaker f320 mobiledata_off f034 mode f097 mode_comment e253 @@ -1995,8 +2180,10 @@ mode_of_travel e7ce mode_off_on f16f mode_standby f037 model_training f0cf +modeling f3aa monetization_on e263 money e57d +money_bag f3ee money_off f038 money_off_csred f038 monitor ef5b @@ -2009,7 +2196,9 @@ monochrome_photos e403 monorail f473 mood ea22 mood_bad e7f3 +moon_stars f34f mop e28d +moped eb28 more e619 more_down f196 more_horiz e5d3 @@ -2024,6 +2213,7 @@ motion_photos_off e9c0 motion_photos_on e9c1 motion_photos_pause f227 motion_photos_paused f227 +motion_play f40b motion_sensor_active e792 motion_sensor_alert e784 motion_sensor_idle e783 @@ -2057,10 +2247,13 @@ moving_ministry e73e mp e9c3 multicooker e293 multiline_chart e6df +multimodal_hand_eye f41b +multiple_airports efab multiple_stop f1b9 museum ea36 music_cast eb1a music_note e405 +music_note_add f391 music_off e440 music_video e063 my_location e55c @@ -2128,6 +2321,8 @@ nest_wifi_pro_2 f56a nest_wifi_router f133 network_cell e1b9 network_check e640 +network_intel_node f371 +network_intelligence efac network_intelligence_history f5f6 network_intelligence_update f5f5 network_locked e61a @@ -2153,6 +2348,7 @@ newsstand e9c4 next_plan ef5d next_week e16a nfc e1bb +nfc_off f369 night_shelter f1f1 night_sight_auto f1d7 night_sight_auto_off f1f9 @@ -2199,6 +2395,8 @@ notes e26c notification_add e399 notification_important e004 notification_multiple e6c2 +notification_settings f367 +notification_sound f353 notifications e7f5 notifications_active e7f7 notifications_none e7f5 @@ -2232,6 +2430,7 @@ open_run f4b7 open_with e89f ophthalmology e115 oral_disease e116 +orbit f426 order_approve f812 order_play f811 orders eb14 @@ -2254,6 +2453,7 @@ oven e9c7 oven_gen e843 overview e4a7 overview_key f7d4 +owl f3b4 oxygen_saturation e4de p2p f52a pace f6b8 @@ -2262,6 +2462,8 @@ package e48f package_2 f569 padding e9c8 page_control e731 +page_footer f383 +page_header f384 page_info f614 pageless f509 pages e7f9 @@ -2345,6 +2547,7 @@ person_play f7fd person_raised_hand f59a person_remove ef66 person_search f106 +person_shield e384 personal_bag eb0e personal_bag_off eb0f personal_bag_question eb10 @@ -2412,6 +2615,8 @@ pin f045 pin_drop e55e pin_end e767 pin_invoke e763 +pinboard f3ab +pinboard_unread f3ac pinch eb38 pinch_zoom_in f1fa pinch_zoom_out f1fb @@ -2421,6 +2626,7 @@ pivot_table_chart e9ce place f1db place_item f1f0 plagiarism ea5a +planet f387 planner_banner_ad_pt e692 planner_review e694 play_arrow e037 @@ -2438,6 +2644,7 @@ playlist_add_check_circle e7e6 playlist_add_circle e7e5 playlist_play e05f playlist_remove eb80 +plug_connect f35a plumbing f107 plus_one e800 podcasts f048 @@ -2447,6 +2654,7 @@ point_of_sale f17e point_scan f70c poker_chip f49b policy ea17 +policy_alert f407 poll f0cc polyline ebbb polymer e8ab @@ -2463,6 +2671,7 @@ power e63c power_input e336 power_off e646 power_rounded f8c7 +power_settings_circle f418 power_settings_new f8c7 prayer_times f838 precision_manufacturing f049 @@ -2523,8 +2732,8 @@ quick_reference e46e quick_reference_all f801 quick_reorder eb15 quickreply ef6c -quiet_time e1f9 -quiet_time_active e291 +quiet_time f159 +quiet_time_active eb76 quiz f04c r_mobiledata f04d radar f04e @@ -2544,6 +2753,7 @@ ramp_left eb9c ramp_right eb96 range_hood e1ea rate_review e560 +rate_review_rtl e706 raven f555 raw_off f04f raw_on f050 @@ -2555,6 +2765,7 @@ rebase f845 rebase_edit f846 receipt e8b0 receipt_long ef6e +receipt_long_off f40a recent_actors e03f recent_patient f808 recenter f4c0 @@ -2590,6 +2801,9 @@ repeat e040 repeat_on e9d6 repeat_one e041 repeat_one_on e9d7 +replace_audio f451 +replace_image f450 +replace_video f44f replay e042 replay_10 e059 replay_30 e05a @@ -2648,6 +2862,7 @@ room_preferences f1b8 room_service eb49 rotate_90_degrees_ccw e418 rotate_90_degrees_cw eaab +rotate_auto f417 rotate_left e419 rotate_right e41a roundabout_left eb99 @@ -2655,6 +2870,7 @@ roundabout_right eba3 rounded_corner e920 route eacd router e328 +router_off f2f4 routine e20c rowing e921 rss_feed e0e5 @@ -2679,6 +2895,7 @@ sauna f6f7 save e161 save_alt f090 save_as eb60 +save_clock f398 saved_search ea11 savings e2eb scale eb5f @@ -2707,6 +2924,7 @@ screen_search_desktop ef70 screen_share e0e2 screenshot f056 screenshot_frame f677 +screenshot_frame_2 f374 screenshot_keyboard f7d3 screenshot_monitor ec08 screenshot_region f7d2 @@ -2720,11 +2938,18 @@ sd_card_alert f057 sd_storage e623 sdk e720 search e8b6 +search_activity f3e5 search_check f800 search_check_2 f469 search_hands_free e696 search_insights f4bc search_off ea76 +seat_cool_left f331 +seat_cool_right f330 +seat_heat_left f32f +seat_heat_right f32e +seat_vent_left f32d +seat_vent_right f32c security e32a security_key f503 security_update f072 @@ -2768,6 +2993,7 @@ sentiment_very_dissatisfied e814 sentiment_very_satisfied e815 sentiment_worried f6a1 serif f4ac +server_person f3bd service_toolbox e717 set_meal f1ea settings e8b8 @@ -2811,6 +3037,7 @@ shape_line f8d3 shape_recognition eb01 shapes e602 share e80d +share_eta e5f7 share_location f05f share_off f6cb share_reviews f8a4 @@ -2825,6 +3052,7 @@ shield_locked f592 shield_moon eaa9 shield_person f650 shield_question f529 +shield_watch f30f shield_with_heart e78f shield_with_house e78d shift e5f2 @@ -2834,6 +3062,7 @@ shop e8c9 shop_2 e8ca shop_two e8ca shopping_bag f1cc +shopping_bag_speed f39a shopping_basket e8cb shopping_cart e8cc shopping_cart_checkout eb88 @@ -2883,8 +3112,13 @@ signpost eb91 sim_card e32b sim_card_alert f057 sim_card_download f068 +simulation f3e1 single_bed ea48 sip f069 +siren f3a7 +siren_check f3a6 +siren_open f3a5 +siren_question f3a4 skateboarding e511 skeleton f899 skillet f543 @@ -2892,6 +3126,7 @@ skillet_cooktop f544 skip_next e044 skip_previous e045 skull f89a +skull_list f370 slab_serif f4ab sledding e512 sleep e213 @@ -2908,6 +3143,7 @@ smart_outlet e844 smart_screen f06b smart_toy f06c smartphone e32c +smartphone_camera f44e smb_share f74b smoke_free eb4a smoking_rooms eb4b @@ -2971,6 +3207,11 @@ speed_1_7x f493 speed_2x f4eb speed_camera f470 spellcheck e8ce +split_scene f3bf +split_scene_down f2ff +split_scene_left f2fe +split_scene_right f2fd +split_scene_up f2fc splitscreen f06d splitscreen_add f4fd splitscreen_bottom f676 @@ -3006,9 +3247,12 @@ sports_volleyball ea31 sprinkler e29a sprint f81f square eb36 +square_dot f3b3 square_foot ea49 ssid_chart eb66 stack f609 +stack_group f359 +stack_hexagon f41c stack_off f608 stack_star f607 stacked_bar_chart e9e6 @@ -3028,7 +3272,9 @@ star_outline f09a star_purple500 f09a star_rate f0ec star_rate_half ec45 +star_shine f31d stars e8d0 +stars_2 f31c start e089 stat_0 e697 stat_1 e698 @@ -3041,6 +3287,7 @@ stay_current_landscape e0d3 stay_current_portrait e0d4 stay_primary_landscape e0d5 stay_primary_portrait e0d6 +steering_wheel_heat f32b step f6fe step_into f701 step_out f700 @@ -3076,8 +3323,13 @@ stroller f1ae style e41d styler e273 stylus f604 +stylus_brush f366 +stylus_fountain_pen f365 +stylus_highlighter f364 stylus_laser_pointer f747 stylus_note f603 +stylus_pen f363 +stylus_pencil f362 subdirectory_arrow_left e5d9 subdirectory_arrow_right e5da subheader e9ea @@ -3085,6 +3337,7 @@ subject e8d2 subscript f111 subscriptions e064 subtitles e048 +subtitles_gear f355 subtitles_off ef72 subway e56f summarize f071 @@ -3120,6 +3373,7 @@ swipe_vertical eb51 switch e1f4 switch_access f6fd switch_access_2 f506 +switch_access_3 f34d switch_access_shortcut e7e1 switch_access_shortcut_add e7e2 switch_account e9ed @@ -3134,6 +3388,9 @@ symptoms e132 synagogue eab0 sync e627 sync_alt ea18 +sync_arrow_down f37c +sync_arrow_up f37b +sync_desktop f41a sync_disabled e628 sync_lock eaee sync_problem e629 @@ -3146,17 +3403,22 @@ system_update f072 system_update_alt e8d7 tab e8d8 tab_close f745 +tab_close_inactive f3d0 tab_close_right f746 tab_duplicate f744 tab_group f743 +tab_inactive f43b tab_move f742 tab_new_right f741 tab_recent f740 +tab_search f2f2 tab_unselected e8d9 table f191 table_bar ead2 table_chart e265 table_chart_view f6ef +table_convert f3c7 +table_edit f3c6 table_eye f466 table_lamp e1f2 table_restaurant eac6 @@ -3165,6 +3427,7 @@ table_rows_narrow f73f table_view f1be tablet e32f tablet_android e330 +tablet_camera f44d tablet_mac e331 tabs e9ee tactic f564 @@ -3189,6 +3452,7 @@ tenancy f0e3 terminal eb8e terrain e564 text_ad e728 +text_compare f3c5 text_decrease eadd text_fields e262 text_fields_alt e9f1 @@ -3225,10 +3489,13 @@ thermometer_gain f6d8 thermometer_loss f6d7 thermometer_minus f581 thermostat f076 +thermostat_arrow_down f37a +thermostat_arrow_up f379 thermostat_auto f077 thermostat_carbon f178 things_to_do eb2a thread_unread f4f9 +threat_intelligence eaed thumb_down f578 thumb_down_alt f578 thumb_down_filled f578 @@ -3244,6 +3511,9 @@ thumbs_up_down e8dd thunderstorm ebdb tibia f89b tibia_alt f89c +tile_large f3c3 +tile_medium f3c2 +tile_small f3c1 time_auto f0e4 time_to_leave eff7 timelapse e422 @@ -3257,6 +3527,8 @@ timer_3_alt_1 efc0 timer_3_select f07b timer_5 f4b1 timer_5_shutter f4b2 +timer_arrow_down f378 +timer_arrow_up f377 timer_off e426 timer_pause f4bb timer_play f4ba @@ -3282,12 +3554,16 @@ tools_pliers_wire_stripper e2aa tools_power_drill e1e9 tools_wrench f8cd tooltip e9f8 +tooltip_2 f3ed top_panel_close f733 top_panel_open f732 topic f1c8 tornado e199 total_dissolved_solids f877 touch_app e913 +touch_double f38b +touch_long f38a +touch_triple f389 touchpad_mouse f687 touchpad_mouse_off f4e6 tour ef75 @@ -3296,6 +3572,8 @@ toys_and_games efc2 toys_fan f887 track_changes e8e1 trackpad_input f4c7 +trackpad_input_2 f409 +trackpad_input_3 f408 traffic e565 traffic_jam f46f trail_length eb5e @@ -3308,6 +3586,7 @@ transfer_within_a_station e572 transform e428 transgender e58d transit_enterexit e579 +transit_ticket f3f1 transition_chop f50e transition_dissolve f50d transition_fade f50c @@ -3342,8 +3621,10 @@ turn_slight_right eb9a turned_in e8e7 turned_in_not e8e7 tv e63b +tv_displays f3ec tv_gen e830 tv_guide e1dc +tv_next f3eb tv_off e647 tv_options_edit_channels e1dd tv_options_input_settings e1de @@ -3351,6 +3632,7 @@ tv_remote f5d9 tv_signin e71b tv_with_assistant e785 two_pager f51f +two_pager_store f3c4 two_wheeler e9f9 type_specimen f8f0 u_turn_left eba1 @@ -3382,6 +3664,7 @@ upcoming f07e update e923 update_disabled e075 upgrade f0fb +upi_pay f3cf upload f09b upload_2 f521 upload_file e9fc @@ -3401,6 +3684,7 @@ variable_remove f51c variables f851 ventilator e139 verified ef76 +verified_off f30e verified_user f013 vertical_align_bottom e258 vertical_align_center e259 @@ -3412,6 +3696,7 @@ vertical_split e949 vibration e62d video_call e070 video_camera_back f07f +video_camera_back_add f40c video_camera_front f080 video_camera_front_off f83b video_chat f8a0 @@ -3422,10 +3707,12 @@ video_search efc6 video_settings ea75 video_stable f081 videocam e04b +videocam_alert f390 videocam_off e04c videogame_asset e338 videogame_asset_off e500 view_agenda e8e9 +view_apps f376 view_array e8ea view_carousel e8eb view_column e8ec @@ -3443,6 +3730,7 @@ view_in_ar_off f61b view_kanban eb7f view_list e8ef view_module e8f0 +view_object_track f432 view_quilt e8f1 view_real_size f4c2 view_sidebar f114 @@ -3460,7 +3748,9 @@ vo2_max f4aa voice_chat e62e voice_over_off e94a voice_selection f58a +voice_selection_off f42c voicemail e0d9 +voicemail_2 f352 volcano ebda volume_down e04d volume_down_alt e79c @@ -3473,6 +3763,7 @@ vpn_key e0da vpn_key_alert f6cc vpn_key_off eb7a vpn_lock e62f +vpn_lock_2 f350 vr180_create2d efca vr180_create2d_off f571 vrpano f082 @@ -3481,6 +3772,8 @@ wall_lamp e2b4 wallet f8ff wallpaper e1bc wallpaper_slideshow f672 +wand_shine f31f +wand_stars f31e ward e13c warehouse ebb8 warning f083 @@ -3538,6 +3831,9 @@ west f1e6 whatshot e80e wheelchair_pickup f1ab where_to_vote e177 +widget_medium f3ba +widget_small f3b9 +widget_width f3b8 widgets e1bd width f730 width_full f8f5 @@ -3551,6 +3847,9 @@ wifi_calling ef77 wifi_calling_1 f0e7 wifi_calling_2 f0f6 wifi_calling_3 f0e7 +wifi_calling_bar_1 f44c +wifi_calling_bar_2 f44b +wifi_calling_bar_3 f44a wifi_channel eb6a wifi_find eb31 wifi_home f671 @@ -3568,6 +3867,9 @@ window f088 window_closed e77e window_open e78c window_sensor e2bb +windshield_defrost_front f32a +windshield_defrost_rear f329 +windshield_heat_front f328 wine_bar f1e8 woman e13e woman_2 f8e7 @@ -3596,4 +3898,4 @@ zone_person_urgent e788 zoom_in e8ff zoom_in_map eb2d zoom_out e900 -zoom_out_map e56b +zoom_out_map e56b \ No newline at end of file diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.json b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.json similarity index 91% rename from client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.json rename to client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.json index 2be9d15d69..2eb48b234b 100644 --- a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.json +++ b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.json @@ -20,6 +20,7 @@ "21mp": 59743, "22mp": 59744, "23mp": 59745, + "24fps_select": 62450, "24mp": 59746, "2d": 61239, "2k": 59747, @@ -28,6 +29,7 @@ "30fps": 61390, "30fps_select": 61391, "360": 58743, + "3d": 60728, "3d_rotation": 59469, "3g_mobiledata": 61392, "3g_mobiledata_badge": 63472, @@ -72,6 +74,7 @@ "accessibility_new": 59692, "accessible": 59668, "accessible_forward": 59700, + "accessible_menu": 62286, "account_balance": 59471, "account_balance_wallet": 59472, "account_box": 59473, @@ -93,6 +96,7 @@ "adaptive_audio_mic_off": 62667, "adb": 58894, "add": 57669, + "add_2": 62429, "add_a_photo": 58425, "add_ad": 59178, "add_alarm": 59478, @@ -104,6 +108,8 @@ "add_chart": 61244, "add_circle": 58298, "add_circle_outline": 58298, + "add_column_left": 62501, + "add_column_right": 62500, "add_comment": 57958, "add_diamond": 62620, "add_home": 63723, @@ -117,6 +123,8 @@ "add_photo_alternate": 58430, "add_reaction": 57811, "add_road": 61243, + "add_row_above": 62499, + "add_row_below": 62498, "add_shopping_cart": 59476, "add_task": 62010, "add_to_drive": 58972, @@ -157,6 +165,7 @@ "alarm_add": 59478, "alarm_off": 59479, "alarm_on": 59480, + "alarm_pause": 62299, "alarm_smart_wake": 63152, "album": 57369, "align_center": 58198, @@ -231,6 +240,7 @@ "arming_countdown": 59274, "arrow_and_edge": 62935, "arrow_back": 58820, + "arrow_back_2": 62522, "arrow_back_ios": 58848, "arrow_back_ios_new": 58090, "arrow_circle_down": 61825, @@ -248,6 +258,8 @@ "arrow_insert": 63543, "arrow_left": 58846, "arrow_left_alt": 61309, + "arrow_menu_close": 62419, + "arrow_menu_open": 62418, "arrow_or_edge": 62934, "arrow_outward": 63694, "arrow_range": 63131, @@ -257,14 +269,19 @@ "arrow_split": 59908, "arrow_top_left": 63278, "arrow_top_right": 63277, + "arrow_upload_progress": 62452, + "arrow_upload_ready": 62453, "arrow_upward": 58840, "arrow_upward_alt": 59782, "arrow_warm_up": 62645, + "arrows_input": 62356, "arrows_more_down": 63659, "arrows_more_up": 63660, + "arrows_output": 62355, "arrows_outward": 63276, "art_track": 57440, "article": 61250, + "article_person": 62312, "article_shortcut": 62855, "artist": 57370, "aspect_ratio": 59483, @@ -325,6 +342,7 @@ "auto_transmission": 62783, "auto_videocam": 63168, "autofps_select": 61404, + "automation": 62497, "autopause": 63158, "autopay": 63563, "autoplay": 63157, @@ -359,6 +377,7 @@ "ballot": 57714, "bar_chart": 57963, "bar_chart_4_bars": 63105, + "bar_chart_off": 62481, "barcode": 59147, "barcode_reader": 63580, "barcode_scanner": 59148, @@ -383,6 +402,20 @@ "battery_80": 61600, "battery_90": 61601, "battery_alert": 57756, + "battery_android_0": 62221, + "battery_android_1": 62220, + "battery_android_2": 62219, + "battery_android_3": 62218, + "battery_android_4": 62217, + "battery_android_5": 62216, + "battery_android_6": 62215, + "battery_android_alert": 62214, + "battery_android_bolt": 62213, + "battery_android_full": 62212, + "battery_android_plus": 62211, + "battery_android_question": 62210, + "battery_android_share": 62209, + "battery_android_shield": 62208, "battery_change": 63467, "battery_charging_20": 61602, "battery_charging_30": 61603, @@ -414,7 +447,7 @@ "bedroom_baby": 61408, "bedroom_child": 61409, "bedroom_parent": 61410, - "bedtime": 61252, + "bedtime": 61785, "bedtime_off": 60278, "beenhere": 58669, "bento": 61940, @@ -446,6 +479,8 @@ "blur_off": 58276, "blur_on": 58277, "blur_short": 59599, + "boat_bus": 62317, + "boat_railway": 62316, "body_fat": 57496, "body_system": 57497, "bolt": 59915, @@ -455,10 +490,13 @@ "book_3": 62781, "book_4": 62780, "book_5": 62779, + "book_6": 62431, "book_online": 61975, + "book_ribbon": 62439, "bookmark": 59623, "bookmark_add": 58776, "bookmark_added": 58777, + "bookmark_bag": 62480, "bookmark_border": 59623, "bookmark_check": 62551, "bookmark_flag": 62550, @@ -467,6 +505,7 @@ "bookmark_remove": 58778, "bookmark_star": 62548, "bookmarks": 59787, + "books_movies_and_music": 61314, "border_all": 57896, "border_bottom": 57897, "border_clear": 57898, @@ -479,6 +518,7 @@ "border_style": 57905, "border_top": 57906, "border_vertical": 57907, + "borg": 62477, "bottom_app_bar": 59184, "bottom_drawer": 59181, "bottom_navigation": 59788, @@ -497,6 +537,7 @@ "breaking_news": 59912, "breaking_news_alt_1": 61626, "breastfeeding": 63574, + "brick": 62344, "brightness_1": 58362, "brightness_2": 61494, "brightness_3": 58280, @@ -530,6 +571,7 @@ "bungalow": 58769, "burst_mode": 58428, "bus_alert": 59791, + "bus_railway": 62315, "business": 59374, "business_center": 60223, "business_chip": 63564, @@ -580,9 +622,26 @@ "cancel_schedule_send": 59961, "candle": 62856, "candlestick_chart": 60116, + "cannabis": 62195, "captive_portal": 63272, "capture": 63271, "car_crash": 60402, + "car_defrost_left": 62276, + "car_defrost_low_left": 62275, + "car_defrost_low_right": 62274, + "car_defrost_mid_low_left": 62273, + "car_defrost_mid_right": 62272, + "car_defrost_right": 62271, + "car_fan_low_left": 62270, + "car_fan_low_mid_left": 62269, + "car_fan_low_right": 62268, + "car_fan_mid_left": 62267, + "car_fan_mid_low_right": 62266, + "car_fan_mid_right": 62265, + "car_fan_recirculate": 62264, + "car_gear": 62263, + "car_lock": 62262, + "car_mirror_heat": 62261, "car_rental": 59989, "car_repair": 59990, "car_tag": 62691, @@ -592,6 +651,7 @@ "cardio_load": 62649, "cardiology": 57500, "cards": 59793, + "cards_star": 62325, "carpenter": 61944, "carry_on_bag": 60168, "carry_on_bag_checked": 60171, @@ -606,6 +666,7 @@ "cast_warning": 62959, "castle": 60081, "category": 58740, + "category_search": 62519, "celebration": 60005, "cell_merge": 63534, "cell_tower": 60346, @@ -628,6 +689,7 @@ "chat_error": 63404, "chat_info": 62763, "chat_paste_go": 63165, + "chat_paste_go_2": 62411, "check": 58826, "check_box": 59444, "check_box_outline_blank": 59445, @@ -644,7 +706,9 @@ "checklist_rtl": 59059, "checkroom": 61854, "cheer": 63144, + "chef_hat": 62295, "chess": 62951, + "chess_pawn": 62390, "chevron_backward": 62571, "chevron_forward": 62570, "chevron_left": 58827, @@ -675,6 +739,8 @@ "clear_night": 61785, "climate_mini_split": 63669, "clinical_notes": 57502, + "clock_arrow_down": 62338, + "clock_arrow_up": 62337, "clock_loader_10": 63270, "clock_loader_20": 63269, "clock_loader_40": 63268, @@ -689,9 +755,11 @@ "closed_caption_disabled": 61916, "closed_caption_off": 59798, "cloud": 61788, + "cloud_alert": 62412, "cloud_circle": 58046, "cloud_done": 58047, "cloud_download": 58048, + "cloud_lock": 62342, "cloud_off": 58049, "cloud_queue": 61788, "cloud_sync": 60250, @@ -707,6 +775,7 @@ "coffee": 61423, "coffee_maker": 61424, "cognition": 57503, + "cognition_2": 62389, "collapse_all": 59716, "collapse_content": 62727, "collections": 58323, @@ -714,6 +783,7 @@ "color_lens": 58378, "colorize": 58296, "colors": 59799, + "combine_columns": 62496, "comedy_mask": 62678, "comic_bubble": 62941, "comment": 57932, @@ -731,6 +801,8 @@ "compost": 59233, "compress": 59725, "computer": 58142, + "computer_arrow_up": 62199, + "computer_cancel": 62198, "concierge": 62817, "conditions": 57504, "confirmation_number": 58936, @@ -770,6 +842,7 @@ "controller_gen": 59453, "conversion_path": 61633, "conversion_path_off": 63412, + "convert_to_text": 62495, "conveyor_belt": 63591, "cookie": 60076, "cookie_off": 63386, @@ -794,6 +867,7 @@ "create": 61591, "create_new_folder": 58060, "credit_card": 59553, + "credit_card_clock": 62520, "credit_card_gear": 62765, "credit_card_heart": 62764, "credit_card_off": 58612, @@ -815,6 +889,7 @@ "crop_square": 58310, "crossword": 62949, "crowdsource": 60184, + "crown": 60595, "cruelty_free": 59289, "css": 60307, "csv": 59087, @@ -837,6 +912,7 @@ "dangerous": 59802, "dark_mode": 58652, "dashboard": 59505, + "dashboard_2": 62442, "dashboard_customize": 59803, "data_alert": 63478, "data_array": 60113, @@ -851,6 +927,9 @@ "data_thresholding": 60319, "data_usage": 61426, "database": 61966, + "database_off": 62484, + "database_search": 62350, + "database_upload": 62428, "dataset": 63726, "dataset_linked": 63727, "date_range": 59670, @@ -865,6 +944,9 @@ "delete_history": 62744, "delete_outline": 59694, "delete_sweep": 57708, + "delivery_dining": 60200, + "delivery_truck_bolt": 62370, + "delivery_truck_speed": 62369, "demography": 58505, "density_large": 60329, "density_medium": 60318, @@ -883,7 +965,10 @@ "desk": 63732, "deskphone": 63482, "desktop_access_disabled": 59805, + "desktop_cloud": 62427, + "desktop_cloud_stack": 62398, "desktop_landscape": 62558, + "desktop_landscape_add": 62521, "desktop_mac": 58123, "desktop_portrait": 62557, "desktop_windows": 58124, @@ -902,17 +987,20 @@ "developer_guide": 59806, "developer_mode": 57776, "developer_mode_tv": 59508, + "device_band": 62197, "device_hub": 58165, "device_reset": 59571, "device_thermostat": 57855, "device_unknown": 58169, "devices": 58150, "devices_fold": 60382, + "devices_fold_2": 62470, "devices_off": 63397, "devices_other": 58167, "devices_wearables": 63147, "dew_point": 63609, "diagnosis": 57512, + "diagonal_line": 62494, "dialer_sip": 57531, "dialogs": 59807, "dialpad": 57532, @@ -974,9 +1062,11 @@ "dock_to_bottom": 63462, "dock_to_left": 63461, "dock_to_right": 63460, + "docs": 60029, "docs_add_on": 61634, "docs_apps_script": 61635, "document_scanner": 58874, + "document_search": 62341, "domain": 59374, "domain_add": 60258, "domain_disabled": 57583, @@ -1016,6 +1106,7 @@ "drawing_recognition": 60160, "dresser": 57872, "drive_eta": 61431, + "drive_export": 62493, "drive_file_move": 59809, "drive_file_move_outline": 59809, "drive_file_move_rtl": 59809, @@ -1023,6 +1114,7 @@ "drive_folder_upload": 59811, "drive_fusiontable": 59000, "dropdown": 59812, + "dropper_eye": 62289, "dry": 61875, "dry_cleaning": 59992, "dual_screen": 63183, @@ -1034,7 +1126,12 @@ "e911_emergency": 61721, "e_mobiledata": 61442, "e_mobiledata_badge": 63459, + "ear_sound": 62294, + "earbud_case": 62247, + "earbud_left": 62246, + "earbud_right": 62245, "earbuds": 61443, + "earbuds_2": 62244, "earbuds_battery": 61444, "early_on": 58042, "earthquake": 63055, @@ -1046,7 +1143,10 @@ "edgesensor_high": 61445, "edgesensor_low": 61446, "edit": 61591, + "edit_arrow_down": 62336, + "edit_arrow_up": 62335, "edit_attributes": 58744, + "edit_audio": 62509, "edit_calendar": 59202, "edit_document": 63628, "edit_location": 58728, @@ -1094,6 +1194,10 @@ "empty_dashboard": 63556, "enable": 61832, "encrypted": 58771, + "encrypted_add": 62505, + "encrypted_add_circle": 62506, + "encrypted_minus_circle": 62504, + "encrypted_off": 62503, "endocrinology": 57513, "energy": 59814, "energy_program_saving": 61791, @@ -1106,6 +1210,11 @@ "enterprise_off": 60237, "equal": 63355, "equalizer": 57373, + "eraser_size_1": 62460, + "eraser_size_2": 62459, + "eraser_size_3": 62458, + "eraser_size_4": 62457, + "eraser_size_5": 62456, "error": 63670, "error_circle_rounded": 63670, "error_med": 58523, @@ -1139,6 +1248,8 @@ "expand_content": 63536, "expand_less": 58830, "expand_more": 58831, + "expansion_panels": 61328, + "expension_panels": 61328, "experiment": 59014, "explicit": 57374, "explore": 59514, @@ -1162,9 +1273,15 @@ "face_4": 63708, "face_5": 63709, "face_6": 63710, + "face_down": 62466, + "face_left": 62465, + "face_nod": 62464, "face_retouching_natural": 61262, "face_retouching_off": 61447, + "face_right": 62463, + "face_shake": 62462, "face_unlock": 61448, + "face_up": 62461, "fact_check": 61637, "factory": 60348, "falling": 62989, @@ -1174,6 +1291,8 @@ "family_link": 60185, "family_restroom": 61858, "family_star": 62759, + "fan_focus": 62260, + "fan_indirect": 62259, "farsight_digital": 62809, "fast_forward": 57375, "fast_rewind": 57376, @@ -1204,13 +1323,18 @@ "file_download": 61584, "file_download_done": 61585, "file_download_off": 58622, + "file_export": 62386, + "file_json": 62395, "file_map": 58053, + "file_map_stack": 62434, "file_open": 60147, + "file_png": 62396, "file_present": 59918, "file_save": 61823, "file_save_off": 58629, "file_upload": 61595, "file_upload_off": 63622, + "files": 60037, "filter": 58323, "filter_1": 58320, "filter_2": 58321, @@ -1224,6 +1348,7 @@ "filter_9_plus": 58330, "filter_alt": 61263, "filter_alt_off": 60210, + "filter_arrow_right": 62417, "filter_b_and_w": 58331, "filter_center_focus": 58332, "filter_drama": 58333, @@ -1249,11 +1374,15 @@ "fireplace": 59971, "first_page": 58844, "fit_page": 63354, + "fit_page_height": 62359, + "fit_page_width": 62358, "fit_screen": 59920, "fit_width": 63353, "fitness_center": 60227, "fitness_tracker": 62563, "flag": 61638, + "flag_2": 62479, + "flag_check": 62424, "flag_circle": 60152, "flag_filled": 61638, "flaky": 61264, @@ -1284,6 +1413,7 @@ "floor": 63204, "floor_lamp": 57886, "flourescent": 61565, + "flowchart": 62349, "flowsheet": 57518, "fluid": 58499, "fluid_balance": 63501, @@ -1297,11 +1427,17 @@ "foggy": 59416, "folded_hands": 62957, "folder": 58055, + "folder_check": 62423, + "folder_check_2": 62422, + "folder_code": 62408, "folder_copy": 60349, "folder_data": 62854, "folder_delete": 60212, + "folder_eye": 62421, + "folder_info": 62357, "folder_limited": 62692, "folder_managed": 63349, + "folder_match": 62420, "folder_off": 60291, "folder_open": 58056, "folder_shared": 58057, @@ -1318,6 +1454,7 @@ "forest": 60057, "fork_left": 60320, "fork_right": 60332, + "fork_spoon": 62436, "forklift": 63592, "format_align_center": 57908, "format_align_justify": 57909, @@ -1354,6 +1491,7 @@ "format_paint": 57923, "format_paragraph": 63589, "format_quote": 57924, + "format_quote_off": 62483, "format_shapes": 57950, "format_size": 57925, "format_strikethrough": 57926, @@ -1377,6 +1515,7 @@ "forward_media": 63220, "forward_to_inbox": 61831, "foundation": 61952, + "fragrance": 62277, "frame_inspect": 63346, "frame_person": 63654, "frame_person_mic": 62677, @@ -1418,12 +1557,15 @@ "gesture_select": 63063, "get_app": 61584, "gif": 59656, + "gif_2": 62478, "gif_box": 59299, "girl": 60264, "gite": 58763, "glass_cup": 63203, "globe": 58956, "globe_asia": 63385, + "globe_book": 62409, + "globe_location_pin": 62301, "globe_uk": 63384, "glucose": 58528, "glyphs": 63651, @@ -1440,10 +1582,17 @@ "gps_fixed": 58716, "gps_not_fixed": 57783, "gps_off": 57782, - "grade": 59525, + "grade": 61594, "gradient": 58345, "grading": 59983, "grain": 58346, + "graph_1": 62368, + "graph_2": 62367, + "graph_3": 62366, + "graph_4": 62365, + "graph_5": 62364, + "graph_6": 62363, + "graph_7": 62278, "graphic_eq": 57784, "grass": 61957, "grid_3x3": 61461, @@ -1459,6 +1608,7 @@ "group_add": 59376, "group_off": 59207, "group_remove": 59309, + "group_search": 62414, "group_work": 59526, "grouped_bar_chart": 61969, "groups": 62003, @@ -1474,12 +1624,14 @@ "hallway": 59128, "hand_bones": 63636, "hand_gesture": 61340, + "hand_gesture_off": 62451, "handheld_controller": 62662, "handshake": 60363, "handwriting_recognition": 60162, "handyman": 61707, "hangout_video": 57537, "hangout_video_off": 57538, + "hard_disk": 62426, "hard_drive": 63502, "hard_drive_2": 63396, "hardware": 59993, @@ -1510,6 +1662,7 @@ "heap_snapshot_thumbnail": 63340, "hearing": 57379, "hearing_aid": 62564, + "hearing_aid_disabled": 62384, "hearing_disabled": 61700, "heart_broken": 60098, "heart_check": 62986, @@ -1534,6 +1687,7 @@ "high_quality": 57380, "high_res": 62795, "highlight": 57951, + "highlight_alt": 61266, "highlight_keyboard_focus": 62736, "highlight_mouse_cursor": 62737, "highlight_off": 59528, @@ -1545,6 +1699,7 @@ "highlighter_size_5": 63335, "hiking": 58634, "history": 59571, + "history_2": 62438, "history_edu": 59966, "history_off": 62682, "history_toggle_off": 61821, @@ -1570,14 +1725,18 @@ "horizontal_distribute": 57364, "horizontal_rule": 61704, "horizontal_split": 59719, + "host": 62425, "hot_tub": 60230, "hotel": 58697, "hotel_class": 59203, "hourglass": 60415, + "hourglass_arrow_down": 62334, + "hourglass_arrow_up": 62333, "hourglass_bottom": 59996, "hourglass_disabled": 61267, "hourglass_empty": 59531, "hourglass_full": 59532, + "hourglass_pause": 62348, "hourglass_top": 59995, "house": 59972, "house_siding": 61954, @@ -1600,13 +1759,17 @@ "humidity_mid": 61797, "humidity_percentage": 63614, "hvac": 61710, + "hvac_max_defrost": 62258, "ice_skating": 58635, "icecream": 60009, "id_card": 62666, + "identity_aware_proxy": 58077, + "identity_platform": 60343, "ifl": 57381, "iframe": 63259, "iframe_off": 63260, "image": 58356, + "image_arrow_up": 62231, "image_aspect_ratio": 58357, "image_not_supported": 61718, "image_search": 58431, @@ -1620,6 +1783,10 @@ "inactive_order": 57596, "inbox": 57686, "inbox_customize": 63577, + "inbox_text": 62361, + "inbox_text_asterisk": 62304, + "inbox_text_person": 62302, + "inbox_text_share": 62300, "incomplete_circle": 59291, "indeterminate_check_box": 59657, "indeterminate_question_box": 62829, @@ -1632,6 +1799,7 @@ "ink_highlighter_move": 62756, "ink_marker": 59090, "ink_pen": 59091, + "ink_selection": 61266, "inpatient": 57598, "input": 59536, "input_circle": 63258, @@ -1727,6 +1895,7 @@ "lan": 60207, "landscape": 58724, "landscape_2": 62660, + "landscape_2_edit": 62224, "landscape_2_off": 62659, "landslide": 60375, "language": 59540, @@ -1748,6 +1917,7 @@ "language_us_dvorak": 63322, "laps": 63161, "laptop": 58142, + "laptop_car": 62413, "laptop_chromebook": 58143, "laptop_mac": 58144, "laptop_windows": 58145, @@ -1779,6 +1949,7 @@ "light_mode": 58648, "light_off": 59832, "lightbulb": 59663, + "lightbulb_2": 62435, "lightbulb_circle": 60414, "lightbulb_outline": 59663, "lightning_stand": 61348, @@ -1807,6 +1978,7 @@ "list": 59542, "list_alt": 57582, "list_alt_add": 63318, + "list_alt_check": 62430, "lists": 59833, "live_help": 57542, "live_tv": 58938, @@ -1856,6 +2028,7 @@ "lock": 59545, "lock_clock": 61271, "lock_open": 59544, + "lock_open_circle": 62305, "lock_open_right": 63062, "lock_outline": 59545, "lock_person": 63731, @@ -1907,6 +2080,7 @@ "manga": 62947, "manufacturing": 59174, "map": 58715, + "map_search": 62410, "maps_home_work": 61488, "maps_ugc": 61272, "margin": 59835, @@ -1922,8 +2096,10 @@ "markunread": 57689, "markunread_mailbox": 59547, "masked_transitions": 59182, + "masked_transitions_add": 62507, "masks": 61976, "match_case": 63217, + "match_case_off": 62319, "match_word": 63216, "matter": 59655, "maximize": 59696, @@ -1953,6 +2129,7 @@ "metro": 62580, "mfg_nest_yale_lock": 61725, "mic": 58141, + "mic_alert": 62354, "mic_double": 62929, "mic_external_off": 61273, "mic_external_on": 61274, @@ -1976,8 +2153,16 @@ "mixture_med": 58568, "mms": 58904, "mobile_friendly": 57856, + "mobile_hand": 62243, + "mobile_hand_left": 62227, + "mobile_hand_left_off": 62226, + "mobile_hand_off": 62228, + "mobile_loupe": 62242, "mobile_off": 57857, "mobile_screen_share": 57575, + "mobile_screensaver": 62241, + "mobile_sound_2": 62232, + "mobile_speaker": 62240, "mobiledata_off": 61492, "mode": 61591, "mode_comment": 57939, @@ -1996,8 +2181,10 @@ "mode_off_on": 61807, "mode_standby": 61495, "model_training": 61647, + "modeling": 62378, "monetization_on": 57955, "money": 58749, + "money_bag": 62446, "money_off": 61496, "money_off_csred": 61496, "monitor": 61275, @@ -2010,7 +2197,9 @@ "monorail": 62579, "mood": 59938, "mood_bad": 59379, + "moon_stars": 62287, "mop": 57997, + "moped": 60200, "more": 58905, "more_down": 61846, "more_horiz": 58835, @@ -2025,6 +2214,7 @@ "motion_photos_on": 59841, "motion_photos_pause": 61991, "motion_photos_paused": 61991, + "motion_play": 62475, "motion_sensor_active": 59282, "motion_sensor_alert": 59268, "motion_sensor_idle": 59267, @@ -2058,10 +2248,13 @@ "mp": 59843, "multicooker": 58003, "multiline_chart": 59103, + "multimodal_hand_eye": 62491, + "multiple_airports": 61355, "multiple_stop": 61881, "museum": 59958, "music_cast": 60186, "music_note": 58373, + "music_note_add": 62353, "music_off": 58432, "music_video": 57443, "my_location": 58716, @@ -2129,6 +2322,8 @@ "nest_wifi_router": 61747, "network_cell": 57785, "network_check": 58944, + "network_intel_node": 62321, + "network_intelligence": 61356, "network_intelligence_history": 62966, "network_intelligence_update": 62965, "network_locked": 58906, @@ -2154,6 +2349,7 @@ "next_plan": 61277, "next_week": 57706, "nfc": 57787, + "nfc_off": 62313, "night_shelter": 61937, "night_sight_auto": 61911, "night_sight_auto_off": 61945, @@ -2200,6 +2396,8 @@ "notification_add": 58265, "notification_important": 57348, "notification_multiple": 59074, + "notification_settings": 62311, + "notification_sound": 62291, "notifications": 59381, "notifications_active": 59383, "notifications_none": 59381, @@ -2233,6 +2431,7 @@ "open_with": 59551, "ophthalmology": 57621, "oral_disease": 57622, + "orbit": 62502, "order_approve": 63506, "order_play": 63505, "orders": 60180, @@ -2255,6 +2454,7 @@ "oven_gen": 59459, "overview": 58535, "overview_key": 63444, + "owl": 62388, "oxygen_saturation": 58590, "p2p": 62762, "pace": 63160, @@ -2263,6 +2463,8 @@ "package_2": 62825, "padding": 59848, "page_control": 59185, + "page_footer": 62339, + "page_header": 62340, "page_info": 62996, "pageless": 62729, "pages": 59385, @@ -2346,6 +2548,7 @@ "person_raised_hand": 62874, "person_remove": 61286, "person_search": 61702, + "person_shield": 58244, "personal_bag": 60174, "personal_bag_off": 60175, "personal_bag_question": 60176, @@ -2413,6 +2616,8 @@ "pin_drop": 58718, "pin_end": 59239, "pin_invoke": 59235, + "pinboard": 62379, + "pinboard_unread": 62380, "pinch": 60216, "pinch_zoom_in": 61946, "pinch_zoom_out": 61947, @@ -2422,6 +2627,7 @@ "place": 61915, "place_item": 61936, "plagiarism": 59994, + "planet": 62343, "planner_banner_ad_pt": 59026, "planner_review": 59028, "play_arrow": 57399, @@ -2439,6 +2645,7 @@ "playlist_add_circle": 59365, "playlist_play": 57439, "playlist_remove": 60288, + "plug_connect": 62298, "plumbing": 61703, "plus_one": 59392, "podcasts": 61512, @@ -2448,6 +2655,7 @@ "point_scan": 63244, "poker_chip": 62619, "policy": 59927, + "policy_alert": 62471, "poll": 61644, "polyline": 60347, "polymer": 59563, @@ -2464,6 +2672,7 @@ "power_input": 58166, "power_off": 58950, "power_rounded": 63687, + "power_settings_circle": 62488, "power_settings_new": 63687, "prayer_times": 63544, "precision_manufacturing": 61513, @@ -2524,8 +2733,8 @@ "quick_reference_all": 63489, "quick_reorder": 60181, "quickreply": 61292, - "quiet_time": 57849, - "quiet_time_active": 58001, + "quiet_time": 61785, + "quiet_time_active": 60278, "quiz": 61516, "r_mobiledata": 61517, "radar": 61518, @@ -2545,6 +2754,7 @@ "ramp_right": 60310, "range_hood": 57834, "rate_review": 58720, + "rate_review_rtl": 59142, "raven": 62805, "raw_off": 61519, "raw_on": 61520, @@ -2556,6 +2766,7 @@ "rebase_edit": 63558, "receipt": 59568, "receipt_long": 61294, + "receipt_long_off": 62474, "recent_actors": 57407, "recent_patient": 63496, "recenter": 62656, @@ -2591,6 +2802,9 @@ "repeat_on": 59862, "repeat_one": 57409, "repeat_one_on": 59863, + "replace_audio": 62545, + "replace_image": 62544, + "replace_video": 62543, "replay": 57410, "replay_10": 57433, "replay_30": 57434, @@ -2649,6 +2863,7 @@ "room_service": 60233, "rotate_90_degrees_ccw": 58392, "rotate_90_degrees_cw": 60075, + "rotate_auto": 62487, "rotate_left": 58393, "rotate_right": 58394, "roundabout_left": 60313, @@ -2656,6 +2871,7 @@ "rounded_corner": 59680, "route": 60109, "router": 58152, + "router_off": 62196, "routine": 57868, "rowing": 59681, "rss_feed": 57573, @@ -2680,6 +2896,7 @@ "save": 57697, "save_alt": 61584, "save_as": 60256, + "save_clock": 62360, "saved_search": 59921, "savings": 58091, "scale": 60255, @@ -2708,6 +2925,7 @@ "screen_share": 57570, "screenshot": 61526, "screenshot_frame": 63095, + "screenshot_frame_2": 62324, "screenshot_keyboard": 63443, "screenshot_monitor": 60424, "screenshot_region": 63442, @@ -2721,11 +2939,18 @@ "sd_storage": 58915, "sdk": 59168, "search": 59574, + "search_activity": 62437, "search_check": 63488, "search_check_2": 62569, "search_hands_free": 59030, "search_insights": 62652, "search_off": 60022, + "seat_cool_left": 62257, + "seat_cool_right": 62256, + "seat_heat_left": 62255, + "seat_heat_right": 62254, + "seat_vent_left": 62253, + "seat_vent_right": 62252, "security": 58154, "security_key": 62723, "security_update": 61554, @@ -2769,6 +2994,7 @@ "sentiment_very_satisfied": 59413, "sentiment_worried": 63137, "serif": 62636, + "server_person": 62397, "service_toolbox": 59159, "set_meal": 61930, "settings": 59576, @@ -2812,6 +3038,7 @@ "shape_recognition": 60161, "shapes": 58882, "share": 59405, + "share_eta": 58871, "share_location": 61535, "share_off": 63179, "share_reviews": 63652, @@ -2826,6 +3053,7 @@ "shield_moon": 60073, "shield_person": 63056, "shield_question": 62761, + "shield_watch": 62223, "shield_with_heart": 59279, "shield_with_house": 59277, "shift": 58866, @@ -2835,6 +3063,7 @@ "shop_2": 59594, "shop_two": 59594, "shopping_bag": 61900, + "shopping_bag_speed": 62362, "shopping_basket": 59595, "shopping_cart": 59596, "shopping_cart_checkout": 60296, @@ -2884,8 +3113,13 @@ "sim_card": 58155, "sim_card_alert": 61527, "sim_card_download": 61544, + "simulation": 62433, "single_bed": 59976, "sip": 61545, + "siren": 62375, + "siren_check": 62374, + "siren_open": 62373, + "siren_question": 62372, "skateboarding": 58641, "skeleton": 63641, "skillet": 62787, @@ -2893,6 +3127,7 @@ "skip_next": 57412, "skip_previous": 57413, "skull": 63642, + "skull_list": 62320, "slab_serif": 62635, "sledding": 58642, "sleep": 57875, @@ -2909,6 +3144,7 @@ "smart_screen": 61547, "smart_toy": 61548, "smartphone": 58156, + "smartphone_camera": 62542, "smb_share": 63307, "smoke_free": 60234, "smoking_rooms": 60235, @@ -2972,6 +3208,11 @@ "speed_2x": 62699, "speed_camera": 62576, "spellcheck": 59598, + "split_scene": 62399, + "split_scene_down": 62207, + "split_scene_left": 62206, + "split_scene_right": 62205, + "split_scene_up": 62204, "splitscreen": 61549, "splitscreen_add": 62717, "splitscreen_bottom": 63094, @@ -3007,9 +3248,12 @@ "sprinkler": 58010, "sprint": 63519, "square": 60214, + "square_dot": 62387, "square_foot": 59977, "ssid_chart": 60262, "stack": 62985, + "stack_group": 62297, + "stack_hexagon": 62492, "stack_off": 62984, "stack_star": 62983, "stacked_bar_chart": 59878, @@ -3029,7 +3273,9 @@ "star_purple500": 61594, "star_rate": 61676, "star_rate_half": 60485, + "star_shine": 62237, "stars": 59600, + "stars_2": 62236, "start": 57481, "stat_0": 59031, "stat_1": 59032, @@ -3042,6 +3288,7 @@ "stay_current_portrait": 57556, "stay_primary_landscape": 57557, "stay_primary_portrait": 57558, + "steering_wheel_heat": 62251, "step": 63230, "step_into": 63233, "step_out": 63232, @@ -3077,8 +3324,13 @@ "style": 58397, "styler": 57971, "stylus": 62980, + "stylus_brush": 62310, + "stylus_fountain_pen": 62309, + "stylus_highlighter": 62308, "stylus_laser_pointer": 63303, "stylus_note": 62979, + "stylus_pen": 62307, + "stylus_pencil": 62306, "subdirectory_arrow_left": 58841, "subdirectory_arrow_right": 58842, "subheader": 59882, @@ -3086,6 +3338,7 @@ "subscript": 61713, "subscriptions": 57444, "subtitles": 57416, + "subtitles_gear": 62293, "subtitles_off": 61298, "subway": 58735, "summarize": 61553, @@ -3121,6 +3374,7 @@ "switch": 57844, "switch_access": 63229, "switch_access_2": 62726, + "switch_access_3": 62285, "switch_access_shortcut": 59361, "switch_access_shortcut_add": 59362, "switch_account": 59885, @@ -3135,6 +3389,9 @@ "synagogue": 60080, "sync": 58919, "sync_alt": 59928, + "sync_arrow_down": 62332, + "sync_arrow_up": 62331, + "sync_desktop": 62490, "sync_disabled": 58920, "sync_lock": 60142, "sync_problem": 58921, @@ -3147,17 +3404,22 @@ "system_update_alt": 59607, "tab": 59608, "tab_close": 63301, + "tab_close_inactive": 62416, "tab_close_right": 63302, "tab_duplicate": 63300, "tab_group": 63299, + "tab_inactive": 62523, "tab_move": 63298, "tab_new_right": 63297, "tab_recent": 63296, + "tab_search": 62194, "tab_unselected": 59609, "table": 61841, "table_bar": 60114, "table_chart": 57957, "table_chart_view": 63215, + "table_convert": 62407, + "table_edit": 62406, "table_eye": 62566, "table_lamp": 57842, "table_restaurant": 60102, @@ -3166,6 +3428,7 @@ "table_view": 61886, "tablet": 58159, "tablet_android": 58160, + "tablet_camera": 62541, "tablet_mac": 58161, "tabs": 59886, "tactic": 62820, @@ -3190,6 +3453,7 @@ "terminal": 60302, "terrain": 58724, "text_ad": 59176, + "text_compare": 62405, "text_decrease": 60125, "text_fields": 57954, "text_fields_alt": 59889, @@ -3226,10 +3490,13 @@ "thermometer_loss": 63191, "thermometer_minus": 62849, "thermostat": 61558, + "thermostat_arrow_down": 62330, + "thermostat_arrow_up": 62329, "thermostat_auto": 61559, "thermostat_carbon": 61816, "things_to_do": 60202, "thread_unread": 62713, + "threat_intelligence": 60141, "thumb_down": 62840, "thumb_down_alt": 62840, "thumb_down_filled": 62840, @@ -3245,6 +3512,9 @@ "thunderstorm": 60379, "tibia": 63643, "tibia_alt": 63644, + "tile_large": 62403, + "tile_medium": 62402, + "tile_small": 62401, "time_auto": 61668, "time_to_leave": 61431, "timelapse": 58402, @@ -3258,6 +3528,8 @@ "timer_3_select": 61563, "timer_5": 62641, "timer_5_shutter": 62642, + "timer_arrow_down": 62328, + "timer_arrow_up": 62327, "timer_off": 58406, "timer_pause": 62651, "timer_play": 62650, @@ -3283,12 +3555,16 @@ "tools_power_drill": 57833, "tools_wrench": 63693, "tooltip": 59896, + "tooltip_2": 62445, "top_panel_close": 63283, "top_panel_open": 63282, "topic": 61896, "tornado": 57753, "total_dissolved_solids": 63607, "touch_app": 59667, + "touch_double": 62347, + "touch_long": 62346, + "touch_triple": 62345, "touchpad_mouse": 63111, "touchpad_mouse_off": 62694, "tour": 61301, @@ -3297,6 +3573,8 @@ "toys_fan": 63623, "track_changes": 59617, "trackpad_input": 62663, + "trackpad_input_2": 62473, + "trackpad_input_3": 62472, "traffic": 58725, "traffic_jam": 62575, "trail_length": 60254, @@ -3309,6 +3587,7 @@ "transform": 58408, "transgender": 58765, "transit_enterexit": 58745, + "transit_ticket": 62449, "transition_chop": 62734, "transition_dissolve": 62733, "transition_fade": 62732, @@ -3343,8 +3622,10 @@ "turned_in": 59623, "turned_in_not": 59623, "tv": 58939, + "tv_displays": 62444, "tv_gen": 59440, "tv_guide": 57820, + "tv_next": 62443, "tv_off": 58951, "tv_options_edit_channels": 57821, "tv_options_input_settings": 57822, @@ -3352,6 +3633,7 @@ "tv_signin": 59163, "tv_with_assistant": 59269, "two_pager": 62751, + "two_pager_store": 62404, "two_wheeler": 59897, "type_specimen": 63728, "u_turn_left": 60321, @@ -3383,6 +3665,7 @@ "update": 59683, "update_disabled": 57461, "upgrade": 61691, + "upi_pay": 62415, "upload": 61595, "upload_2": 62753, "upload_file": 59900, @@ -3402,6 +3685,7 @@ "variables": 63569, "ventilator": 57657, "verified": 61302, + "verified_off": 62222, "verified_user": 61459, "vertical_align_bottom": 57944, "vertical_align_center": 57945, @@ -3413,6 +3697,7 @@ "vibration": 58925, "video_call": 57456, "video_camera_back": 61567, + "video_camera_back_add": 62476, "video_camera_front": 61568, "video_camera_front_off": 63547, "video_chat": 63648, @@ -3423,10 +3708,12 @@ "video_settings": 60021, "video_stable": 61569, "videocam": 57419, + "videocam_alert": 62352, "videocam_off": 57420, "videogame_asset": 58168, "videogame_asset_off": 58624, "view_agenda": 59625, + "view_apps": 62326, "view_array": 59626, "view_carousel": 59627, "view_column": 59628, @@ -3444,6 +3731,7 @@ "view_kanban": 60287, "view_list": 59631, "view_module": 59632, + "view_object_track": 62514, "view_quilt": 59633, "view_real_size": 62658, "view_sidebar": 61716, @@ -3461,7 +3749,9 @@ "voice_chat": 58926, "voice_over_off": 59722, "voice_selection": 62858, + "voice_selection_off": 62508, "voicemail": 57561, + "voicemail_2": 62290, "volcano": 60378, "volume_down": 57421, "volume_down_alt": 59292, @@ -3474,6 +3764,7 @@ "vpn_key_alert": 63180, "vpn_key_off": 60282, "vpn_lock": 58927, + "vpn_lock_2": 62288, "vr180_create2d": 61386, "vr180_create2d_off": 62833, "vrpano": 61570, @@ -3482,6 +3773,8 @@ "wallet": 63743, "wallpaper": 57788, "wallpaper_slideshow": 63090, + "wand_shine": 62239, + "wand_stars": 62238, "ward": 57660, "warehouse": 60344, "warning": 61571, @@ -3539,6 +3832,9 @@ "whatshot": 59406, "wheelchair_pickup": 61867, "where_to_vote": 57719, + "widget_medium": 62394, + "widget_small": 62393, + "widget_width": 62392, "widgets": 57789, "width": 63280, "width_full": 63733, @@ -3552,6 +3848,9 @@ "wifi_calling_1": 61671, "wifi_calling_2": 61686, "wifi_calling_3": 61671, + "wifi_calling_bar_1": 62540, + "wifi_calling_bar_2": 62539, + "wifi_calling_bar_3": 62538, "wifi_channel": 60266, "wifi_find": 60209, "wifi_home": 63089, @@ -3569,6 +3868,9 @@ "window_closed": 59262, "window_open": 59276, "window_sensor": 58043, + "windshield_defrost_front": 62250, + "windshield_defrost_rear": 62249, + "windshield_heat_front": 62248, "wine_bar": 61928, "woman": 57662, "woman_2": 63719, diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.ttf b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.ttf new file mode 100644 index 0000000000..26f767e075 Binary files /dev/null and b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.ttf differ diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.ttf b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.ttf deleted file mode 100644 index 255db6f479..0000000000 Binary files a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.ttf and /dev/null differ diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/__init__.py b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/__init__.py index 6da4c6986b..581b37c213 100644 --- a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/__init__.py +++ b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/__init__.py @@ -5,12 +5,32 @@ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) def get_font_filepath( - font_name: Optional[str] = "MaterialSymbolsOutlined" + font_name: Optional[str] = "MaterialSymbolsOutlined-Regular" ) -> str: return os.path.join(CURRENT_DIR, f"{font_name}.ttf") def get_mapping_filepath( - font_name: Optional[str] = "MaterialSymbolsOutlined" + font_name: Optional[str] = "MaterialSymbolsOutlined-Regular" ) -> str: return os.path.join(CURRENT_DIR, f"{font_name}.json") + + +def regenerate_mapping(): + """Regenerate the MaterialSymbolsOutlined.json file, assuming + MaterialSymbolsOutlined.codepoints and the TrueType font file have been + updated to support the new symbols. + """ + import json + jfile = get_mapping_filepath() + cpfile = jfile.replace(".json", ".codepoints") + with open(cpfile, "r") as cpf: + codepoints = cpf.read() + + mapping = {} + for cp in codepoints.splitlines(): + name, code = cp.split() + mapping[name] = int(f"0x{code}", 16) + + with open(jfile, "w") as jf: + json.dump(mapping, jf, indent=4) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index de5c199428..11fc31799b 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.1.5+dev" +__version__ = "1.3.2+dev" diff --git a/client/pyproject.toml b/client/pyproject.toml index edf7f57317..6416d9b8e1 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -4,6 +4,7 @@ description="AYON core addon." [tool.poetry.dependencies] python = ">=3.9.1,<3.10" +markdown = "^3.4.1" clique = "1.6.*" jsonschema = "^2.6.0" pyblish-base = "^1.8.11" diff --git a/docs/css/custom.css b/docs/css/custom.css new file mode 100644 index 0000000000..28461e6eca --- /dev/null +++ b/docs/css/custom.css @@ -0,0 +1,12 @@ +[data-md-color-scheme="slate"] { + /* simple slate overrides */ + --md-primary-fg-color: hsl(155, 49%, 50%); + --md-accent-fg-color: rgb(93, 200, 156); + --md-typeset-a-color: hsl(155, 49%, 45%) !important; +} +[data-md-color-scheme="default"] { + /* simple default overrides */ + --md-primary-fg-color: hsl(155, 49%, 50%); + --md-accent-fg-color: rgb(93, 200, 156); + --md-typeset-a-color: hsl(155, 49%, 45%) !important; +} diff --git a/docs/img/ay-symbol-blackw-full.png b/docs/img/ay-symbol-blackw-full.png new file mode 100644 index 0000000000..5edda784cc Binary files /dev/null and b/docs/img/ay-symbol-blackw-full.png differ diff --git a/docs/img/favicon.ico b/docs/img/favicon.ico new file mode 100644 index 0000000000..62b656e501 Binary files /dev/null and b/docs/img/favicon.ico differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000000..612c7a5e0d --- /dev/null +++ b/docs/index.md @@ -0,0 +1 @@ +--8<-- "README.md" diff --git a/docs/license.md b/docs/license.md new file mode 100644 index 0000000000..f409d45232 --- /dev/null +++ b/docs/license.md @@ -0,0 +1 @@ +--8<-- "LICENSE" diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000000..8e4c2663bc --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,71 @@ +site_name: ayon-core +repo_url: https://github.com/ynput/ayon-core + +nav: + - Home: index.md + - License: license.md + +theme: + name: material + palette: + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/toggle-switch-off-outline + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/toggle-switch + name: Switch to dark mode + logo: img/ay-symbol-blackw-full.png + favicon: img/favicon.ico + features: + - navigation.sections + - navigation.path + - navigation.prune + +extra: + version: + provider: mike + +extra_css: [css/custom.css] + +markdown_extensions: + - mdx_gh_links + - pymdownx.snippets + +plugins: + - search + - offline + - mkdocs-autoapi: + autoapi_dir: ./ + autoapi_add_nav_entry: Reference + autoapi_ignore: + - .* + - docs/**/* + - tests/**/* + - tools/**/* + - stubs/**/* # mocha fix + - ./**/pythonrc.py # houdini fix + - .*/**/* + - ./*.py + - mkdocstrings: + handlers: + python: + paths: + - ./ + - client/* + - server/* + - services/* + - minify: + minify_html: true + minify_js: true + minify_css: true + htmlmin_opts: + remove_comments: true + cache_safe: true + - mike + +hooks: + - mkdocs_hooks.py diff --git a/mkdocs_hooks.py b/mkdocs_hooks.py new file mode 100644 index 0000000000..1faa1954f9 --- /dev/null +++ b/mkdocs_hooks.py @@ -0,0 +1,191 @@ +import os +from pathlib import Path +from shutil import rmtree +import json +import glob +import logging + +TMP_FILE = "./missing_init_files.json" +NFILES = [] + +# ----------------------------------------------------------------------------- + + +class ColorFormatter(logging.Formatter): + grey = "\x1b[38;20m" + green = "\x1b[32;20m" + yellow = "\x1b[33;20m" + red = "\x1b[31;20m" + bold_red = "\x1b[31;1m" + reset = "\x1b[0m" + fmt = ( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s " # noqa + "(%(filename)s:%(lineno)d)" + ) + + FORMATS = { + logging.DEBUG: grey + fmt + reset, + logging.INFO: green + fmt + reset, + logging.WARNING: yellow + fmt + reset, + logging.ERROR: red + fmt + reset, + logging.CRITICAL: bold_red + fmt + reset, + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + + +ch = logging.StreamHandler() +ch.setFormatter(ColorFormatter()) + +logging.basicConfig( + level=logging.INFO, + handlers=[ch], +) + + +# ----------------------------------------------------------------------------- + + +def create_init_file(dirpath, msg): + global NFILES + ini_file = f"{dirpath}/__init__.py" + Path(ini_file).touch() + NFILES.append(ini_file) + logging.info(f"{msg}: created '{ini_file}'") + + +def create_parent_init_files(dirpath: str, rootpath: str, msg: str): + parent_path = dirpath + while parent_path != rootpath: + parent_path = os.path.dirname(parent_path) + parent_init = os.path.join(parent_path, "__init__.py") + if not os.path.exists(parent_init): + create_init_file(parent_path, msg) + else: + break + + +def add_missing_init_files(*roots, msg=""): + """ + This function takes in one or more root directories as arguments and scans + them for Python files without an `__init__.py` file. It generates a JSON + file named `missing_init_files.json` containing the paths of these files. + + Args: + *roots: Variable number of root directories to scan. + + Returns: + None + """ + + for root in roots: + if not os.path.exists(root): + continue + rootpath = os.path.abspath(root) + for dirpath, dirs, files in os.walk(rootpath): + if "__init__.py" in files: + continue + + if "." in dirpath: + continue + + if not glob.glob(os.path.join(dirpath, "*.py")): + continue + + create_init_file(dirpath, msg) + create_parent_init_files(dirpath, rootpath, msg) + + with open(TMP_FILE, "w") as f: + json.dump(NFILES, f) + + +def remove_missing_init_files(msg=""): + """ + This function removes temporary `__init__.py` files created in the + `add_missing_init_files()` function. It reads the paths of these files from + a JSON file named `missing_init_files.json`. + + Args: + None + + Returns: + None + """ + global NFILES + nfiles = [] + if os.path.exists(TMP_FILE): + with open(TMP_FILE, "r") as f: + nfiles = json.load(f) + else: + nfiles = NFILES + + for file in nfiles: + Path(file).unlink() + logging.info(f"{msg}: removed {file}") + + os.remove(TMP_FILE) + NFILES = [] + + +def remove_pychache_dirs(msg=""): + """ + This function walks the current directory and removes all existing + '__pycache__' directories. + + Args: + msg: An optional message to display during the removal process. + + Returns: + None + """ + nremoved = 0 + + for dirpath, dirs, files in os.walk("."): + if "__pycache__" in dirs: + pydir = Path(f"{dirpath}/__pycache__") + rmtree(pydir) + nremoved += 1 + logging.info(f"{msg}: removed '{pydir}'") + + if not nremoved: + logging.info(f"{msg}: no __pycache__ dirs found") + + +# mkdocs hooks ---------------------------------------------------------------- + + +def on_startup(command, dirty): + remove_pychache_dirs(msg="HOOK - on_startup") + + +def on_pre_build(config): + """ + This function is called before the MkDocs build process begins. It adds + temporary `__init__.py` files to directories that do not contain one, to + make sure mkdocs doesn't ignore them. + """ + try: + add_missing_init_files( + "client", + "server", + "services", + msg="HOOK - on_pre_build", + ) + except BaseException as e: + logging.error(e) + remove_missing_init_files( + msg="HOOK - on_post_build: cleaning up on error !" + ) + raise + + +def on_post_build(config): + """ + This function is called after the MkDocs build process ends. It removes + temporary `__init__.py` files that were added in the `on_pre_build()` + function. + """ + remove_missing_init_files(msg="HOOK - on_post_build") diff --git a/package.py b/package.py index 250b77fe52..908d34ffa8 100644 --- a/package.py +++ b/package.py @@ -1,12 +1,18 @@ name = "core" title = "Core" -version = "1.1.5+dev" +version = "1.3.2+dev" client_dir = "ayon_core" plugin_for = ["ayon_server"] -ayon_server_version = ">=1.0.3,<2.0.0" +ayon_server_version = ">=1.8.4,<2.0.0" ayon_launcher_version = ">=1.0.2" ayon_required_addons = {} -ayon_compatible_addons = {} +ayon_compatible_addons = { + "ayon_ocio": ">=1.2.1", + "applications": ">=1.1.2", + "harmony": ">0.4.0", + "fusion": ">=0.3.3", + "openrv": ">=1.0.2", +} diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 2d040a5f91..0000000000 --- a/poetry.lock +++ /dev/null @@ -1,728 +0,0 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. - -[[package]] -name = "appdirs" -version = "1.4.4" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, -] - -[[package]] -name = "attrs" -version = "25.1.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"}, - {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"}, -] - -[package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] - -[[package]] -name = "ayon-python-api" -version = "1.0.12" -description = "AYON Python API" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "ayon-python-api-1.0.12.tar.gz", hash = "sha256:8e4c03436df8afdda4c6ad4efce436068771995bb0153a90e003364afa0e7f55"}, - {file = "ayon_python_api-1.0.12-py3-none-any.whl", hash = "sha256:65f61c2595dd6deb26fed5e3fda7baef887f475fa4b21df12513646ddccf4a7d"}, -] - -[package.dependencies] -appdirs = ">=1,<2" -requests = ">=2.27.1" -Unidecode = ">=1.3.0" - -[[package]] -name = "certifi" -version = "2025.1.31" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, - {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, -] - -[[package]] -name = "cfgv" -version = "3.4.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.1" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, - {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, - {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, -] - -[[package]] -name = "clique" -version = "2.0.0" -description = "Manage collections with common numerical component" -optional = false -python-versions = ">=3.0, <4.0" -groups = ["dev"] -files = [ - {file = "clique-2.0.0-py2.py3-none-any.whl", hash = "sha256:45e2a4c6078382e0b217e5e369494279cf03846d95ee601f93290bed5214c22e"}, - {file = "clique-2.0.0.tar.gz", hash = "sha256:6e1115dbf21b1726f4b3db9e9567a662d6bdf72487c4a0a1f8cb7f10cf4f4754"}, -] - -[package.extras] -dev = ["lowdown (>=0.2.0,<1)", "pytest (>=2.3.5,<5)", "pytest-cov (>=2,<3)", "pytest-runner (>=2.7,<3)", "sphinx (>=2,<4)", "sphinx-rtd-theme (>=0.1.6,<1)"] -doc = ["lowdown (>=0.2.0,<1)", "sphinx (>=2,<4)", "sphinx-rtd-theme (>=0.1.6,<1)"] -test = ["pytest (>=2.3.5,<5)", "pytest-cov (>=2,<3)", "pytest-runner (>=2.7,<3)"] - -[[package]] -name = "codespell" -version = "2.4.1" -description = "Fix common misspellings in text files" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425"}, - {file = "codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5"}, -] - -[package.extras] -dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] -hard-encoding-detection = ["chardet"] -toml = ["tomli ; python_version < \"3.11\""] -types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "distlib" -version = "0.3.9" -description = "Distribution utilities" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, - {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "filelock" -version = "3.17.0" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338"}, - {file = "filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] -typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] - -[[package]] -name = "identify" -version = "2.6.7" -description = "File identification library for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "identify-2.6.7-py2.py3-none-any.whl", hash = "sha256:155931cb617a401807b09ecec6635d6c692d180090a1cedca8ef7d58ba5b6aa0"}, - {file = "identify-2.6.7.tar.gz", hash = "sha256:3fa266b42eba321ee0b2bb0936a6a6b9e36a1351cbb69055b3082f4193035684"}, -] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "mock" -version = "5.1.0" -description = "Rolling backport of unittest.mock for all Pythons" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"}, - {file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"}, -] - -[package.extras] -build = ["blurb", "twine", "wheel"] -docs = ["sphinx"] -test = ["pytest", "pytest-cov"] - -[[package]] -name = "nodeenv" -version = "1.9.1" -description = "Node.js virtual environment builder" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -files = [ - {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, - {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, -] - -[[package]] -name = "opentimelineio" -version = "0.17.0" -description = "Editorial interchange format and API" -optional = false -python-versions = "!=3.9.0,>=3.7" -groups = ["dev"] -files = [ - {file = "OpenTimelineIO-0.17.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:2dd31a570cabfd6227c1b1dd0cc038da10787492c26c55de058326e21fe8a313"}, - {file = "OpenTimelineIO-0.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a1da5d4803d1ba5e846b181a9e0f4a392c76b9acc5e08947772bc086f2ebfc0"}, - {file = "OpenTimelineIO-0.17.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3527977aec8202789a42d60e1e0dc11b4154f585ef72921760445f43e7967a00"}, - {file = "OpenTimelineIO-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b3aafb4c50455832ed2627c2cac654b896473a5c1f8348ddc07c10be5cfbd59"}, - {file = "OpenTimelineIO-0.17.0-cp310-cp310-win32.whl", hash = "sha256:fee45af9f6330773893cd0858e92f8256bb5bde4229b44a76f03e59a9fb1b1b6"}, - {file = "OpenTimelineIO-0.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:d51887619689c21d67cc4b11b1088f99ae44094513315e7a144be00f1393bfa8"}, - {file = "OpenTimelineIO-0.17.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:cbf05c3e8c0187969f79e91f7495d1f0dc3609557874d8e601ba2e072c70ddb1"}, - {file = "OpenTimelineIO-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d3430c3f4e88c5365d7b6afbee920b0815b62ecf141abe44cd739c9eedc04284"}, - {file = "OpenTimelineIO-0.17.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1912345227b0bd1654c7153863eadbcee60362aa46340678e576e5d2aa3106a"}, - {file = "OpenTimelineIO-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51e06eb11a868d970c1534e39faf916228d5163bf3598076d408d8f393ab0bd4"}, - {file = "OpenTimelineIO-0.17.0-cp311-cp311-win32.whl", hash = "sha256:5c3a3f4780b25a8c1a80d788becba691d12b629069ad8783d0db21027639276f"}, - {file = "OpenTimelineIO-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:43c8726b33af30ba42928972192311ea0f986edbbd5f74651bada182d4fe805c"}, - {file = "OpenTimelineIO-0.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:9a9af4105a088c0ab131780e49db268db7e37871aac33db842de6b2b16f14e39"}, - {file = "OpenTimelineIO-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e653ad1dd3b85f5c312a742dc24b61b330964aa391dc5bc072fe8b9c85adff1"}, - {file = "OpenTimelineIO-0.17.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a77823c27a1b93c6b87682372c3734ac5fddc10bfe53875e657d43c60fb885"}, - {file = "OpenTimelineIO-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4f4efcf3ddd81b62c4feb49a0bcc309b50ffeb6a8c48ab173d169a029006f4d"}, - {file = "OpenTimelineIO-0.17.0-cp312-cp312-win32.whl", hash = "sha256:9872ab74a20bb2bb3a50af04e80fe9238998d67d6be4e30e45aebe25d3eefac6"}, - {file = "OpenTimelineIO-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:c83b78be3312d3152d7e07ab32b0086fe220acc2a5b035b70ad69a787c0ece62"}, - {file = "OpenTimelineIO-0.17.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:0e671a6f2a1f772445bb326c7640dc977cfc3db589fe108a783a0311939cfac8"}, - {file = "OpenTimelineIO-0.17.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b931a3189b4ce064f06f15a89fe08ef4de01f7dcf0abc441fe2e02ef2a3311bb"}, - {file = "OpenTimelineIO-0.17.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923cb54d806c981cf1e91916c3e57fba5664c22f37763dd012bad5a5a7bd4db4"}, - {file = "OpenTimelineIO-0.17.0-cp37-cp37m-win32.whl", hash = "sha256:8e16598c5084dcb21df3d83978b0e5f72300af9edd4cdcb85e3b0ba5da0df4e8"}, - {file = "OpenTimelineIO-0.17.0-cp37-cp37m-win_amd64.whl", hash = "sha256:7eed5033494888fb3f802af50e60559e279b2f398802748872903c2f54efd2c9"}, - {file = "OpenTimelineIO-0.17.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:118baa22b9227da5003bee653601a68686ae2823682dcd7d13c88178c63081c3"}, - {file = "OpenTimelineIO-0.17.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:43389eacdee2169de454e1c79ecfea82f54a9e73b67151427a9b621349a22b7f"}, - {file = "OpenTimelineIO-0.17.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17659b1e6aa42ed617a942f7a2bfc6ecc375d0464ec127ce9edf896278ecaee9"}, - {file = "OpenTimelineIO-0.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36d5ea8cfbebf3c9013cc680eef5be48bffb515aafa9dc31e99bf66052a4ca3d"}, - {file = "OpenTimelineIO-0.17.0-cp38-cp38-win32.whl", hash = "sha256:cc67c74eb4b73bc0f7d135d3ff3dbbd86b2d451a9b142690a8d1631ad79c46f2"}, - {file = "OpenTimelineIO-0.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:69b39079bee6fa4aff34c6ad6544df394bc7388483fa5ce958ecd16e243a53ad"}, - {file = "OpenTimelineIO-0.17.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:a33554894dea17c22feec0201991e705c2c90a679ba2a012a0c558a7130df711"}, - {file = "OpenTimelineIO-0.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6b1ad3b3155370245b851b2f7b60006b2ebbb5bb76dd0fdc49bb4dce73fa7d96"}, - {file = "OpenTimelineIO-0.17.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:030454a9c0e9e82e5a153119f9afb8f3f4e64a3b27f80ac0dcde44b029fd3f3f"}, - {file = "OpenTimelineIO-0.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce64376a28919533bd4f744ff8885118abefa73f78fd408f95fa7a9489855b6"}, - {file = "OpenTimelineIO-0.17.0-cp39-cp39-win32.whl", hash = "sha256:fa8cdceb25f9003c3c0b5b32baef2c764949d88b867161ddc6f44f48f6bbfa4a"}, - {file = "OpenTimelineIO-0.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:fbcf8a000cd688633c8dc5d22e91912013c67c674329eba603358e3b54da32bf"}, - {file = "opentimelineio-0.17.0.tar.gz", hash = "sha256:10ef324e710457e9977387cd9ef91eb24a9837bfb370aec3330f9c0f146cea85"}, -] - -[package.extras] -dev = ["check-manifest", "coverage (>=4.5)", "flake8 (>=3.5)", "urllib3 (>=1.24.3)"] -view = ["PySide2 (>=5.11,<6.0) ; platform_machine == \"x86_64\"", "PySide6 (>=6.2,<7.0) ; platform_machine == \"aarch64\""] - -[[package]] -name = "packaging" -version = "24.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, -] - -[[package]] -name = "platformdirs" -version = "4.3.6" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, - {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.11.2)"] - -[[package]] -name = "pluggy" -version = "1.5.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pre-commit" -version = "3.8.0" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, - {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - -[[package]] -name = "pyblish-base" -version = "1.8.12" -description = "Plug-in driven automation framework for content" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "pyblish-base-1.8.12.tar.gz", hash = "sha256:ebc184eb038864380555227a8b58055dd24ece7e6ef7f16d33416c718512871b"}, - {file = "pyblish_base-1.8.12-py2.py3-none-any.whl", hash = "sha256:2cbe956bfbd4175a2d7d22b344cd345800f4d4437153434ab658fc12646a11e8"}, -] - -[[package]] -name = "pytest" -version = "8.3.4" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, - {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.5,<2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-print" -version = "1.0.2" -description = "pytest-print adds the printer fixture you can use to print messages to the user (directly to the pytest runner, not stdout)" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest_print-1.0.2-py3-none-any.whl", hash = "sha256:3ae7891085dddc3cd697bd6956787240107fe76d6b5cdcfcd782e33ca6543de9"}, - {file = "pytest_print-1.0.2.tar.gz", hash = "sha256:2780350a7bbe7117f99c5d708dc7b0431beceda021b1fd3f11200670d7f33679"}, -] - -[package.dependencies] -pytest = ">=8.3.2" - -[package.extras] -test = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "pytest-mock (>=3.14)"] - -[[package]] -name = "pyyaml" -version = "6.0.2" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, -] - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "ruff" -version = "0.3.7" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0e8377cccb2f07abd25e84fc5b2cbe48eeb0fea9f1719cad7caedb061d70e5ce"}, - {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:15a4d1cc1e64e556fa0d67bfd388fed416b7f3b26d5d1c3e7d192c897e39ba4b"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d28bdf3d7dc71dd46929fafeec98ba89b7c3550c3f0978e36389b5631b793663"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:379b67d4f49774ba679593b232dcd90d9e10f04d96e3c8ce4a28037ae473f7bb"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c060aea8ad5ef21cdfbbe05475ab5104ce7827b639a78dd55383a6e9895b7c51"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ebf8f615dde968272d70502c083ebf963b6781aacd3079081e03b32adfe4d58a"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d48098bd8f5c38897b03604f5428901b65e3c97d40b3952e38637b5404b739a2"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da8a4fda219bf9024692b1bc68c9cff4b80507879ada8769dc7e985755d662ea"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c44e0149f1d8b48c4d5c33d88c677a4aa22fd09b1683d6a7ff55b816b5d074f"}, - {file = "ruff-0.3.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3050ec0af72b709a62ecc2aca941b9cd479a7bf2b36cc4562f0033d688e44fa1"}, - {file = "ruff-0.3.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a29cc38e4c1ab00da18a3f6777f8b50099d73326981bb7d182e54a9a21bb4ff7"}, - {file = "ruff-0.3.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b15cc59c19edca917f51b1956637db47e200b0fc5e6e1878233d3a938384b0b"}, - {file = "ruff-0.3.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e491045781b1e38b72c91247cf4634f040f8d0cb3e6d3d64d38dcf43616650b4"}, - {file = "ruff-0.3.7-py3-none-win32.whl", hash = "sha256:bc931de87593d64fad3a22e201e55ad76271f1d5bfc44e1a1887edd0903c7d9f"}, - {file = "ruff-0.3.7-py3-none-win_amd64.whl", hash = "sha256:5ef0e501e1e39f35e03c2acb1d1238c595b8bb36cf7a170e7c1df1b73da00e74"}, - {file = "ruff-0.3.7-py3-none-win_arm64.whl", hash = "sha256:789e144f6dc7019d1f92a812891c645274ed08af6037d11fc65fcbc183b7d59f"}, - {file = "ruff-0.3.7.tar.gz", hash = "sha256:d5c1aebee5162c2226784800ae031f660c350e7a3402c4d1f8ea4e97e232e3ba"}, -] - -[[package]] -name = "semver" -version = "3.0.4" -description = "Python helper for Semantic Versioning (https://semver.org)" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, - {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, -] - -[[package]] -name = "tomli" -version = "2.2.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, -] - -[[package]] -name = "unidecode" -version = "1.3.8" -description = "ASCII transliterations of Unicode text" -optional = false -python-versions = ">=3.5" -groups = ["dev"] -files = [ - {file = "Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39"}, - {file = "Unidecode-1.3.8.tar.gz", hash = "sha256:cfdb349d46ed3873ece4586b96aa75258726e2fa8ec21d6f00a591d98806c2f4"}, -] - -[[package]] -name = "urllib3" -version = "2.3.0" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, - {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "virtualenv" -version = "20.29.2" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "virtualenv-20.29.2-py3-none-any.whl", hash = "sha256:febddfc3d1ea571bdb1dc0f98d7b45d24def7428214d4fb73cc486c9568cce6a"}, - {file = "virtualenv-20.29.2.tar.gz", hash = "sha256:fdaabebf6d03b5ba83ae0a02cfe96f48a716f4fae556461d180825866f75b728"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<5" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] - -[metadata] -lock-version = "2.1" -python-versions = ">=3.9.1,<3.10" -content-hash = "0a399d239c49db714c1166c20286fdd5cd62faf12e45ab85833c4d6ea7a04a2a" diff --git a/pyproject.toml b/pyproject.toml index 94badd2f1a..246781b12f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.1.5+dev" +version = "1.3.2+dev" description = "" authors = ["Ynput Team "] readme = "README.md" @@ -20,7 +20,7 @@ pytest = "^8.0" pytest-print = "^1.0" ayon-python-api = "^1.0" # linting dependencies -ruff = "^0.3.3" +ruff = "0.11.7" pre-commit = "^3.6.2" codespell = "^2.2.6" semver = "^3.0.2" @@ -29,80 +29,17 @@ attrs = "^25.0.0" pyblish-base = "^1.8.7" clique = "^2.0.0" opentimelineio = "^0.17.0" - - -[tool.ruff] -# Exclude a variety of commonly ignored directories. -exclude = [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".git-rewrite", - ".hg", - ".ipynb_checkpoints", - ".mypy_cache", - ".nox", - ".pants.d", - ".pyenv", - ".pytest_cache", - ".pytype", - ".ruff_cache", - ".svn", - ".tox", - ".venv", - ".vscode", - "__pypackages__", - "_build", - "buck-out", - "build", - "dist", - "node_modules", - "site-packages", - "venv", - "vendor", - "generated", -] - -# Same as Black. -line-length = 79 -indent-width = 4 - -# Assume Python 3.9 -target-version = "py39" - -[tool.ruff.lint] -# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -select = ["E", "F", "W"] -ignore = [] - -# Allow fix for all enabled rules (when `--fix`) is provided. -fixable = ["ALL"] -unfixable = [] - -# Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" - -exclude = [ - "client/ayon_core/modules/click_wrap.py", - "client/ayon_core/scripts/slates/__init__.py" -] - -[tool.ruff.lint.per-file-ignores] -"client/ayon_core/lib/__init__.py" = ["E402"] - -[tool.ruff.format] -# Like Black, use double quotes for strings. -quote-style = "double" - -# Like Black, indent with spaces, rather than tabs. -indent-style = "space" - -# Like Black, respect magic trailing commas. -skip-magic-trailing-comma = false - -# Like Black, automatically detect the appropriate line ending. -line-ending = "auto" +tomlkit = "^0.13.2" +requests = "^2.32.3" +mkdocs-material = "^9.6.7" +mkdocs-autoapi = "^0.4.0" +mkdocstrings-python = "^1.16.2" +mkdocs-minify-plugin = "^0.8.0" +markdown-checklist = "^0.4.4" +mdx-gh-links = "^0.4" +pymdown-extensions = "^10.14.3" +mike = "^2.1.3" +mkdocstrings-shell = "^1.0.2" [tool.codespell] # Ignore words that are not in the dictionary. @@ -112,7 +49,7 @@ ignore-words-list = "ayon,ynput,parms,parm,hda,developpement" # Remove with next codespell release (>2.2.6) ignore-regex = ".*codespell:ignore.*" -skip = "./.*,./package/*,*/vendor/*,*/unreal/integration/*,*/aftereffects/api/extension/js/libs/*" +skip = "./.*,./package/*,*/client/ayon_core/vendor/*" count = true quiet-level = 3 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000000..f9b073e818 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,86 @@ +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", + "vendor", + "generated", +] + +# Same as Black. +line-length = 79 +indent-width = 4 + +# Assume Python 3.9 +target-version = "py39" + +[lint] +preview = true +pydocstyle.convention = "google" +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +select = ["E", "F", "W"] +ignore = [] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +exclude = [ + "client/ayon_core/scripts/slates/__init__.py" +] + +[lint.per-file-ignores] +"client/ayon_core/lib/__init__.py" = ["E402"] + +[format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" + +# Enable auto-formatting of code examples in docstrings. Markdown, +# reStructuredText code/literal blocks and doctests are all supported. +# +# This is currently disabled by default, but it is planned for this +# to be opt-out in the future. +docstring-code-format = false + +# Set the line length limit used when formatting code snippets in +# docstrings. +# +# This only has an effect when the `docstring-code-format` setting is +# enabled. +docstring-code-line-length = "dynamic" diff --git a/server/settings/main.py b/server/settings/main.py index 249bab85fd..dd6af0a104 100644 --- a/server/settings/main.py +++ b/server/settings/main.py @@ -71,6 +71,24 @@ def _fallback_ocio_config_profile_types(): def _ocio_built_in_paths(): return [ + { + "value": "{BUILTIN_OCIO_ROOT}/aces_2.0/studio-config-v3.0.0_aces-v2.0_ocio-v2.4.ocio", # noqa: E501 + "label": "ACES 2.0 Studio (OCIO v2.4)", + "description": ( + "Aces 2.0 Studio OCIO config file. Requires OCIO v2.4.") + }, + { + "value": "{BUILTIN_OCIO_ROOT}/aces_1.3/studio-config-v1.0.0_aces-v1.3_ocio-v2.1.ocio", # noqa: E501 + "label": "ACES 1.3 Studio (OCIO v2.1)", + "description": ( + "Aces 1.3 Studio OCIO config file. Requires OCIO v2.1.") + }, + { + "value": "{BUILTIN_OCIO_ROOT}/aces_1.3/studio-config-v1.0.0_aces-v1.3_ocio-v2.0.ocio", # noqa: E501 + "label": "ACES 1.3 Studio (OCIO v2)", + "description": ( + "Aces 1.3 Studio OCIO config file. Requires OCIO v2.") + }, { "value": "{BUILTIN_OCIO_ROOT}/aces_1.2/config.ocio", "label": "ACES 1.2", diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index c9c66e65d9..793ca659e5 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1,4 +1,5 @@ from pydantic import validator +from typing import Any from ayon_server.settings import ( BaseSettingsModel, @@ -7,11 +8,25 @@ from ayon_server.settings import ( normalize_name, ensure_unique_names, task_types_enum, + anatomy_template_items_enum ) - +from ayon_server.exceptions import BadRequestException from ayon_server.types import ColorRGBA_uint8 +def _handle_missing_frames_enum(): + return [ + {"value": "closest_existing", "label": "Use closest existing"}, + {"value": "blank", "label": "Generate blank frame"}, + {"value": "previous_version", "label": "Use previous version"}, + {"value": "only_rendered", "label": "Use only rendered"}, + ] + + +class EnabledModel(BaseSettingsModel): + enabled: bool = SettingsField(True) + + class ValidateBaseModel(BaseSettingsModel): _isGroup = True enabled: bool = SettingsField(True) @@ -153,6 +168,78 @@ class CollectUSDLayerContributionsModel(BaseSettingsModel): return value +class ResolutionOptionsModel(BaseSettingsModel): + _layout = "compact" + width: int = SettingsField( + 1920, + ge=0, + le=100000, + title="Width", + description=( + "Width resolution number value"), + placeholder="Width" + ) + height: int = SettingsField( + 1080, + title="Height", + ge=0, + le=100000, + description=( + "Height resolution number value"), + placeholder="Height" + ) + pixel_aspect: float = SettingsField( + 1.0, + title="Pixel aspect", + ge=0.0, + le=100000.0, + description=( + "Pixel Aspect resolution decimal number value"), + placeholder="Pixel aspect" + ) + + +def ensure_unique_resolution_option( + objects: list[Any], field_name: str | None = None) -> None: # noqa: C901 + """Ensure a list of objects have unique option attributes. + + This function checks if the list of objects has unique 'width', + 'height' and 'pixel_aspect' properties. + """ + options = set() + for obj in objects: + item_test_text = f"{obj.width}x{obj.height}x{obj.pixel_aspect}" + if item_test_text in options: + raise BadRequestException( + f"Duplicate option '{item_test_text}'") + + options.add(item_test_text) + + +class CollectExplicitResolutionModel(BaseSettingsModel): + enabled: bool = SettingsField(True, title="Enabled") + product_types: list[str] = SettingsField( + default_factory=list, + title="Product types", + description=( + "Only activate the attribute for following product types." + ) + ) + options: list[ResolutionOptionsModel] = SettingsField( + default_factory=list, + title="Resolution choices", + description=( + "Available resolution choices to be displayed in " + "the publishers attribute." + ) + ) + + @validator("options") + def validate_unique_resolution_options(cls, value): + ensure_unique_resolution_option(value) + return value + + class AyonEntityURIModel(BaseSettingsModel): use_ayon_entity_uri: bool = SettingsField( title="Use AYON Entity URI", @@ -638,6 +725,12 @@ class ExtractReviewOutputDefModel(BaseSettingsModel): default_factory=ExtractReviewLetterBox, title="Letter Box" ) + fill_missing_frames: str = SettingsField( + title="Handle missing frames", + default="closest_existing", + description="How to handle gaps in sequence frame ranges.", + enum_resolver=_handle_missing_frames_enum + ) @validator("name") def validate_name(cls, value): @@ -885,7 +978,11 @@ class IntegrateANTemplateNameProfileModel(BaseSettingsModel): default_factory=list, title="Task names" ) - template_name: str = SettingsField("", title="Template name") + template_name: str = SettingsField( + "", + title="Template name", + enum_resolver=anatomy_template_items_enum(category="publish") + ) class IntegrateHeroTemplateNameProfileModel(BaseSettingsModel): @@ -906,7 +1003,11 @@ class IntegrateHeroTemplateNameProfileModel(BaseSettingsModel): default_factory=list, title="Task names" ) - template_name: str = SettingsField("", title="Template name") + template_name: str = SettingsField( + "", + title="Template name", + enum_resolver=anatomy_template_items_enum(category="hero") + ) class IntegrateHeroVersionModel(BaseSettingsModel): @@ -925,6 +1026,20 @@ class IntegrateHeroVersionModel(BaseSettingsModel): "hero versions.") +class CollectRenderedFilesModel(BaseSettingsModel): + remove_files: bool = SettingsField( + False, + title="Remove rendered files", + description=( + "Remove rendered files and metadata json on publish.\n\n" + "Note that when enabled but the render is to a configured " + "persistent staging directory the files will not be removed. " + "However with this disabled the files will **not** be removed in " + "either case." + ) + ) + + class CleanUpModel(BaseSettingsModel): _isGroup = True paterns: list[str] = SettingsField( # codespell:ignore paterns @@ -970,6 +1085,10 @@ class PublishPuginsModel(BaseSettingsModel): title="Collect USD Layer Contributions", ) ) + CollectExplicitResolution: CollectExplicitResolutionModel = SettingsField( + default_factory=CollectExplicitResolutionModel, + title="Collect Explicit Resolution" + ) ValidateEditorialAssetName: ValidateBaseModel = SettingsField( default_factory=ValidateBaseModel, title="Validate Editorial Asset Name" @@ -1026,6 +1145,21 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=IntegrateHeroVersionModel, title="Integrate Hero Version" ) + AttachReviewables: EnabledModel = SettingsField( + default_factory=EnabledModel, + title="Attach Reviewables", + description=( + "When enabled, expose an 'Attach Reviewables' attribute on review" + " and render instances in the publisher to allow including the" + " media to be attached to another instance.\n\n" + "If a reviewable is attached to another instance it will not be " + "published as a render/review product of its own." + ) + ) + CollectRenderedFiles: CollectRenderedFilesModel = SettingsField( + default_factory=CollectRenderedFilesModel, + title="Clean up farm rendered files" + ) CleanUp: CleanUpModel = SettingsField( default_factory=CleanUpModel, title="Clean Up" @@ -1129,6 +1263,13 @@ DEFAULT_PUBLISH_VALUES = { }, ] }, + "CollectExplicitResolution": { + "enabled": True, + "product_types": [ + "shot" + ], + "options": [] + }, "ValidateEditorialAssetName": { "enabled": True, "optional": False, @@ -1246,7 +1387,8 @@ DEFAULT_PUBLISH_VALUES = { "fill_color": [0, 0, 0, 1.0], "line_thickness": 0, "line_color": [255, 0, 0, 1.0] - } + }, + "fill_missing_frames": "closest_existing" }, { "name": "h264", @@ -1296,7 +1438,8 @@ DEFAULT_PUBLISH_VALUES = { "fill_color": [0, 0, 0, 1.0], "line_thickness": 0, "line_color": [255, 0, 0, 1.0] - } + }, + "fill_missing_frames": "closest_existing" } ] } @@ -1410,6 +1553,12 @@ DEFAULT_PUBLISH_VALUES = { ], "use_hardlinks": False }, + "AttachReviewables": { + "enabled": True, + }, + "CollectRenderedFiles": { + "remove_files": False + }, "CleanUp": { "paterns": [], # codespell:ignore paterns "remove_temp_renders": False diff --git a/server/settings/tools.py b/server/settings/tools.py index 32c72e7a98..815ef40f8e 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -5,6 +5,7 @@ from ayon_server.settings import ( normalize_name, ensure_unique_names, task_types_enum, + anatomy_template_items_enum ) @@ -283,7 +284,34 @@ class PublishTemplateNameProfile(BaseSettingsModel): task_names: list[str] = SettingsField( default_factory=list, title="Task names" ) - template_name: str = SettingsField("", title="Template name") + template_name: str = SettingsField( + "", + title="Template name", + enum_resolver=anatomy_template_items_enum(category="publish") + ) + + +class HeroTemplateNameProfile(BaseSettingsModel): + _layout = "expanded" + product_types: list[str] = SettingsField( + default_factory=list, + title="Product types" + ) + # TODO this should use hosts enum + hosts: list[str] = SettingsField(default_factory=list, title="Hosts") + task_types: list[str] = SettingsField( + default_factory=list, + title="Task types", + enum_resolver=task_types_enum + ) + task_names: list[str] = SettingsField( + default_factory=list, title="Task names" + ) + template_name: str = SettingsField( + "", + title="Template name", + enum_resolver=anatomy_template_items_enum(category="hero") + ) class CustomStagingDirProfileModel(BaseSettingsModel): @@ -306,7 +334,11 @@ class CustomStagingDirProfileModel(BaseSettingsModel): custom_staging_dir_persistent: bool = SettingsField( False, title="Custom Staging Folder Persistent" ) - template_name: str = SettingsField("", title="Template Name") + template_name: str = SettingsField( + "", + title="Template name", + enum_resolver=anatomy_template_items_enum(category="staging") + ) class PublishToolModel(BaseSettingsModel): @@ -314,7 +346,7 @@ class PublishToolModel(BaseSettingsModel): default_factory=list, title="Template name profiles" ) - hero_template_name_profiles: list[PublishTemplateNameProfile] = ( + hero_template_name_profiles: list[HeroTemplateNameProfile] = ( SettingsField( default_factory=list, title="Hero template name profiles" @@ -326,6 +358,14 @@ class PublishToolModel(BaseSettingsModel): title="Custom Staging Dir Profiles" ) ) + comment_minimum_required_chars: int = SettingsField( + 0, + title="Publish comment minimum required characters", + description=( + "Minimum number of characters required in the comment field " + "before the publisher UI is allowed to continue publishing" + ) + ) class GlobalToolsModel(BaseSettingsModel): @@ -639,6 +679,7 @@ DEFAULT_TOOLS_VALUES = { "task_names": [], "template_name": "simpleUnrealTextureHero" } - ] + ], + "comment_minimum_required_chars": 0, } } diff --git a/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py b/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py index 20f0c05804..2f67ee244c 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py +++ b/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py @@ -101,6 +101,7 @@ def test_image_sequence(): expected_data, ) + def test_media_retimed(): """ EXR image sequence. diff --git a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py index 8ad2e44b06..6a74df7f43 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py +++ b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py @@ -103,17 +103,18 @@ def test_image_sequence_with_embedded_tc_and_handles_out_of_range(): # 10 head black handles generated from gap (991-1000) "/path/to/ffmpeg -t 0.4166666666666667 -r 24.0 -f lavfi -i " "color=c=black:s=1280x720 -tune stillimage -start_number 991 " - "C:/result/output.%04d.jpg", + "-pix_fmt rgba C:/result/output.%04d.png", # 10 tail black handles generated from gap (1102-1111) "/path/to/ffmpeg -t 0.4166666666666667 -r 24.0 -f lavfi -i " "color=c=black:s=1280x720 -tune stillimage -start_number 1102 " - "C:/result/output.%04d.jpg", + "-pix_fmt rgba C:/result/output.%04d.png", # Report from source exr (1001-1101) with enforce framerate "/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i " - f"C:\\exr_embedded_tc{os.sep}output.%04d.exr -start_number 1001 " - "C:/result/output.%04d.jpg" + f"C:\\exr_embedded_tc{os.sep}output.%04d.exr " + "-vf scale=1280:720:flags=lanczos -compression_level 5 " + "-start_number 1001 -pix_fmt rgba C:/result/output.%04d.png" ] assert calls == expected @@ -130,20 +131,23 @@ def test_image_sequence_and_handles_out_of_range(): expected = [ # 5 head black frames generated from gap (991-995) - "/path/to/ffmpeg -t 0.2 -r 25.0 -f lavfi -i color=c=black:s=1280x720" - " -tune stillimage -start_number 991 C:/result/output.%04d.jpg", + "/path/to/ffmpeg -t 0.2 -r 25.0 -f lavfi -i color=c=black:s=1280x720 " + "-tune stillimage -start_number 991 -pix_fmt rgba " + "C:/result/output.%04d.png", # 9 tail back frames generated from gap (1097-1105) - "/path/to/ffmpeg -t 0.36 -r 25.0 -f lavfi -i color=c=black:s=1280x720" - " -tune stillimage -start_number 1097 C:/result/output.%04d.jpg", + "/path/to/ffmpeg -t 0.36 -r 25.0 -f lavfi -i color=c=black:s=1280x720 " + "-tune stillimage -start_number 1097 -pix_fmt rgba " + "C:/result/output.%04d.png", # Report from source tiff (996-1096) # 996-1000 = additional 5 head frames # 1001-1095 = source range conformed to 25fps # 1096-1096 = additional 1 tail frames "/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i " - f"C:\\tif_seq{os.sep}output.%04d.tif -start_number 996" - f" C:/result/output.%04d.jpg" + f"C:\\tif_seq{os.sep}output.%04d.tif " + "-vf scale=1280:720:flags=lanczos -compression_level 5 " + "-start_number 996 -pix_fmt rgba C:/result/output.%04d.png" ] assert calls == expected @@ -163,8 +167,9 @@ def test_movie_with_embedded_tc_no_gap_handles(): # - first_frame = 14 src - 10 (head tail) = frame 4 = 0.1666s # - duration = 68fr (source) + 20fr (handles) = 88frames = 3.666s "/path/to/ffmpeg -ss 0.16666666666666666 -t 3.6666666666666665 " - "-i C:\\data\\qt_embedded_tc.mov -start_number 991 " - "C:/result/output.%04d.jpg" + "-i C:\\data\\qt_embedded_tc.mov -vf scale=1280:720:flags=lanczos " + "-compression_level 5 -start_number 991 -pix_fmt rgba " + "C:/result/output.%04d.png" ] assert calls == expected @@ -181,12 +186,14 @@ def test_short_movie_head_gap_handles(): expected = [ # 10 head black frames generated from gap (991-1000) "/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi -i color=c=black:s=1280x720" - " -tune stillimage -start_number 991 C:/result/output.%04d.jpg", + " -tune stillimage -start_number 991 -pix_fmt rgba " + "C:/result/output.%04d.png", # source range + 10 tail frames # duration = 50fr (source) + 10fr (tail handle) = 60 fr = 2.4s - "/path/to/ffmpeg -ss 0.0 -t 2.4 -i C:\\data\\movie.mp4" - " -start_number 1001 C:/result/output.%04d.jpg" + "/path/to/ffmpeg -ss 0.0 -t 2.4 -i C:\\data\\movie.mp4 -vf " + "scale=1280:720:flags=lanczos -compression_level 5 " + "-start_number 1001 -pix_fmt rgba C:/result/output.%04d.png" ] assert calls == expected @@ -204,17 +211,19 @@ def test_short_movie_tail_gap_handles(): # 10 tail black frames generated from gap (1067-1076) "/path/to/ffmpeg -t 0.4166666666666667 -r 24.0 -f lavfi -i " "color=c=black:s=1280x720 -tune stillimage -start_number 1067 " - "C:/result/output.%04d.jpg", + "-pix_fmt rgba C:/result/output.%04d.png", # 10 head frames + source range # duration = 10fr (head handle) + 66fr (source) = 76fr = 3.16s "/path/to/ffmpeg -ss 1.0416666666666667 -t 3.1666666666666665 -i " - "C:\\data\\qt_no_tc_24fps.mov -start_number 991" - " C:/result/output.%04d.jpg" + "C:\\data\\qt_no_tc_24fps.mov -vf scale=1280:720:flags=lanczos " + "-compression_level 5 -start_number 991 -pix_fmt rgba " + "C:/result/output.%04d.png" ] assert calls == expected + def test_multiple_review_clips_no_gap(): """ Use multiple review clips (image sequence). @@ -238,66 +247,80 @@ def test_multiple_review_clips_no_gap(): # 10 head black frames generated from gap (991-1000) '/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi' ' -i color=c=black:s=1280x720 -tune ' - 'stillimage -start_number 991 C:/result/output.%04d.jpg', + 'stillimage -start_number 991 -pix_fmt rgba C:/result/output.%04d.png', # Alternance 25fps tiff sequence and 24fps exr sequence # for 100 frames each '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1001 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1001 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-start_number 1102 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1102 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1198 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1198 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-start_number 1299 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1299 -pix_fmt rgba C:/result/output.%04d.png', # Repeated 25fps tiff sequence multiple times till the end '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1395 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1395 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1496 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1496 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1597 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1597 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1698 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1698 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1799 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1799 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1900 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1900 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 2001 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 2001 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 2102 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 2102 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 2203 C:/result/output.%04d.jpg' + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 2203 -pix_fmt rgba C:/result/output.%04d.png' ] assert calls == expected + def test_multiple_review_clips_with_gap(): """ Use multiple review clips (image sequence) with gap. @@ -321,15 +344,17 @@ def test_multiple_review_clips_with_gap(): # Gap on review track (12 frames) '/path/to/ffmpeg -t 0.5 -r 24.0 -f lavfi' ' -i color=c=black:s=1280x720 -tune ' - 'stillimage -start_number 991 C:/result/output.%04d.jpg', + 'stillimage -start_number 991 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-start_number 1003 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1003 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-start_number 1091 C:/result/output.%04d.jpg' + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1091 -pix_fmt rgba C:/result/output.%04d.png' ] assert calls == expected diff --git a/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py b/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py index 112d00b3e4..b475d629bb 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py +++ b/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py @@ -257,7 +257,6 @@ def test_movie_timewarp(): ) - def test_img_sequence_no_handles(): """ Img sequence clip (no embedded timecode) @@ -334,6 +333,7 @@ def test_img_sequence_relative_source_range(): expected_data ) + def test_img_sequence_conform_to_23_976fps(): """ Img sequence clip @@ -409,6 +409,7 @@ def test_img_sequence_reverse_speed_no_tc(): handle_end=0, ) + def test_img_sequence_reverse_speed_from_24_to_23_976fps(): """ Img sequence clip