From be20a9f69626240bbb941e7661d5866a97b736cc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 29 Nov 2024 10:14:47 +0100 Subject: [PATCH 001/312] Add ShapeFX Loki support --- client/ayon_core/hooks/pre_add_last_workfile_arg.py | 3 ++- client/ayon_core/hooks/pre_ocio_hook.py | 3 ++- client/ayon_core/plugins/publish/validate_file_saved.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/hooks/pre_add_last_workfile_arg.py b/client/ayon_core/hooks/pre_add_last_workfile_arg.py index d5914c2352..0652e7c5aa 100644 --- a/client/ayon_core/hooks/pre_add_last_workfile_arg.py +++ b/client/ayon_core/hooks/pre_add_last_workfile_arg.py @@ -29,7 +29,8 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "aftereffects", "wrap", "openrv", - "cinema4d" + "cinema4d", + "loki" } launch_types = {LaunchTypes.local} diff --git a/client/ayon_core/hooks/pre_ocio_hook.py b/client/ayon_core/hooks/pre_ocio_hook.py index 7406aa42cf..6462d1a3ae 100644 --- a/client/ayon_core/hooks/pre_ocio_hook.py +++ b/client/ayon_core/hooks/pre_ocio_hook.py @@ -20,7 +20,8 @@ class OCIOEnvHook(PreLaunchHook): "hiero", "resolve", "openrv", - "cinema4d" + "cinema4d", + "loki" } launch_types = set() diff --git a/client/ayon_core/plugins/publish/validate_file_saved.py b/client/ayon_core/plugins/publish/validate_file_saved.py index f52998cef3..78c243d5aa 100644 --- a/client/ayon_core/plugins/publish/validate_file_saved.py +++ b/client/ayon_core/plugins/publish/validate_file_saved.py @@ -37,7 +37,7 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): label = "Validate File Saved" order = pyblish.api.ValidatorOrder - 0.1 hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter", - "cinema4d"] + "cinema4d", "loki"] actions = [SaveByVersionUpAction, ShowWorkfilesAction] def process(self, context): From 3ddd64bbc3b12b4bbd2ac6c4d6280a91b6aed81c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 26 Mar 2025 14:22:51 +0100 Subject: [PATCH 002/312] handle project argument --- client/ayon_core/cli.py | 75 +++++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 6f89a6d17d..dc8ca44082 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -24,22 +24,35 @@ from ayon_core.lib.env_tools import ( ) - @click.group(invoke_without_command=True) @click.pass_context -@click.option("--use-staging", is_flag=True, - expose_value=False, help="use staging variants") -@click.option("--debug", is_flag=True, expose_value=False, - help="Enable debug") -@click.option("--verbose", expose_value=False, - help=("Change AYON log level (debug - critical or 0-50)")) -@click.option("--force", is_flag=True, hidden=True) -def main_cli(ctx, force): +@click.option( + "--use-staging", + is_flag=True, + expose_value=False, + help="use staging variants") +@click.option( + "--debug", + is_flag=True, + expose_value=False, + help="Enable debug") +@click.option( + "--project", + help="Project name") +@click.option( + "--verbose", + expose_value=False, + help="Change AYON log level (debug - critical or 0-50)") +@click.option( + "--use-dev", + is_flag=True, + expose_value=False, + help="use dev bundle") +def main_cli(ctx, *_args, **_kwargs): """AYON is main command serving as entry point to pipeline system. It wraps different commands together. """ - if ctx.invoked_subcommand is None: # Print help if headless mode is used if os.getenv("AYON_HEADLESS_MODE") == "1": @@ -60,7 +73,6 @@ def tray(force): Default action of AYON command is to launch tray widget to control basic aspects of AYON. See documentation for more information. """ - from ayon_core.tools.tray import main main(force) @@ -283,6 +295,43 @@ def _add_addons(addons_manager): ) +def _cleanup_project_args(): + rem_args = list(sys.argv[1:]) + if "--project" not in rem_args: + return + + cmd = None + current_ctx = None + parent_name = "ayon" + parent_cmd = main_cli + while hasattr(parent_cmd, "resolve_command"): + if current_ctx is None: + current_ctx = main_cli.make_context(parent_name, rem_args) + else: + current_ctx = parent_cmd.make_context( + parent_name, + rem_args, + parent=current_ctx + ) + if not rem_args: + break + cmd_name, cmd, rem_args = parent_cmd.resolve_command( + current_ctx, rem_args + ) + parent_name = cmd_name + parent_cmd = cmd + + if cmd is None: + return + + param_names = {param.name for param in cmd.params} + if "project" in param_names: + return + idx = sys.argv.index("--project") + sys.argv.pop(idx) + sys.argv.pop(idx) + + def main(*args, **kwargs): initialize_ayon_connection() python_path = os.getenv("PYTHONPATH", "") @@ -307,10 +356,14 @@ def main(*args, **kwargs): addons_manager = AddonsManager() _set_addons_environments(addons_manager) _add_addons(addons_manager) + + _cleanup_project_args() + try: main_cli( prog_name="ayon", obj={"addons_manager": addons_manager}, + args=(sys.argv[1:]), ) except Exception: # noqa exc_info = sys.exc_info() From 09fe05025c85aecbe3f7635fe8c5c53b559906d2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 26 Mar 2025 14:23:07 +0100 Subject: [PATCH 003/312] modified settings fetching --- client/ayon_core/settings/lib.py | 68 +++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/settings/lib.py b/client/ayon_core/settings/lib.py index aa56fa8326..d251439221 100644 --- a/client/ayon_core/settings/lib.py +++ b/client/ayon_core/settings/lib.py @@ -4,6 +4,7 @@ import logging import collections import copy import time +from urllib.parse import urlencode import ayon_api @@ -35,6 +36,35 @@ class CacheItem: return time.time() > self._outdate_time +def _get_addons_settings( + studio_bundle_name, + project_bundle_name, + variant, + project_name=None, +): + """Modified version of `ayon_api.get_addons_settings` function.""" + query_values = { + key: value + for key, value in ( + ("bundle_name", studio_bundle_name), + ("project_bundle_name ", project_bundle_name), + ("variant", variant), + ("project_name", project_name), + ) + if value + } + site_id = ayon_api.get_site_id() + if site_id: + query_values["site_id"] = site_id + + response = ayon_api.get(f"settings?{urlencode(query_values)}") + response.raise_for_status() + return { + addon["name"]: addon["settings"] + for addon in response.data["addons"] + } + + class _AyonSettingsCache: use_bundles = None variant = None @@ -60,7 +90,7 @@ class _AyonSettingsCache: variant = "production" if is_dev_mode_enabled(): - variant = cls._get_bundle_name() + variant = cls._get_studio_bundle_name() elif is_staging_enabled(): variant = "staging" @@ -72,27 +102,33 @@ class _AyonSettingsCache: return _AyonSettingsCache.variant @classmethod - def _get_bundle_name(cls): + def _get_studio_bundle_name(cls): + bundle_name = os.environ.get("AYON_STUDIO_BUNDLE_NAME") + if bundle_name: + return bundle_name + return os.environ["AYON_BUNDLE_NAME"] + + @classmethod + def _get_project_bundle_name(cls): return os.environ["AYON_BUNDLE_NAME"] @classmethod def get_value_by_project(cls, project_name): cache_item = _AyonSettingsCache.cache_by_project_name[project_name] if cache_item.is_outdated: - if cls._use_bundles(): - value = ayon_api.get_addons_settings( - bundle_name=cls._get_bundle_name(), + cache_item.update_value( + _get_addons_settings( + studio_bundle_name=cls._get_studio_bundle_name(), + project_bundle_name=cls._get_project_bundle_name(), project_name=project_name, - variant=cls._get_variant() + variant=cls._get_variant(), ) - else: - value = ayon_api.get_addons_settings(project_name) - cache_item.update_value(value) + ) return cache_item.get_value() @classmethod def _get_addon_versions_from_bundle(cls): - expected_bundle = cls._get_bundle_name() + expected_bundle = cls._get_project_bundle_name() bundles = ayon_api.get_bundles()["bundles"] bundle = next( ( @@ -110,15 +146,9 @@ class _AyonSettingsCache: def get_addon_versions(cls): cache_item = _AyonSettingsCache.addon_versions if cache_item.is_outdated: - if cls._use_bundles(): - addons = cls._get_addon_versions_from_bundle() - else: - settings_data = ayon_api.get_addons_settings( - only_values=False, - variant=cls._get_variant() - ) - addons = settings_data["versions"] - cache_item.update_value(addons) + cache_item.update_value( + cls._get_addon_versions_from_bundle() + ) return cache_item.get_value() From 0a54f569ceb80e491e21ff1f8b80131191f0c48f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 26 Mar 2025 16:55:26 +0100 Subject: [PATCH 004/312] fix addons discovery --- client/ayon_core/addon/base.py | 25 ++++++++++++++++++++----- client/ayon_core/cli.py | 1 + client/ayon_core/settings/lib.py | 27 ++++++++++++++++++++++----- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 72270fa585..7d02acf548 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -155,18 +155,33 @@ def load_addons(force=False): def _get_ayon_bundle_data(): + studio_bundle_name = os.environ.get("AYON_STUDIO_BUNDLE_NAME") + project_bundle_name = os.getenv("AYON_BUNDLE_NAME") bundles = ayon_api.get_bundles()["bundles"] - - bundle_name = os.getenv("AYON_BUNDLE_NAME") - - return next( + project_bundle = next( ( bundle for bundle in bundles - if bundle["name"] == bundle_name + if bundle["name"] == project_bundle_name ), None ) + studio_bundle = None + if studio_bundle_name and project_bundle_name != studio_bundle_name: + studio_bundle = next( + ( + bundle + for bundle in bundles + if bundle["name"] == studio_bundle_name + ), + None + ) + + if project_bundle and studio_bundle: + addons = copy.deepcopy(studio_bundle["addons"]) + addons.update(project_bundle["addons"]) + project_bundle["addons"] = addons + return project_bundle def _get_ayon_addons_information(bundle_info): diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index dc8ca44082..8f2abbaeab 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -59,6 +59,7 @@ def main_cli(ctx, *_args, **_kwargs): print(ctx.get_help()) sys.exit(0) else: + ctx.params.pop("project") ctx.forward(tray) diff --git a/client/ayon_core/settings/lib.py b/client/ayon_core/settings/lib.py index d251439221..7b4c08bc04 100644 --- a/client/ayon_core/settings/lib.py +++ b/client/ayon_core/settings/lib.py @@ -128,18 +128,35 @@ class _AyonSettingsCache: @classmethod def _get_addon_versions_from_bundle(cls): - expected_bundle = cls._get_project_bundle_name() + studio_bundle_name = cls._get_studio_bundle_name() + project_bundle_name = cls._get_project_bundle_name() bundles = ayon_api.get_bundles()["bundles"] - bundle = next( + project_bundle = next( ( bundle for bundle in bundles - if bundle["name"] == expected_bundle + if bundle["name"] == project_bundle_name ), None ) - if bundle is not None: - return bundle["addons"] + studio_bundle = None + if studio_bundle_name and project_bundle_name != studio_bundle_name: + studio_bundle = next( + ( + bundle + for bundle in bundles + if bundle["name"] == studio_bundle_name + ), + None + ) + + if studio_bundle and project_bundle: + addons = copy.deepcopy(studio_bundle["addons"]) + addons.update(project_bundle["addons"]) + project_bundle["addons"] = addons + + if project_bundle is not None: + return project_bundle["addons"] return {} @classmethod From a8baf72a7fd583fb8fb8f7701b295e00d428b907 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 26 Mar 2025 17:01:54 +0100 Subject: [PATCH 005/312] automatically restart tray if is running in project bundle mode --- client/ayon_core/tools/tray/ui/tray.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index aad89b6081..f090be063e 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -240,6 +240,11 @@ class TrayManager: self.log.warning("Other tray started meanwhile. Exiting.") self.exit() + project_bundle = os.getenv("AYON_BUNDLE_NAME") + studio_bundle = os.getenv("AYON_STUDIO_BUNDLE_NAME") + if studio_bundle and project_bundle != studio_bundle: + self.restart() + def get_services_submenu(self): return self._services_submenu @@ -270,11 +275,18 @@ class TrayManager: elif is_staging_enabled(): additional_args.append("--use-staging") + if "--project" in additional_args: + idx = additional_args.index("--project") + additional_args.pop(idx) + additional_args.pop(idx) + args.extend(additional_args) envs = dict(os.environ.items()) for key in { "AYON_BUNDLE_NAME", + "AYON_STUDIO_BUNDLE_NAME", + "AYON_PROJECT_NAME", }: envs.pop(key, None) @@ -329,6 +341,7 @@ class TrayManager: return json_response({ "username": self._cached_username, "bundle": os.getenv("AYON_BUNDLE_NAME"), + "studio_bundle": os.getenv("AYON_STUDIO_BUNDLE_NAME"), "dev_mode": is_dev_mode_enabled(), "staging_mode": is_staging_enabled(), "addons": { @@ -516,6 +529,8 @@ class TrayManager: "AYON_SERVER_URL", "AYON_API_KEY", "AYON_BUNDLE_NAME", + "AYON_STUDIO_BUNDLE_NAME", + "AYON_PROJECT_NAME", }: os.environ.pop(key, None) self.restart() @@ -549,6 +564,8 @@ class TrayManager: envs = dict(os.environ.items()) for key in { "AYON_BUNDLE_NAME", + "AYON_STUDIO_BUNDLE_NAME", + "AYON_PROJECT_NAME", }: envs.pop(key, None) From c194fe2dd2c1016827dce3fcf2f162c5888a6c1b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 27 Mar 2025 07:44:10 +0100 Subject: [PATCH 006/312] set project bundle name only if is different from studio bundle name --- client/ayon_core/settings/lib.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/settings/lib.py b/client/ayon_core/settings/lib.py index 7b4c08bc04..cd219a153b 100644 --- a/client/ayon_core/settings/lib.py +++ b/client/ayon_core/settings/lib.py @@ -47,12 +47,14 @@ def _get_addons_settings( key: value for key, value in ( ("bundle_name", studio_bundle_name), - ("project_bundle_name ", project_bundle_name), ("variant", variant), ("project_name", project_name), ) if value } + if project_bundle_name != studio_bundle_name: + query_values["project_bundle_name"] = project_bundle_name + site_id = ayon_api.get_site_id() if site_id: query_values["site_id"] = site_id From 3bfb11d09b02c113d63c6294a286d5531cc674eb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Apr 2025 17:12:18 +0200 Subject: [PATCH 007/312] moved interfaces to subfolder --- client/ayon_core/host/interfaces/__init__.py | 16 ++++++++++++++++ .../host/{ => interfaces}/interfaces.py | 0 2 files changed, 16 insertions(+) create mode 100644 client/ayon_core/host/interfaces/__init__.py rename client/ayon_core/host/{ => interfaces}/interfaces.py (100%) diff --git a/client/ayon_core/host/interfaces/__init__.py b/client/ayon_core/host/interfaces/__init__.py new file mode 100644 index 0000000000..fb6bdc661a --- /dev/null +++ b/client/ayon_core/host/interfaces/__init__.py @@ -0,0 +1,16 @@ +from .interfaces import ( + MissingMethodsError, + IPublishHost, + INewPublisher, + ILoadHost, + IWorkfileHost, +) + + +__all__ = ( + "MissingMethodsError", + "IWorkfileHost", + "IPublishHost", + "INewPublisher", + "ILoadHost", +) diff --git a/client/ayon_core/host/interfaces.py b/client/ayon_core/host/interfaces/interfaces.py similarity index 100% rename from client/ayon_core/host/interfaces.py rename to client/ayon_core/host/interfaces/interfaces.py From 28eaf12d9d17dc832d6f0affadd8f671c6c2588a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Apr 2025 17:13:09 +0200 Subject: [PATCH 008/312] move exception into separate file --- client/ayon_core/host/interfaces/__init__.py | 2 +- .../ayon_core/host/interfaces/exceptions.py | 15 ++++++++++++ .../ayon_core/host/interfaces/interfaces.py | 24 +------------------ 3 files changed, 17 insertions(+), 24 deletions(-) create mode 100644 client/ayon_core/host/interfaces/exceptions.py diff --git a/client/ayon_core/host/interfaces/__init__.py b/client/ayon_core/host/interfaces/__init__.py index fb6bdc661a..efe1ea6c5c 100644 --- a/client/ayon_core/host/interfaces/__init__.py +++ b/client/ayon_core/host/interfaces/__init__.py @@ -1,5 +1,5 @@ +from .exceptions import MissingMethodsError from .interfaces import ( - MissingMethodsError, IPublishHost, INewPublisher, ILoadHost, diff --git a/client/ayon_core/host/interfaces/exceptions.py b/client/ayon_core/host/interfaces/exceptions.py new file mode 100644 index 0000000000..c6b4cef4b4 --- /dev/null +++ b/client/ayon_core/host/interfaces/exceptions.py @@ -0,0 +1,15 @@ +class MissingMethodsError(ValueError): + """Exception when host miss some required methods for specific workflow. + + Args: + host (HostBase): Host implementation where are missing methods. + missing_methods (list[str]): List of missing methods. + """ + + def __init__(self, host, missing_methods): + joined_missing = ", ".join( + ['"{}"'.format(item) for item in missing_methods] + ) + super().__init__( + f"Host \"{host.name}\" miss methods {joined_missing}" + ) diff --git a/client/ayon_core/host/interfaces/interfaces.py b/client/ayon_core/host/interfaces/interfaces.py index c077dfeae9..5fc40134f8 100644 --- a/client/ayon_core/host/interfaces/interfaces.py +++ b/client/ayon_core/host/interfaces/interfaces.py @@ -1,28 +1,6 @@ from abc import ABC, abstractmethod - -class MissingMethodsError(ValueError): - """Exception when host miss some required methods for specific workflow. - - Args: - host (HostBase): Host implementation where are missing methods. - missing_methods (list[str]): List of missing methods. - """ - - def __init__(self, host, missing_methods): - joined_missing = ", ".join( - ['"{}"'.format(item) for item in missing_methods] - ) - host_name = getattr(host, "name", None) - if not host_name: - try: - host_name = host.__file__.replace("\\", "/").split("/")[-3] - except Exception: - host_name = str(host) - message = ( - "Host \"{}\" miss methods {}".format(host_name, joined_missing) - ) - super(MissingMethodsError, self).__init__(message) +from .exceptions import MissingMethodsError class ILoadHost: From 9d0d8309d0f818d9a6747c374f3265f55cb7a776 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Apr 2025 17:14:16 +0200 Subject: [PATCH 009/312] move workfiles interface to separate file --- client/ayon_core/host/interfaces/__init__.py | 2 +- .../ayon_core/host/interfaces/interfaces.py | 177 +---------------- client/ayon_core/host/interfaces/workfiles.py | 178 ++++++++++++++++++ 3 files changed, 180 insertions(+), 177 deletions(-) create mode 100644 client/ayon_core/host/interfaces/workfiles.py diff --git a/client/ayon_core/host/interfaces/__init__.py b/client/ayon_core/host/interfaces/__init__.py index efe1ea6c5c..560cd3c0b4 100644 --- a/client/ayon_core/host/interfaces/__init__.py +++ b/client/ayon_core/host/interfaces/__init__.py @@ -1,9 +1,9 @@ from .exceptions import MissingMethodsError +from .workfiles import IWorkfileHost from .interfaces import ( IPublishHost, INewPublisher, ILoadHost, - IWorkfileHost, ) diff --git a/client/ayon_core/host/interfaces/interfaces.py b/client/ayon_core/host/interfaces/interfaces.py index 5fc40134f8..a41dffe92a 100644 --- a/client/ayon_core/host/interfaces/interfaces.py +++ b/client/ayon_core/host/interfaces/interfaces.py @@ -1,4 +1,4 @@ -from abc import ABC, abstractmethod +from abc import abstractmethod from .exceptions import MissingMethodsError @@ -83,181 +83,6 @@ class ILoadHost: return self.get_containers() -class IWorkfileHost(ABC): - """Implementation requirements to be able use workfile utils and tool.""" - - @staticmethod - def get_missing_workfile_methods(host): - """Look for missing methods on "old type" host implementation. - - Method is used for validation of implemented functions related to - workfiles. Checks only existence of methods. - - Args: - Union[ModuleType, HostBase]: Object of host where to look for - required methods. - - Returns: - list[str]: Missing method implementations for workfiles workflow. - """ - - if isinstance(host, IWorkfileHost): - return [] - - required = [ - "open_file", - "save_file", - "current_file", - "has_unsaved_changes", - "file_extensions", - "work_root", - ] - missing = [] - for name in required: - if not hasattr(host, name): - missing.append(name) - return missing - - @staticmethod - def validate_workfile_methods(host): - """Validate methods of "old type" host for workfiles workflow. - - Args: - Union[ModuleType, HostBase]: Object of host to validate. - - Raises: - MissingMethodsError: If there are missing methods on host - implementation. - """ - - missing = IWorkfileHost.get_missing_workfile_methods(host) - if missing: - raise MissingMethodsError(host, missing) - - @abstractmethod - def get_workfile_extensions(self): - """Extensions that can be used as save. - - Questions: - This could potentially use 'HostDefinition'. - """ - - return [] - - @abstractmethod - def save_workfile(self, dst_path=None): - """Save currently opened scene. - - Args: - dst_path (str): Where the current scene should be saved. Or use - current path if 'None' is passed. - """ - - pass - - @abstractmethod - def open_workfile(self, filepath): - """Open passed filepath in the host. - - Args: - filepath (str): Path to workfile. - """ - - pass - - @abstractmethod - def get_current_workfile(self): - """Retrieve path to current opened file. - - Returns: - str: Path to file which is currently opened. - None: If nothing is opened. - """ - - return None - - def workfile_has_unsaved_changes(self): - """Currently opened scene is saved. - - Not all hosts can know if current scene is saved because the API of - DCC does not support it. - - Returns: - bool: True if scene is saved and False if has unsaved - modifications. - None: Can't tell if workfiles has modifications. - """ - - return None - - def work_root(self, session): - """Modify workdir per host. - - Default implementation keeps workdir untouched. - - Warnings: - We must handle this modification with more sophisticated way - because this can't be called out of DCC so opening of last workfile - (calculated before DCC is launched) is complicated. Also breaking - defined work template is not a good idea. - Only place where it's really used and can make sense is Maya. There - workspace.mel can modify subfolders where to look for maya files. - - Args: - session (dict): Session context data. - - Returns: - str: Path to new workdir. - """ - - return session["AYON_WORKDIR"] - - # --- Deprecated method names --- - def file_extensions(self): - """Deprecated variant of 'get_workfile_extensions'. - - Todo: - Remove when all usages are replaced. - """ - return self.get_workfile_extensions() - - def save_file(self, dst_path=None): - """Deprecated variant of 'save_workfile'. - - Todo: - Remove when all usages are replaced. - """ - - self.save_workfile(dst_path) - - def open_file(self, filepath): - """Deprecated variant of 'open_workfile'. - - Todo: - Remove when all usages are replaced. - """ - - return self.open_workfile(filepath) - - def current_file(self): - """Deprecated variant of 'get_current_workfile'. - - Todo: - Remove when all usages are replaced. - """ - - return self.get_current_workfile() - - def has_unsaved_changes(self): - """Deprecated variant of 'workfile_has_unsaved_changes'. - - Todo: - Remove when all usages are replaced. - """ - - return self.workfile_has_unsaved_changes() - - class IPublishHost: """Functions related to new creation system in new publisher. diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py new file mode 100644 index 0000000000..433c66277e --- /dev/null +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -0,0 +1,178 @@ +from abc import ABC, abstractmethod + +from .exceptions import MissingMethodsError + + +class IWorkfileHost(ABC): + """Implementation requirements to be able use workfile utils and tool.""" + + @staticmethod + def get_missing_workfile_methods(host): + """Look for missing methods on "old type" host implementation. + + Method is used for validation of implemented functions related to + workfiles. Checks only existence of methods. + + Args: + Union[ModuleType, HostBase]: Object of host where to look for + required methods. + + Returns: + list[str]: Missing method implementations for workfiles workflow. + """ + + if isinstance(host, IWorkfileHost): + return [] + + required = [ + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root", + ] + missing = [] + for name in required: + if not hasattr(host, name): + missing.append(name) + return missing + + @staticmethod + def validate_workfile_methods(host): + """Validate methods of "old type" host for workfiles workflow. + + Args: + Union[ModuleType, HostBase]: Object of host to validate. + + Raises: + MissingMethodsError: If there are missing methods on host + implementation. + """ + + missing = IWorkfileHost.get_missing_workfile_methods(host) + if missing: + raise MissingMethodsError(host, missing) + + @abstractmethod + def get_workfile_extensions(self): + """Extensions that can be used as save. + + Questions: + This could potentially use 'HostDefinition'. + """ + + return [] + + @abstractmethod + def save_workfile(self, dst_path=None): + """Save currently opened scene. + + Args: + dst_path (str): Where the current scene should be saved. Or use + current path if 'None' is passed. + """ + + pass + + @abstractmethod + def open_workfile(self, filepath): + """Open passed filepath in the host. + + Args: + filepath (str): Path to workfile. + """ + + pass + + @abstractmethod + def get_current_workfile(self): + """Retrieve path to current opened file. + + Returns: + str: Path to file which is currently opened. + None: If nothing is opened. + """ + + return None + + def workfile_has_unsaved_changes(self): + """Currently opened scene is saved. + + Not all hosts can know if current scene is saved because the API of + DCC does not support it. + + Returns: + bool: True if scene is saved and False if has unsaved + modifications. + None: Can't tell if workfiles has modifications. + """ + + return None + + def work_root(self, session): + """Modify workdir per host. + + Default implementation keeps workdir untouched. + + Warnings: + We must handle this modification with more sophisticated way + because this can't be called out of DCC so opening of last workfile + (calculated before DCC is launched) is complicated. Also breaking + defined work template is not a good idea. + Only place where it's really used and can make sense is Maya. There + workspace.mel can modify subfolders where to look for maya files. + + Args: + session (dict): Session context data. + + Returns: + str: Path to new workdir. + """ + + return session["AYON_WORKDIR"] + + # --- Deprecated method names --- + def file_extensions(self): + """Deprecated variant of 'get_workfile_extensions'. + + Todo: + Remove when all usages are replaced. + """ + return self.get_workfile_extensions() + + def save_file(self, dst_path=None): + """Deprecated variant of 'save_workfile'. + + Todo: + Remove when all usages are replaced. + """ + + self.save_workfile(dst_path) + + def open_file(self, filepath): + """Deprecated variant of 'open_workfile'. + + Todo: + Remove when all usages are replaced. + """ + + return self.open_workfile(filepath) + + def current_file(self): + """Deprecated variant of 'get_current_workfile'. + + Todo: + Remove when all usages are replaced. + """ + + return self.get_current_workfile() + + def has_unsaved_changes(self): + """Deprecated variant of 'workfile_has_unsaved_changes'. + + Todo: + Remove when all usages are replaced. + """ + + return self.workfile_has_unsaved_changes() \ No newline at end of file From 9e1e36c412ce1a6e753bf7bba621b08d59492f23 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Apr 2025 17:15:22 +0200 Subject: [PATCH 010/312] remove ABC form 'IWorkfileHost' --- client/ayon_core/host/interfaces/workfiles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 433c66277e..6245b2e144 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from .exceptions import MissingMethodsError -class IWorkfileHost(ABC): +class IWorkfileHost: """Implementation requirements to be able use workfile utils and tool.""" @staticmethod From 7e5d8612a7817bbd8a3cedcdb100a05f37a21291 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Apr 2025 17:18:13 +0200 Subject: [PATCH 011/312] remove validation of methods --- client/ayon_core/host/interfaces/workfiles.py | 50 +------------------ client/ayon_core/tools/workfiles/control.py | 7 +-- 2 files changed, 2 insertions(+), 55 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 6245b2e144..496ee06e4b 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -1,58 +1,10 @@ -from abc import ABC, abstractmethod +from abc import abstractmethod -from .exceptions import MissingMethodsError class IWorkfileHost: """Implementation requirements to be able use workfile utils and tool.""" - @staticmethod - def get_missing_workfile_methods(host): - """Look for missing methods on "old type" host implementation. - - Method is used for validation of implemented functions related to - workfiles. Checks only existence of methods. - - Args: - Union[ModuleType, HostBase]: Object of host where to look for - required methods. - - Returns: - list[str]: Missing method implementations for workfiles workflow. - """ - - if isinstance(host, IWorkfileHost): - return [] - - required = [ - "open_file", - "save_file", - "current_file", - "has_unsaved_changes", - "file_extensions", - "work_root", - ] - missing = [] - for name in required: - if not hasattr(host, name): - missing.append(name) - return missing - - @staticmethod - def validate_workfile_methods(host): - """Validate methods of "old type" host for workfiles workflow. - - Args: - Union[ModuleType, HostBase]: Object of host to validate. - - Raises: - MissingMethodsError: If there are missing methods on host - implementation. - """ - - missing = IWorkfileHost.get_missing_workfile_methods(host) - if missing: - raise MissingMethodsError(host, missing) @abstractmethod def get_workfile_extensions(self): diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index 3a7459da0c..9cd3c0f76a 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -140,12 +140,7 @@ class BaseWorkfileController( if host is None: host = registered_host() - host_is_valid = False - if host is not None: - missing_methods = ( - IWorkfileHost.get_missing_workfile_methods(host) - ) - host_is_valid = len(missing_methods) == 0 + host_is_valid = isinstance(host, IWorkfileHost) self._host = host self._host_is_valid = host_is_valid From 9140d1124d38fdaa825f4d47ea47bf672d81e3f5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Apr 2025 17:22:12 +0200 Subject: [PATCH 012/312] added helper data structure for colleting workfiles --- client/ayon_core/host/interfaces/workfiles.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 496ee06e4b..077ececeb6 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -1,6 +1,73 @@ +import os from abc import abstractmethod +from dataclasses import dataclass, asdict +from typing import Optional +@dataclass +class WorkfileInfo: + filepath: str + rootless_path: str + file_size: Optional[float] + file_created: Optional[float] + file_modified: Optional[float] + workfile_entity_id: Optional[str] + description: str + created_by: Optional[str] + updated_by: Optional[str] + available: bool + + @classmethod + def new(cls, filepath, rootless_path, available, workfile_entity): + file_size = file_modified = file_created = None + if filepath and os.path.exists(filepath): + filestat = os.stat(filepath) + file_size = filestat.st_size + file_created = filestat.st_ctime + file_modified = filestat.st_mtime + + if workfile_entity is None: + workfile_entity = {} + + attrib = {} + if workfile_entity: + attrib = workfile_entity["attrib"] + + return cls( + filepath=filepath, + rootless_path=rootless_path, + file_size=file_size, + file_created=file_created, + file_modified=file_modified, + workfile_entity_id=workfile_entity.get("id"), + description=attrib.get("description") or "", + created_by=workfile_entity.get("createdBy"), + updated_by=workfile_entity.get("updatedBy"), + available=available, + ) + + def to_data(self): + """Converts file item to data. + + Returns: + dict[str, Any]: Workfile item data. + + """ + return asdict(self) + + @classmethod + def from_data(self, data): + """Converts data to workfile item. + + Args: + data (dict[str, Any]): Workfile item data. + + Returns: + WorkfileInfo: File item. + + """ + return WorkfileInfo(**data) + class IWorkfileHost: """Implementation requirements to be able use workfile utils and tool.""" From 156eb14bf645ff341463ba2bff3ed9e06174fcb9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Apr 2025 17:23:22 +0200 Subject: [PATCH 013/312] remove unnecessary 'work_root' --- client/ayon_core/host/interfaces/workfiles.py | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 077ececeb6..263651a422 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -129,28 +129,6 @@ class IWorkfileHost: return None - def work_root(self, session): - """Modify workdir per host. - - Default implementation keeps workdir untouched. - - Warnings: - We must handle this modification with more sophisticated way - because this can't be called out of DCC so opening of last workfile - (calculated before DCC is launched) is complicated. Also breaking - defined work template is not a good idea. - Only place where it's really used and can make sense is Maya. There - workspace.mel can modify subfolders where to look for maya files. - - Args: - session (dict): Session context data. - - Returns: - str: Path to new workdir. - """ - - return session["AYON_WORKDIR"] - # --- Deprecated method names --- def file_extensions(self): """Deprecated variant of 'get_workfile_extensions'. From fe28391ce8cfab29f37ee5f2fecb59258d490a8d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Apr 2025 17:24:11 +0200 Subject: [PATCH 014/312] add new line char at the end --- client/ayon_core/host/interfaces/workfiles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 263651a422..7b69404f60 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -172,4 +172,4 @@ class IWorkfileHost: Remove when all usages are replaced. """ - return self.workfile_has_unsaved_changes() \ No newline at end of file + return self.workfile_has_unsaved_changes() From 515cd79a1ad81ebcc656c19dc06302e51b6897e4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Apr 2025 17:28:38 +0200 Subject: [PATCH 015/312] first implementation of list workfiles --- client/ayon_core/host/interfaces/workfiles.py | 132 +++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 7b69404f60..a2a9ee511a 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -1,7 +1,11 @@ +from __future__ import annotations import os +import platform from abc import abstractmethod from dataclasses import dataclass, asdict -from typing import Optional +from typing import Optional, Any + +import ayon_api @dataclass @@ -129,6 +133,132 @@ class IWorkfileHost: return None + def list_workfiles( + self, + project_name: str, + folder_id: str, + task_id: str, + project_entity: Optional[dict[str, Any]] = None, + folder_entity: Optional[dict[str, Any]] = None, + task_entity: Optional[dict[str, Any]] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, + template_key: Optional[str] = None, + project_settings: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + ) -> list[WorkfileInfo]: + """List workfiles in the given folder. + + NOTES: + - Better method name? + - This method is pre-implemented as the logic can be shared across + 95% of host integrations. Ad-hoc implementation to give host + integration workfile api functionality. + - Should this method also handle workfiles based on workfile entities? + + Args: + project_name (str): Name of project. + folder_id (str): ID of folder. + task_id (str): ID of task. + project_entity (Optional[dict[str, Any]]): Project entity. + folder_entity (Optional[dict[str, Any]]): Folder entity. + task_entity (Optional[dict[str, Any]]): Task entity. + workfile_entities (Optional[list[dict[str, Any]]]): Workfile + entities. + template_key (Optional[str]): Template key. + project_settings (Optional[dict[str, Any]]): Project settings. + anatomy (Anatomy): Project anatomy. + + Returns: + list[WorkfileInfo]: List of workfiles. + + """ + from ayon_core.pipeline import Anatomy + from ayon_core.pipeline.template_data import get_template_data + from ayon_core.pipeline.workfile import get_workdir_with_workdir_data + + extensions = self.get_workfile_extensions() + if not extensions: + return [] + + if project_entity is None: + project_entity = ayon_api.get_project(project_name) + + if folder_entity is None: + folder_entity = ayon_api.get_folder_by_id(project_name, folder_id) + + if task_entity is None: + task_entity = ayon_api.get_task_by_id(project_name, task_id) + + if workfile_entities is None: + workfile_entities = list(ayon_api.get_workfiles_info( + project_name, task_ids=[task_id] + )) + + if anatomy is None: + anatomy = Anatomy(project_name, project_entity=project_entity) + + workfile_entities_by_path = { + workfile_entity["path"]: workfile_entity + for workfile_entity in workfile_entities + } + + workdir_data = get_template_data( + project_entity, + folder_entity, + task_entity, + host_name=self.name, + ) + workdir = get_workdir_with_workdir_data( + workdir_data, + project_name, + anatomy=anatomy, + template_key=template_key, + project_settings=project_settings, + ) + + if platform.system().lower() == "windows": + rootless_workdir = workdir.replace("\\", "/") + else: + rootless_workdir = workdir + + used_roots = workdir.used_values.get("root") + if used_roots: + used_root_name = next(iter(used_roots)) + root_value = used_roots[used_root_name] + workdir_end = rootless_workdir[len(root_value):].lstrip("/") + rootless_workdir = f"{{root[{used_root_name}]}}/{workdir_end}" + + filenames = [] + if os.path.exists(workdir): + filenames = list(os.listdir(workdir)) + + items = [] + for filename in filenames: + filepath = os.path.join(workdir, filename) + # TODO add 'default' support for folders + ext = os.path.splitext(filepath)[1].lower() + if ext not in extensions: + continue + + rootless_path = f"{rootless_workdir}/{filename}" + workfile_entity = workfile_entities_by_path.pop( + rootless_path, None + ) + items.append(WorkfileInfo.new( + filepath, rootless_path, True, workfile_entity + )) + + for workfile_entity in workfile_entities_by_path.values(): + # Workfile entity is not in the filesystem + # but it is in the database + rootless_path = workfile_entity["path"] + filepath = anatomy.fill_root(rootless_path) + items.append(WorkfileInfo.new( + filepath, rootless_path, False, workfile_entity + )) + + return items + # --- Deprecated method names --- def file_extensions(self): """Deprecated variant of 'get_workfile_extensions'. From 152211a047a5543fa9ae66b2bbb8e0e393b99c79 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 09:16:50 +0200 Subject: [PATCH 016/312] 'get_workfile_extensions' is not abstract anymore --- client/ayon_core/host/interfaces/workfiles.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index a2a9ee511a..34d7dddef6 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -77,16 +77,6 @@ class IWorkfileHost: """Implementation requirements to be able use workfile utils and tool.""" - @abstractmethod - def get_workfile_extensions(self): - """Extensions that can be used as save. - - Questions: - This could potentially use 'HostDefinition'. - """ - - return [] - @abstractmethod def save_workfile(self, dst_path=None): """Save currently opened scene. @@ -133,6 +123,18 @@ class IWorkfileHost: return None + def get_workfile_extensions(self) -> list[str]: + """Extensions that can be used as save. + + Questions: + This could potentially use 'HostDefinition'. + + Returns: + list[str]: List of extensions that can be used for saving. + + """ + return [] + def list_workfiles( self, project_name: str, From d78e25c7ecac46768c06efb407f7de513242c31e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 10:09:32 +0200 Subject: [PATCH 017/312] added helper function to collect comments from existing workfiles --- .../pipeline/workfile/path_resolving.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 9b2fe25199..bd4a7f0035 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import re import copy @@ -12,6 +13,7 @@ from ayon_core.lib import ( Logger, StringTemplate, ) +from ayon_core.lib.path_templates import TemplateResult from ayon_core.pipeline import version_start, Anatomy from ayon_core.pipeline.template_data import get_template_data @@ -562,3 +564,99 @@ def create_workdir_extra_folders( fullpath = os.path.join(workdir, subfolder) if not os.path.exists(fullpath): os.makedirs(fullpath) + + +class CommentMatcher: + """Use anatomy and work file data to parse comments from filenames. + + Args: + extensions (set[str]): Set of extensions. + file_template (StringTemplate): Workfile file template. + data (dict[str, Any]): Data to fill the template with. + + """ + def __init__( + self, + extensions: set[str], + file_template: StringTemplate, + data: dict[str, Any] + ): + self._fname_regex = None + + if "{comment}" not in file_template: + # Don't look for comment if template doesn't allow it + return + + # Create a regex group for extensions + any_extension = "(?:{})".format( + "|".join(re.escape(ext.lstrip(".")) for ext in extensions) + ) + + # Use placeholders that will never be in the filename + temp_data = copy.deepcopy(data) + temp_data["comment"] = "<>" + temp_data["version"] = "<>" + temp_data["ext"] = "<>" + + fname_pattern = re.escape( + file_template.format_strict(temp_data) + ) + + # Replace comment and version with something we can match with regex + replacements = ( + ("<>", r"(?P.+)"), + ("<>", r"[0-9]+"), + ("<>", any_extension), + ) + for src, dest in replacements: + fname_pattern = fname_pattern.replace(re.escape(src), dest) + + # Match from beginning to end of string to be safe + self._fname_regex = re.compile(f"^{fname_pattern}$") + + def parse_comment(self, filename: str) -> Optional[str]: + """Parse the {comment} part from a filename""" + if self._fname_regex: + match = self._fname_regex.match(filename) + if match: + return match.group("comment") + return None + + +def get_comments_from_work_filenames( + filenames: list[str], + extensions: set[str], + file_template: StringTemplate, + template_data: dict[str, Any], + current_filename: Optional[str] = None, +) -> tuple[list[str], str]: + """Collect comments from workfile filenames. + + Based on 'current_filename' is also returned "current comment". + + Args: + filenames (list[str]): List of filenames to parse. + extensions (set[str]): Set of file extensions. + file_template (StringTemplate): Workfile file template. + template_data (dict[str, Any]): Data to fill the template with. + current_filename (str): Filename to check for current comment. + + Returns: + tuple[list[str], str]: List of comments and the current comment. + + """ + current_comment = "" + if not filenames: + return [], current_comment + + matcher = CommentMatcher(extensions, file_template, template_data) + + comment_hints = set() + for filename in filenames: + comment = matcher.parse_comment(filename) + if comment: + comment_hints.add(comment) + if filename == current_filename: + current_comment = comment + + return list(comment_hints), current_comment From 615529fa85a3348dd2c407d771656d31814f6603 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:31:56 +0200 Subject: [PATCH 018/312] added 'WorkfileInfo' to host public api --- client/ayon_core/host/__init__.py | 2 ++ client/ayon_core/host/interfaces/__init__.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/host/__init__.py b/client/ayon_core/host/__init__.py index da1237c739..80ff0f2e38 100644 --- a/client/ayon_core/host/__init__.py +++ b/client/ayon_core/host/__init__.py @@ -4,6 +4,7 @@ from .host import ( from .interfaces import ( IWorkfileHost, + WorkfileInfo, ILoadHost, IPublishHost, INewPublisher, @@ -16,6 +17,7 @@ __all__ = ( "HostBase", "IWorkfileHost", + "WorkfileInfo", "ILoadHost", "IPublishHost", "INewPublisher", diff --git a/client/ayon_core/host/interfaces/__init__.py b/client/ayon_core/host/interfaces/__init__.py index 560cd3c0b4..4ee6375012 100644 --- a/client/ayon_core/host/interfaces/__init__.py +++ b/client/ayon_core/host/interfaces/__init__.py @@ -1,5 +1,5 @@ from .exceptions import MissingMethodsError -from .workfiles import IWorkfileHost +from .workfiles import IWorkfileHost, WorkfileInfo from .interfaces import ( IPublishHost, INewPublisher, @@ -10,6 +10,7 @@ from .interfaces import ( __all__ = ( "MissingMethodsError", "IWorkfileHost", + "WorkfileInfo", "IPublishHost", "INewPublisher", "ILoadHost", From a61a94d1a9b5b9e62a376e7c77522c68c311d83b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:32:19 +0200 Subject: [PATCH 019/312] added more helper functions to workfile path mapping --- .../ayon_core/pipeline/workfile/__init__.py | 8 + .../pipeline/workfile/path_resolving.py | 279 ++++++++++++------ 2 files changed, 197 insertions(+), 90 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/__init__.py b/client/ayon_core/pipeline/workfile/__init__.py index aa7e150bca..5b8a10c288 100644 --- a/client/ayon_core/pipeline/workfile/__init__.py +++ b/client/ayon_core/pipeline/workfile/__init__.py @@ -4,6 +4,8 @@ from .path_resolving import ( get_workdir_with_workdir_data, get_workdir, + get_last_workfile_with_version_from_paths, + get_last_workfile_from_paths, get_last_workfile_with_version, get_last_workfile, @@ -11,6 +13,8 @@ from .path_resolving import ( get_custom_workfile_template_by_string_context, create_workdir_extra_folders, + + get_comments_from_workfile_paths, ) from .utils import ( @@ -37,6 +41,8 @@ __all__ = ( "get_workdir_with_workdir_data", "get_workdir", + "get_last_workfile_with_version_from_paths", + "get_last_workfile_from_paths", "get_last_workfile_with_version", "get_last_workfile", @@ -45,6 +51,8 @@ __all__ = ( "create_workdir_extra_folders", + "get_comments_from_workfile_paths", + "should_use_last_workfile_on_launch", "should_open_workfiles_tool_on_launch", "MissingWorkdirError", diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index bd4a7f0035..ac915060eb 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -113,7 +113,7 @@ def get_workdir_with_workdir_data( anatomy=None, template_key=None, project_settings=None -): +) -> TemplateResult: """Fill workdir path from entered data and project's anatomy. It is possible to pass only project's name instead of project's anatomy but @@ -133,8 +133,8 @@ def get_workdir_with_workdir_data( Returns: TemplateResult: Workdir path. - """ + """ if not anatomy: anatomy = Anatomy(project_name) @@ -176,8 +176,8 @@ def get_workdir( is stored under `AYON_HOST_NAME` key. anatomy (Anatomy): Optional argument. Anatomy object is created using project name from `project_entity`. It is preferred to pass this - argument as initialization of a new Anatomy object may be time - consuming. + argument as initialization of a new Anatomy object may be + time-consuming. template_key (str): Key of work templates in anatomy templates. Default value is defined in `get_workdir_with_workdir_data`. project_settings(Dict[str, Any]): Prepared project settings for @@ -209,6 +209,159 @@ def get_workdir( ) +def get_last_workfile_with_version_from_paths( + filepaths, file_template, template_data, extensions +): + """Return last workfile version. + + Using workfile template and it's filling data find most possible last + version of workfile which was created for the context. + + Functionality is fully based on knowing which keys are optional or what + values are expected as value. + + The last modified file is used if more files can be considered as + last workfile. + + Args: + filepaths (list[str]): Workfile paths. + file_template (str): Template of file name. + template_data (Dict[str, Any]): Data for filling template. + extensions (Iterable[str]): All allowed file extensions of workfile. + + Returns: + tuple[Union[str, None], Union[int, None]]: Last workfile with version + if there is any workfile otherwise None for both. + + """ + if not filepaths: + return None, None + + dotted_extensions = set() + for ext in extensions: + if not ext.startswith("."): + ext = f".{ext}" + dotted_extensions.add(re.escape(ext)) + + # Build template without optionals, version to digits only regex + # and comment to any definable value. + # Escape extensions dot for regex + ext_expression = "(?:" + "|".join(dotted_extensions) + ")" + + for pattern, replacement in ( + # Replace `.{ext}` with `{ext}` so we are sure dot is not at the end + (r"\.?{ext}", ext_expression), + # Replace optional keys with optional content regex + (r"<.*?>", r".*?"), + # Replace `{version}` with group regex + (r"{version.*?}", r"([0-9]+)"), + (r"{comment.*?}", r".+?"), + ): + file_template = re.sub(pattern, replacement, file_template) + + file_template = StringTemplate.format_strict_template( + file_template, template_data + ) + + # Match with ignore case on Windows due to the Windows + # OS not being case-sensitive. This avoids later running + # into the error that the file did exist if it existed + # with a different upper/lower-case. + kwargs = {} + if platform.system().lower() == "windows": + kwargs["flags"] = re.IGNORECASE + + # Get highest version among existing matching files + version = None + output_filepaths = [] + for filepath in sorted(filepaths): + filename = os.path.basename(filepath) + match = re.match(file_template, filename, **kwargs) + if not match: + continue + + if not match.groups(): + output_filepaths.append(filename) + continue + + file_version = int(match.group(1)) + if version is None or file_version > version: + output_filepaths.clear() + version = file_version + + if file_version == version: + output_filepaths.append(filepath) + + output_filepath = None + last_time = None + for _output_filepath in output_filepaths: + mod_time = None + if os.path.exists(_output_filepath): + mod_time = os.path.getmtime(_output_filepath) + if ( + last_time is None + or (mod_time is not None and last_time < mod_time) + ): + output_filepath = _output_filepath + last_time = mod_time + + return output_filepath, version + + +def get_last_workfile_from_paths( + filepaths: list[str], + file_template: str, + template_data: dict[str, Any], + extensions: set[str], +): + """Return last workfile filename. + + Returns file with version 1 if there is not workfile yet. + + Args: + filepaths (list[str]): Paths to workfiles. + file_template (str): Template of file name. + template_data (dict[str, Any]): Data for filling template. + extensions (set[str]): All allowed file extensions of workfile. + + Returns: + Optional[str]: Last or first workfile as filename of full path + to filename. + + """ + filepath, _version = get_last_workfile_with_version_from_paths( + filepaths, file_template, template_data, extensions + ) + return filepath + + +def _filter_dir_files_by_ext( + dirpath: str, + extensions: set[str], +): + """Filter files by extensions. + + Args: + dirpath (str): List of file paths. + extensions (set[str]): Set of file extensions. + + Returns: + tuple[list[str], set[str]]: Filtered list of file paths. + + """ + dotted_extensions = set() + for ext in extensions: + if not ext.startswith("."): + ext = f".{ext}" + dotted_extensions.add(ext) + filtered_paths = [ + os.path.join(dirpath, filename) + for filename in os.listdir(dirpath) + if os.path.splitext(filename)[-1] in dotted_extensions + ] + return filtered_paths, dotted_extensions + + def get_last_workfile_with_version( workdir, file_template, fill_data, extensions ): @@ -237,85 +390,24 @@ def get_last_workfile_with_version( if not os.path.exists(workdir): return None, None - dotted_extensions = set() - for ext in extensions: - if not ext.startswith("."): - ext = ".{}".format(ext) - dotted_extensions.add(ext) - - # Fast match on extension - filenames = [ - filename - for filename in os.listdir(workdir) - if os.path.splitext(filename)[-1] in dotted_extensions - ] - - # Build template without optionals, version to digits only regex - # and comment to any definable value. - # Escape extensions dot for regex - regex_exts = [ - "\\" + ext - for ext in dotted_extensions - ] - ext_expression = "(?:" + "|".join(regex_exts) + ")" - - # Replace `.{ext}` with `{ext}` so we are sure there is not dot at the end - file_template = re.sub(r"\.?{ext}", ext_expression, file_template) - # Replace optional keys with optional content regex - file_template = re.sub(r"<.*?>", r".*?", file_template) - # Replace `{version}` with group regex - file_template = re.sub(r"{version.*?}", r"([0-9]+)", file_template) - file_template = re.sub(r"{comment.*?}", r".+?", file_template) - file_template = StringTemplate.format_strict_template( - file_template, fill_data + filepaths, dotted_extensions = _filter_dir_files_by_ext( + workdir, extensions ) - # Match with ignore case on Windows due to the Windows - # OS not being case-sensitive. This avoids later running - # into the error that the file did exist if it existed - # with a different upper/lower-case. - kwargs = {} - if platform.system().lower() == "windows": - kwargs["flags"] = re.IGNORECASE - - # Get highest version among existing matching files - version = None - output_filenames = [] - for filename in sorted(filenames): - match = re.match(file_template, filename, **kwargs) - if not match: - continue - - if not match.groups(): - output_filenames.append(filename) - continue - - file_version = int(match.group(1)) - if version is None or file_version > version: - output_filenames[:] = [] - version = file_version - - if file_version == version: - output_filenames.append(filename) - - output_filename = None - if output_filenames: - if len(output_filenames) == 1: - output_filename = output_filenames[0] - else: - last_time = None - for _output_filename in output_filenames: - full_path = os.path.join(workdir, _output_filename) - mod_time = os.path.getmtime(full_path) - if last_time is None or last_time < mod_time: - output_filename = _output_filename - last_time = mod_time - - return output_filename, version + return get_last_workfile_with_version_from_paths( + filepaths, + file_template, + fill_data, + dotted_extensions, + ) def get_last_workfile( - workdir, file_template, fill_data, extensions, full_path=False + workdir: str, + file_template: str, + fill_data: dict[str, Any], + extensions: set[str], + full_path: bool = False ): """Return last workfile filename. @@ -326,17 +418,23 @@ def get_last_workfile( file_template (str): Template of file name. fill_data (Dict[str, Any]): Data for filling template. extensions (Iterable[str]): All allowed file extensions of workfile. - full_path (Optional[bool]): Full path to file is returned if + full_path (bool): Full path to file is returned if set to True. Returns: str: Last or first workfile as filename of full path to filename. """ - filename, _version = get_last_workfile_with_version( - workdir, file_template, fill_data, extensions + filepaths, dotted_extensions = _filter_dir_files_by_ext( + workdir, extensions ) - if filename is None: + filepath = get_last_workfile_from_paths( + filepaths, + file_template, + fill_data, + dotted_extensions + ) + if filepath is None: data = copy.deepcopy(fill_data) data["version"] = version_start.get_versioning_start( data["project"]["name"], @@ -350,11 +448,11 @@ def get_last_workfile( data["ext"] = extensions[0] data["ext"] = data["ext"].lstrip(".") filename = StringTemplate.format_strict_template(file_template, data) + filepath = os.path.join(workdir, filename) if full_path: - return os.path.normpath(os.path.join(workdir, filename)) - - return filename + return os.path.normpath(filepath) + return os.path.basename(filepath) def get_custom_workfile_template( @@ -623,8 +721,8 @@ class CommentMatcher: return None -def get_comments_from_work_filenames( - filenames: list[str], +def get_comments_from_workfile_paths( + filepaths: list[str], extensions: set[str], file_template: StringTemplate, template_data: dict[str, Any], @@ -635,7 +733,7 @@ def get_comments_from_work_filenames( Based on 'current_filename' is also returned "current comment". Args: - filenames (list[str]): List of filenames to parse. + filepaths (list[str]): List of filepaths to parse. extensions (set[str]): Set of file extensions. file_template (StringTemplate): Workfile file template. template_data (dict[str, Any]): Data to fill the template with. @@ -646,13 +744,14 @@ def get_comments_from_work_filenames( """ current_comment = "" - if not filenames: + if not filepaths: return [], current_comment matcher = CommentMatcher(extensions, file_template, template_data) comment_hints = set() - for filename in filenames: + for filepath in filepaths: + filename = os.path.basename(filepath) comment = matcher.parse_comment(filename) if comment: comment_hints.add(comment) From b5f8997248601ea96c021f61f478e08522b93440 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:32:30 +0200 Subject: [PATCH 020/312] selection cares about more information --- client/ayon_core/tools/workfiles/models/selection.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/workfiles/models/selection.py b/client/ayon_core/tools/workfiles/models/selection.py index 2f0896842d..9a6440b2a1 100644 --- a/client/ayon_core/tools/workfiles/models/selection.py +++ b/client/ayon_core/tools/workfiles/models/selection.py @@ -62,7 +62,9 @@ class SelectionModel(object): def get_selected_workfile_path(self): return self._workfile_path - def set_selected_workfile_path(self, path): + def set_selected_workfile_path( + self, rootless_path, path, workfile_entity_id + ): if path == self._workfile_path: return @@ -72,9 +74,11 @@ class SelectionModel(object): { "project_name": self._controller.get_current_project_name(), "path": path, + "rootless_path": rootless_path, "folder_id": self._folder_id, "task_name": self._task_name, "task_id": self._task_id, + "workfile_entity_id": workfile_entity_id, }, self.event_source ) From 1037776e93f71599a9200e7fd60c952875770056 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:34:23 +0200 Subject: [PATCH 021/312] pass project settings to template key getter --- client/ayon_core/tools/workfiles/models/workfiles.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index cc034571f3..da4e455cb4 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -213,6 +213,7 @@ class WorkareaModel: self.project_name, task_type, self._controller.get_host_name(), + project_settings=self._controller.project_settings, ) def _get_last_workfile_version( From 4220f9200081db683136941907c0901e235e1717 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:34:52 +0200 Subject: [PATCH 022/312] pass host name to template data getter --- client/ayon_core/tools/workfiles/models/workfiles.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index da4e455cb4..7d56f02a2f 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -114,9 +114,9 @@ class WorkareaModel: def _get_base_data(self): if self._base_data is None: base_data = get_template_data( - ayon_api.get_project(self.project_name) + ayon_api.get_project(self._project_name), + host_name=self._controller.get_host_name(), ) - base_data["app"] = self._controller.get_host_name() self._base_data = base_data return copy.deepcopy(self._base_data) From 67f478d8b54be64f719dea395269d925c0c52ca6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:38:08 +0200 Subject: [PATCH 023/312] modified controller base --- client/ayon_core/tools/workfiles/abstract.py | 269 +++++++++---------- 1 file changed, 120 insertions(+), 149 deletions(-) diff --git a/client/ayon_core/tools/workfiles/abstract.py b/client/ayon_core/tools/workfiles/abstract.py index 152ca33d99..78e31f9abd 100644 --- a/client/ayon_core/tools/workfiles/abstract.py +++ b/client/ayon_core/tools/workfiles/abstract.py @@ -3,75 +3,7 @@ from abc import ABC, abstractmethod from ayon_core.style import get_default_entity_icon_color - -class WorkfileInfo: - """Information about workarea file with possible additional from database. - - Args: - folder_id (str): Folder id. - task_id (str): Task id. - filepath (str): Filepath. - filesize (int): File size. - creation_time (float): Creation time (timestamp). - modification_time (float): Modification time (timestamp). - created_by (Union[str, none]): User who created the file. - updated_by (Union[str, none]): User who last updated the file. - note (str): Note. - """ - - def __init__( - self, - folder_id, - task_id, - filepath, - filesize, - creation_time, - modification_time, - created_by, - updated_by, - note, - ): - self.folder_id = folder_id - self.task_id = task_id - self.filepath = filepath - self.filesize = filesize - self.creation_time = creation_time - self.modification_time = modification_time - self.created_by = created_by - self.updated_by = updated_by - self.note = note - - def to_data(self): - """Converts WorkfileInfo item to data. - - Returns: - dict[str, Any]: Folder item data. - """ - - return { - "folder_id": self.folder_id, - "task_id": self.task_id, - "filepath": self.filepath, - "filesize": self.filesize, - "creation_time": self.creation_time, - "modification_time": self.modification_time, - "created_by": self.created_by, - "updated_by": self.updated_by, - "note": self.note, - } - - @classmethod - def from_data(cls, data): - """Re-creates WorkfileInfo item from data. - - Args: - data (dict[str, Any]): Workfile info item data. - - Returns: - WorkfileInfo: Workfile info item. - """ - - return cls(**data) +from ayon_core.host import WorkfileInfo class FolderItem: @@ -87,8 +19,8 @@ class FolderItem: label (str): Folder label. icon_name (str): Name of icon from font awesome. icon_color (str): Hex color string that will be used for icon. - """ + """ def __init__( self, entity_id, parent_id, name, label, icon_name, icon_color ): @@ -104,8 +36,8 @@ class FolderItem: Returns: dict[str, Any]: Folder item data. - """ + """ return { "entity_id": self.entity_id, "parent_id": self.parent_id, @@ -124,8 +56,8 @@ class FolderItem: Returns: FolderItem: Folder item. - """ + """ return cls(**data) @@ -144,8 +76,8 @@ class TaskItem: parent_id (str): Parent folder id. icon_name (str): Name of icon from font awesome. icon_color (str): Hex color string that will be used for icon. - """ + """ def __init__( self, task_id, name, task_type, parent_id, icon_name, icon_color ): @@ -163,8 +95,8 @@ class TaskItem: Returns: str: Task id. - """ + """ return self.task_id @property @@ -173,8 +105,8 @@ class TaskItem: Returns: str: Label of task item. - """ + """ if self._label is None: self._label = "{} ({})".format(self.name, self.task_type) return self._label @@ -184,8 +116,8 @@ class TaskItem: Returns: dict[str, Any]: Task item data. - """ + """ return { "task_id": self.task_id, "name": self.name, @@ -204,8 +136,8 @@ class TaskItem: Returns: TaskItem: Task item. - """ + """ return cls(**data) @@ -224,8 +156,8 @@ class FileItem: workfile. filepath (Optional[str]): Prepared filepath. exists (Optional[bool]): If file exists on disk. - """ + """ def __init__( self, dirpath, @@ -252,8 +184,8 @@ class FileItem: Returns: str: Full path to a file. - """ + """ if self._filepath is None: self._filepath = os.path.join(self.dirpath, self.filename) return self._filepath @@ -264,8 +196,8 @@ class FileItem: Returns: bool: If file exists on disk. - """ + """ if self._exists is None: self._exists = os.path.exists(self.filepath) return self._exists @@ -275,8 +207,8 @@ class FileItem: Returns: dict[str, Any]: File item data. - """ + """ return { "filename": self.filename, "dirpath": self.dirpath, @@ -296,8 +228,8 @@ class FileItem: Returns: FileItem: File item. - """ + """ required_keys = { "filename", "dirpath", @@ -323,8 +255,8 @@ class WorkareaFilepathResult: exists (bool): True if file exists. filepath (str): Filepath. If not provided it will be constructed from root and filename. - """ + """ def __init__(self, root, filename, exists, filepath=None): if not filepath and root and filename: filepath = os.path.join(root, filename) @@ -341,8 +273,8 @@ class AbstractWorkfilesCommon(ABC): Returns: bool: True if host is valid. - """ + """ pass @abstractmethod @@ -353,8 +285,8 @@ class AbstractWorkfilesCommon(ABC): Returns: Iterable[str]: List of extensions. - """ + """ pass @abstractmethod @@ -363,8 +295,8 @@ class AbstractWorkfilesCommon(ABC): Returns: bool: True if save is enabled. - """ + """ pass @abstractmethod @@ -373,8 +305,8 @@ class AbstractWorkfilesCommon(ABC): Args: enabled (bool): Enable save workfile when True. - """ + """ pass @@ -386,6 +318,7 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: str: Name of host. + """ pass @@ -395,8 +328,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: str: Name of project. - """ + """ pass @abstractmethod @@ -406,8 +339,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: Union[str, None]: Folder id or None if host does not have any context. - """ + """ pass @abstractmethod @@ -417,8 +350,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: Union[str, None]: Task name or None if host does not have any context. - """ + """ pass @abstractmethod @@ -428,8 +361,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: Union[str, None]: Path to workfile or None if host does not have opened specific file. - """ + """ pass @property @@ -439,8 +372,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: Anatomy: Project anatomy. - """ + """ pass @property @@ -450,8 +383,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: dict[str, Any]: Project settings. - """ + """ pass @abstractmethod @@ -463,8 +396,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: dict[str, Any]: Project entity data. - """ + """ pass @abstractmethod @@ -477,8 +410,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: dict[str, Any]: Folder entity data. - """ + """ pass @abstractmethod @@ -491,10 +424,24 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): Returns: dict[str, Any]: Task entity data. - """ + """ pass + @abstractmethod + def get_workfile_entities(self, task_id: str): + """Workfile entities for given task. + + Args: + task_id (str): Task id. + + Returns: + list[dict[str, Any]]: List of workfile entities. + + """ + pass + + @abstractmethod def emit_event(self, topic, data=None, source=None): """Emit event. @@ -502,8 +449,8 @@ class AbstractWorkfilesBackend(AbstractWorkfilesCommon): topic (str): Event topic used for callbacks filtering. data (Optional[dict[str, Any]]): Event data. source (Optional[str]): Event source. - """ + """ pass @@ -530,8 +477,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): topic (str): Name of topic. callback (Callable): Callback that will be called when event is triggered. - """ + """ pass @abstractmethod @@ -592,8 +539,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: List[str]: File extensions that can be used as workfile for current host. - """ + """ pass # Selection information @@ -603,8 +550,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: Union[str, None]: Folder id or None if no folder is selected. - """ + """ pass @abstractmethod @@ -616,8 +563,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Args: folder_id (Union[str, None]): Folder id or None if no folder is selected. - """ + """ pass @abstractmethod @@ -626,8 +573,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: Union[str, None]: Task id or None if no folder is selected. - """ + """ pass @abstractmethod @@ -649,8 +596,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): is selected. task_name (Union[str, None]): Task name or None if no task is selected. - """ + """ pass @abstractmethod @@ -659,18 +606,22 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: Union[str, None]: Selected workfile path. - """ + """ pass @abstractmethod - def set_selected_workfile_path(self, path): + def set_selected_workfile_path( + self, rootless_path, path, workfile_entity_id + ): """Change selected workfile path. Args: + rootless_path (Union[str, None]): Selected workfile rootless path. path (Union[str, None]): Selected workfile path. - """ + workfile_entity_id (Union[str, None]): Workfile entity id. + """ pass @abstractmethod @@ -680,8 +631,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: Union[str, None]: Representation id or None if no representation is selected. - """ + """ pass @abstractmethod @@ -691,8 +642,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Args: representation_id (Union[str, None]): Selected workfile representation id. - """ + """ pass def get_selected_context(self): @@ -700,8 +651,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: dict[str, Union[str, None]]: Selected context. - """ + """ return { "folder_id": self.get_selected_folder_id(), "task_id": self.get_selected_task_id(), @@ -737,8 +688,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): files UI element. representation_id (Optional[str]): Representation id. Used for published filed UI element. - """ + """ pass @abstractmethod @@ -750,8 +701,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: dict[str, Any]: Expected selection data. - """ + """ pass @abstractmethod @@ -760,8 +711,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Args: folder_id (str): Folder id which was selected. - """ + """ pass @abstractmethod @@ -771,8 +722,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Args: folder_id (str): Folder id under which task is. task_name (str): Task name which was selected. - """ + """ pass @abstractmethod @@ -785,8 +736,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): folder_id (str): Folder id under which representation is. task_name (str): Task name under which representation is. representation_id (str): Representation id which was selected. - """ + """ pass @abstractmethod @@ -797,8 +748,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): folder_id (str): Folder id under which workfile is. task_name (str): Task name under which workfile is. workfile_name (str): Workfile filename which was selected. - """ + """ pass @abstractmethod @@ -823,8 +774,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: list[FolderItem]: Minimum possible information needed for visualisation of folder hierarchy. - """ + """ pass @abstractmethod @@ -843,8 +794,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: list[TaskItem]: Minimum possible information needed for visualisation of tasks. - """ + """ pass @abstractmethod @@ -853,8 +804,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: bool: Has unsaved changes. - """ + """ pass @abstractmethod @@ -867,8 +818,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: str: Workarea directory. - """ + """ pass @abstractmethod @@ -881,9 +832,9 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): sender (Optional[str]): Who requested workarea file items. Returns: - list[FileItem]: List of workarea file items. - """ + list[WorkfileInfo]: List of workarea file items. + """ pass @abstractmethod @@ -899,8 +850,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: dict[str, Any]: Data for Save As operation. - """ + """ pass @abstractmethod @@ -925,8 +876,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: WorkareaFilepathResult: Result of the operation. - """ + """ pass @abstractmethod @@ -939,43 +890,51 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Returns: list[FileItem]: List of published file items. - """ + """ pass @abstractmethod - def get_workfile_info(self, folder_id, task_name, filepath): + def get_workfile_info(self, folder_id, task_id, rootless_path): """Workfile info from database. Args: folder_id (str): Folder id. - task_name (str): Task id. - filepath (str): Workfile path. + task_id (str): Task id. + rootless_path (str): Workfile path. Returns: Union[WorkfileInfo, None]: Workfile info or None if was passed invalid context. - """ + """ pass @abstractmethod - def save_workfile_info(self, folder_id, task_name, filepath, note): + def save_workfile_info( + self, + task_id, + rootless_path, + version=None, + comment=None, + description=None, + ): """Save workfile info to database. At this moment the only information which can be saved about - workfile is 'note'. + workfile is 'description'. - When 'note' is 'None' it is only validated if workfile info exists, - and if not then creates one with empty note. + If value of 'version', 'comment' or 'description' is 'None' it is not + added/updated to entity. Args: - folder_id (str): Folder id. - task_name (str): Task id. - filepath (str): Workfile path. - note (Union[str, None]): Note. - """ + task_id (str): Task id. + rootless_path (str): Rootless workfile path. + version (Optional[int]): Version of workfile. + comment (Optional[str]): User's comment (subversion). + description (Optional[str]): Workfile description. + """ pass # General commands @@ -985,8 +944,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Triggers 'controller.reset.started' event at the beginning and 'controller.reset.finished' at the end. - """ + """ pass # Controller actions @@ -998,8 +957,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): folder_id (str): Folder id. task_id (str): Task id. filepath (str): Workfile path. - """ + """ pass @abstractmethod @@ -1013,22 +972,27 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): self, folder_id, task_id, - workdir, + rootless_workdir, filename, template_key, - artist_note, + version, + comment, + description, ): """Save current state of workfile to workarea. Args: folder_id (str): Folder id. task_id (str): Task id. - workdir (str): Workarea directory. + rootless_workdir (str): Workarea directory. filename (str): Workarea filename. template_key (str): Template key used to get the workdir and filename. - """ + version (Optional[int]): Version of workfile. + comment (Optional[str]): User's comment (subversion). + description (Optional[str]): Workfile description. + """ pass @abstractmethod @@ -1041,7 +1005,9 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): workdir, filename, template_key, - artist_note, + version, + comment, + description, ): """Action to copy published workfile representation to workarea. @@ -1056,13 +1022,17 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): workdir (str): Workarea directory. filename (str): Workarea filename. template_key (str): Template key. - artist_note (str): Artist note. - """ + version (int): Workfile version. + comment (str): User's comment (subversion). + description (str): Description note. + """ pass @abstractmethod - def duplicate_workfile(self, src_filepath, workdir, filename, artist_note): + def duplicate_workfile( + self, src_filepath, workdir, filename, description, version, comment + ): """Duplicate workfile. Workfiles is not opened when done. @@ -1071,7 +1041,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): src_filepath (str): Source workfile path. workdir (str): Destination workdir. filename (str): Destination filename. - artist_note (str): Artist note. + version (int): Workfile version. + comment (str): User's comment (subversion). + description (str): Workfile description. """ - pass From ea12998f5b3ebac3163d2875d61f48fcb2cef18e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:40:29 +0200 Subject: [PATCH 024/312] use only IWorkfileHost methods --- client/ayon_core/tools/workfiles/control.py | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index 9cd3c0f76a..0bbec856ca 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -10,7 +10,6 @@ from ayon_core.settings import get_project_settings from ayon_core.pipeline import Anatomy, registered_host from ayon_core.pipeline.context_tools import ( change_current_context, - get_current_host_name, get_global_context, ) from ayon_core.pipeline.workfile import create_workdir_extra_folders @@ -288,23 +287,14 @@ class BaseWorkfileController( # Host information def get_workfile_extensions(self): - host = self._host - if isinstance(host, IWorkfileHost): - return host.get_workfile_extensions() - return host.file_extensions() + return self._host.get_workfile_extensions() def has_unsaved_changes(self): - host = self._host - if isinstance(host, IWorkfileHost): - return host.workfile_has_unsaved_changes() - return host.has_unsaved_changes() + return self._host.workfile_has_unsaved_changes() # Current context def get_host_name(self): - host = self._host - if isinstance(host, IWorkfileHost): - return host.name - return get_current_host_name() + return self._host.name def _get_host_current_context(self): if hasattr(self._host, "get_current_context"): @@ -321,10 +311,7 @@ class BaseWorkfileController( return self._current_task_name def get_current_workfile(self): - host = self._host - if isinstance(host, IWorkfileHost): - return host.get_current_workfile() - return host.current_file() + return self._host.get_current_workfile() # Selection information def get_selected_folder_id(self): From f5c8f01da520d64e3d56728c8c9fcc148b848bcb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:41:06 +0200 Subject: [PATCH 025/312] pass host to workfiles model --- client/ayon_core/tools/workfiles/control.py | 2 +- client/ayon_core/tools/workfiles/models/workfiles.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index 0bbec856ca..76a113097d 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -176,7 +176,7 @@ class BaseWorkfileController( return UsersModel(self) def _create_workfiles_model(self): - return WorkfilesModel(self) + return WorkfilesModel(self._host, self) def _create_expected_selection_obj(self): return WorkfilesToolExpectedSelection(self) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 7d56f02a2f..0be559fef4 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -91,7 +91,8 @@ class WorkareaModel: by host integration. """ - def __init__(self, controller): + def __init__(self, host, controller): + self._host = host self._controller = controller extensions = None if controller.is_host_valid(): @@ -741,11 +742,11 @@ class PublishWorkfilesModel: class WorkfilesModel: """Workfiles model.""" - def __init__(self, controller): + def __init__(self, host, controller): self._controller = controller self._entities_model = WorkfileEntitiesModel(controller) - self._workarea_model = WorkareaModel(controller) + self._workarea_model = WorkareaModel(host, controller) self._published_model = PublishWorkfilesModel(controller) def get_workfile_info(self, folder_id, task_id, filepath): From 326a182aa23fe4d8bd17197e02e64957d6e2a6fa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:41:47 +0200 Subject: [PATCH 026/312] updated 'set_selected_workfile_path' --- client/ayon_core/tools/workfiles/control.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index 76a113097d..4c30a93d78 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -332,8 +332,12 @@ class BaseWorkfileController( def get_selected_workfile_path(self): return self._selection_model.get_selected_workfile_path() - def set_selected_workfile_path(self, path): - self._selection_model.set_selected_workfile_path(path) + def set_selected_workfile_path( + self, rootless_path, path, workfile_entity_id + ): + self._selection_model.set_selected_workfile_path( + rootless_path, path, workfile_entity_id + ) def get_selected_representation_id(self): return self._selection_model.get_selected_representation_id() From ad7b2c4790f29fae272bd0ebf2f9708e58a16744 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:42:36 +0200 Subject: [PATCH 027/312] more methods requiring 'IWorkfileHost' --- client/ayon_core/tools/workfiles/control.py | 36 ++++++++------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index 4c30a93d78..bde300ad2c 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -531,7 +531,7 @@ class BaseWorkfileController( def save_current_workfile(self): current_file = self.get_current_workfile() - self._host_save_workfile(current_file) + self._host.save_workfile(current_file) def save_as_workfile( self, @@ -614,21 +614,6 @@ class BaseWorkfileController( {"failed": failed}, ) - # Helper host methods that resolve 'IWorkfileHost' interface - def _host_open_workfile(self, filepath): - host = self._host - if isinstance(host, IWorkfileHost): - host.open_workfile(filepath) - else: - host.open_file(filepath) - - def _host_save_workfile(self, filepath): - host = self._host - if isinstance(host, IWorkfileHost): - host.save_workfile(filepath) - else: - host.save_file(filepath) - def _emit_event(self, topic, data=None): self.emit_event(topic, data, "controller") @@ -685,7 +670,7 @@ class BaseWorkfileController( ): self._change_current_context(project_name, folder_id, task_id) - self._host_open_workfile(filepath) + self._host.open_workfile(filepath) emit_event("workfile.open.after", event_data, source="workfiles.tool") @@ -734,16 +719,23 @@ class BaseWorkfileController( dst_filepath = os.path.join(workdir, filename) if src_filepath: shutil.copyfile(src_filepath, dst_filepath) - self._host_open_workfile(dst_filepath) + self._host.open_workfile(dst_filepath) else: - self._host_save_workfile(dst_filepath) + self._host.save_workfile(dst_filepath) # Make sure workfile info exists - if not artist_note: - artist_note = None + if not description: + description = None + if not comment: + comment = None self.save_workfile_info( - folder_id, task_name, dst_filepath, note=artist_note + task_id, + f"{rootless_workdir}/{filename}", + version, + comment, + description, ) + self._workfiles_model.reset_workarea_file_items(task_id) # Create extra folders create_workdir_extra_folders( From ec579ca93a93c2adb8c5a0339fcac69dbd6dcd65 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:43:54 +0200 Subject: [PATCH 028/312] updated controller arguments to match needs --- client/ayon_core/tools/workfiles/control.py | 56 +++++++++++++++------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index bde300ad2c..649db71981 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -1,5 +1,6 @@ import os import shutil +from typing import Optional import ayon_api @@ -410,7 +411,7 @@ class BaseWorkfileController( def get_workarea_file_items(self, folder_id, task_name, sender=None): task_id = self._get_task_id(folder_id, task_name) return self._workfiles_model.get_workarea_file_items( - folder_id, task_id, task_name + folder_id, task_id ) def get_workarea_save_as_data(self, folder_id, task_id): @@ -446,16 +447,25 @@ class BaseWorkfileController( return self._workfiles_model.get_published_file_items( folder_id, task_name) - def get_workfile_info(self, folder_id, task_name, filepath): - task_id = self._get_task_id(folder_id, task_name) + def get_workfile_info(self, folder_id, task_id, rootless_path): return self._workfiles_model.get_workfile_info( - folder_id, task_id, filepath + folder_id, task_id, rootless_path ) - def save_workfile_info(self, folder_id, task_name, filepath, note): - task_id = self._get_task_id(folder_id, task_name) + def save_workfile_info( + self, + task_id, + rootless_path, + version=None, + comment=None, + description=None, + ): self._workfiles_model.save_workfile_info( - folder_id, task_id, filepath, note + task_id, + rootless_path, + version, + comment, + description, ) def reset(self): @@ -537,10 +547,12 @@ class BaseWorkfileController( self, folder_id, task_id, - workdir, + rootless_workdir, filename, template_key, - artist_note, + version, + comment, + description, ): self._emit_event("save_as.started") @@ -549,10 +561,12 @@ class BaseWorkfileController( self._save_as_workfile( folder_id, task_id, - workdir, + rootless_workdir, filename, template_key, - artist_note=artist_note, + version, + comment, + description, ) except Exception: failed = True @@ -572,7 +586,9 @@ class BaseWorkfileController( workdir, filename, template_key, - artist_note, + version, + comment, + description, ): self._emit_event("copy_representation.started") @@ -584,7 +600,9 @@ class BaseWorkfileController( workdir, filename, template_key, - artist_note, + version, + comment, + description, src_filepath=representation_filepath ) except Exception: @@ -598,7 +616,9 @@ class BaseWorkfileController( {"failed": failed}, ) - def duplicate_workfile(self, src_filepath, workdir, filename, artist_note): + def duplicate_workfile( + self, src_filepath, workdir, filename, version, comment, description + ): self._emit_event("workfile_duplicate.started") failed = False @@ -678,10 +698,12 @@ class BaseWorkfileController( self, folder_id: str, task_id: str, - workdir: str, + rootless_workdir: str, filename: str, template_key: str, - artist_note: str, + version: Optional[int], + comment: Optional[str], + description: Optional[str], src_filepath=None, ): # Trigger before save event @@ -690,6 +712,8 @@ class BaseWorkfileController( task = self.get_task_entity(project_name, task_id) task_name = task["name"] + workdir = self.project_anatomy.fill_root(rootless_workdir) + # QUESTION should the data be different for 'before' and 'after'? event_data = self._get_event_context_data( project_name, folder_id, task_id, folder, task From 16b29a6b0a102925e4af8c25975b69be2601269f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:49:55 +0200 Subject: [PATCH 029/312] added reset to workfile model --- client/ayon_core/tools/workfiles/control.py | 1 + client/ayon_core/tools/workfiles/models/workfiles.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index 649db71981..cce6bfca10 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -505,6 +505,7 @@ class BaseWorkfileController( self._projects_model.reset() self._hierarchy_model.reset() + self._workfiles_model.reset() if not expected_folder_id: expected_folder_id = folder_id diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 0be559fef4..5392402063 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -455,6 +455,10 @@ class WorkfileEntitiesModel: self._items = {} self._current_username = _NOT_SET + def reset(self): + self._cache = {} + self._items = {} + def _get_workfile_info_identifier( self, folder_id, task_id, rootless_path ): @@ -749,6 +753,10 @@ class WorkfilesModel: self._workarea_model = WorkareaModel(host, controller) self._published_model = PublishWorkfilesModel(controller) + def reset(self): + self._entities_model.reset() + self._workarea_model.reset() + def get_workfile_info(self, folder_id, task_id, filepath): return self._entities_model.get_workfile_info( folder_id, task_id, filepath From 085f4cbbd7e5572f388992f5b3345c511330ab7d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:51:28 +0200 Subject: [PATCH 030/312] added more cache items --- .../ayon_core/tools/workfiles/models/workfiles.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 5392402063..7b928cf57e 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -7,7 +7,11 @@ import arrow import ayon_api from ayon_api.operations import OperationsSession -from ayon_core.lib import get_ayon_username +from ayon_core.lib import ( + get_ayon_username, + NestedCacheItem, + CacheItem, +) from ayon_core.pipeline.template_data import ( get_template_data, get_task_template_data, @@ -102,6 +106,10 @@ class WorkareaModel: self._fill_data_by_folder_id = {} self._task_data_by_folder_id = {} self._workdir_by_context = {} + self._file_items_mapping = {} + self._file_items_cache = NestedCacheItem( + levels=1, default_factory=list + ) @property def project_name(self): @@ -111,6 +119,9 @@ class WorkareaModel: self._base_data = None self._fill_data_by_folder_id = {} self._task_data_by_folder_id = {} + self._workdir_by_context = {} + self._file_items_mapping = {} + self._file_items_cache.reset() def _get_base_data(self): if self._base_data is None: From 1211a714362da13687740946958ab0203776bc6c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:52:45 +0200 Subject: [PATCH 031/312] move private methods below public ones --- .../tools/workfiles/models/workfiles.py | 294 +++++++++--------- 1 file changed, 147 insertions(+), 147 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 7b928cf57e..9cb174b840 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -123,51 +123,6 @@ class WorkareaModel: self._file_items_mapping = {} self._file_items_cache.reset() - def _get_base_data(self): - if self._base_data is None: - base_data = get_template_data( - ayon_api.get_project(self._project_name), - host_name=self._controller.get_host_name(), - ) - self._base_data = base_data - return copy.deepcopy(self._base_data) - - def _get_folder_data(self, folder_id): - fill_data = self._fill_data_by_folder_id.get(folder_id) - if fill_data is None: - folder = self._controller.get_folder_entity( - self.project_name, folder_id - ) - fill_data = get_folder_template_data(folder, self.project_name) - self._fill_data_by_folder_id[folder_id] = fill_data - return copy.deepcopy(fill_data) - - def _get_task_data(self, project_entity, folder_id, task_id): - task_data = self._task_data_by_folder_id.setdefault(folder_id, {}) - if task_id not in task_data: - task = self._controller.get_task_entity( - self.project_name, task_id - ) - if task: - task_data[task_id] = get_task_template_data( - project_entity, task) - return copy.deepcopy(task_data[task_id]) - - def _prepare_fill_data(self, folder_id, task_id): - if not folder_id or not task_id: - return {} - - base_data = self._get_base_data() - project_name = base_data["project"]["name"] - folder_data = self._get_folder_data(folder_id) - project_entity = self._controller.get_project_entity(project_name) - task_data = self._get_task_data(project_entity, folder_id, task_id) - - base_data.update(folder_data) - base_data.update(task_data) - - return base_data - def get_workarea_dir_by_context(self, folder_id, task_id): if not folder_id or not task_id: return None @@ -218,108 +173,6 @@ class WorkareaModel: )) return items - def _get_template_key(self, fill_data): - task_type = fill_data.get("task", {}).get("type") - # TODO cache - return get_workfile_template_key( - self.project_name, - task_type, - self._controller.get_host_name(), - project_settings=self._controller.project_settings, - ) - - def _get_last_workfile_version( - self, workdir, file_template, fill_data, extensions - ): - """ - - Todos: - Validate if logic of this function is correct. It does return - last version + 1 which might be wrong. - - Args: - workdir (str): Workdir path. - file_template (str): File template. - fill_data (dict[str, Any]): Fill data. - extensions (set[str]): Extensions. - - Returns: - int: Next workfile version. - - """ - version = get_last_workfile_with_version( - workdir, file_template, fill_data, extensions - )[1] - - if version is None: - task_info = fill_data.get("task", {}) - version = get_versioning_start( - self.project_name, - self._controller.get_host_name(), - task_name=task_info.get("name"), - task_type=task_info.get("type"), - product_type="workfile", - project_settings=self._controller.project_settings, - ) - else: - version += 1 - return version - - def _get_comments_from_root( - self, - file_template, - extensions, - fill_data, - root, - current_filename, - ): - """Get comments from root directory. - - Args: - file_template (AnatomyStringTemplate): File template. - extensions (set[str]): Extensions. - fill_data (dict[str, Any]): Fill data. - root (str): Root directory. - current_filename (str): Current filename. - - Returns: - Tuple[list[str], Union[str, None]]: Comment hints and current - comment. - - """ - current_comment = None - filenames = [] - if root and os.path.exists(root): - for filename in os.listdir(root): - path = os.path.join(root, filename) - if not os.path.isfile(path): - continue - - ext = os.path.splitext(filename)[-1].lower() - if ext in extensions: - filenames.append(filename) - - if not filenames: - return [], current_comment - - matcher = CommentMatcher(extensions, file_template, fill_data) - - comment_hints = set() - for filename in filenames: - comment = matcher.parse_comment(filename) - if comment: - comment_hints.add(comment) - if filename == current_filename: - current_comment = comment - - return list(comment_hints), current_comment - - def _get_workdir(self, anatomy, template_key, fill_data): - directory_template = anatomy.get_template_item( - "work", template_key, "directory" - ) - return directory_template.format_strict(fill_data).normalized() - def get_workarea_save_as_data(self, folder_id, task_id): folder_entity = None task_entity = None @@ -452,6 +305,153 @@ class WorkareaModel: exists ) + def _get_base_data(self): + if self._base_data is None: + base_data = get_template_data( + ayon_api.get_project(self._project_name), + host_name=self._controller.get_host_name(), + ) + self._base_data = base_data + return copy.deepcopy(self._base_data) + + def _get_folder_data(self, folder_id): + fill_data = self._fill_data_by_folder_id.get(folder_id) + if fill_data is None: + folder = self._controller.get_folder_entity( + self.project_name, folder_id + ) + fill_data = get_folder_template_data(folder, self.project_name) + self._fill_data_by_folder_id[folder_id] = fill_data + return copy.deepcopy(fill_data) + + def _get_task_data(self, project_entity, folder_id, task_id): + task_data = self._task_data_by_folder_id.setdefault(folder_id, {}) + if task_id not in task_data: + task = self._controller.get_task_entity( + self.project_name, task_id + ) + if task: + task_data[task_id] = get_task_template_data( + project_entity, task) + return copy.deepcopy(task_data[task_id]) + + def _prepare_fill_data(self, folder_id, task_id): + if not folder_id or not task_id: + return {} + + base_data = self._get_base_data() + project_name = base_data["project"]["name"] + folder_data = self._get_folder_data(folder_id) + project_entity = self._controller.get_project_entity(project_name) + task_data = self._get_task_data(project_entity, folder_id, task_id) + + base_data.update(folder_data) + base_data.update(task_data) + + return base_data + + def _get_template_key(self, fill_data): + task_type = fill_data.get("task", {}).get("type") + # TODO cache + return get_workfile_template_key( + self.project_name, + task_type, + self._controller.get_host_name(), + project_settings=self._controller.project_settings, + ) + + def _get_last_workfile_version( + self, workdir, file_template, fill_data, extensions + ): + """ + + Todos: + Validate if logic of this function is correct. It does return + last version + 1 which might be wrong. + + Args: + workdir (str): Workdir path. + file_template (str): File template. + fill_data (dict[str, Any]): Fill data. + extensions (set[str]): Extensions. + + Returns: + int: Next workfile version. + + """ + version = get_last_workfile_with_version( + workdir, file_template, fill_data, extensions + )[1] + + if version is None: + task_info = fill_data.get("task", {}) + version = get_versioning_start( + self.project_name, + self._controller.get_host_name(), + task_name=task_info.get("name"), + task_type=task_info.get("type"), + product_type="workfile", + project_settings=self._controller.project_settings, + ) + else: + version += 1 + return version + + def _get_comments_from_root( + self, + file_template, + extensions, + fill_data, + root, + current_filename, + ): + """Get comments from root directory. + + Args: + file_template (AnatomyStringTemplate): File template. + extensions (set[str]): Extensions. + fill_data (dict[str, Any]): Fill data. + root (str): Root directory. + current_filename (str): Current filename. + + Returns: + Tuple[list[str], Union[str, None]]: Comment hints and current + comment. + + """ + current_comment = None + filenames = [] + if root and os.path.exists(root): + for filename in os.listdir(root): + path = os.path.join(root, filename) + if not os.path.isfile(path): + continue + + ext = os.path.splitext(filename)[-1].lower() + if ext in extensions: + filenames.append(filename) + + if not filenames: + return [], current_comment + + matcher = CommentMatcher(extensions, file_template, fill_data) + + comment_hints = set() + for filename in filenames: + comment = matcher.parse_comment(filename) + if comment: + comment_hints.add(comment) + if filename == current_filename: + current_comment = comment + + return list(comment_hints), current_comment + + def _get_workdir(self, anatomy, template_key, fill_data): + directory_template = anatomy.get_template_item( + "work", template_key, "directory" + ) + return directory_template.format_strict(fill_data).normalized() + class WorkfileEntitiesModel: """Workfile entities model. From a2dad64fb57d76988630d87a14415d94e238f97f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:53:28 +0200 Subject: [PATCH 032/312] move public methods above private --- .../tools/workfiles/models/workfiles.py | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 9cb174b840..14eee7b895 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -470,61 +470,6 @@ class WorkfileEntitiesModel: self._cache = {} self._items = {} - def _get_workfile_info_identifier( - self, folder_id, task_id, rootless_path - ): - return "_".join([folder_id, task_id, rootless_path]) - - def _get_rootless_path(self, filepath): - anatomy = self._controller.project_anatomy - - workdir, filename = os.path.split(filepath) - _, rootless_dir = anatomy.find_root_template_from_path(workdir) - return "/".join([ - os.path.normpath(rootless_dir).replace("\\", "/"), - filename - ]) - - def _prepare_workfile_info_item( - self, folder_id, task_id, workfile_info, filepath - ): - note = "" - created_by = None - updated_by = None - if workfile_info: - note = workfile_info["attrib"].get("description") or "" - created_by = workfile_info.get("createdBy") - updated_by = workfile_info.get("updatedBy") - - filestat = os.stat(filepath) - return WorkfileInfo( - folder_id, - task_id, - filepath, - filesize=filestat.st_size, - creation_time=filestat.st_ctime, - modification_time=filestat.st_mtime, - created_by=created_by, - updated_by=updated_by, - note=note - ) - - def _get_workfile_info(self, folder_id, task_id, identifier): - workfile_info = self._cache.get(identifier) - if workfile_info is not None: - return workfile_info - - for workfile_info in ayon_api.get_workfiles_info( - self._controller.get_current_project_name(), - task_ids=[task_id], - fields=["id", "path", "attrib", "createdBy", "updatedBy"], - ): - workfile_identifier = self._get_workfile_info_identifier( - folder_id, task_id, workfile_info["path"] - ) - self._cache[workfile_identifier] = workfile_info - return self._cache.get(identifier) - def get_workfile_info( self, folder_id, task_id, filepath, rootless_path=None ): @@ -599,6 +544,61 @@ class WorkfileEntitiesModel: ) session.commit() + def _get_workfile_info_identifier( + self, folder_id, task_id, rootless_path + ): + return "_".join([folder_id, task_id, rootless_path]) + + def _get_rootless_path(self, filepath): + anatomy = self._controller.project_anatomy + + workdir, filename = os.path.split(filepath) + _, rootless_dir = anatomy.find_root_template_from_path(workdir) + return "/".join([ + os.path.normpath(rootless_dir).replace("\\", "/"), + filename + ]) + + def _prepare_workfile_info_item( + self, folder_id, task_id, workfile_info, filepath + ): + note = "" + created_by = None + updated_by = None + if workfile_info: + note = workfile_info["attrib"].get("description") or "" + created_by = workfile_info.get("createdBy") + updated_by = workfile_info.get("updatedBy") + + filestat = os.stat(filepath) + return WorkfileInfo( + folder_id, + task_id, + filepath, + filesize=filestat.st_size, + creation_time=filestat.st_ctime, + modification_time=filestat.st_mtime, + created_by=created_by, + updated_by=updated_by, + note=note + ) + + def _get_workfile_info(self, folder_id, task_id, identifier): + workfile_info = self._cache.get(identifier) + if workfile_info is not None: + return workfile_info + + for workfile_info in ayon_api.get_workfiles_info( + self._controller.get_current_project_name(), + task_ids=[task_id], + fields=["id", "path", "attrib", "createdBy", "updatedBy"], + ): + workfile_identifier = self._get_workfile_info_identifier( + folder_id, task_id, workfile_info["path"] + ) + self._cache[workfile_identifier] = workfile_info + return self._cache.get(identifier) + def _create_workfile_info_entity(self, task_id, rootless_path, note): extension = os.path.splitext(rootless_path)[1] From 0f64ab1ab64022644a80a1d57abb9c103214e488 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:54:43 +0200 Subject: [PATCH 033/312] remove unused method --- .../tools/workfiles/models/workfiles.py | 49 ------------------- 1 file changed, 49 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 14eee7b895..0f44a960a1 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -397,55 +397,6 @@ class WorkareaModel: version += 1 return version - def _get_comments_from_root( - self, - file_template, - extensions, - fill_data, - root, - current_filename, - ): - """Get comments from root directory. - - Args: - file_template (AnatomyStringTemplate): File template. - extensions (set[str]): Extensions. - fill_data (dict[str, Any]): Fill data. - root (str): Root directory. - current_filename (str): Current filename. - - Returns: - Tuple[list[str], Union[str, None]]: Comment hints and current - comment. - - """ - current_comment = None - filenames = [] - if root and os.path.exists(root): - for filename in os.listdir(root): - path = os.path.join(root, filename) - if not os.path.isfile(path): - continue - - ext = os.path.splitext(filename)[-1].lower() - if ext in extensions: - filenames.append(filename) - - if not filenames: - return [], current_comment - - matcher = CommentMatcher(extensions, file_template, fill_data) - - comment_hints = set() - for filename in filenames: - comment = matcher.parse_comment(filename) - if comment: - comment_hints.add(comment) - if filename == current_filename: - current_comment = comment - - return list(comment_hints), current_comment - def _get_workdir(self, anatomy, template_key, fill_data): directory_template = anatomy.get_template_item( "work", template_key, "directory" From 5fe625a8bd140e1d8804a2b757f3d9f6a45ebb86 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:55:32 +0200 Subject: [PATCH 034/312] remove more unnecessary methods --- .../tools/workfiles/models/workfiles.py | 55 ------------------- 1 file changed, 55 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 0f44a960a1..4e2bce3e31 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -495,61 +495,6 @@ class WorkfileEntitiesModel: ) session.commit() - def _get_workfile_info_identifier( - self, folder_id, task_id, rootless_path - ): - return "_".join([folder_id, task_id, rootless_path]) - - def _get_rootless_path(self, filepath): - anatomy = self._controller.project_anatomy - - workdir, filename = os.path.split(filepath) - _, rootless_dir = anatomy.find_root_template_from_path(workdir) - return "/".join([ - os.path.normpath(rootless_dir).replace("\\", "/"), - filename - ]) - - def _prepare_workfile_info_item( - self, folder_id, task_id, workfile_info, filepath - ): - note = "" - created_by = None - updated_by = None - if workfile_info: - note = workfile_info["attrib"].get("description") or "" - created_by = workfile_info.get("createdBy") - updated_by = workfile_info.get("updatedBy") - - filestat = os.stat(filepath) - return WorkfileInfo( - folder_id, - task_id, - filepath, - filesize=filestat.st_size, - creation_time=filestat.st_ctime, - modification_time=filestat.st_mtime, - created_by=created_by, - updated_by=updated_by, - note=note - ) - - def _get_workfile_info(self, folder_id, task_id, identifier): - workfile_info = self._cache.get(identifier) - if workfile_info is not None: - return workfile_info - - for workfile_info in ayon_api.get_workfiles_info( - self._controller.get_current_project_name(), - task_ids=[task_id], - fields=["id", "path", "attrib", "createdBy", "updatedBy"], - ): - workfile_identifier = self._get_workfile_info_identifier( - folder_id, task_id, workfile_info["path"] - ) - self._cache[workfile_identifier] = workfile_info - return self._cache.get(identifier) - def _create_workfile_info_entity(self, task_id, rootless_path, note): extension = os.path.splitext(rootless_path)[1] From f4961bc1f9c3a1b571406c6752ac7740e79b5bcf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:02:15 +0200 Subject: [PATCH 035/312] updated workarea model --- .../tools/workfiles/models/workfiles.py | 316 ++++++++++-------- 1 file changed, 182 insertions(+), 134 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 4e2bce3e31..6fc76ac458 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -1,7 +1,10 @@ +from __future__ import annotations import os -import re import copy import uuid +import platform +import typing +from typing import Optional, Any import arrow import ayon_api @@ -12,6 +15,7 @@ from ayon_core.lib import ( NestedCacheItem, CacheItem, ) +from ayon_core.host import WorkfileInfo from ayon_core.pipeline.template_data import ( get_template_data, get_task_template_data, @@ -20,73 +24,22 @@ from ayon_core.pipeline.template_data import ( from ayon_core.pipeline.workfile import ( get_workdir_with_workdir_data, get_workfile_template_key, - get_last_workfile_with_version, + get_last_workfile_with_version_from_paths, + get_comments_from_workfile_paths, ) from ayon_core.pipeline.version_start import get_versioning_start from ayon_core.tools.workfiles.abstract import ( WorkareaFilepathResult, FileItem, - WorkfileInfo, ) +if typing.TYPE_CHECKING: + from typing import Union + from ayon_core.pipeline import Anatomy + _NOT_SET = object() -class CommentMatcher(object): - """Use anatomy and work file data to parse comments from filenames. - - Args: - extensions (set[str]): Set of extensions. - file_template (AnatomyStringTemplate): File template. - data (dict[str, Any]): Data to fill the template with. - - """ - def __init__(self, extensions, file_template, data): - self.fname_regex = None - - if "{comment}" not in file_template: - # Don't look for comment if template doesn't allow it - return - - # Create a regex group for extensions - any_extension = "(?:{})".format( - "|".join(re.escape(ext.lstrip(".")) for ext in extensions) - ) - - # Use placeholders that will never be in the filename - temp_data = copy.deepcopy(data) - temp_data["comment"] = "<>" - temp_data["version"] = "<>" - temp_data["ext"] = "<>" - - fname_pattern = file_template.format_strict(temp_data) - fname_pattern = re.escape(fname_pattern) - - # Replace comment and version with something we can match with regex - replacements = { - "<>": "(.+)", - "<>": "[0-9]+", - "<>": any_extension, - } - for src, dest in replacements.items(): - fname_pattern = fname_pattern.replace(re.escape(src), dest) - - # Match from beginning to end of string to be safe - fname_pattern = "^{}$".format(fname_pattern) - - self.fname_regex = re.compile(fname_pattern) - - def parse_comment(self, filepath): - """Parse the {comment} part from a filename""" - if not self.fname_regex: - return - - fname = os.path.basename(filepath) - match = self.fname_regex.match(fname) - if match: - return match.group(1) - - class WorkareaModel: """Workfiles model looking for workfiles in workare folder. @@ -111,10 +64,6 @@ class WorkareaModel: levels=1, default_factory=list ) - @property - def project_name(self): - return self._controller.get_current_project_name() - def reset(self): self._base_data = None self._fill_data_by_folder_id = {} @@ -123,7 +72,14 @@ class WorkareaModel: self._file_items_mapping = {} self._file_items_cache.reset() - def get_workarea_dir_by_context(self, folder_id, task_id): + def reset_file_items(self, task_id: str): + cache: CacheItem = self._file_items_cache[task_id] + cache.set_invalid() + self._file_items_mapping.pop(task_id, None) + + def get_workarea_dir_by_context( + self, folder_id: str, task_id: str + ) -> Optional[str]: if not folder_id or not task_id: return None folder_mapping = self._workdir_by_context.setdefault(folder_id, {}) @@ -135,54 +91,56 @@ class WorkareaModel: workdir = get_workdir_with_workdir_data( workdir_data, - self.project_name, + self._project_name, anatomy=self._controller.project_anatomy, ) folder_mapping[task_id] = workdir return workdir - def get_file_items(self, folder_id, task_id, task_name): - items = [] - if not folder_id or not task_id: - return items + def get_file_items( + self, + folder_id: Optional[str], + task_id: Optional[str], + ) -> list[WorkfileInfo]: + return self._cache_file_items(folder_id, task_id) - workdir = self.get_workarea_dir_by_context(folder_id, task_id) - if not os.path.exists(workdir): - return items + def get_workfile_info( + self, + folder_id: Optional[str], + task_id: Optional[str], + rootless_path: Optional[str] + ): + if not folder_id or not task_id or not rootless_path: + return None - for filename in os.listdir(workdir): - # We want to support both files and folders. e.g. Silhoutte uses - # folders as its project files. So we do not check whether it is - # a file or not. - filepath = os.path.join(workdir, filename) + mapping = self._file_items_mapping.get(task_id) + if mapping is None: + self._cache_file_items(folder_id, task_id) + mapping = self._file_items_mapping[task_id] + return mapping.get(rootless_path) - ext = os.path.splitext(filename)[1].lower() - if ext not in self._extensions: - continue + def update_file_description( + self, task_id: str, rootless_path: str, description: str + ): + mapping = self._file_items_mapping.get(task_id) + if not mapping: + return + item = mapping.get(rootless_path) + if item is not None: + item.description = description - workfile_info = self._controller.get_workfile_info( - folder_id, task_name, filepath - ) - modified = os.path.getmtime(filepath) - items.append(FileItem( - workdir, - filename, - modified, - workfile_info.created_by, - workfile_info.updated_by, - )) - return items - - def get_workarea_save_as_data(self, folder_id, task_id): + def get_workarea_save_as_data( + self, folder_id: Optional[str], task_id: Optional[str] + ) -> dict[str, Any]: folder_entity = None task_entity = None if folder_id: folder_entity = self._controller.get_folder_entity( - self.project_name, folder_id + self._project_name, folder_id ) if folder_entity and task_id: task_entity = self._controller.get_task_entity( - self.project_name, task_id + self._project_name, task_id ) if not folder_entity or not task_entity: @@ -192,6 +150,7 @@ class WorkareaModel: "template_has_comment": None, "ext": None, "workdir": None, + "rootless_workdir": None, "comment": None, "comment_hints": None, "last_version": None, @@ -215,6 +174,17 @@ class WorkareaModel: workdir = self._get_workdir(anatomy, template_key, fill_data) + rootless_workdir = workdir + if platform.system().lower() == "windows": + rootless_workdir = rootless_workdir.replace("\\", "/") + + used_roots = workdir.used_values.get("root") + if used_roots: + used_root_name = next(iter(used_roots)) + root_value = used_roots[used_root_name] + workdir_end = rootless_workdir[len(root_value):].lstrip("/") + rootless_workdir = f"{{root[{used_root_name}]}}/{workdir_end}" + file_template = anatomy.get_template_item( "work", template_key, "file" ) @@ -223,15 +193,20 @@ class WorkareaModel: template_has_version = "{version" in file_template_str template_has_comment = "{comment" in file_template_str - comment_hints, comment = self._get_comments_from_root( + file_items = self.get_file_items(folder_id, task_id) + filepaths = [ + item.filepath + for item in file_items + ] + comment_hints, comment = get_comments_from_workfile_paths( + filepaths, file_template, extensions, fill_data, - workdir, current_filename, ) last_version = self._get_last_workfile_version( - workdir, file_template_str, fill_data, extensions + filepaths, file_template_str, fill_data, extensions ) return { @@ -240,6 +215,7 @@ class WorkareaModel: "template_has_comment": template_has_comment, "ext": current_ext, "workdir": workdir, + "rootless_workdir": rootless_workdir, "comment": comment, "comment_hints": comment_hints, "last_version": last_version, @@ -248,13 +224,13 @@ class WorkareaModel: def fill_workarea_filepath( self, - folder_id, - task_id, - extension, - use_last_version, - version, - comment, - ): + folder_id: str, + task_id: str, + extension: str, + use_last_version: bool, + version: int, + comment: str, + ) -> WorkareaFilepathResult: """Fill workarea filepath based on context. Args: @@ -281,8 +257,16 @@ class WorkareaModel: ) if use_last_version: + file_items = self.get_file_items(folder_id, task_id) + filepaths = [ + item.filepath + for item in file_items + ] version = self._get_last_workfile_version( - workdir, file_template.template, fill_data, self._extensions + filepaths, + file_template.template, + fill_data, + self._extensions ) fill_data["version"] = version fill_data["ext"] = extension.lstrip(".") @@ -305,7 +289,11 @@ class WorkareaModel: exists ) - def _get_base_data(self): + @property + def _project_name(self) -> str: + return self._controller.get_current_project_name() + + def _get_base_data(self) -> dict[str, Any]: if self._base_data is None: base_data = get_template_data( ayon_api.get_project(self._project_name), @@ -314,28 +302,35 @@ class WorkareaModel: self._base_data = base_data return copy.deepcopy(self._base_data) - def _get_folder_data(self, folder_id): + def _get_folder_data(self, folder_id: str) -> dict[str, Any]: fill_data = self._fill_data_by_folder_id.get(folder_id) if fill_data is None: folder = self._controller.get_folder_entity( - self.project_name, folder_id + self._project_name, folder_id ) - fill_data = get_folder_template_data(folder, self.project_name) + fill_data = get_folder_template_data(folder, self._project_name) self._fill_data_by_folder_id[folder_id] = fill_data return copy.deepcopy(fill_data) - def _get_task_data(self, project_entity, folder_id, task_id): + def _get_task_data( + self, + project_entity: dict[str, Any], + folder_id: str, + task_id: str + ) -> dict[str, Any]: task_data = self._task_data_by_folder_id.setdefault(folder_id, {}) if task_id not in task_data: task = self._controller.get_task_entity( - self.project_name, task_id + self._project_name, task_id ) if task: task_data[task_id] = get_task_template_data( project_entity, task) return copy.deepcopy(task_data[task_id]) - def _prepare_fill_data(self, folder_id, task_id): + def _prepare_fill_data( + self, folder_id: str, task_id: str + ) -> dict[str, Any]: if not folder_id or not task_id: return {} @@ -350,19 +345,71 @@ class WorkareaModel: return base_data - def _get_template_key(self, fill_data): + def _cache_file_items( + self, folder_id: Optional[str], task_id: Optional[str] + ) -> list[WorkfileInfo]: + if not folder_id or not task_id: + return [] + + cache: CacheItem = self._file_items_cache[task_id] + if cache.is_valid: + return cache.get_data() + + project_entity = self._controller.get_project_entity( + self._project_name + ) + folder_entity = self._controller.get_folder_entity( + self._project_name, folder_id + ) + task_entity = self._controller.get_task_entity( + self._project_name, task_id + ) + anatomy = self._controller.project_anatomy + project_settings = self._controller.project_settings + workfile_entities = self._controller.get_workfile_entities(task_id) + + fill_data = self._prepare_fill_data(folder_id, task_id) + template_key = self._get_template_key(fill_data) + + items = self._host.list_workfiles( + self._project_name, + folder_id, + task_id, + project_entity=project_entity, + folder_entity=folder_entity, + task_entity=task_entity, + anatomy=anatomy, + template_key=template_key, + project_settings=project_settings, + workfile_entities=workfile_entities, + ) + cache.update_data(items) + + # Cache items by entity ids and rootless path + self._file_items_mapping[task_id] = { + item.rootless_path: item + for item in items + } + + return items + + def _get_template_key(self, fill_data: dict[str, Any]) -> str: task_type = fill_data.get("task", {}).get("type") # TODO cache return get_workfile_template_key( - self.project_name, + self._project_name, task_type, self._controller.get_host_name(), project_settings=self._controller.project_settings, ) def _get_last_workfile_version( - self, workdir, file_template, fill_data, extensions - ): + self, + filepaths: list[str], + file_template: str, + fill_data: dict[str, Any], + extensions: set[str] + ) -> int: """ Todos: @@ -370,7 +417,7 @@ class WorkareaModel: last version + 1 which might be wrong. Args: - workdir (str): Workdir path. + filepaths (list[str]): Workfile paths. file_template (str): File template. fill_data (dict[str, Any]): Fill data. extensions (set[str]): Extensions. @@ -379,25 +426,26 @@ class WorkareaModel: int: Next workfile version. """ - version = get_last_workfile_with_version( - workdir, file_template, fill_data, extensions + version = get_last_workfile_with_version_from_paths( + filepaths, file_template, fill_data, extensions )[1] + if version is not None: + return version + 1 - if version is None: - task_info = fill_data.get("task", {}) - version = get_versioning_start( - self.project_name, - self._controller.get_host_name(), - task_name=task_info.get("name"), - task_type=task_info.get("type"), - product_type="workfile", - project_settings=self._controller.project_settings, - ) - else: - version += 1 - return version - def _get_workdir(self, anatomy, template_key, fill_data): + task_info = fill_data.get("task", {}) + return get_versioning_start( + self._project_name, + self._controller.get_host_name(), + task_name=task_info.get("name"), + task_type=task_info.get("type"), + product_type="workfile", + project_settings=self._controller.project_settings, + ) + + def _get_workdir( + self, anatomy: "Anatomy", template_key: str, fill_data: dict[str, Any] + ): directory_template = anatomy.get_template_item( "work", template_key, "directory" ) From 98acfd8dfcbbe7d5d4fe4eee0ec74d0539d3be92 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:03:21 +0200 Subject: [PATCH 036/312] updated entities model --- .../tools/workfiles/models/workfiles.py | 194 ++++++++++++------ 1 file changed, 126 insertions(+), 68 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 6fc76ac458..e4d555261e 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -456,107 +456,145 @@ class WorkfileEntitiesModel: """Workfile entities model. Args: - control (AbstractWorkfileController): Controller object. - """ + controller (AbstractWorkfileController): Controller object. + """ def __init__(self, controller): self._controller = controller - self._cache = {} - self._items = {} + self._workfile_entities_by_task_id = {} self._current_username = _NOT_SET def reset(self): - self._cache = {} - self._items = {} + self._workfile_entities_by_task_id = {} - def get_workfile_info( - self, folder_id, task_id, filepath, rootless_path=None + def get_workfile_entities(self, task_id: str): + if not task_id: + return [] + workfile_entities = self._workfile_entities_by_task_id.get(task_id) + if workfile_entities is None: + workfile_entities = list(ayon_api.get_workfiles_info( + self._controller.get_current_project_name(), + task_ids=[task_id], + )) + self._workfile_entities_by_task_id[task_id] = workfile_entities + return workfile_entities + + def save_workfile_info( + self, + task_id: str, + rootless_path: str, + version: Optional[int], + comment: Optional[str], + description: Optional[str], ): - if not folder_id or not task_id or not filepath: - return None - - if rootless_path is None: - rootless_path = self._get_rootless_path(filepath) - - identifier = self._get_workfile_info_identifier( - folder_id, task_id, rootless_path) - item = self._items.get(identifier) - if item is None: - workfile_info = self._get_workfile_info( - folder_id, task_id, identifier - ) - item = self._prepare_workfile_info_item( - folder_id, task_id, workfile_info, filepath - ) - self._items[identifier] = item - return item - - def save_workfile_info(self, folder_id, task_id, filepath, note): - rootless_path = self._get_rootless_path(filepath) - identifier = self._get_workfile_info_identifier( - folder_id, task_id, rootless_path + # TODO create pipeline function for this + workfile_entities = self.get_workfile_entities(task_id) + workfile_entity = next( + ( + _ent + for _ent in workfile_entities + if _ent["path"] == rootless_path + ), + None ) - workfile_info = self._get_workfile_info( - folder_id, task_id, identifier - ) - if not workfile_info: - self._cache[identifier] = self._create_workfile_info_entity( - task_id, rootless_path, note or "") - self._items.pop(identifier, None) + if not workfile_entity: + workfile_entity = self._create_workfile_info_entity( + task_id, + rootless_path, + version, + comment, + description, + ) + workfile_entities.append(workfile_entity) return - old_note = workfile_info.get("attrib", {}).get("note") + data = {} + for key, value in ( + ("host_name", self._controller.get_host_name()), + ("version", version), + ("comment", comment), + ): + if value is not None: + data[key] = value + + old_data = workfile_entity["data"] + + changed_data = {} + for key, value in data.items(): + if key not in old_data or old_data[key] != value: + changed_data[key] = value - new_workfile_info = copy.deepcopy(workfile_info) update_data = {} - if note is not None and old_note != note: - update_data["attrib"] = {"description": note} - attrib = new_workfile_info.setdefault("attrib", {}) - attrib["description"] = note + if changed_data: + update_data["data"] = changed_data + + old_description = workfile_entity["attrib"].get("description") + if description is not None and old_description != description: + update_data["attrib"] = {"description": description} + workfile_entity["attrib"]["description"] = description username = self._get_current_username() # Automatically fix 'createdBy' and 'updatedBy' fields # NOTE both fields were not automatically filled by server # until 1.1.3 release. - if workfile_info.get("createdBy") is None: + if workfile_entity.get("createdBy") is None: update_data["createdBy"] = username - new_workfile_info["createdBy"] = username + workfile_entity["createdBy"] = username - if workfile_info.get("updatedBy") != username: + if workfile_entity.get("updatedBy") != username: update_data["updatedBy"] = username - new_workfile_info["updatedBy"] = username + workfile_entity["updatedBy"] = username if not update_data: return - self._cache[identifier] = new_workfile_info - self._items.pop(identifier, None) - project_name = self._controller.get_current_project_name() session = OperationsSession() session.update_entity( project_name, "workfile", - workfile_info["id"], + workfile_entity["id"], update_data, ) session.commit() - def _create_workfile_info_entity(self, task_id, rootless_path, note): + def _create_workfile_info_entity( + self, + task_id: str, + rootless_path: str, + version: Optional[int], + comment: Optional[str], + description: str, + ) -> dict[str, Any]: extension = os.path.splitext(rootless_path)[1] project_name = self._controller.get_current_project_name() + attrib = {} + for key, value in ( + ("extension", extension), + ("description", description), + ): + if value is not None: + attrib[key] = value + + data = {} + for key, value in ( + ("host_name", self._controller.get_host_name()), + ("version", version), + ("comment", comment), + ): + if value is not None: + data[key] = value + username = self._get_current_username() workfile_info = { "id": uuid.uuid4().hex, "path": rootless_path, "taskId": task_id, - "attrib": { - "extension": extension, - "description": note - }, + "attrib": attrib, + "data": data, # TODO remove 'createdBy' and 'updatedBy' fields when server is # or above 1.1.3 . "createdBy": username, @@ -568,7 +606,7 @@ class WorkfileEntitiesModel: session.commit() return workfile_info - def _get_current_username(self): + def _get_current_username(self) -> str: if self._current_username is _NOT_SET: self._current_username = get_ayon_username() return self._current_username @@ -709,18 +747,38 @@ class WorkfilesModel: self._published_model = PublishWorkfilesModel(controller) def reset(self): - self._entities_model.reset() self._workarea_model.reset() + self._entities_model.reset() - def get_workfile_info(self, folder_id, task_id, filepath): - return self._entities_model.get_workfile_info( - folder_id, task_id, filepath + def reset_workarea_file_items(self, task_id): + self._workarea_model.reset_file_items(task_id) + + def get_workfile_info(self, folder_id, task_id, rootless_path): + return self._workarea_model.get_workfile_info( + folder_id, task_id, rootless_path ) - def save_workfile_info(self, folder_id, task_id, filepath, note): + def save_workfile_info( + self, + task_id, + rootless_path, + version, + comment, + description, + ): self._entities_model.save_workfile_info( - folder_id, task_id, filepath, note + task_id, + rootless_path, + version, + comment, + description, ) + self._workarea_model.update_file_description( + task_id, rootless_path, description + ) + + def get_workfile_entities(self, task_id): + return self._entities_model.get_workfile_entities(task_id) def get_workarea_dir_by_context(self, folder_id, task_id): """Workarea dir for passed context. @@ -738,20 +796,20 @@ class WorkfilesModel: return self._workarea_model.get_workarea_dir_by_context( folder_id, task_id) - def get_workarea_file_items(self, folder_id, task_id, task_name): + def get_workarea_file_items(self, folder_id, task_id): """Workfile items for passed context from workarea. Args: folder_id (Union[str, None]): Folder id. task_id (Union[str, None]): Task id. - task_name (Union[str, None]): Task name. Returns: - list[FileItem]: List of file items matching workarea of passed + list[WorkfileInfo]: List of file items matching workarea of passed context. + """ return self._workarea_model.get_file_items( - folder_id, task_id, task_name + folder_id, task_id ) def get_workarea_save_as_data(self, folder_id, task_id): From 60c2c4e01848450ffe73948a80355993e592f7bd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:04:18 +0200 Subject: [PATCH 037/312] move public method above private --- .../tools/workfiles/models/workfiles.py | 118 +++++++++--------- 1 file changed, 61 insertions(+), 57 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index e4d555261e..283b707865 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -626,63 +626,7 @@ class PublishWorkfilesModel: self._cached_extensions = None self._cached_repre_extensions = None - @property - def _extensions(self): - if self._cached_extensions is None: - exts = self._controller.get_workfile_extensions() or [] - self._cached_extensions = exts - return self._cached_extensions - - @property - def _repre_extensions(self): - if self._cached_repre_extensions is None: - self._cached_repre_extensions = { - ext.lstrip(".") for ext in self._extensions - } - return self._cached_repre_extensions - - def _file_item_from_representation( - self, repre_entity, project_anatomy, author, task_name=None - ): - if task_name is not None: - task_info = repre_entity["context"].get("task") - if not task_info or task_info["name"] != task_name: - return None - - # Filter by extension - extensions = self._repre_extensions - workfile_path = None - for repre_file in repre_entity["files"]: - ext = ( - os.path.splitext(repre_file["name"])[1] - .lower() - .lstrip(".") - ) - if ext in extensions: - workfile_path = repre_file["path"] - break - - if not workfile_path: - return None - - try: - workfile_path = workfile_path.format( - root=project_anatomy.roots) - except Exception as exc: - print("Failed to format workfile path: {}".format(exc)) - - dirpath, filename = os.path.split(workfile_path) - created_at = arrow.get(repre_entity["createdAt"]).to("local") - return FileItem( - dirpath, - filename, - created_at.float_timestamp, - author, - None, - repre_entity["id"] - ) - - def get_file_items(self, folder_id, task_name): + def get_file_items(self, folder_id: str, task_name: str) -> list[FileItem]: # TODO refactor to use less server API calls project_name = self._controller.get_current_project_name() # Get subset docs of folder @@ -735,6 +679,66 @@ class PublishWorkfilesModel: return file_items + @property + def _extensions(self): + if self._cached_extensions is None: + exts = self._controller.get_workfile_extensions() or [] + self._cached_extensions = exts + return self._cached_extensions + + @property + def _repre_extensions(self): + if self._cached_repre_extensions is None: + self._cached_repre_extensions = { + ext.lstrip(".") for ext in self._extensions + } + return self._cached_repre_extensions + + def _file_item_from_representation( + self, + repre_entity: dict[str, Any], + project_anatomy: "Anatomy", + author: str, + task_name: Optional[str] = None + ): + if task_name is not None: + task_info = repre_entity["context"].get("task") + if not task_info or task_info["name"] != task_name: + return None + + # Filter by extension + extensions = self._repre_extensions + workfile_path = None + for repre_file in repre_entity["files"]: + ext = ( + os.path.splitext(repre_file["name"])[1] + .lower() + .lstrip(".") + ) + if ext in extensions: + workfile_path = repre_file["path"] + break + + if not workfile_path: + return None + + try: + workfile_path = workfile_path.format( + root=project_anatomy.roots) + except Exception as exc: + print("Failed to format workfile path: {}".format(exc)) + + dirpath, filename = os.path.split(workfile_path) + created_at = arrow.get(repre_entity["createdAt"]).to("local") + return FileItem( + dirpath, + filename, + created_at.float_timestamp, + author, + None, + repre_entity["id"] + ) + class WorkfilesModel: """Workfiles model.""" From e4f6342b3f6436627fd5092d12ed5e6778b1dd67 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:04:50 +0200 Subject: [PATCH 038/312] implement 'get_workfile_entities' on controller --- client/ayon_core/tools/workfiles/control.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index cce6bfca10..cddfb90256 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -468,6 +468,9 @@ class BaseWorkfileController( description, ) + def get_workfile_entities(self, task_id): + return self._workfiles_model.get_workfile_entities(task_id) + def reset(self): if not self._host_is_valid: self._emit_event("controller.reset.started") From b8b012df26b1ab52e07ead08632aa8b225454209 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:05:12 +0200 Subject: [PATCH 039/312] updated UI to work with new methods and structures --- .../tools/workfiles/widgets/files_widget.py | 16 ++- .../widgets/files_widget_workarea.py | 69 ++++++--- .../tools/workfiles/widgets/save_as_dialog.py | 21 +-- .../tools/workfiles/widgets/side_panel.py | 135 ++++++++++-------- 4 files changed, 144 insertions(+), 97 deletions(-) diff --git a/client/ayon_core/tools/workfiles/widgets/files_widget.py b/client/ayon_core/tools/workfiles/widgets/files_widget.py index f0b74f4289..b57192b27a 100644 --- a/client/ayon_core/tools/workfiles/widgets/files_widget.py +++ b/client/ayon_core/tools/workfiles/widgets/files_widget.py @@ -212,9 +212,11 @@ class FilesWidget(QtWidgets.QWidget): return self._controller.duplicate_workfile( filepath, - result["workdir"], + result["rootless_workdir"], result["filename"], - artist_note=result["artist_note"] + version=result["version"], + comment=result["comment"], + description=result["description"] ) def _on_workarea_browse_clicked(self): @@ -259,10 +261,12 @@ class FilesWidget(QtWidgets.QWidget): self._controller.save_as_workfile( result["folder_id"], result["task_id"], - result["workdir"], + result["rootless_workdir"], result["filename"], result["template_key"], - artist_note=result["artist_note"] + version=result["version"], + comment=result["comment"], + description=result["description"] ) def _on_workarea_path_changed(self, event): @@ -315,7 +319,9 @@ class FilesWidget(QtWidgets.QWidget): result["workdir"], result["filename"], result["template_key"], - artist_note=result["artist_note"] + version=result["version"], + comment=result["comment"], + description=result["description"], ) def _on_save_as_request(self): diff --git a/client/ayon_core/tools/workfiles/widgets/files_widget_workarea.py b/client/ayon_core/tools/workfiles/widgets/files_widget_workarea.py index 7f76b6a8ab..47d4902812 100644 --- a/client/ayon_core/tools/workfiles/widgets/files_widget_workarea.py +++ b/client/ayon_core/tools/workfiles/widgets/files_widget_workarea.py @@ -1,3 +1,5 @@ +import os + import qtawesome from qtpy import QtWidgets, QtCore, QtGui @@ -10,8 +12,10 @@ from ayon_core.tools.utils.delegates import PrettyTimeDelegate FILENAME_ROLE = QtCore.Qt.UserRole + 1 FILEPATH_ROLE = QtCore.Qt.UserRole + 2 -AUTHOR_ROLE = QtCore.Qt.UserRole + 3 -DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 4 +ROOTLESS_PATH_ROLE = QtCore.Qt.UserRole + 3 +AUTHOR_ROLE = QtCore.Qt.UserRole + 4 +DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 5 +WORKFILE_ENTITY_ID_ROLE = QtCore.Qt.UserRole + 6 class WorkAreaFilesModel(QtGui.QStandardItemModel): @@ -198,7 +202,7 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): items_to_remove = set(self._items_by_filename.keys()) new_items = [] for file_item in file_items: - filename = file_item.filename + filename = os.path.basename(file_item.filepath) if filename in self._items_by_filename: items_to_remove.discard(filename) item = self._items_by_filename[filename] @@ -206,23 +210,28 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem() new_items.append(item) item.setColumnCount(self.columnCount()) - item.setFlags( - QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - ) item.setData(self._file_icon, QtCore.Qt.DecorationRole) - item.setData(file_item.filename, QtCore.Qt.DisplayRole) - item.setData(file_item.filename, FILENAME_ROLE) + item.setData(filename, QtCore.Qt.DisplayRole) + item.setData(filename, FILENAME_ROLE) + flags = QtCore.Qt.ItemIsSelectable + if file_item.available: + flags |= QtCore.Qt.ItemIsEnabled + item.setFlags(flags) updated_by = file_item.updated_by user_item = user_items_by_name.get(updated_by) if user_item is not None and user_item.full_name: updated_by = user_item.full_name + item.setData( + file_item.workfile_entity_id, WORKFILE_ENTITY_ID_ROLE + ) item.setData(file_item.filepath, FILEPATH_ROLE) + item.setData(file_item.rootless_path, ROOTLESS_PATH_ROLE) + item.setData(file_item.file_modified, DATE_MODIFIED_ROLE) item.setData(updated_by, AUTHOR_ROLE) - item.setData(file_item.modified, DATE_MODIFIED_ROLE) - self._items_by_filename[file_item.filename] = item + self._items_by_filename[filename] = item if new_items: root_item.appendRows(new_items) @@ -354,14 +363,18 @@ class WorkAreaFilesWidget(QtWidgets.QWidget): def _get_selected_info(self): selection_model = self._view.selectionModel() - filepath = None - filename = None + workfile_entity_id = filename = rootless_path = filepath = None for index in selection_model.selectedIndexes(): filepath = index.data(FILEPATH_ROLE) + rootless_path = index.data(ROOTLESS_PATH_ROLE) filename = index.data(FILENAME_ROLE) + workfile_entity_id = index.data(WORKFILE_ENTITY_ID_ROLE) + return { "filepath": filepath, + "rootless_path": rootless_path, "filename": filename, + "workfile_entity_id": workfile_entity_id, } def get_selected_path(self): @@ -374,8 +387,12 @@ class WorkAreaFilesWidget(QtWidgets.QWidget): return self._get_selected_info()["filepath"] def _on_selection_change(self): - filepath = self.get_selected_path() - self._controller.set_selected_workfile_path(filepath) + info = self._get_selected_info() + self._controller.set_selected_workfile_path( + info["rootless_path"], + info["filepath"], + info["workfile_entity_id"], + ) def _on_mouse_double_click(self, event): if event.button() == QtCore.Qt.LeftButton: @@ -430,19 +447,25 @@ class WorkAreaFilesWidget(QtWidgets.QWidget): ) def _on_model_refresh(self): - if ( - not self._change_selection_on_refresh - or self._proxy_model.rowCount() < 1 - ): + if not self._change_selection_on_refresh: return # Find the row with latest date modified + indexes = [ + self._proxy_model.index(idx, 0) + for idx in range(self._proxy_model.rowCount()) + ] + filtered_indexes = [ + index + for index in indexes + if self._proxy_model.flags(index) & QtCore.Qt.ItemIsEnabled + ] + if not filtered_indexes: + return + latest_index = max( - ( - self._proxy_model.index(idx, 0) - for idx in range(self._proxy_model.rowCount()) - ), - key=lambda model_index: model_index.data(DATE_MODIFIED_ROLE) + filtered_indexes, + key=lambda model_index: model_index.data(DATE_MODIFIED_ROLE) or 0 ) # Select row of latest modified diff --git a/client/ayon_core/tools/workfiles/widgets/save_as_dialog.py b/client/ayon_core/tools/workfiles/widgets/save_as_dialog.py index bddff816fe..24d64319ca 100644 --- a/client/ayon_core/tools/workfiles/widgets/save_as_dialog.py +++ b/client/ayon_core/tools/workfiles/widgets/save_as_dialog.py @@ -108,6 +108,7 @@ class SaveAsDialog(QtWidgets.QDialog): self._ext_value = None self._filename = None self._workdir = None + self._rootless_workdir = None self._result = None @@ -144,8 +145,8 @@ class SaveAsDialog(QtWidgets.QDialog): version_layout.addWidget(last_version_check) # Artist note widget - artist_note_input = PlaceholderPlainTextEdit(inputs_widget) - artist_note_input.setPlaceholderText( + description_input = PlaceholderPlainTextEdit(inputs_widget) + description_input.setPlaceholderText( "Provide a note about this workfile.") # Preview widget @@ -166,7 +167,7 @@ class SaveAsDialog(QtWidgets.QDialog): subversion_label = QtWidgets.QLabel("Subversion:", inputs_widget) extension_label = QtWidgets.QLabel("Extension:", inputs_widget) preview_label = QtWidgets.QLabel("Preview:", inputs_widget) - artist_note_label = QtWidgets.QLabel("Artist Note:", inputs_widget) + description_label = QtWidgets.QLabel("Artist Note:", inputs_widget) # Build inputs inputs_layout = QtWidgets.QGridLayout(inputs_widget) @@ -178,8 +179,8 @@ class SaveAsDialog(QtWidgets.QDialog): inputs_layout.addWidget(extension_combobox, 2, 1) inputs_layout.addWidget(preview_label, 3, 0) inputs_layout.addWidget(preview_widget, 3, 1) - inputs_layout.addWidget(artist_note_label, 4, 0, 1, 2) - inputs_layout.addWidget(artist_note_input, 5, 0, 1, 2) + inputs_layout.addWidget(description_label, 4, 0, 1, 2) + inputs_layout.addWidget(description_input, 5, 0, 1, 2) # Build layout main_layout = QtWidgets.QVBoxLayout(self) @@ -214,13 +215,13 @@ class SaveAsDialog(QtWidgets.QDialog): self._extension_combobox = extension_combobox self._subversion_input = subversion_input self._preview_widget = preview_widget - self._artist_note_input = artist_note_input + self._description_input = description_input self._version_label = version_label self._subversion_label = subversion_label self._extension_label = extension_label self._preview_label = preview_label - self._artist_note_label = artist_note_label + self._description_label = description_label # Post init setup @@ -255,6 +256,7 @@ class SaveAsDialog(QtWidgets.QDialog): self._folder_id = folder_id self._task_id = task_id self._workdir = data["workdir"] + self._rootless_workdir = data["rootless_workdir"] self._comment_value = data["comment"] self._ext_value = data["ext"] self._template_key = data["template_key"] @@ -329,10 +331,13 @@ class SaveAsDialog(QtWidgets.QDialog): self._result = { "filename": self._filename, "workdir": self._workdir, + "rootless_workdir": self._rootless_workdir, "folder_id": self._folder_id, "task_id": self._task_id, "template_key": self._template_key, - "artist_note": self._artist_note_input.toPlainText(), + "version": self._version_value, + "comment": self._comment_value, + "description": self._description_input.toPlainText(), } self.close() diff --git a/client/ayon_core/tools/workfiles/widgets/side_panel.py b/client/ayon_core/tools/workfiles/widgets/side_panel.py index 7ba60b5544..2e146fddbe 100644 --- a/client/ayon_core/tools/workfiles/widgets/side_panel.py +++ b/client/ayon_core/tools/workfiles/widgets/side_panel.py @@ -4,6 +4,8 @@ from qtpy import QtWidgets, QtCore def file_size_to_string(file_size): + if not file_size: + return "N/A" size = 0 size_ending_mapping = { "KB": 1024 ** 1, @@ -43,44 +45,45 @@ class SidePanelWidget(QtWidgets.QWidget): details_input = QtWidgets.QPlainTextEdit(self) details_input.setReadOnly(True) - artist_note_widget = QtWidgets.QWidget(self) - note_label = QtWidgets.QLabel("Artist note", artist_note_widget) - note_input = QtWidgets.QPlainTextEdit(artist_note_widget) - btn_note_save = QtWidgets.QPushButton("Save note", artist_note_widget) + description_widget = QtWidgets.QWidget(self) + description_label = QtWidgets.QLabel("Artist note", description_widget) + description_input = QtWidgets.QPlainTextEdit(description_widget) + btn_description_save = QtWidgets.QPushButton("Save note", description_widget) - artist_note_layout = QtWidgets.QVBoxLayout(artist_note_widget) - artist_note_layout.setContentsMargins(0, 0, 0, 0) - artist_note_layout.addWidget(note_label, 0) - artist_note_layout.addWidget(note_input, 1) - artist_note_layout.addWidget( - btn_note_save, 0, alignment=QtCore.Qt.AlignRight + description_layout = QtWidgets.QVBoxLayout(description_widget) + description_layout.setContentsMargins(0, 0, 0, 0) + description_layout.addWidget(description_label, 0) + description_layout.addWidget(description_input, 1) + description_layout.addWidget( + btn_description_save, 0, alignment=QtCore.Qt.AlignRight ) main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(details_label, 0) main_layout.addWidget(details_input, 1) - main_layout.addWidget(artist_note_widget, 1) + main_layout.addWidget(description_widget, 1) - note_input.textChanged.connect(self._on_note_change) - btn_note_save.clicked.connect(self._on_save_click) + description_input.textChanged.connect(self._on_description_change) + btn_description_save.clicked.connect(self._on_save_click) controller.register_event_callback( "selection.workarea.changed", self._on_selection_change ) self._details_input = details_input - self._artist_note_widget = artist_note_widget - self._note_input = note_input - self._btn_note_save = btn_note_save + self._description_widget = description_widget + self._description_input = description_input + self._btn_description_save = btn_description_save self._folder_id = None - self._task_name = None + self._task_id = None self._filepath = None - self._orig_note = "" + self._rootless_path = None + self._orig_description = "" self._controller = controller - self._set_context(None, None, None) + self._set_context(None, None, None, None) def set_published_mode(self, published_mode): """Change published mode. @@ -89,64 +92,69 @@ class SidePanelWidget(QtWidgets.QWidget): published_mode (bool): Published mode enabled. """ - self._artist_note_widget.setVisible(not published_mode) + self._description_widget.setVisible(not published_mode) def _on_selection_change(self, event): folder_id = event["folder_id"] - task_name = event["task_name"] + task_id = event["task_id"] filepath = event["path"] + rootless_path = event["rootless_path"] - self._set_context(folder_id, task_name, filepath) + self._set_context(folder_id, task_id, rootless_path, filepath) - def _on_note_change(self): - text = self._note_input.toPlainText() - self._btn_note_save.setEnabled(self._orig_note != text) + def _on_description_change(self): + text = self._description_input.toPlainText() + self._btn_description_save.setEnabled(self._orig_description != text) def _on_save_click(self): - note = self._note_input.toPlainText() + description = self._description_input.toPlainText() self._controller.save_workfile_info( - self._folder_id, - self._task_name, - self._filepath, - note + self._task_id, + self._rootless_path, + description=description, ) - self._orig_note = note - self._btn_note_save.setEnabled(False) + self._orig_description = description + self._btn_description_save.setEnabled(False) - def _set_context(self, folder_id, task_name, filepath): + def _set_context(self, folder_id, task_id, rootless_path, filepath): workfile_info = None # Check if folder, task and file are selected - if bool(folder_id) and bool(task_name) and bool(filepath): + if folder_id and task_id and rootless_path: workfile_info = self._controller.get_workfile_info( - folder_id, task_name, filepath + folder_id, task_id, rootless_path ) enabled = workfile_info is not None self._details_input.setEnabled(enabled) - self._note_input.setEnabled(enabled) - self._btn_note_save.setEnabled(enabled) + self._description_input.setEnabled(enabled) + self._btn_description_save.setEnabled(enabled) self._folder_id = folder_id - self._task_name = task_name + self._task_id = task_id self._filepath = filepath + self._rootless_path = rootless_path # Disable inputs and remove texts if any required arguments are # missing if not enabled: - self._orig_note = "" + self._orig_description = "" self._details_input.setPlainText("") - self._note_input.setPlainText("") + self._description_input.setPlainText("") return - note = workfile_info.note - size_value = file_size_to_string(workfile_info.filesize) + description = workfile_info.description + size_value = file_size_to_string(workfile_info.file_size) # Append html string datetime_format = "%b %d %Y %H:%M:%S" - creation_time = datetime.datetime.fromtimestamp( - workfile_info.creation_time) - modification_time = datetime.datetime.fromtimestamp( - workfile_info.modification_time) + file_created = workfile_info.file_created + modification_time = workfile_info.file_modified + if file_created: + file_created = datetime.datetime.fromtimestamp(file_created) + + if modification_time: + modification_time = datetime.datetime.fromtimestamp( + modification_time) user_items_by_name = self._controller.get_user_items_by_name() @@ -156,33 +164,38 @@ class SidePanelWidget(QtWidgets.QWidget): return user_item.full_name return username - created_lines = [ - creation_time.strftime(datetime_format) - ] + created_lines = [] if workfile_info.created_by: - created_lines.insert( - 0, convert_username(workfile_info.created_by) + created_lines.append( + convert_username(workfile_info.created_by) ) + if file_created: + created_lines.append(file_created.strftime(datetime_format)) - modified_lines = [ - modification_time.strftime(datetime_format) - ] + if created_lines: + created_lines.insert(0, "Created:") + + modified_lines = [] if workfile_info.updated_by: - modified_lines.insert( - 0, convert_username(workfile_info.updated_by) + modified_lines.append( + convert_username(workfile_info.updated_by) ) + if modification_time: + modified_lines.append( + modification_time.strftime(datetime_format) + ) + if modified_lines: + modified_lines.insert(0, "Modified:") lines = ( "Size:", size_value, - "Created:", "
".join(created_lines), - "Modified:", "
".join(modified_lines), ) - self._orig_note = note - self._note_input.setPlainText(note) + self._orig_description = description + self._description_input.setPlainText(description) # Set as empty string self._details_input.setPlainText("") - self._details_input.appendHtml("
".join(lines)) + self._details_input.appendHtml("
".join(lines)) From e70535831973126adcffbe56f423a899e5a25b88 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:05:23 +0200 Subject: [PATCH 040/312] fix abstract property --- client/ayon_core/host/host.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 5a29de6cd7..3333cf3778 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -1,7 +1,7 @@ import os import logging import contextlib -from abc import ABC, abstractproperty +from abc import ABC, abstractmethod # NOTE can't import 'typing' because of issues in Maya 2020 # - shiboken crashes on 'typing' module import @@ -92,7 +92,8 @@ class HostBase(ABC): self._log = logging.getLogger(self.__class__.__name__) return self._log - @abstractproperty + @property + @abstractmethod def name(self): """Host name.""" From 95b1820c8318c075e27d1dd94a405ebf6bdff5ad Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:05:47 +0200 Subject: [PATCH 041/312] added some typehints into IWorkfileHost --- client/ayon_core/host/interfaces/workfiles.py | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 34d7dddef6..97985b754a 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -3,10 +3,14 @@ import os import platform from abc import abstractmethod from dataclasses import dataclass, asdict +import typing from typing import Optional, Any import ayon_api +if typing.TYPE_CHECKING: + from ayon_core.pipeline import Anatomy + @dataclass class WorkfileInfo: @@ -76,51 +80,50 @@ class WorkfileInfo: class IWorkfileHost: """Implementation requirements to be able use workfile utils and tool.""" - @abstractmethod - def save_workfile(self, dst_path=None): + def save_workfile(self, dst_path: Optional[str] = None): """Save currently opened scene. Args: dst_path (str): Where the current scene should be saved. Or use current path if 'None' is passed. - """ + """ pass @abstractmethod - def open_workfile(self, filepath): + def open_workfile(self, filepath: str): """Open passed filepath in the host. Args: filepath (str): Path to workfile. - """ + """ pass @abstractmethod - def get_current_workfile(self): + def get_current_workfile(self) -> Optional[str]: """Retrieve path to current opened file. Returns: - str: Path to file which is currently opened. - None: If nothing is opened. - """ + Optional[str]: Path to file which is currently opened. None if + nothing is opened. + """ return None - def workfile_has_unsaved_changes(self): + def workfile_has_unsaved_changes(self) -> Optional[bool]: """Currently opened scene is saved. Not all hosts can know if current scene is saved because the API of DCC does not support it. Returns: - bool: True if scene is saved and False if has unsaved + Optional[bool]: True if scene is saved and False if has unsaved + modifications. None if can't tell if workfiles has modifications. - None: Can't tell if workfiles has modifications. - """ + """ return None def get_workfile_extensions(self) -> list[str]: From bef56a526fb19035b6b6809d4af33d7237ebe762 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:05:58 +0200 Subject: [PATCH 042/312] added todos into controller --- client/ayon_core/tools/workfiles/control.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index cddfb90256..b0d4cb16b2 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -528,6 +528,7 @@ class BaseWorkfileController( # Controller actions def open_workfile(self, folder_id, task_id, filepath): + # TODO move to workfiles model self._emit_event("open_workfile.started") failed = False @@ -544,6 +545,7 @@ class BaseWorkfileController( ) def save_current_workfile(self): + # TODO move to workfiles model current_file = self.get_current_workfile() self._host.save_workfile(current_file) @@ -594,6 +596,7 @@ class BaseWorkfileController( comment, description, ): + # TODO move to workfiles model self._emit_event("copy_representation.started") failed = False @@ -623,6 +626,8 @@ class BaseWorkfileController( def duplicate_workfile( self, src_filepath, workdir, filename, version, comment, description ): + # TODO move to workfiles model + # TODO save workfile information self._emit_event("workfile_duplicate.started") failed = False @@ -678,6 +683,7 @@ class BaseWorkfileController( } def _open_workfile(self, folder_id, task_id, filepath): + # TODO move to workfiles model project_name = self.get_current_project_name() event_data = self._get_event_context_data( project_name, folder_id, task_id @@ -710,6 +716,7 @@ class BaseWorkfileController( description: Optional[str], src_filepath=None, ): + # TODO move to workfiles model # Trigger before save event project_name = self.get_current_project_name() folder = self.get_folder_entity(project_name, folder_id) From 552bc03aa613216a61ed4e6c2ef7de0e21d72ee2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:50:14 +0200 Subject: [PATCH 043/312] added comment --- client/ayon_core/tools/workfiles/widgets/files_widget.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/tools/workfiles/widgets/files_widget.py b/client/ayon_core/tools/workfiles/widgets/files_widget.py index b57192b27a..d45e057192 100644 --- a/client/ayon_core/tools/workfiles/widgets/files_widget.py +++ b/client/ayon_core/tools/workfiles/widgets/files_widget.py @@ -200,6 +200,9 @@ class FilesWidget(QtWidgets.QWidget): self._open_workfile(folder_id, task_id, path) def _on_current_open_requests(self): + # TODO validate if item under mouse is enabled + # - thi uses selected item, but that does not have to be the one + # under mouse self._on_workarea_open_clicked() def _on_duplicate_request(self): From 80397a3cc69bcf8b67c17a49fba287b88d4128fc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Apr 2025 17:31:53 +0200 Subject: [PATCH 044/312] implemented base of published workfile collection --- client/ayon_core/host/interfaces/workfiles.py | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 97985b754a..456ba0b9d4 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -7,6 +7,7 @@ import typing from typing import Optional, Any import ayon_api +import arrow if typing.TYPE_CHECKING: from ayon_core.pipeline import Anatomy @@ -77,6 +78,70 @@ class WorkfileInfo: return WorkfileInfo(**data) +@dataclass +class PublishedWorkfileInfo: + folder_id: str + task_id: Optional[str] + representation_id: str + filepath: str + created_at: float + author: str + available: bool + file_size: Optional[float] + file_created: Optional[float] + file_modified: Optional[float] + + @classmethod + def new( + cls, + folder_id: str, + task_id: Optional[str], + repre_entity: dict[str, Any], + filepath: str, + author: str, + available: bool, + file_size: Optional[float], + file_modified: Optional[float], + file_created: Optional[float], + ): + created_at = arrow.get(repre_entity["createdAt"]).to("local") + + return cls( + folder_id=folder_id, + task_id=task_id, + representation_id=repre_entity["id"], + filepath=filepath, + created_at=created_at.float_timestamp, + author=author, + available=available, + file_size=file_size, + file_created=file_created, + file_modified=file_modified, + ) + + def to_data(self): + """Converts file item to data. + + Returns: + dict[str, Any]: Workfile item data. + + """ + return asdict(self) + + @classmethod + def from_data(self, data): + """Converts data to workfile item. + + Args: + data (dict[str, Any]): Workfile item data. + + Returns: + WorkfileInfo: File item. + + """ + return WorkfileInfo(**data) + + class IWorkfileHost: """Implementation requirements to be able use workfile utils and tool.""" @@ -264,6 +329,110 @@ class IWorkfileHost: return items + def list_published_workfiles( + self, + project_name: str, + folder_id: str, + anatomy: Optional["Anatomy"] = None, + version_entities: Optional[list[dict[str, Any]]] = None, + repre_entities: Optional[list[dict[str, Any]]] = None, + ) -> list[PublishedWorkfileInfo]: + """List published workfiles for given folder. + + Default implementation looks for products with 'workfile' + product type. + + Pre-fetched entities have mandatory fields to be fetched. + - Version: 'id', 'author', 'taskId' + - Representation: 'id', 'versionId', 'files' + + Args: + project_name (str): Project name. + folder_id (str): Folder id. + anatomy (Anatomy): Project anatomy. + version_entities (Optional[list[dict[str, Any]]]): Pre-fetched + version entities. + repre_entities (Optional[list[dict[str, Any]]]): Pre-fetched + representation entities. + + Returns: + list[PublishedWorkfileInfo]: Published workfile information for + given context. + + """ + from ayon_core.pipeline import Anatomy + + # Get all representations of the folder + ( + version_entities, + repre_entities + ) = self._fetch_workfile_entities( + project_name, + folder_id, + version_entities, + repre_entities, + ) + if not repre_entities: + return [] + + if anatomy is None: + anatomy = Anatomy(project_name) + + versions_by_id = { + version_entity["id"]: version_entity + for version_entity in version_entities + } + extensions = self.get_workfile_extensions() + items = [] + for repre_entity in repre_entities: + version_id = repre_entity["versionId"] + version_entity = versions_by_id[version_id] + task_id = version_entity["taskId"] + + # Filter by extension + workfile_path = None + for repre_file in repre_entity["files"]: + ext = ( + os.path.splitext(repre_file["name"])[1] + .lower() + .lstrip(".") + ) + if ext in extensions: + workfile_path = repre_file["path"] + break + + if not workfile_path: + continue + + try: + workfile_path = workfile_path.format(root=anatomy.roots) + except Exception as exc: + print(f"Failed to format workfile path: {exc}") + + is_available = False + file_size = file_modified = file_created = None + if workfile_path and os.path.exists(workfile_path): + filestat = os.stat(workfile_path) + is_available = True + file_size = filestat.st_size + file_created = filestat.st_ctime + file_modified = filestat.st_mtime + + workfile_item = PublishedWorkfileInfo.new( + folder_id, + task_id, + repre_entity, + workfile_path, + version_entity["author"], + is_available, + file_size, + file_created, + file_modified, + ) + items.append(workfile_item) + + return items + # --- Deprecated method names --- def file_extensions(self): """Deprecated variant of 'get_workfile_extensions'. @@ -308,3 +477,53 @@ class IWorkfileHost: """ return self.workfile_has_unsaved_changes() + + def _fetch_workfile_entities( + self, + project_name: str, + folder_id: str, + version_entities: Optional[list[dict[str, Any]]], + repre_entities: Optional[list[dict[str, Any]]], + ) -> tuple[ + list[dict[str, Any]], + list[dict[str, Any]] + ]: + if repre_entities is not None and version_entities is None: + # Get versions of representations + version_ids = {r["versionId"] for r in repre_entities} + version_entities = list(ayon_api.get_versions( + project_name, + version_ids=version_ids, + fields={"id", "author", "taskId"}, + )) + + if version_entities is None: + # Get product entities of folder + product_entities = ayon_api.get_products( + project_name, + folder_ids={folder_id}, + product_types={"workfile"}, + fields={"id", "name"} + ) + + version_entities = [] + product_ids = {product["id"] for product in product_entities} + if product_ids: + # Get version docs of products with their families + version_entities = list(ayon_api.get_versions( + project_name, + product_ids=product_ids, + fields={"id", "author", "taskId"}, + )) + + # Fetch representations of filtered versions and add filter for + # extension + if repre_entities is None: + repre_entities = [] + if version_entities: + repre_entities = list(ayon_api.get_representations( + project_name, + version_ids={v["id"] for v in version_entities} + )) + + return version_entities, repre_entities From dde5c6a46ffa2223a42c17a19a6b4a4f01a6e8ed Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:34:09 +0200 Subject: [PATCH 045/312] use collect published files from host --- client/ayon_core/host/__init__.py | 2 ++ client/ayon_core/host/interfaces/__init__.py | 5 ++- client/ayon_core/host/interfaces/workfiles.py | 5 ++- .../tools/workfiles/models/workfiles.py | 34 +++++++++++++------ .../widgets/files_widget_published.py | 13 ++++--- 5 files changed, 41 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/host/__init__.py b/client/ayon_core/host/__init__.py index 80ff0f2e38..b252b03d76 100644 --- a/client/ayon_core/host/__init__.py +++ b/client/ayon_core/host/__init__.py @@ -5,6 +5,7 @@ from .host import ( from .interfaces import ( IWorkfileHost, WorkfileInfo, + PublishedWorkfileInfo, ILoadHost, IPublishHost, INewPublisher, @@ -18,6 +19,7 @@ __all__ = ( "IWorkfileHost", "WorkfileInfo", + "PublishedWorkfileInfo", "ILoadHost", "IPublishHost", "INewPublisher", diff --git a/client/ayon_core/host/interfaces/__init__.py b/client/ayon_core/host/interfaces/__init__.py index 4ee6375012..379d8555fb 100644 --- a/client/ayon_core/host/interfaces/__init__.py +++ b/client/ayon_core/host/interfaces/__init__.py @@ -1,5 +1,5 @@ from .exceptions import MissingMethodsError -from .workfiles import IWorkfileHost, WorkfileInfo +from .workfiles import IWorkfileHost, WorkfileInfo, PublishedWorkfileInfo from .interfaces import ( IPublishHost, INewPublisher, @@ -9,8 +9,11 @@ from .interfaces import ( __all__ = ( "MissingMethodsError", + "IWorkfileHost", "WorkfileInfo", + "PublishedWorkfileInfo", + "IPublishHost", "INewPublisher", "ILoadHost", diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 456ba0b9d4..21085abaa8 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -382,7 +382,10 @@ class IWorkfileHost: version_entity["id"]: version_entity for version_entity in version_entities } - extensions = self.get_workfile_extensions() + extensions = { + ext.lstrip(".") + for ext in self.get_workfile_extensions() + } items = [] for repre_entity in repre_entities: version_id = repre_entity["versionId"] diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 283b707865..74b8f1aeb3 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -15,7 +15,7 @@ from ayon_core.lib import ( NestedCacheItem, CacheItem, ) -from ayon_core.host import WorkfileInfo +from ayon_core.host import WorkfileInfo, PublishedWorkfileInfo from ayon_core.pipeline.template_data import ( get_template_data, get_task_template_data, @@ -28,10 +28,7 @@ from ayon_core.pipeline.workfile import ( get_comments_from_workfile_paths, ) from ayon_core.pipeline.version_start import get_versioning_start -from ayon_core.tools.workfiles.abstract import ( - WorkareaFilepathResult, - FileItem, -) +from ayon_core.tools.workfiles.abstract import WorkareaFilepathResult if typing.TYPE_CHECKING: from typing import Union @@ -432,7 +429,6 @@ class WorkareaModel: if version is not None: return version + 1 - task_info = fill_data.get("task", {}) return get_versioning_start( self._project_name, @@ -744,11 +740,11 @@ class WorkfilesModel: """Workfiles model.""" def __init__(self, host, controller): + self._host = host self._controller = controller self._entities_model = WorkfileEntitiesModel(controller) self._workarea_model = WorkareaModel(host, controller) - self._published_model = PublishWorkfilesModel(controller) def reset(self): self._workarea_model.reset() @@ -825,7 +821,9 @@ class WorkfilesModel: *args, **kwargs ) - def get_published_file_items(self, folder_id, task_name): + def get_published_file_items( + self, folder_id, task_id + ) -> PublishedWorkfileInfo: """Published workfiles for passed context. Args: @@ -833,7 +831,21 @@ class WorkfilesModel: task_name (str): Task name. Returns: - list[FileItem]: List of files for published workfiles. - """ + list[PublishedWorkfileInfo]: List of files for published workfiles. + + """ + project_name = self._project_name + anatomy = self._controller.project_anatomy + items = self._host.list_published_workfiles( + project_name, + folder_id, + anatomy, + ) + if task_id: + items = [ + item + for item in items + if item.task_id == task_id + ] + return items - return self._published_model.get_file_items(folder_id, task_name) diff --git a/client/ayon_core/tools/workfiles/widgets/files_widget_published.py b/client/ayon_core/tools/workfiles/widgets/files_widget_published.py index 07122046be..250204a7d7 100644 --- a/client/ayon_core/tools/workfiles/widgets/files_widget_published.py +++ b/client/ayon_core/tools/workfiles/widgets/files_widget_published.py @@ -1,3 +1,5 @@ +import os + import qtawesome from qtpy import QtWidgets, QtCore, QtGui @@ -205,24 +207,25 @@ class PublishedFilesModel(QtGui.QStandardItemModel): new_items.append(item) item.setColumnCount(self.columnCount()) item.setData(self._file_icon, QtCore.Qt.DecorationRole) - item.setData(file_item.filename, QtCore.Qt.DisplayRole) item.setData(repre_id, REPRE_ID_ROLE) - if file_item.exists: + if file_item.available: flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable else: flags = QtCore.Qt.NoItemFlags - author = file_item.created_by + author = file_item.author user_item = user_items_by_name.get(author) if user_item is not None and user_item.full_name: author = user_item.full_name - item.setFlags(flags) + filename = os.path.basename(file_item.filepath) + item.setFlags(flags) + item.setData(filename, QtCore.Qt.DisplayRole) item.setData(file_item.filepath, FILEPATH_ROLE) item.setData(author, AUTHOR_ROLE) - item.setData(file_item.modified, DATE_MODIFIED_ROLE) + item.setData(file_item.file_modified, DATE_MODIFIED_ROLE) self._items_by_id[repre_id] = item From b76ae8ffd37ff78472ede1fe2292729e788100ab Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:35:43 +0200 Subject: [PATCH 046/312] update controller --- client/ayon_core/tools/workfiles/abstract.py | 113 +------------------ client/ayon_core/tools/workfiles/control.py | 10 +- 2 files changed, 5 insertions(+), 118 deletions(-) diff --git a/client/ayon_core/tools/workfiles/abstract.py b/client/ayon_core/tools/workfiles/abstract.py index 78e31f9abd..6d7d0b4c0e 100644 --- a/client/ayon_core/tools/workfiles/abstract.py +++ b/client/ayon_core/tools/workfiles/abstract.py @@ -3,8 +3,6 @@ from abc import ABC, abstractmethod from ayon_core.style import get_default_entity_icon_color -from ayon_core.host import WorkfileInfo - class FolderItem: """Item representing folder entity on a server. @@ -141,111 +139,6 @@ class TaskItem: return cls(**data) -class FileItem: - """File item that represents a file. - - Can be used for both Workarea and Published workfile. Workarea file - will always exist on disk which is not the case for Published workfile. - - Args: - dirpath (str): Directory path of file. - filename (str): Filename. - modified (float): Modified timestamp. - created_by (Optional[str]): Username. - representation_id (Optional[str]): Representation id of published - workfile. - filepath (Optional[str]): Prepared filepath. - exists (Optional[bool]): If file exists on disk. - - """ - def __init__( - self, - dirpath, - filename, - modified, - created_by=None, - updated_by=None, - representation_id=None, - filepath=None, - exists=None - ): - self.filename = filename - self.dirpath = dirpath - self.modified = modified - self.created_by = created_by - self.updated_by = updated_by - self.representation_id = representation_id - self._filepath = filepath - self._exists = exists - - @property - def filepath(self): - """Filepath of file. - - Returns: - str: Full path to a file. - - """ - if self._filepath is None: - self._filepath = os.path.join(self.dirpath, self.filename) - return self._filepath - - @property - def exists(self): - """File is available. - - Returns: - bool: If file exists on disk. - - """ - if self._exists is None: - self._exists = os.path.exists(self.filepath) - return self._exists - - def to_data(self): - """Converts file item to data. - - Returns: - dict[str, Any]: File item data. - - """ - return { - "filename": self.filename, - "dirpath": self.dirpath, - "modified": self.modified, - "created_by": self.created_by, - "representation_id": self.representation_id, - "filepath": self.filepath, - "exists": self.exists, - } - - @classmethod - def from_data(cls, data): - """Re-creates file item from data. - - Args: - data (dict[str, Any]): File item data. - - Returns: - FileItem: File item. - - """ - required_keys = { - "filename", - "dirpath", - "modified", - "representation_id" - } - missing_keys = required_keys - set(data.keys()) - if missing_keys: - raise KeyError("Missing keys: {}".format(missing_keys)) - - return cls(**{ - key: data[key] - for key in required_keys - }) - - class WorkareaFilepathResult: """Result of workarea file formatting. @@ -881,7 +774,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): pass @abstractmethod - def get_published_file_items(self, folder_id, task_id): + def get_published_file_items(self, folder_id: str, task_id: str): """Get published file items. Args: @@ -889,7 +782,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): task_id (Union[str, None]): Task id. Returns: - list[FileItem]: List of published file items. + list[PublishedWorkfileInfo]: List of published file items. """ pass @@ -904,7 +797,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): rootless_path (str): Workfile path. Returns: - Union[WorkfileInfo, None]: Workfile info or None if was passed + Optional[WorkfileInfo]: Workfile info or None if was passed invalid context. """ diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index b0d4cb16b2..37a3f4115b 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -437,15 +437,9 @@ class BaseWorkfileController( ) def get_published_file_items(self, folder_id, task_id): - task_name = None - if task_id: - task = self.get_task_entity( - self.get_current_project_name(), task_id - ) - task_name = task.get("name") - return self._workfiles_model.get_published_file_items( - folder_id, task_name) + folder_id, task_id + ) def get_workfile_info(self, folder_id, task_id, rootless_path): return self._workfiles_model.get_workfile_info( From 1b7474bb99660c56101deb49c3d2289f82313972 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:51:56 +0200 Subject: [PATCH 047/312] Merge workfile entities model into workfiles model --- .../tools/workfiles/models/workfiles.py | 358 +++++++++--------- 1 file changed, 173 insertions(+), 185 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 74b8f1aeb3..892ca66d94 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -6,7 +6,6 @@ import platform import typing from typing import Optional, Any -import arrow import ayon_api from ayon_api.operations import OperationsSession @@ -448,166 +447,6 @@ class WorkareaModel: return directory_template.format_strict(fill_data).normalized() -class WorkfileEntitiesModel: - """Workfile entities model. - - Args: - controller (AbstractWorkfileController): Controller object. - - """ - def __init__(self, controller): - self._controller = controller - self._workfile_entities_by_task_id = {} - self._current_username = _NOT_SET - - def reset(self): - self._workfile_entities_by_task_id = {} - - def get_workfile_entities(self, task_id: str): - if not task_id: - return [] - workfile_entities = self._workfile_entities_by_task_id.get(task_id) - if workfile_entities is None: - workfile_entities = list(ayon_api.get_workfiles_info( - self._controller.get_current_project_name(), - task_ids=[task_id], - )) - self._workfile_entities_by_task_id[task_id] = workfile_entities - return workfile_entities - - def save_workfile_info( - self, - task_id: str, - rootless_path: str, - version: Optional[int], - comment: Optional[str], - description: Optional[str], - ): - # TODO create pipeline function for this - workfile_entities = self.get_workfile_entities(task_id) - workfile_entity = next( - ( - _ent - for _ent in workfile_entities - if _ent["path"] == rootless_path - ), - None - ) - if not workfile_entity: - workfile_entity = self._create_workfile_info_entity( - task_id, - rootless_path, - version, - comment, - description, - ) - workfile_entities.append(workfile_entity) - return - - data = {} - for key, value in ( - ("host_name", self._controller.get_host_name()), - ("version", version), - ("comment", comment), - ): - if value is not None: - data[key] = value - - old_data = workfile_entity["data"] - - changed_data = {} - for key, value in data.items(): - if key not in old_data or old_data[key] != value: - changed_data[key] = value - - update_data = {} - if changed_data: - update_data["data"] = changed_data - - old_description = workfile_entity["attrib"].get("description") - if description is not None and old_description != description: - update_data["attrib"] = {"description": description} - workfile_entity["attrib"]["description"] = description - - username = self._get_current_username() - # Automatically fix 'createdBy' and 'updatedBy' fields - # NOTE both fields were not automatically filled by server - # until 1.1.3 release. - if workfile_entity.get("createdBy") is None: - update_data["createdBy"] = username - workfile_entity["createdBy"] = username - - if workfile_entity.get("updatedBy") != username: - update_data["updatedBy"] = username - workfile_entity["updatedBy"] = username - - if not update_data: - return - - project_name = self._controller.get_current_project_name() - - session = OperationsSession() - session.update_entity( - project_name, - "workfile", - workfile_entity["id"], - update_data, - ) - session.commit() - - def _create_workfile_info_entity( - self, - task_id: str, - rootless_path: str, - version: Optional[int], - comment: Optional[str], - description: str, - ) -> dict[str, Any]: - extension = os.path.splitext(rootless_path)[1] - - project_name = self._controller.get_current_project_name() - - attrib = {} - for key, value in ( - ("extension", extension), - ("description", description), - ): - if value is not None: - attrib[key] = value - - data = {} - for key, value in ( - ("host_name", self._controller.get_host_name()), - ("version", version), - ("comment", comment), - ): - if value is not None: - data[key] = value - - username = self._get_current_username() - workfile_info = { - "id": uuid.uuid4().hex, - "path": rootless_path, - "taskId": task_id, - "attrib": attrib, - "data": data, - # TODO remove 'createdBy' and 'updatedBy' fields when server is - # or above 1.1.3 . - "createdBy": username, - "updatedBy": username, - } - - session = OperationsSession() - session.create_entity(project_name, "workfile", workfile_info) - session.commit() - return workfile_info - - def _get_current_username(self) -> str: - if self._current_username is _NOT_SET: - self._current_username = get_ayon_username() - return self._current_username - - class PublishWorkfilesModel: """Model for handling of published workfiles. @@ -743,12 +582,48 @@ class WorkfilesModel: self._host = host self._controller = controller - self._entities_model = WorkfileEntitiesModel(controller) self._workarea_model = WorkareaModel(host, controller) + # self._published_model = PublishWorkfilesModel(controller) + + self._workfile_entities_by_task_id = {} + self._current_username = _NOT_SET def reset(self): self._workarea_model.reset() - self._entities_model.reset() + + self._workfile_entities_by_task_id = {} + + def get_workfile_entities(self, task_id: str): + if not task_id: + return [] + workfile_entities = self._workfile_entities_by_task_id.get(task_id) + if workfile_entities is None: + workfile_entities = list(ayon_api.get_workfiles_info( + self._controller.get_current_project_name(), + task_ids=[task_id], + )) + self._workfile_entities_by_task_id[task_id] = workfile_entities + return workfile_entities + + def save_workfile_info( + self, + task_id: str, + rootless_path: str, + version: Optional[int], + comment: Optional[str], + description: Optional[str], + ): + self._save_workfile_info( + task_id, + rootless_path, + version, + comment, + description, + ) + + self._workarea_model.update_file_description( + task_id, rootless_path, description + ) def reset_workarea_file_items(self, task_id): self._workarea_model.reset_file_items(task_id) @@ -758,28 +633,6 @@ class WorkfilesModel: folder_id, task_id, rootless_path ) - def save_workfile_info( - self, - task_id, - rootless_path, - version, - comment, - description, - ): - self._entities_model.save_workfile_info( - task_id, - rootless_path, - version, - comment, - description, - ) - self._workarea_model.update_file_description( - task_id, rootless_path, description - ) - - def get_workfile_entities(self, task_id): - return self._entities_model.get_workfile_entities(task_id) - def get_workarea_dir_by_context(self, folder_id, task_id): """Workarea dir for passed context. @@ -848,4 +701,139 @@ class WorkfilesModel: if item.task_id == task_id ] return items + # return self._published_model.get_file_items(folder_id, task_name) + @property + def _project_name(self) -> str: + return self._controller.get_current_project_name() + + def _get_current_username(self) -> str: + if self._current_username is _NOT_SET: + self._current_username = get_ayon_username() + return self._current_username + + # --- Workfile entities --- + def _save_workfile_info( + self, + task_id: str, + rootless_path: str, + version: Optional[int], + comment: Optional[str], + description: Optional[str], + ): + # TODO create pipeline function for this + workfile_entities = self.get_workfile_entities(task_id) + workfile_entity = next( + ( + _ent + for _ent in workfile_entities + if _ent["path"] == rootless_path + ), + None + ) + if not workfile_entity: + workfile_entity = self._create_workfile_info_entity( + task_id, + rootless_path, + version, + comment, + description, + ) + workfile_entities.append(workfile_entity) + return + + data = {} + for key, value in ( + ("host_name", self._controller.get_host_name()), + ("version", version), + ("comment", comment), + ): + if value is not None: + data[key] = value + + old_data = workfile_entity["data"] + + changed_data = {} + for key, value in data.items(): + if key not in old_data or old_data[key] != value: + changed_data[key] = value + + update_data = {} + if changed_data: + update_data["data"] = changed_data + + old_description = workfile_entity["attrib"].get("description") + if description is not None and old_description != description: + update_data["attrib"] = {"description": description} + workfile_entity["attrib"]["description"] = description + + username = self._get_current_username() + # Automatically fix 'createdBy' and 'updatedBy' fields + # NOTE both fields were not automatically filled by server + # until 1.1.3 release. + if workfile_entity.get("createdBy") is None: + update_data["createdBy"] = username + workfile_entity["createdBy"] = username + + if workfile_entity.get("updatedBy") != username: + update_data["updatedBy"] = username + workfile_entity["updatedBy"] = username + + if not update_data: + return + + session = OperationsSession() + session.update_entity( + self._project_name, + "workfile", + workfile_entity["id"], + update_data, + ) + session.commit() + + def _create_workfile_info_entity( + self, + task_id: str, + rootless_path: str, + version: Optional[int], + comment: Optional[str], + description: str, + ) -> dict[str, Any]: + extension = os.path.splitext(rootless_path)[1] + + attrib = {} + for key, value in ( + ("extension", extension), + ("description", description), + ): + if value is not None: + attrib[key] = value + + data = {} + for key, value in ( + ("host_name", self._controller.get_host_name()), + ("version", version), + ("comment", comment), + ): + if value is not None: + data[key] = value + + username = self._get_current_username() + workfile_info = { + "id": uuid.uuid4().hex, + "path": rootless_path, + "taskId": task_id, + "attrib": attrib, + "data": data, + # TODO remove 'createdBy' and 'updatedBy' fields when server is + # or above 1.1.3 . + "createdBy": username, + "updatedBy": username, + } + + session = OperationsSession() + session.create_entity( + self._project_name, "workfile", workfile_info + ) + session.commit() + return workfile_info From 2f6ed068e722ac2d434b0fd8d910870bcd006099 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:52:28 +0200 Subject: [PATCH 048/312] Remove publish workfiles model --- .../tools/workfiles/models/workfiles.py | 130 ------------------ 1 file changed, 130 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 892ca66d94..181d963ec7 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -447,134 +447,6 @@ class WorkareaModel: return directory_template.format_strict(fill_data).normalized() -class PublishWorkfilesModel: - """Model for handling of published workfiles. - - Todos: - Cache workfiles products and representations for some time. - Note Representations won't change. Only what can change are - versions. - """ - - def __init__(self, controller): - self._controller = controller - self._cached_extensions = None - self._cached_repre_extensions = None - - def get_file_items(self, folder_id: str, task_name: str) -> list[FileItem]: - # TODO refactor to use less server API calls - project_name = self._controller.get_current_project_name() - # Get subset docs of folder - product_entities = ayon_api.get_products( - project_name, - folder_ids={folder_id}, - product_types={"workfile"}, - fields={"id", "name"} - ) - - output = [] - product_ids = {product["id"] for product in product_entities} - if not product_ids: - return output - - # Get version docs of products with their families - version_entities = ayon_api.get_versions( - project_name, - product_ids=product_ids, - fields={"id", "author"} - ) - versions_by_id = { - version["id"]: version - for version in version_entities - } - if not versions_by_id: - return output - - # Query representations of filtered versions and add filter for - # extension - repre_entities = ayon_api.get_representations( - project_name, - version_ids=set(versions_by_id) - ) - project_anatomy = self._controller.project_anatomy - - # Filter queried representations by task name if task is set - file_items = [] - for repre_entity in repre_entities: - version_id = repre_entity["versionId"] - version_entity = versions_by_id[version_id] - file_item = self._file_item_from_representation( - repre_entity, - project_anatomy, - version_entity["author"], - task_name, - ) - if file_item is not None: - file_items.append(file_item) - - return file_items - - @property - def _extensions(self): - if self._cached_extensions is None: - exts = self._controller.get_workfile_extensions() or [] - self._cached_extensions = exts - return self._cached_extensions - - @property - def _repre_extensions(self): - if self._cached_repre_extensions is None: - self._cached_repre_extensions = { - ext.lstrip(".") for ext in self._extensions - } - return self._cached_repre_extensions - - def _file_item_from_representation( - self, - repre_entity: dict[str, Any], - project_anatomy: "Anatomy", - author: str, - task_name: Optional[str] = None - ): - if task_name is not None: - task_info = repre_entity["context"].get("task") - if not task_info or task_info["name"] != task_name: - return None - - # Filter by extension - extensions = self._repre_extensions - workfile_path = None - for repre_file in repre_entity["files"]: - ext = ( - os.path.splitext(repre_file["name"])[1] - .lower() - .lstrip(".") - ) - if ext in extensions: - workfile_path = repre_file["path"] - break - - if not workfile_path: - return None - - try: - workfile_path = workfile_path.format( - root=project_anatomy.roots) - except Exception as exc: - print("Failed to format workfile path: {}".format(exc)) - - dirpath, filename = os.path.split(workfile_path) - created_at = arrow.get(repre_entity["createdAt"]).to("local") - return FileItem( - dirpath, - filename, - created_at.float_timestamp, - author, - None, - repre_entity["id"] - ) - - class WorkfilesModel: """Workfiles model.""" @@ -583,7 +455,6 @@ class WorkfilesModel: self._controller = controller self._workarea_model = WorkareaModel(host, controller) - # self._published_model = PublishWorkfilesModel(controller) self._workfile_entities_by_task_id = {} self._current_username = _NOT_SET @@ -701,7 +572,6 @@ class WorkfilesModel: if item.task_id == task_id ] return items - # return self._published_model.get_file_items(folder_id, task_name) @property def _project_name(self) -> str: From eb3328157172e90e0d6603796b20329ac6819e72 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Apr 2025 18:12:56 +0200 Subject: [PATCH 049/312] merge workfile models into one --- .../tools/workfiles/models/workfiles.py | 365 ++++++++---------- 1 file changed, 171 insertions(+), 194 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 181d963ec7..d04975bafb 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -14,7 +14,12 @@ from ayon_core.lib import ( NestedCacheItem, CacheItem, ) -from ayon_core.host import WorkfileInfo, PublishedWorkfileInfo +from ayon_core.host import ( + HostBase, + IWorkfileHost, + WorkfileInfo, + PublishedWorkfileInfo, +) from ayon_core.pipeline.template_data import ( get_template_data, get_task_template_data, @@ -27,30 +32,40 @@ from ayon_core.pipeline.workfile import ( get_comments_from_workfile_paths, ) from ayon_core.pipeline.version_start import get_versioning_start -from ayon_core.tools.workfiles.abstract import WorkareaFilepathResult +from ayon_core.tools.workfiles.abstract import ( + WorkareaFilepathResult, + AbstractWorkfilesBackend, +) if typing.TYPE_CHECKING: - from typing import Union from ayon_core.pipeline import Anatomy _NOT_SET = object() -class WorkareaModel: - """Workfiles model looking for workfiles in workare folder. +class HostType(HostBase, IWorkfileHost): + pass - Workarea folder is usually task and host specific, defined by - anatomy templates. Is looking for files with extensions defined - by host integration. - """ - def __init__(self, host, controller): - self._host = host - self._controller = controller +class WorkfilesModel: + """Workfiles model.""" + + def __init__( + self, + host: HostType, + controller: AbstractWorkfilesBackend + ): + self._host: HostType = host + self._controller: AbstractWorkfilesBackend = controller + extensions = None if controller.is_host_valid(): extensions = controller.get_workfile_extensions() - self._extensions = extensions + self._extensions: Optional[set[str]] = extensions + + self._current_username = _NOT_SET + + # Workarea self._base_data = None self._fill_data_by_folder_id = {} self._task_data_by_folder_id = {} @@ -60,6 +75,9 @@ class WorkareaModel: levels=1, default_factory=list ) + # Entities + self._workfile_entities_by_task_id = {} + def reset(self): self._base_data = None self._fill_data_by_folder_id = {} @@ -68,14 +86,73 @@ class WorkareaModel: self._file_items_mapping = {} self._file_items_cache.reset() - def reset_file_items(self, task_id: str): - cache: CacheItem = self._file_items_cache[task_id] - cache.set_invalid() - self._file_items_mapping.pop(task_id, None) + self._workfile_entities_by_task_id = {} + + def get_workfile_entities(self, task_id: str): + if not task_id: + return [] + workfile_entities = self._workfile_entities_by_task_id.get(task_id) + if workfile_entities is None: + workfile_entities = list(ayon_api.get_workfiles_info( + self._controller.get_current_project_name(), + task_ids=[task_id], + )) + self._workfile_entities_by_task_id[task_id] = workfile_entities + return workfile_entities + + def get_workfile_info( + self, + folder_id: Optional[str], + task_id: Optional[str], + rootless_path: Optional[str] + ): + if not folder_id or not task_id or not rootless_path: + return None + + mapping = self._file_items_mapping.get(task_id) + if mapping is None: + self._cache_file_items(folder_id, task_id) + mapping = self._file_items_mapping[task_id] + return mapping.get(rootless_path) + + def save_workfile_info( + self, + task_id: str, + rootless_path: str, + version: Optional[int], + comment: Optional[str], + description: Optional[str], + ): + self._save_workfile_info( + task_id, + rootless_path, + version, + comment, + description, + ) + + self._update_file_description( + task_id, rootless_path, description + ) + + def reset_workarea_file_items(self, task_id): + self._reset_file_items(task_id) def get_workarea_dir_by_context( - self, folder_id: str, task_id: str + self, folder_id: str, task_id: str ) -> Optional[str]: + """Workarea dir for passed context. + + The directory path is based on project anatomy templates. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + Optional[str]: Workarea dir path or None for invalid context. + + """ if not folder_id or not task_id: return None folder_mapping = self._workdir_by_context.setdefault(folder_id, {}) @@ -93,38 +170,20 @@ class WorkareaModel: folder_mapping[task_id] = workdir return workdir - def get_file_items( - self, - folder_id: Optional[str], - task_id: Optional[str], - ) -> list[WorkfileInfo]: + def get_workarea_file_items(self, folder_id, task_id): + """Workfile items for passed context from workarea. + + Args: + folder_id (Optional[str]): Folder id. + task_id (Optional[str]): Task id. + + Returns: + list[WorkfileInfo]: List of file items matching workarea of passed + context. + + """ return self._cache_file_items(folder_id, task_id) - def get_workfile_info( - self, - folder_id: Optional[str], - task_id: Optional[str], - rootless_path: Optional[str] - ): - if not folder_id or not task_id or not rootless_path: - return None - - mapping = self._file_items_mapping.get(task_id) - if mapping is None: - self._cache_file_items(folder_id, task_id) - mapping = self._file_items_mapping[task_id] - return mapping.get(rootless_path) - - def update_file_description( - self, task_id: str, rootless_path: str, description: str - ): - mapping = self._file_items_mapping.get(task_id) - if not mapping: - return - item = mapping.get(rootless_path) - if item is not None: - item.description = description - def get_workarea_save_as_data( self, folder_id: Optional[str], task_id: Optional[str] ) -> dict[str, Any]: @@ -139,7 +198,7 @@ class WorkareaModel: self._project_name, task_id ) - if not folder_entity or not task_entity: + if not folder_entity or not task_entity or self._extensions is None: return { "template_key": None, "template_has_version": None, @@ -189,15 +248,15 @@ class WorkareaModel: template_has_version = "{version" in file_template_str template_has_comment = "{comment" in file_template_str - file_items = self.get_file_items(folder_id, task_id) + file_items = self.get_workarea_file_items(folder_id, task_id) filepaths = [ item.filepath for item in file_items ] comment_hints, comment = get_comments_from_workfile_paths( filepaths, - file_template, extensions, + file_template, fill_data, current_filename, ) @@ -253,7 +312,7 @@ class WorkareaModel: ) if use_last_version: - file_items = self.get_file_items(folder_id, task_id) + file_items = self.get_workarea_file_items(folder_id, task_id) filepaths = [ item.filepath for item in file_items @@ -285,15 +344,58 @@ class WorkareaModel: exists ) + def get_published_file_items( + self, folder_id: str, task_id: str + ) -> PublishedWorkfileInfo: + """Published workfiles for passed context. + + Args: + folder_id (str): Folder id. + task_id (str): Task id. + + Returns: + list[PublishedWorkfileInfo]: List of files for published workfiles. + + """ + project_name = self._project_name + anatomy = self._controller.project_anatomy + items = self._host.list_published_workfiles( + project_name, + folder_id, + anatomy, + ) + if task_id: + items = [ + item + for item in items + if item.task_id == task_id + ] + return items + @property def _project_name(self) -> str: return self._controller.get_current_project_name() + @property + def _host_name(self) -> str: + return self._host.name + + def _get_current_username(self) -> str: + if self._current_username is _NOT_SET: + self._current_username = get_ayon_username() + return self._current_username + + # --- Workarea --- + def _reset_file_items(self, task_id: str): + cache: CacheItem = self._file_items_cache[task_id] + cache.set_invalid() + self._file_items_mapping.pop(task_id, None) + def _get_base_data(self) -> dict[str, Any]: if self._base_data is None: base_data = get_template_data( ayon_api.get_project(self._project_name), - host_name=self._controller.get_host_name(), + host_name=self._host_name, ) self._base_data = base_data return copy.deepcopy(self._base_data) @@ -316,12 +418,13 @@ class WorkareaModel: ) -> dict[str, Any]: task_data = self._task_data_by_folder_id.setdefault(folder_id, {}) if task_id not in task_data: - task = self._controller.get_task_entity( + task_entity = self._controller.get_task_entity( self._project_name, task_id ) - if task: + if task_entity: task_data[task_id] = get_task_template_data( - project_entity, task) + project_entity, task_entity + ) return copy.deepcopy(task_data[task_id]) def _prepare_fill_data( @@ -395,7 +498,7 @@ class WorkareaModel: return get_workfile_template_key( self._project_name, task_type, - self._controller.get_host_name(), + self._host_name, project_settings=self._controller.project_settings, ) @@ -431,7 +534,7 @@ class WorkareaModel: task_info = fill_data.get("task", {}) return get_versioning_start( self._project_name, - self._controller.get_host_name(), + self._host_name, task_name=task_info.get("name"), task_type=task_info.get("type"), product_type="workfile", @@ -446,141 +549,15 @@ class WorkareaModel: ) return directory_template.format_strict(fill_data).normalized() - -class WorkfilesModel: - """Workfiles model.""" - - def __init__(self, host, controller): - self._host = host - self._controller = controller - - self._workarea_model = WorkareaModel(host, controller) - - self._workfile_entities_by_task_id = {} - self._current_username = _NOT_SET - - def reset(self): - self._workarea_model.reset() - - self._workfile_entities_by_task_id = {} - - def get_workfile_entities(self, task_id: str): - if not task_id: - return [] - workfile_entities = self._workfile_entities_by_task_id.get(task_id) - if workfile_entities is None: - workfile_entities = list(ayon_api.get_workfiles_info( - self._controller.get_current_project_name(), - task_ids=[task_id], - )) - self._workfile_entities_by_task_id[task_id] = workfile_entities - return workfile_entities - - def save_workfile_info( - self, - task_id: str, - rootless_path: str, - version: Optional[int], - comment: Optional[str], - description: Optional[str], + def _update_file_description( + self, task_id: str, rootless_path: str, description: str ): - self._save_workfile_info( - task_id, - rootless_path, - version, - comment, - description, - ) - - self._workarea_model.update_file_description( - task_id, rootless_path, description - ) - - def reset_workarea_file_items(self, task_id): - self._workarea_model.reset_file_items(task_id) - - def get_workfile_info(self, folder_id, task_id, rootless_path): - return self._workarea_model.get_workfile_info( - folder_id, task_id, rootless_path - ) - - def get_workarea_dir_by_context(self, folder_id, task_id): - """Workarea dir for passed context. - - The directory path is based on project anatomy templates. - - Args: - folder_id (str): Folder id. - task_id (str): Task id. - - Returns: - Union[str, None]: Workarea dir path or None for invalid context. - """ - - return self._workarea_model.get_workarea_dir_by_context( - folder_id, task_id) - - def get_workarea_file_items(self, folder_id, task_id): - """Workfile items for passed context from workarea. - - Args: - folder_id (Union[str, None]): Folder id. - task_id (Union[str, None]): Task id. - - Returns: - list[WorkfileInfo]: List of file items matching workarea of passed - context. - - """ - return self._workarea_model.get_file_items( - folder_id, task_id - ) - - def get_workarea_save_as_data(self, folder_id, task_id): - return self._workarea_model.get_workarea_save_as_data( - folder_id, task_id) - - def fill_workarea_filepath(self, *args, **kwargs): - return self._workarea_model.fill_workarea_filepath( - *args, **kwargs - ) - - def get_published_file_items( - self, folder_id, task_id - ) -> PublishedWorkfileInfo: - """Published workfiles for passed context. - - Args: - folder_id (str): Folder id. - task_name (str): Task name. - - Returns: - list[PublishedWorkfileInfo]: List of files for published workfiles. - - """ - project_name = self._project_name - anatomy = self._controller.project_anatomy - items = self._host.list_published_workfiles( - project_name, - folder_id, - anatomy, - ) - if task_id: - items = [ - item - for item in items - if item.task_id == task_id - ] - return items - - @property - def _project_name(self) -> str: - return self._controller.get_current_project_name() - - def _get_current_username(self) -> str: - if self._current_username is _NOT_SET: - self._current_username = get_ayon_username() - return self._current_username + mapping = self._file_items_mapping.get(task_id) + if not mapping: + return + item = mapping.get(rootless_path) + if item is not None: + item.description = description # --- Workfile entities --- def _save_workfile_info( @@ -614,7 +591,7 @@ class WorkfilesModel: data = {} for key, value in ( - ("host_name", self._controller.get_host_name()), + ("host_name", self._host_name), ("version", version), ("comment", comment), ): @@ -681,7 +658,7 @@ class WorkfilesModel: data = {} for key, value in ( - ("host_name", self._controller.get_host_name()), + ("host_name", self._host_name), ("version", version), ("comment", comment), ): From 6a7f41f80a172a928b9e3a5fe745256590d744d4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Apr 2025 19:06:10 +0200 Subject: [PATCH 050/312] move workfiles related logic to workfiles model --- client/ayon_core/tools/workfiles/control.py | 261 +++------------ .../tools/workfiles/models/workfiles.py | 305 +++++++++++++++++- 2 files changed, 325 insertions(+), 241 deletions(-) diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index 37a3f4115b..f5df9f83ce 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -5,15 +5,11 @@ from typing import Optional import ayon_api from ayon_core.host import IWorkfileHost -from ayon_core.lib import Logger, emit_event +from ayon_core.lib import Logger from ayon_core.lib.events import QueuedEventSystem from ayon_core.settings import get_project_settings from ayon_core.pipeline import Anatomy, registered_host -from ayon_core.pipeline.context_tools import ( - change_current_context, - get_global_context, -) -from ayon_core.pipeline.workfile import create_workdir_extra_folders +from ayon_core.pipeline.context_tools import get_global_context from ayon_core.tools.common_models import ( HierarchyModel, @@ -297,11 +293,6 @@ class BaseWorkfileController( def get_host_name(self): return self._host.name - def _get_host_current_context(self): - if hasattr(self._host, "get_current_context"): - return self._host.get_current_context() - return get_global_context() - def get_current_project_name(self): return self._current_project_name @@ -312,7 +303,7 @@ class BaseWorkfileController( return self._current_task_name def get_current_workfile(self): - return self._host.get_current_workfile() + return self._workfiles_model.get_current_workfile() # Selection information def get_selected_folder_id(self): @@ -522,26 +513,10 @@ class BaseWorkfileController( # Controller actions def open_workfile(self, folder_id, task_id, filepath): - # TODO move to workfiles model - self._emit_event("open_workfile.started") - - failed = False - try: - self._open_workfile(folder_id, task_id, filepath) - - except Exception: - failed = True - self.log.warning("Open of workfile failed", exc_info=True) - - self._emit_event( - "open_workfile.finished", - {"failed": failed}, - ) + self._workfiles_model.open_workfile(folder_id, task_id, filepath) def save_current_workfile(self): - # TODO move to workfiles model - current_file = self.get_current_workfile() - self._host.save_workfile(current_file) + self._workfiles_model.save_current_workfile() def save_as_workfile( self, @@ -554,27 +529,15 @@ class BaseWorkfileController( comment, description, ): - self._emit_event("save_as.started") - - failed = False - try: - self._save_as_workfile( - folder_id, - task_id, - rootless_workdir, - filename, - template_key, - version, - comment, - description, - ) - except Exception: - failed = True - self.log.warning("Save as failed", exc_info=True) - - self._emit_event( - "save_as.finished", - {"failed": failed}, + self._workfiles_model.save_as_workfile( + folder_id, + task_id, + rootless_workdir, + filename, + template_key, + version, + comment, + description, ) def copy_workfile_representation( @@ -590,51 +553,29 @@ class BaseWorkfileController( comment, description, ): - # TODO move to workfiles model - self._emit_event("copy_representation.started") - - failed = False - try: - self._save_as_workfile( - folder_id, - task_id, - workdir, - filename, - template_key, - version, - comment, - description, - src_filepath=representation_filepath - ) - except Exception: - failed = True - self.log.warning( - "Copy of workfile representation failed", exc_info=True - ) - - self._emit_event( - "copy_representation.finished", - {"failed": failed}, + self._workfiles_model.copy_workfile_representation( + representation_id, + representation_filepath, + folder_id, + task_id, + workdir, + filename, + template_key, + version, + comment, + description, ) def duplicate_workfile( self, src_filepath, workdir, filename, version, comment, description ): - # TODO move to workfiles model - # TODO save workfile information - self._emit_event("workfile_duplicate.started") - - failed = False - try: - dst_filepath = os.path.join(workdir, filename) - shutil.copy(src_filepath, dst_filepath) - except Exception: - failed = True - self.log.warning("Duplication of workfile failed", exc_info=True) - - self._emit_event( - "workfile_duplicate.finished", - {"failed": failed}, + self._workfiles_model.duplicate_workfile( + src_filepath, + workdir, + filename, + version, + comment, + description, ) def _emit_event(self, topic, data=None): @@ -651,6 +592,11 @@ class BaseWorkfileController( return None return task_item.id + def _get_host_current_context(self): + if hasattr(self._host, "get_current_context"): + return self._host.get_current_context() + return get_global_context() + # Expected selection # - expected selection is used to restore selection after refresh # or when current context should be used @@ -659,136 +605,3 @@ class BaseWorkfileController( "expected_selection_changed", self._expected_selection.get_expected_selection_data(), ) - - def _get_event_context_data( - self, project_name, folder_id, task_id, folder=None, task=None - ): - if folder is None: - folder = self.get_folder_entity(project_name, folder_id) - if task is None: - task = self.get_task_entity(project_name, task_id) - return { - "project_name": project_name, - "folder_id": folder_id, - "folder_path": folder["path"], - "task_id": task_id, - "task_name": task["name"], - "host_name": self.get_host_name(), - } - - def _open_workfile(self, folder_id, task_id, filepath): - # TODO move to workfiles model - project_name = self.get_current_project_name() - event_data = self._get_event_context_data( - project_name, folder_id, task_id - ) - event_data["filepath"] = filepath - - emit_event("workfile.open.before", event_data, source="workfiles.tool") - - # Change context - task_name = event_data["task_name"] - if ( - folder_id != self.get_current_folder_id() - or task_name != self.get_current_task_name() - ): - self._change_current_context(project_name, folder_id, task_id) - - self._host.open_workfile(filepath) - - emit_event("workfile.open.after", event_data, source="workfiles.tool") - - def _save_as_workfile( - self, - folder_id: str, - task_id: str, - rootless_workdir: str, - filename: str, - template_key: str, - version: Optional[int], - comment: Optional[str], - description: Optional[str], - src_filepath=None, - ): - # TODO move to workfiles model - # Trigger before save event - project_name = self.get_current_project_name() - folder = self.get_folder_entity(project_name, folder_id) - task = self.get_task_entity(project_name, task_id) - task_name = task["name"] - - workdir = self.project_anatomy.fill_root(rootless_workdir) - - # QUESTION should the data be different for 'before' and 'after'? - event_data = self._get_event_context_data( - project_name, folder_id, task_id, folder, task - ) - event_data.update({ - "filename": filename, - "workdir_path": workdir, - }) - - emit_event("workfile.save.before", event_data, source="workfiles.tool") - - # Create workfiles root folder - if not os.path.exists(workdir): - self.log.debug("Initializing work directory: %s", workdir) - os.makedirs(workdir) - - # Change context - if ( - folder_id != self.get_current_folder_id() - or task_name != self.get_current_task_name() - ): - self._change_current_context( - project_name, folder_id, task_id, template_key - ) - - # Save workfile - dst_filepath = os.path.join(workdir, filename) - if src_filepath: - shutil.copyfile(src_filepath, dst_filepath) - self._host.open_workfile(dst_filepath) - else: - self._host.save_workfile(dst_filepath) - - # Make sure workfile info exists - if not description: - description = None - if not comment: - comment = None - self.save_workfile_info( - task_id, - f"{rootless_workdir}/{filename}", - version, - comment, - description, - ) - self._workfiles_model.reset_workarea_file_items(task_id) - - # Create extra folders - create_workdir_extra_folders( - workdir, - self.get_host_name(), - task["taskType"], - task_name, - project_name - ) - - # Trigger after save events - emit_event("workfile.save.after", event_data, source="workfiles.tool") - - def _change_current_context( - self, project_name, folder_id, task_id, template_key=None - ): - # Change current context - folder_entity = self.get_folder_entity(project_name, folder_id) - task_entity = self.get_task_entity(project_name, task_id) - change_current_context( - folder_entity, - task_entity, - template_key=template_key - ) - self._current_folder_id = folder_entity["id"] - self._current_folder_path = folder_entity["path"] - self._current_task_name = task_entity["name"] diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index d04975bafb..6508f693dd 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -4,6 +4,7 @@ import copy import uuid import platform import typing +import shutil from typing import Optional, Any import ayon_api @@ -13,6 +14,8 @@ from ayon_core.lib import ( get_ayon_username, NestedCacheItem, CacheItem, + emit_event, + Logger, ) from ayon_core.host import ( HostBase, @@ -30,8 +33,10 @@ from ayon_core.pipeline.workfile import ( get_workfile_template_key, get_last_workfile_with_version_from_paths, get_comments_from_workfile_paths, + create_workdir_extra_folders, ) from ayon_core.pipeline.version_start import get_versioning_start +from ayon_core.pipeline.context_tools import change_current_context from ayon_core.tools.workfiles.abstract import ( WorkareaFilepathResult, AbstractWorkfilesBackend, @@ -58,6 +63,7 @@ class WorkfilesModel: self._host: HostType = host self._controller: AbstractWorkfilesBackend = controller + self._log = Logger.get_logger("WorkfilesModel") extensions = None if controller.is_host_valid(): extensions = controller.get_workfile_extensions() @@ -70,8 +76,8 @@ class WorkfilesModel: self._fill_data_by_folder_id = {} self._task_data_by_folder_id = {} self._workdir_by_context = {} - self._file_items_mapping = {} - self._file_items_cache = NestedCacheItem( + self._workarea_file_items_mapping = {} + self._workarea_file_items_cache = NestedCacheItem( levels=1, default_factory=list ) @@ -83,18 +89,135 @@ class WorkfilesModel: self._fill_data_by_folder_id = {} self._task_data_by_folder_id = {} self._workdir_by_context = {} - self._file_items_mapping = {} - self._file_items_cache.reset() + self._workarea_file_items_mapping = {} + self._workarea_file_items_cache.reset() self._workfile_entities_by_task_id = {} + # Host functionality + def get_current_workfile(self): + return self._host.get_current_workfile() + + def open_workfile(self, folder_id, task_id, filepath): + self._emit_event("open_workfile.started") + + failed = False + try: + self._open_workfile(folder_id, task_id, filepath) + + except Exception: + failed = True + self._log.warning("Open of workfile failed", exc_info=True) + + self._emit_event( + "open_workfile.finished", + {"failed": failed}, + ) + + def save_current_workfile(self): + current_file = self.get_current_workfile() + self._host.save_workfile(current_file) + + def save_as_workfile( + self, + folder_id, + task_id, + rootless_workdir, + filename, + template_key, + version, + comment, + description, + ): + self._emit_event("save_as.started") + + failed = False + try: + self._save_as_workfile( + folder_id, + task_id, + rootless_workdir, + filename, + template_key, + version, + comment, + description, + ) + except Exception: + failed = True + self._log.warning("Save as failed", exc_info=True) + + self._emit_event( + "save_as.finished", + {"failed": failed}, + ) + + def copy_workfile_representation( + self, + representation_id, + representation_filepath, + folder_id, + task_id, + workdir, + filename, + template_key, + version, + comment, + description, + ): + # TODO move to workfiles pipeline + self._emit_event("copy_representation.started") + + failed = False + try: + self._save_as_workfile( + folder_id, + task_id, + workdir, + filename, + template_key, + version, + comment, + description, + src_filepath=representation_filepath + ) + except Exception: + failed = True + self._log.warning( + "Copy of workfile representation failed", exc_info=True + ) + + self._emit_event( + "copy_representation.finished", + {"failed": failed}, + ) + + def duplicate_workfile( + self, src_filepath, workdir, filename, version, comment, description + ): + # TODO save workfile information + self._emit_event("workfile_duplicate.started") + + failed = False + try: + dst_filepath = os.path.join(workdir, filename) + shutil.copy(src_filepath, dst_filepath) + except Exception: + failed = True + self._log.warning("Duplication of workfile failed", exc_info=True) + + self._emit_event( + "workfile_duplicate.finished", + {"failed": failed}, + ) + def get_workfile_entities(self, task_id: str): if not task_id: return [] workfile_entities = self._workfile_entities_by_task_id.get(task_id) if workfile_entities is None: workfile_entities = list(ayon_api.get_workfiles_info( - self._controller.get_current_project_name(), + self._project_name, task_ids=[task_id], )) self._workfile_entities_by_task_id[task_id] = workfile_entities @@ -109,10 +232,10 @@ class WorkfilesModel: if not folder_id or not task_id or not rootless_path: return None - mapping = self._file_items_mapping.get(task_id) + mapping = self._workarea_file_items_mapping.get(task_id) if mapping is None: self._cache_file_items(folder_id, task_id) - mapping = self._file_items_mapping[task_id] + mapping = self._workarea_file_items_mapping[task_id] return mapping.get(rootless_path) def save_workfile_info( @@ -135,11 +258,11 @@ class WorkfilesModel: task_id, rootless_path, description ) - def reset_workarea_file_items(self, task_id): - self._reset_file_items(task_id) + def reset_workarea_file_items(self, task_id: str): + self._reset_workarea_file_items(task_id) def get_workarea_dir_by_context( - self, folder_id: str, task_id: str + self, folder_id: str, task_id: str ) -> Optional[str]: """Workarea dir for passed context. @@ -346,7 +469,7 @@ class WorkfilesModel: def get_published_file_items( self, folder_id: str, task_id: str - ) -> PublishedWorkfileInfo: + ) -> list[PublishedWorkfileInfo]: """Published workfiles for passed context. Args: @@ -380,16 +503,164 @@ class WorkfilesModel: def _host_name(self) -> str: return self._host.name + def _emit_event(self, topic, data=None): + self._controller.emit_event(topic, data, "workfiles") + def _get_current_username(self) -> str: if self._current_username is _NOT_SET: self._current_username = get_ayon_username() return self._current_username + # --- Host --- + def _get_event_context_data( + self, + project_name: str, + folder_id: str, + task_id: str, + folder_entity: Optional[dict[str, Any]] = None, + task_entity: Optional[dict[str, Any]] = None, + ): + if folder_entity is None: + folder_entity = self._controller.get_folder_entity( + project_name, folder_id + ) + if task_entity is None: + task_entity = self._controller.get_task_entity( + project_name, task_id + ) + return { + "project_name": project_name, + "folder_id": folder_id, + "folder_path": folder_entity["path"], + "task_id": task_id, + "task_name": task_entity["name"], + "host_name": self._host_name, + } + + def _open_workfile(self, folder_id: str, task_id: str, filepath: str): + # TODO move to workfiles pipeline + project_name = self._project_name + event_data = self._get_event_context_data( + project_name, folder_id, task_id + ) + event_data["filepath"] = filepath + + emit_event("workfile.open.before", event_data, source="workfiles.tool") + + # Change context + task_name = event_data["task_name"] + if ( + folder_id != self._controller.get_current_folder_id() + or task_name != self._controller.get_current_task_name() + ): + self._change_current_context(project_name, folder_id, task_id) + + self._host.open_workfile(filepath) + + emit_event("workfile.open.after", event_data, source="workfiles.tool") + + def _save_as_workfile( + self, + folder_id: str, + task_id: str, + rootless_workdir: str, + filename: str, + template_key: str, + version: Optional[int], + comment: Optional[str], + description: Optional[str], + src_filepath=None, + ): + # TODO move to workfiles pipeline + # Trigger before save event + project_name = self._project_name + folder = self._controller.get_folder_entity(project_name, folder_id) + task = self._controller.get_task_entity(project_name, task_id) + task_name = task["name"] + + workdir = self._controller.project_anatomy.fill_root(rootless_workdir) + + # QUESTION should the data be different for 'before' and 'after'? + event_data = self._get_event_context_data( + project_name, folder_id, task_id, folder, task + ) + event_data.update({ + "filename": filename, + "workdir_path": workdir, + }) + + emit_event("workfile.save.before", event_data, source="workfiles.tool") + + # Create workfiles root folder + if not os.path.exists(workdir): + self._log.debug("Initializing work directory: %s", workdir) + os.makedirs(workdir) + + # Change context + if ( + folder_id != self._controller.get_current_folder_id() + or task_name != self._controller.get_current_task_name() + ): + self._change_current_context( + project_name, folder_id, task_id, template_key + ) + + # Save workfile + dst_filepath = os.path.join(workdir, filename) + if src_filepath: + shutil.copyfile(src_filepath, dst_filepath) + self._host.open_workfile(dst_filepath) + else: + self._host.save_workfile(dst_filepath) + + # Make sure workfile info exists + if not description: + description = None + if not comment: + comment = None + self.save_workfile_info( + task_id, + f"{rootless_workdir}/{filename}", + version, + comment, + description, + ) + self.reset_workarea_file_items(task_id) + + # Create extra folders + create_workdir_extra_folders( + workdir, + self._host_name, + task["taskType"], + task_name, + project_name + ) + + # Trigger after save events + emit_event("workfile.save.after", event_data, source="workfiles.tool") + + def _change_current_context( + self, project_name, folder_id, task_id, template_key=None + ): + # Change current context + folder_entity = self._controller.get_folder_entity( + project_name, folder_id + ) + task_entity = self._controller.get_task_entity(project_name, task_id) + change_current_context( + folder_entity, + task_entity, + template_key=template_key + ) + self._current_folder_id = folder_entity["id"] + self._current_folder_path = folder_entity["path"] + self._current_task_name = task_entity["name"] + # --- Workarea --- - def _reset_file_items(self, task_id: str): - cache: CacheItem = self._file_items_cache[task_id] + def _reset_workarea_file_items(self, task_id: str): + cache: CacheItem = self._workarea_file_items_cache[task_id] cache.set_invalid() - self._file_items_mapping.pop(task_id, None) + self._workarea_file_items_mapping.pop(task_id, None) def _get_base_data(self) -> dict[str, Any]: if self._base_data is None: @@ -450,7 +721,7 @@ class WorkfilesModel: if not folder_id or not task_id: return [] - cache: CacheItem = self._file_items_cache[task_id] + cache: CacheItem = self._workarea_file_items_cache[task_id] if cache.is_valid: return cache.get_data() @@ -485,7 +756,7 @@ class WorkfilesModel: cache.update_data(items) # Cache items by entity ids and rootless path - self._file_items_mapping[task_id] = { + self._workarea_file_items_mapping[task_id] = { item.rootless_path: item for item in items } @@ -552,7 +823,7 @@ class WorkfilesModel: def _update_file_description( self, task_id: str, rootless_path: str, description: str ): - mapping = self._file_items_mapping.get(task_id) + mapping = self._workarea_file_items_mapping.get(task_id) if not mapping: return item = mapping.get(rootless_path) From 3483a7bd0ed9a4c3d9e1f99832ddb9524db3a8b6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 5 May 2025 12:02:22 +0200 Subject: [PATCH 051/312] Updates plugin families Removes "circuit" from plugin families and adds "batchdelivery" to align with current project needs. This change ensures that the collect, extract, and review processes are correctly associated with the appropriate families, streamlining publishing workflows. --- client/ayon_core/plugins/publish/collect_audio.py | 2 +- client/ayon_core/plugins/publish/extract_burnin.py | 2 +- client/ayon_core/plugins/publish/extract_review.py | 2 +- client/ayon_core/plugins/publish/extract_thumbnail.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_audio.py b/client/ayon_core/plugins/publish/collect_audio.py index 57c69ef2b2..069082af37 100644 --- a/client/ayon_core/plugins/publish/collect_audio.py +++ b/client/ayon_core/plugins/publish/collect_audio.py @@ -39,7 +39,7 @@ class CollectAudio(pyblish.api.ContextPlugin): "blender", "houdini", "max", - "circuit", + "batchdelivery", ] audio_product_name = "audioMain" diff --git a/client/ayon_core/plugins/publish/extract_burnin.py b/client/ayon_core/plugins/publish/extract_burnin.py index 3f7c2f4cba..4b285d9990 100644 --- a/client/ayon_core/plugins/publish/extract_burnin.py +++ b/client/ayon_core/plugins/publish/extract_burnin.py @@ -55,7 +55,7 @@ class ExtractBurnin(publish.Extractor): "max", "blender", "unreal", - "circuit", + "batchdelivery", ] optional = True diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index a15886451b..7a0627d05c 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -92,7 +92,7 @@ class ExtractReview(pyblish.api.InstancePlugin): "aftereffects", "flame", "unreal", - "circuit", + "batchdelivery", ] # Supported extensions diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 3a428c46a7..b4309a6038 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -40,7 +40,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): "aftereffects", "unreal", "houdini", - "circuit", + "batchdelivery", ] enabled = False From 21c1a8bda2b219894c51a9a52b36195de227ab3d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 16 May 2025 10:23:07 +0200 Subject: [PATCH 052/312] added base implementation to workfiles interface --- client/ayon_core/host/interfaces/workfiles.py | 161 +++++++++++++++++- 1 file changed, 156 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 21085abaa8..970e31bc88 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -1,6 +1,7 @@ from __future__ import annotations import os import platform +import shutil from abc import abstractmethod from dataclasses import dataclass, asdict import typing @@ -65,7 +66,7 @@ class WorkfileInfo: return asdict(self) @classmethod - def from_data(self, data): + def from_data(cls, data): """Converts data to workfile item. Args: @@ -80,6 +81,7 @@ class WorkfileInfo: @dataclass class PublishedWorkfileInfo: + project_name: str folder_id: str task_id: Optional[str] representation_id: str @@ -94,6 +96,7 @@ class PublishedWorkfileInfo: @classmethod def new( cls, + project_name: str, folder_id: str, task_id: Optional[str], repre_entity: dict[str, Any], @@ -107,6 +110,7 @@ class PublishedWorkfileInfo: created_at = arrow.get(repre_entity["createdAt"]).to("local") return cls( + project_name=project_name, folder_id=folder_id, task_id=task_id, representation_id=repre_entity["id"], @@ -129,7 +133,7 @@ class PublishedWorkfileInfo: return asdict(self) @classmethod - def from_data(self, data): + def from_data(cls, data): """Converts data to workfile item. Args: @@ -192,10 +196,12 @@ class IWorkfileHost: return None def get_workfile_extensions(self) -> list[str]: - """Extensions that can be used as save. + """Extensions that can be used as save workfile. - Questions: - This could potentially use 'HostDefinition'. + Notes: + Method may not be used if 'list_workfiles' and + 'list_published_workfiles' are re-implemented with different + logic. Returns: list[str]: List of extensions that can be used for saving. @@ -203,6 +209,51 @@ class IWorkfileHost: """ return [] + def save_workfile_with_context( + self, + filepath: str, + folder_id: str, + task_id: str, + folder_entity: Optional[dict[str, Any]] = None, + task_entity: Optional[dict[str, Any]] = None, + ): + """Save current workfile with context. + + Notes: + Should this method care about context change? + + Args: + filepath (str): Where the current scene should be saved. + folder_id (str): Folder id. + task_id (str): Task id. + + """ + self.save_workfile(filepath) + + def open_workfile_with_context( + self, + filepath: str, + folder_id: str, + task_id: str, + folder_entity: Optional[dict[str, Any]] = None, + task_entity: Optional[dict[str, Any]] = None, + ): + """Open passed filepath in the host with context. + + This function should be used to open workfile in different context. + + Notes: + Should this method care about context change? + + Args: + filepath (str): Path to workfile. + folder_id (str): Folder id. + task_id (str): Task id. + + """ + self.open_workfile(filepath) + + def list_workfiles( self, project_name: str, @@ -422,6 +473,7 @@ class IWorkfileHost: file_modified = filestat.st_mtime workfile_item = PublishedWorkfileInfo.new( + project_name, folder_id, task_id, repre_entity, @@ -436,6 +488,105 @@ class IWorkfileHost: return items + def copy_workfile( + self, + src_path: str, + dst_path: str, + folder_id: str, + task_id: str, + open_workfile: bool = False, + folder_entity: Optional[dict[str, Any]] = None, + task_entity: Optional[dict[str, Any]] = None, + ): + """Save workfile path with target folder and task context. + + It is expected that workfile is saved to current project, but can be + copied from other project. + + Args: + src_path (str): Path to the source scene. + dst_path (str): Where the scene should be saved. + folder_id (str): Folder id. + task_id (str): Task id. + open_workfile (bool): Open workfile when copied. + + """ + # TODO We might need option to open file once copied as some DCC might + # actually need to open the workfile to copy it. + dst_dir = os.path.dirname(dst_path) + if not os.path.exists(dst_dir): + os.makedirs(dst_dir, exist_ok=True) + shutil.copy(src_path, dst_path) + if open_workfile: + self.open_workfile_with_context( + dst_path, + folder_id, + task_id, + folder_entity=folder_entity, + task_entity=task_entity, + ) + + def copy_workfile_representation( + self, + src_project_name: str, + src_representation_id: str, + dst_path: str, + folder_id: str, + task_id: str, + open_workfile: bool = False, + anatomy: Optional[Anatomy] = None, + src_representation_entity: Optional[dict[str, Any]] = None, + src_representation_path: Optional[str] = None, + folder_entity: Optional[dict[str, Any]] = None, + task_entity: Optional[dict[str, Any]] = None, + ): + """Copy workfile representation. + + Use representation as source for the workfile. + + Args: + src_project_name (str): Project name. + src_representation_id (str): Representation id. + dst_path (str): Where the scene should be saved. + folder_id (str): Folder id. + task_id (str): Task id. + open_workfile (bool): Open workfile when copied. + anatomy (Optional[Anatomy]): Project anatomy. + src_representation_entity (Optional[dict[str, Any]]): Representation + entity. + src_representation_path (Optional[str]): Representation path. + + """ + # TODO We might need option to open file once copied as some DCC might + # actually need to open the workfile to copy it. + from ayon_core.pipeline import Anatomy + from ayon_core.pipeline.load import ( + get_representation_path_with_anatomy + ) + + if src_representation_path is None: + if src_representation_entity is None: + src_representation_entity = ayon_api.get_representation_by_id( + src_project_name, src_representation_id + ) + + if anatomy is None: + anatomy = Anatomy(src_project_name) + src_representation_path = get_representation_path_with_anatomy( + src_representation_entity, + anatomy, + ) + + self.copy_workfile( + src_representation_path, + dst_path, + folder_id, + task_id, + open_workfile=open_workfile, + folder_entity=folder_entity, + task_entity=task_entity, + ) + # --- Deprecated method names --- def file_extensions(self): """Deprecated variant of 'get_workfile_extensions'. From 29d824decfca2a5ee295dbc47c76203de7b8bebd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 16 May 2025 10:25:18 +0200 Subject: [PATCH 053/312] modified change current context function --- client/ayon_core/pipeline/context_tools.py | 47 +++++++++++++++++----- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 66556bbb35..0c6e86ef4b 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -10,7 +10,7 @@ import pyblish.api from pyblish.lib import MessageHandler from ayon_core import AYON_CORE_ROOT -from ayon_core.host import HostBase +from ayon_core.host import HostBase, IWorkfileHost from ayon_core.lib import ( is_in_tests, initialize_ayon_connection, @@ -505,37 +505,64 @@ def get_current_context_custom_workfile_template(project_settings=None): ) -def change_current_context(folder_entity, task_entity, template_key=None): +def change_current_context( + folder_entity, + task_entity, + template_key=None, + workdir=None, + anatomy=None, + project_entity=None, + project_settings=None, +): """Update active Session to a new task work area. This updates the live Session to a different task under folder. + Notes: + This function does a lot of things related to workfiles which + extends arguments options a lot. + We might want to implement 'set_current_context' on host integration + instead. But `AYON_WORKDIR`, which is related to 'IWorkfileHost', + would not be available in that case which might be break some + logic. + Args: folder_entity (Dict[str, Any]): Folder entity to set. task_entity (Dict[str, Any]): Task entity to set. - template_key (Union[str, None]): Prepared template key to be used for + template_key (Optional[str]): Prepared template key to be used for workfile template in Anatomy. + workdir (Optional[str]): Workdir to set. + anatomy (Optional[Anatomy]): Anatomy object used for workdir + calculation. + project_entity (Optional[dict[str, Any]]): Project entity used for + workdir calculation. + project_settings (Optional[dict[str, Any]]): Project settings used for + workdir calculation. Returns: Dict[str, str]: The changed key, values in the current Session. - """ - project_name = get_current_project_name() - workdir = None + """ + host = registered_host() + project_name = host.get_current_project_name() folder_path = None task_name = None if folder_entity: folder_path = folder_entity["path"] if task_entity: task_name = task_entity["name"] - project_entity = ayon_api.get_project(project_name) - host_name = get_current_host_name() + + if isinstance(host, IWorkfileHost) and workdir is None and folder_entity: + if project_entity is None: + project_entity = ayon_api.get_project(project_name) workdir = get_workdir( project_entity, folder_entity, task_entity, - host_name, - template_key=template_key + host.name, + anatomy=anatomy, + template_key=template_key, + project_settings=project_settings, ) envs = { From f4638b92cd9bea6ef3c9a243adda202b9741196d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 16 May 2025 10:25:31 +0200 Subject: [PATCH 054/312] implemented utils functions for workfiles --- .../ayon_core/pipeline/workfile/__init__.py | 10 + client/ayon_core/pipeline/workfile/utils.py | 646 +++++++++++++++++- 2 files changed, 655 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/__init__.py b/client/ayon_core/pipeline/workfile/__init__.py index 5b8a10c288..cc081d676b 100644 --- a/client/ayon_core/pipeline/workfile/__init__.py +++ b/client/ayon_core/pipeline/workfile/__init__.py @@ -21,6 +21,11 @@ from .utils import ( should_use_last_workfile_on_launch, should_open_workfiles_tool_on_launch, MissingWorkdirError, + + open_workfile, + save_current_workfile_to, + copy_and_open_workfile, + copy_and_open_workfile_representation, ) from .build_workfile import BuildWorkfile @@ -57,6 +62,11 @@ __all__ = ( "should_open_workfiles_tool_on_launch", "MissingWorkdirError", + "open_workfile", + "save_current_workfile_to", + "copy_and_open_workfile", + "copy_and_open_workfile_representation", + "BuildWorkfile", "discover_workfile_build_plugins", diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 25be061dec..44c811d5e2 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -1,6 +1,24 @@ -from ayon_core.lib import filter_profiles +from __future__ import annotations +import os +import platform +import uuid +import typing +from typing import Optional, Any + +import ayon_api +from ayon_api.operations import OperationsSession + +from ayon_core.lib import filter_profiles, emit_event, get_ayon_username from ayon_core.settings import get_project_settings +from .path_resolving import ( + create_workdir_extra_folders, + get_workfile_template_key, +) + +if typing.TYPE_CHECKING: + from ayon_core.pipeline import Anatomy + class MissingWorkdirError(Exception): """Raised when accessing a work directory not found on disk.""" @@ -124,3 +142,629 @@ def should_open_workfiles_tool_on_launch( if output is None: return default_output return output + + +def _get_event_context_data( + project_name: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + host_name: str, +): + return { + "project_name": project_name, + "folder_id": folder_entity["id"], + "folder_path": folder_entity["path"], + "task_id": task_entity["id"], + "task_name": task_entity["name"], + "host_name": host_name, + } + + +def save_workfile_info( + project_name: str, + task_id: str, + rootless_path: str, + host_name: str, + version: Optional[int], + comment: Optional[str], + description: Optional[str], + username: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, +): + # TODO create pipeline function for this + if workfile_entities is None: + workfile_entities = list(ayon_api.get_workfiles_info( + project_name, + task_ids=[task_id], + )) + + workfile_entity = next( + ( + _ent + for _ent in workfile_entities + if _ent["path"] == rootless_path + ), + None + ) + + if username is None: + username = get_ayon_username() + + if not workfile_entity: + return _create_workfile_info_entity( + project_name, + task_id, + host_name, + rootless_path, + username, + version, + comment, + description, + ) + + data = {} + for key, value in ( + ("host_name", host_name), + ("version", version), + ("comment", comment), + ): + if value is not None: + data[key] = value + + old_data = workfile_entity["data"] + + changed_data = {} + for key, value in data.items(): + if key not in old_data or old_data[key] != value: + changed_data[key] = value + + update_data = {} + if changed_data: + update_data["data"] = changed_data + + old_description = workfile_entity["attrib"].get("description") + if description is not None and old_description != description: + update_data["attrib"] = {"description": description} + workfile_entity["attrib"]["description"] = description + + # Automatically fix 'createdBy' and 'updatedBy' fields + # NOTE both fields were not automatically filled by server + # until 1.1.3 release. + if workfile_entity.get("createdBy") is None: + update_data["createdBy"] = username + workfile_entity["createdBy"] = username + + if workfile_entity.get("updatedBy") != username: + update_data["updatedBy"] = username + workfile_entity["updatedBy"] = username + + if not update_data: + return + + session = OperationsSession() + session.update_entity( + project_name, + "workfile", + workfile_entity["id"], + update_data, + ) + session.commit() + return workfile_entity + + +def open_workfile( + filepath: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], +): + from ayon_core.pipeline.context_tools import ( + registered_host, change_current_context + ) + + # Trigger before save event + host = registered_host() + context = host.get_current_context() + project_name = context["project_name"] + current_folder_path = context["folder_path"] + current_task_name = context["task_name"] + host_name = host.name + + # TODO move to workfiles pipeline + event_data = _get_event_context_data( + project_name, folder_entity, task_entity, host_name + ) + event_data["filepath"] = filepath + + emit_event("workfile.open.before", event_data, source="workfiles.tool") + + # Change context + if ( + folder_entity["path"] != current_folder_path + or task_entity["name"] != current_task_name + ): + change_current_context( + project_name, + folder_entity, + task_entity, + workdir=os.path.dirname(filepath) + ) + + host.open_workfile(filepath) + + emit_event("workfile.open.after", event_data, source="workfiles.tool") + + +def save_current_workfile_to( + workfile_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + version: Optional[int], + comment: Optional[str] = None, + description: Optional[str] = None, + source: Optional[str] = None, + rootless_path: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, + username: Optional[str] = None, + project_entity: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, +) -> dict[str, Any]: + """Save current workfile to new location or context. + + Args: + workfile_path (str): Destination workfile path. + folder_entity (dict[str, Any]): Target folder entity. + task_entity (dict[str, Any]): Target task entity. + version (Optional[int]): Workfile version. + comment (optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + source (Optional[str]): Source of the save action. + rootless_path (Optional[str]): Rootless path of the workfile. Is + calculated if not passed in. + workfile_entities (Optional[list[dict[str, Any]]]): List of workfile + username (Optional[str]): Username of the user saving the workfile. + Current user is used if not passed. + project_entity (Optional[dict[str, Any]]): Project entity used for + rootless path calculation. + project_settings (Optional[dict[str, Any]]): Project settings used for + rootless path calculation. + anatomy (Optional[Anatomy]): Project anatomy used for rootless + path calculation. + + Returns: + dict[str, Any]: Workfile info entity. + + """ + print("save_current_workfile_to") + return _save_workfile( + None, + None, + None, + None, + workfile_path, + folder_entity, + task_entity, + version, + comment, + description, + source, + rootless_path, + workfile_entities, + username, + project_entity, + project_settings, + anatomy, + ) + + +def copy_and_open_workfile( + src_workfile_path: str, + workfile_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + version: Optional[int], + comment: Optional[str] = None, + description: Optional[str] = None, + source: Optional[str] = None, + rootless_path: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, + username: Optional[str] = None, + project_entity: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, +) -> dict[str, Any]: + """Copy workfile to new location and open it. + + Args: + src_workfile_path (str): Source workfile path. + workfile_path (str): Destination workfile path. + folder_entity (dict[str, Any]): Target folder entity. + task_entity (dict[str, Any]): Target task entity. + version (Optional[int]): Workfile version. + comment (optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + source (Optional[str]): Source of the save action. + rootless_path (Optional[str]): Rootless path of the workfile. Is + calculated if not passed in. + workfile_entities (Optional[list[dict[str, Any]]]): List of workfile + username (Optional[str]): Username of the user saving the workfile. + Current user is used if not passed. + project_entity (Optional[dict[str, Any]]): Project entity used for + rootless path calculation. + project_settings (Optional[dict[str, Any]]): Project settings used for + rootless path calculation. + anatomy (Optional[Anatomy]): Project anatomy used for rootless + path calculation. + + Returns: + dict[str, Any]: Workfile info entity. + + """ + print("copy_and_open_workfile") + return _save_workfile( + src_workfile_path, + None, + None, + None, + workfile_path, + folder_entity, + task_entity, + version, + comment, + description, + source, + rootless_path, + workfile_entities, + username, + project_entity, + project_settings, + anatomy, + ) + + +def copy_and_open_workfile_representation( + project_name: str, + representation_id: str, + workfile_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + version: Optional[int], + comment: Optional[str] = None, + description: Optional[str] = None, + source: Optional[str] = None, + rootless_path: Optional[str] = None, + representation_entity: Optional[dict[str, Any]] = None, + representation_path: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, + username: Optional[str] = None, + project_entity: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, +) -> dict[str, Any]: + """Copy workfile to new location and open it. + + Args: + project_name (str): Project name where representation is stored. + representation_id (str): Source representation id. + workfile_path (str): Destination workfile path. + folder_entity (dict[str, Any]): Target folder entity. + task_entity (dict[str, Any]): Target task entity. + version (Optional[int]): Workfile version. + comment (optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + source (Optional[str]): Source of the save action. + rootless_path (Optional[str]): Rootless path of the workfile. Is + calculated if not passed in. + workfile_entities (Optional[list[dict[str, Any]]]): List of workfile + username (Optional[str]): Username of the user saving the workfile. + Current user is used if not passed. + project_entity (Optional[dict[str, Any]]): Project entity used for + rootless path calculation. + project_settings (Optional[dict[str, Any]]): Project settings used for + rootless path calculation. + anatomy (Optional[Anatomy]): Project anatomy used for rootless + path calculation. + + Returns: + dict[str, Any]: Workfile info entity. + + """ + print("copy_and_open_workfile_representation") + if representation_entity is None: + representation_entity = ayon_api.get_representation_by_id( + project_name, + representation_id, + ) + + return _save_workfile( + None, + project_name, + representation_entity, + representation_path, + workfile_path, + folder_entity, + task_entity, + version, + comment, + description, + source, + rootless_path, + workfile_entities, + username, + project_entity, + project_settings, + anatomy, + ) + + +def _save_workfile( + src_workfile_path: Optional[str], + representation_project_name: Optional[str], + representation_entity: Optional[dict[str, Any]], + representation_path: Optional[str], + workfile_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + version: Optional[int], + comment: Optional[str], + description: Optional[str], + source: Optional[str], + rootless_path: Optional[str], + workfile_entities: Optional[list[dict[str, Any]]], + username: Optional[str], + project_entity: Optional[dict[str, Any]], + project_settings: Optional[dict[str, Any]], + anatomy: Optional["Anatomy"], +) -> dict[str, Any]: + """Function used to save workfile to new location and context. + + Because the functionality for 'save_current_workfile_to' and + 'copy_and_open_workfile' is currently the same, except for used + function on host it is easier to create this wrapper function. + + Args: + src_workfile_path (Optional[str]): Source workfile path. + representation_entity (Optional[dict[str, Any]]): Representation used + as source for workfile. + workfile_path (str): Destination workfile path. + folder_entity (dict[str, Any]): Target folder entity. + task_entity (dict[str, Any]): Target task entity. + version (Optional[int]): Workfile version. + comment (optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + source (Optional[str]): Source of the save action. + rootless_path (Optional[str]): Rootless path of the workfile. Is + calculated if not passed in. + workfile_entities (Optional[list[dict[str, Any]]]): List of workfile + username (Optional[str]): Username of the user saving the workfile. + Current user is used if not passed. + project_entity (Optional[dict[str, Any]]): Project entity used for + rootless path calculation. + project_settings (Optional[dict[str, Any]]): Project settings used for + rootless path calculation. + anatomy (Optional[Anatomy]): Project anatomy used for rootless + path calculation. + + Returns: + dict[str, Any]: Workfile info entity. + + """ + from ayon_core.pipeline.context_tools import ( + registered_host, change_current_context + ) + + # Trigger before save event + host = registered_host() + context = host.get_current_context() + project_name = context["project_name"] + current_folder_path = context["folder_path"] + current_task_name = context["task_name"] + + folder_id = folder_entity["id"] + task_name = task_entity["name"] + task_type = task_entity["taskType"] + task_id = task_entity["id"] + host_name = host.name + + workdir, filename = os.path.split(workfile_path) + + # QUESTION should the data be different for 'before' and 'after'? + event_data = _get_event_context_data( + project_name, folder_entity, task_entity, host_name + ) + event_data.update({ + "filename": filename, + "workdir_path": workdir, + }) + + emit_event("workfile.save.before", event_data, source=source) + + # Change context + if ( + folder_entity["path"] != current_folder_path + or task_entity["name"] != current_task_name + ): + change_current_context( + folder_entity, + task_entity, + workdir=workdir, + anatomy=anatomy, + project_entity=project_entity, + project_settings=project_settings, + ) + + if src_workfile_path: + host.copy_workfile( + src_workfile_path, + workfile_path, + folder_id, + task_id, + open_workfile=True, + dst_folder_entity=folder_entity, + dst_task_entity=task_entity, + ) + elif representation_entity: + host.copy_workfile_representation( + representation_project_name, + representation_entity["id"], + workfile_path, + folder_id, + task_id, + open_workfile=True, + folder_entity=folder_entity, + task_entity=task_entity, + src_representation_entity=representation_entity, + src_representation_path=representation_path, + anatomy=anatomy, + ) + else: + host.save_workfile_with_context( + workfile_path, + folder_id, + task_id, + open_workfile=True, + folder_entity=folder_entity, + task_entity=task_entity, + ) + + if not description: + description = None + + if not comment: + comment = None + + if rootless_path is None: + rootless_path = _find_rootless_path( + workfile_path, + project_name, + task_type, + host_name, + project_entity, + project_settings, + anatomy, + ) + + # It is not possible to create workfile infor without rootless path + workfile_info = None + if rootless_path: + if platform.system().lower() == "windows": + rootless_path = rootless_path.replace("\\", "/") + + workfile_info = save_workfile_info( + project_name, + task_id, + rootless_path, + host_name, + version, + comment, + description, + username=username, + workfile_entities=workfile_entities, + ) + + # Create extra folders + create_workdir_extra_folders( + workdir, + host.name, + task_entity["taskType"], + task_name, + project_name + ) + + # Trigger after save events + emit_event("workfile.save.after", event_data, source=source) + return workfile_info + + +def _find_rootless_path( + workfile_path: str, + project_name: str, + task_type: str, + host_name: str, + project_entity: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, +) -> str: + """Find rootless workfile path.""" + if anatomy is None: + from ayon_core.pipeline import Anatomy + + anatomy = Anatomy(project_name, project_entity=project_entity) + template_key = get_workfile_template_key( + project_name, + task_type, + host_name, + project_settings=project_settings + ) + dir_template = anatomy.get_template_item( + "work", template_key, "directory" + ) + result = dir_template.format({"root": anatomy.roots}) + used_root = result.used_values.get("root") + rootless_path = str(workfile_path) + if platform.system().lower() == "windows": + rootless_path = rootless_path.replace("\\", "/") + + root_key = root_value = None + if used_root is not None: + root_key, root_value = next(iter(used_root.items())) + if platform.system().lower() == "windows": + root_value = root_value.replace("\\", "/") + + if root_value and rootless_path.startswith(root_value): + rootless_path = rootless_path[len(root_value):].lstrip("/") + rootless_path = f"{{root[{root_key}]}}/{rootless_path}" + else: + success, result = anatomy.find_root_template_from_path(rootless_path) + if success: + rootless_path = result + return rootless_path + + +def _create_workfile_info_entity( + project_name: str, + task_id: str, + host_name: str, + rootless_path: str, + username: str, + version: Optional[int], + comment: Optional[str], + description: Optional[str], +) -> dict[str, Any]: + extension = os.path.splitext(rootless_path)[1] + + attrib = {} + for key, value in ( + ("extension", extension), + ("description", description), + ): + if value is not None: + attrib[key] = value + + data = {} + for key, value in ( + ("host_name", host_name), + ("version", version), + ("comment", comment), + ): + if value is not None: + data[key] = value + + workfile_info = { + "id": uuid.uuid4().hex, + "path": rootless_path, + "taskId": task_id, + "attrib": attrib, + "data": data, + # TODO remove 'createdBy' and 'updatedBy' fields when server is + # or above 1.1.3 . + "createdBy": username, + "updatedBy": username, + } + + session = OperationsSession() + session.create_entity( + project_name, "workfile", workfile_info + ) + session.commit() + return workfile_info \ No newline at end of file From 639087937f025edc3cf47205df22abdc47bbfd53 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 16 May 2025 10:26:01 +0200 Subject: [PATCH 055/312] modified workfiles tool accordingly --- client/ayon_core/tools/workfiles/abstract.py | 20 +- client/ayon_core/tools/workfiles/control.py | 21 +- .../tools/workfiles/models/workfiles.py | 343 ++++++++++-------- .../tools/workfiles/widgets/files_widget.py | 9 +- 4 files changed, 235 insertions(+), 158 deletions(-) diff --git a/client/ayon_core/tools/workfiles/abstract.py b/client/ayon_core/tools/workfiles/abstract.py index 6d7d0b4c0e..863d6bb9bc 100644 --- a/client/ayon_core/tools/workfiles/abstract.py +++ b/client/ayon_core/tools/workfiles/abstract.py @@ -866,8 +866,8 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): folder_id, task_id, rootless_workdir, + workdir, filename, - template_key, version, comment, description, @@ -897,7 +897,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): task_id, workdir, filename, - template_key, + rootless_workdir, version, comment, description, @@ -914,7 +914,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): task_id (str): Task id. workdir (str): Workarea directory. filename (str): Workarea filename. - template_key (str): Template key. + rootless_workdir (str): Rootless workdir. version (int): Workfile version. comment (str): User's comment (subversion). description (str): Description note. @@ -924,14 +924,26 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): @abstractmethod def duplicate_workfile( - self, src_filepath, workdir, filename, description, version, comment + self, + folder_id, + task_id, + src_filepath, + rootless_workdir, + workdir, + filename, + description, + version, + comment ): """Duplicate workfile. Workfiles is not opened when done. Args: + folder_id (str): Folder id. + task_id (str): Task id. src_filepath (str): Source workfile path. + rootless_workdir (str): Rootless workdir. workdir (str): Destination workdir. filename (str): Destination filename. version (int): Workfile version. diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index f5df9f83ce..faab199c9f 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -523,8 +523,8 @@ class BaseWorkfileController( folder_id, task_id, rootless_workdir, + workdir, filename, - template_key, version, comment, description, @@ -534,7 +534,6 @@ class BaseWorkfileController( task_id, rootless_workdir, filename, - template_key, version, comment, description, @@ -548,7 +547,7 @@ class BaseWorkfileController( task_id, workdir, filename, - template_key, + rootless_workdir, version, comment, description, @@ -560,17 +559,29 @@ class BaseWorkfileController( task_id, workdir, filename, - template_key, + rootless_workdir, version, comment, description, ) def duplicate_workfile( - self, src_filepath, workdir, filename, version, comment, description + self, + folder_id, + task_id, + src_filepath, + rootless_workdir, + workdir, + filename, + version, + comment, + description ): self._workfiles_model.duplicate_workfile( + folder_id, + task_id, src_filepath, + rootless_workdir, workdir, filename, version, diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 6508f693dd..d9a217653e 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -14,7 +14,6 @@ from ayon_core.lib import ( get_ayon_username, NestedCacheItem, CacheItem, - emit_event, Logger, ) from ayon_core.host import ( @@ -33,10 +32,12 @@ from ayon_core.pipeline.workfile import ( get_workfile_template_key, get_last_workfile_with_version_from_paths, get_comments_from_workfile_paths, - create_workdir_extra_folders, + open_workfile, + save_current_workfile_to, + copy_and_open_workfile, + copy_and_open_workfile_representation, ) from ayon_core.pipeline.version_start import get_versioning_start -from ayon_core.pipeline.context_tools import change_current_context from ayon_core.tools.workfiles.abstract import ( WorkareaFilepathResult, AbstractWorkfilesBackend, @@ -81,6 +82,12 @@ class WorkfilesModel: levels=1, default_factory=list ) + # Published workfiles + self._repre_by_id = {} + self._published_workfile_items_cache = NestedCacheItem( + levels=1, default_factory=list + ) + # Entities self._workfile_entities_by_task_id = {} @@ -92,6 +99,9 @@ class WorkfilesModel: self._workarea_file_items_mapping = {} self._workarea_file_items_cache.reset() + self._repre_by_id = {} + self._published_workfile_items_cache.reset() + self._workfile_entities_by_task_id = {} # Host functionality @@ -123,26 +133,50 @@ class WorkfilesModel: folder_id, task_id, rootless_workdir, + workdir, filename, - template_key, version, comment, description, ): self._emit_event("save_as.started") + filepath = os.path.join(workdir, filename) + rootless_path = f"{rootless_workdir}/{filename}" + project_name = self._controller.get_current_project_name() + folder_entity = self._controller.get_folder_entity( + project_name, folder_id + ) + task_entity = self._controller.get_task_entity( + project_name, task_id + ) + workfile_entities = self.get_workfile_entities(task_id) failed = False try: - self._save_as_workfile( - folder_id, - task_id, - rootless_workdir, - filename, - template_key, + workfile_info = save_current_workfile_to( + filepath, + folder_entity, + task_entity, version, comment, description, + source="workfiles.tool", + rootless_path=rootless_path, + workfile_entities=workfile_entities, + username=self._get_current_username(), + project_entity=self._controller.get_project_entity( + project_name + ), + project_settings=self._controller.project_settings, + anatomy=self._controller.project_anatomy, ) + self._update_workfile_info( + task_id, rootless_path, description, workfile_info + ) + self._update_current_context( + folder_id, folder_entity["path"], task_entity["name"] + ) + except Exception: failed = True self._log.warning("Save as failed", exc_info=True) @@ -160,27 +194,53 @@ class WorkfilesModel: task_id, workdir, filename, - template_key, + rootless_workdir, version, comment, description, ): - # TODO move to workfiles pipeline self._emit_event("copy_representation.started") + project_name = self._project_name + folder_entity = self._controller.get_folder_entity( + self._project_name, folder_id + ) + task_entity = self._controller.get_task_entity( + self._project_name, task_id + ) + repre_entity = self._repre_by_id.get(representation_id) + dst_filepath = os.path.join(workdir, filename) + rootless_path = f"{rootless_workdir}/{filename}" + failed = False try: - self._save_as_workfile( - folder_id, - task_id, - workdir, - filename, - template_key, - version, - comment, - description, - src_filepath=representation_filepath + workfile_info = copy_and_open_workfile_representation( + project_name, + representation_id, + dst_filepath, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + rootless_path=rootless_path, + representation_entity=repre_entity, + representation_path=representation_filepath, + workfile_entities=self.get_workfile_entities(task_id), + username=self._get_current_username(), + project_entity=self._controller.get_project_entity( + project_name + ), + project_settings=self._controller.project_settings, + anatomy=self._controller.project_anatomy, ) + self._update_workfile_info( + task_id, rootless_path, description, workfile_info + ) + self._update_current_context( + folder_id, folder_entity["path"], task_entity["name"] + ) + except Exception: failed = True self._log.warning( @@ -193,15 +253,47 @@ class WorkfilesModel: ) def duplicate_workfile( - self, src_filepath, workdir, filename, version, comment, description + self, + folder_id, + task_id, + src_filepath, + rootless_workdir, + workdir, + filename, + version, + comment, + description ): - # TODO save workfile information self._emit_event("workfile_duplicate.started") + project_name = self._controller.get_current_project_name() + project_entity = self._controller.get_project_entity(project_name) + folder_entity = self._controller.get_folder_entity( + project_name, folder_id + ) + task_entity = self._controller.get_task_entity(project_name, task_id) + workfile_entities = self.get_workfile_entities(task_id) + rootless_path = f"{rootless_workdir}/{filename}" + workfile_path = os.path.join(workdir, filename) failed = False try: - dst_filepath = os.path.join(workdir, filename) - shutil.copy(src_filepath, dst_filepath) + copy_and_open_workfile( + src_filepath, + workfile_path, + folder_entity, + task_entity, + version, + comment, + description, + source="workfiles.tool", + rootless_path=rootless_path, + workfile_entities=workfile_entities, + username=self._get_current_username(), + project_entity=project_entity, + project_settings=self._controller.project_settings, + anatomy=self._controller.project_anatomy, + ) + except Exception: failed = True self._log.warning("Duplication of workfile failed", exc_info=True) @@ -258,9 +350,6 @@ class WorkfilesModel: task_id, rootless_path, description ) - def reset_workarea_file_items(self, task_id: str): - self._reset_workarea_file_items(task_id) - def get_workarea_dir_by_context( self, folder_id: str, task_id: str ) -> Optional[str]: @@ -480,13 +569,51 @@ class WorkfilesModel: list[PublishedWorkfileInfo]: List of files for published workfiles. """ - project_name = self._project_name - anatomy = self._controller.project_anatomy - items = self._host.list_published_workfiles( - project_name, - folder_id, - anatomy, - ) + if not folder_id: + return [] + + cache = self._published_workfile_items_cache[folder_id] + if not cache.is_valid: + project_name = self._project_name + anatomy = self._controller.project_anatomy + + product_entities = ayon_api.get_products( + project_name, + folder_ids={folder_id}, + product_types={"workfile"}, + fields={"id", "name"} + ) + + version_entities = [] + product_ids = {product["id"] for product in product_entities} + if product_ids: + # Get version docs of products with their families + version_entities = list(ayon_api.get_versions( + project_name, + product_ids=product_ids, + fields={"id", "author", "taskId"}, + )) + + repre_entities = [] + if version_entities: + repre_entities = list(ayon_api.get_representations( + project_name, + version_ids={v["id"] for v in version_entities} + )) + + self._repre_by_id.update({ + repre_entity["id"]: repre_entity + for repre_entity in repre_entities + }) + + cache.update_data(self._host.list_published_workfiles( + project_name, + folder_id, + anatomy, + )) + + items = cache.get_data() + if task_id: items = [ item @@ -540,121 +667,21 @@ class WorkfilesModel: def _open_workfile(self, folder_id: str, task_id: str, filepath: str): # TODO move to workfiles pipeline project_name = self._project_name - event_data = self._get_event_context_data( - project_name, folder_id, task_id - ) - event_data["filepath"] = filepath - - emit_event("workfile.open.before", event_data, source="workfiles.tool") - - # Change context - task_name = event_data["task_name"] - if ( - folder_id != self._controller.get_current_folder_id() - or task_name != self._controller.get_current_task_name() - ): - self._change_current_context(project_name, folder_id, task_id) - - self._host.open_workfile(filepath) - - emit_event("workfile.open.after", event_data, source="workfiles.tool") - - def _save_as_workfile( - self, - folder_id: str, - task_id: str, - rootless_workdir: str, - filename: str, - template_key: str, - version: Optional[int], - comment: Optional[str], - description: Optional[str], - src_filepath=None, - ): - # TODO move to workfiles pipeline - # Trigger before save event - project_name = self._project_name - folder = self._controller.get_folder_entity(project_name, folder_id) - task = self._controller.get_task_entity(project_name, task_id) - task_name = task["name"] - - workdir = self._controller.project_anatomy.fill_root(rootless_workdir) - - # QUESTION should the data be different for 'before' and 'after'? - event_data = self._get_event_context_data( - project_name, folder_id, task_id, folder, task - ) - event_data.update({ - "filename": filename, - "workdir_path": workdir, - }) - - emit_event("workfile.save.before", event_data, source="workfiles.tool") - - # Create workfiles root folder - if not os.path.exists(workdir): - self._log.debug("Initializing work directory: %s", workdir) - os.makedirs(workdir) - - # Change context - if ( - folder_id != self._controller.get_current_folder_id() - or task_name != self._controller.get_current_task_name() - ): - self._change_current_context( - project_name, folder_id, task_id, template_key - ) - - # Save workfile - dst_filepath = os.path.join(workdir, filename) - if src_filepath: - shutil.copyfile(src_filepath, dst_filepath) - self._host.open_workfile(dst_filepath) - else: - self._host.save_workfile(dst_filepath) - - # Make sure workfile info exists - if not description: - description = None - if not comment: - comment = None - self.save_workfile_info( - task_id, - f"{rootless_workdir}/{filename}", - version, - comment, - description, - ) - self.reset_workarea_file_items(task_id) - - # Create extra folders - create_workdir_extra_folders( - workdir, - self._host_name, - task["taskType"], - task_name, - project_name - ) - - # Trigger after save events - emit_event("workfile.save.after", event_data, source="workfiles.tool") - - def _change_current_context( - self, project_name, folder_id, task_id, template_key=None - ): - # Change current context folder_entity = self._controller.get_folder_entity( project_name, folder_id ) - task_entity = self._controller.get_task_entity(project_name, task_id) - change_current_context( - folder_entity, - task_entity, - template_key=template_key + task_entity = self._controller.get_task_entity( + project_name, task_id ) - self._current_folder_id = folder_entity["id"] - self._current_folder_path = folder_entity["path"] - self._current_task_name = task_entity["name"] + open_workfile(filepath, folder_entity, task_entity) + self._update_current_context( + folder_id, folder_entity["path"], task_entity["name"] + ) + + def _update_current_context(self, folder_id, folder_path, task_name): + self._current_folder_id = folder_id + self._current_folder_path = folder_path + self._current_task_name = task_name # --- Workarea --- def _reset_workarea_file_items(self, task_id: str): @@ -820,6 +847,28 @@ class WorkfilesModel: ) return directory_template.format_strict(fill_data).normalized() + def _update_workfile_info( + self, + task_id: str, + rootless_path: str, + description: str, + workfile_entity: dict[str, Any], + ): + self._update_file_description(task_id, rootless_path, description) + workfile_entities = self.get_workfile_entities(task_id) + target_idx = None + for idx, workfile_entity in enumerate(workfile_entities): + if workfile_entity["path"] == rootless_path: + target_idx = idx + break + + if target_idx is None: + workfile_entities.append(workfile_entity) + else: + workfile_entities[target_idx] = workfile_entity + + self._reset_workarea_file_items(task_id) + def _update_file_description( self, task_id: str, rootless_path: str, description: str ): diff --git a/client/ayon_core/tools/workfiles/widgets/files_widget.py b/client/ayon_core/tools/workfiles/widgets/files_widget.py index d45e057192..012a12ab17 100644 --- a/client/ayon_core/tools/workfiles/widgets/files_widget.py +++ b/client/ayon_core/tools/workfiles/widgets/files_widget.py @@ -213,9 +213,14 @@ class FilesWidget(QtWidgets.QWidget): result = self._exec_save_as_dialog() if result is None: return + folder_id = self._selected_folder_id + task_id = self._selected_task_id self._controller.duplicate_workfile( + folder_id, + task_id, filepath, result["rootless_workdir"], + result["workdir"], result["filename"], version=result["version"], comment=result["comment"], @@ -265,8 +270,8 @@ class FilesWidget(QtWidgets.QWidget): result["folder_id"], result["task_id"], result["rootless_workdir"], + result["workdir"], result["filename"], - result["template_key"], version=result["version"], comment=result["comment"], description=result["description"] @@ -321,7 +326,7 @@ class FilesWidget(QtWidgets.QWidget): result["task_id"], result["workdir"], result["filename"], - result["template_key"], + result["rootless_workdir"], version=result["version"], comment=result["comment"], description=result["description"], From 723463d44ee578945aa35e6ba4ac17bb440dd590 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 16 May 2025 10:43:02 +0200 Subject: [PATCH 056/312] use correct function --- client/ayon_core/pipeline/workfile/utils.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 44c811d5e2..94f4528205 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -289,7 +289,13 @@ def open_workfile( workdir=os.path.dirname(filepath) ) - host.open_workfile(filepath) + host.open_workfile_with_context( + filepath, + folder_entity["id"], + task_entity["id"], + folder_entity, + task_entity, + ) emit_event("workfile.open.after", event_data, source="workfiles.tool") From a37c074771b5dea0937496023629ad87a7da8be5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 19 May 2025 10:35:59 +0200 Subject: [PATCH 057/312] expect entities instead of ids --- client/ayon_core/host/interfaces/workfiles.py | 80 ++++++------------- client/ayon_core/pipeline/workfile/utils.py | 23 ++---- .../tools/workfiles/models/workfiles.py | 6 +- 3 files changed, 35 insertions(+), 74 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 970e31bc88..de4c096237 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -212,10 +212,8 @@ class IWorkfileHost: def save_workfile_with_context( self, filepath: str, - folder_id: str, - task_id: str, - folder_entity: Optional[dict[str, Any]] = None, - task_entity: Optional[dict[str, Any]] = None, + folder_entity: dict[str, Any] = None, + task_entity: dict[str, Any] = None, ): """Save current workfile with context. @@ -224,8 +222,8 @@ class IWorkfileHost: Args: filepath (str): Where the current scene should be saved. - folder_id (str): Folder id. - task_id (str): Task id. + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. """ self.save_workfile(filepath) @@ -233,10 +231,8 @@ class IWorkfileHost: def open_workfile_with_context( self, filepath: str, - folder_id: str, - task_id: str, - folder_entity: Optional[dict[str, Any]] = None, - task_entity: Optional[dict[str, Any]] = None, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], ): """Open passed filepath in the host with context. @@ -257,11 +253,9 @@ class IWorkfileHost: def list_workfiles( self, project_name: str, - folder_id: str, - task_id: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], project_entity: Optional[dict[str, Any]] = None, - folder_entity: Optional[dict[str, Any]] = None, - task_entity: Optional[dict[str, Any]] = None, workfile_entities: Optional[list[dict[str, Any]]] = None, template_key: Optional[str] = None, project_settings: Optional[dict[str, Any]] = None, @@ -278,11 +272,9 @@ class IWorkfileHost: Args: project_name (str): Name of project. - folder_id (str): ID of folder. - task_id (str): ID of task. + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. project_entity (Optional[dict[str, Any]]): Project entity. - folder_entity (Optional[dict[str, Any]]): Folder entity. - task_entity (Optional[dict[str, Any]]): Task entity. workfile_entities (Optional[list[dict[str, Any]]]): Workfile entities. template_key (Optional[str]): Template key. @@ -304,13 +296,8 @@ class IWorkfileHost: if project_entity is None: project_entity = ayon_api.get_project(project_name) - if folder_entity is None: - folder_entity = ayon_api.get_folder_by_id(project_name, folder_id) - - if task_entity is None: - task_entity = ayon_api.get_task_by_id(project_name, task_id) - if workfile_entities is None: + task_id = task_entity["id"] workfile_entities = list(ayon_api.get_workfiles_info( project_name, task_ids=[task_id] )) @@ -492,11 +479,9 @@ class IWorkfileHost: self, src_path: str, dst_path: str, - folder_id: str, - task_id: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], open_workfile: bool = False, - folder_entity: Optional[dict[str, Any]] = None, - task_entity: Optional[dict[str, Any]] = None, ): """Save workfile path with target folder and task context. @@ -506,8 +491,8 @@ class IWorkfileHost: Args: src_path (str): Path to the source scene. dst_path (str): Where the scene should be saved. - folder_id (str): Folder id. - task_id (str): Task id. + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. open_workfile (bool): Open workfile when copied. """ @@ -520,25 +505,20 @@ class IWorkfileHost: if open_workfile: self.open_workfile_with_context( dst_path, - folder_id, - task_id, - folder_entity=folder_entity, - task_entity=task_entity, + folder_entity, + task_entity, ) def copy_workfile_representation( self, src_project_name: str, - src_representation_id: str, + src_representation_entity: dict[str, Any], dst_path: str, - folder_id: str, - task_id: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], open_workfile: bool = False, anatomy: Optional[Anatomy] = None, - src_representation_entity: Optional[dict[str, Any]] = None, src_representation_path: Optional[str] = None, - folder_entity: Optional[dict[str, Any]] = None, - task_entity: Optional[dict[str, Any]] = None, ): """Copy workfile representation. @@ -546,14 +526,13 @@ class IWorkfileHost: Args: src_project_name (str): Project name. - src_representation_id (str): Representation id. + src_representation_entity (dict[str, Any]): Representation + entity. dst_path (str): Where the scene should be saved. - folder_id (str): Folder id. - task_id (str): Task id. + folder_entity (dict[str, Any): Folder entity. + task_entity (dict[str, Any]): Task entity. open_workfile (bool): Open workfile when copied. anatomy (Optional[Anatomy]): Project anatomy. - src_representation_entity (Optional[dict[str, Any]]): Representation - entity. src_representation_path (Optional[str]): Representation path. """ @@ -565,11 +544,6 @@ class IWorkfileHost: ) if src_representation_path is None: - if src_representation_entity is None: - src_representation_entity = ayon_api.get_representation_by_id( - src_project_name, src_representation_id - ) - if anatomy is None: anatomy = Anatomy(src_project_name) src_representation_path = get_representation_path_with_anatomy( @@ -580,11 +554,9 @@ class IWorkfileHost: self.copy_workfile( src_representation_path, dst_path, - folder_id, - task_id, + folder_entity, + task_entity, open_workfile=open_workfile, - folder_entity=folder_entity, - task_entity=task_entity, ) # --- Deprecated method names --- diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 94f4528205..c5b6b16e2a 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -291,8 +291,6 @@ def open_workfile( host.open_workfile_with_context( filepath, - folder_entity["id"], - task_entity["id"], folder_entity, task_entity, ) @@ -603,34 +601,27 @@ def _save_workfile( host.copy_workfile( src_workfile_path, workfile_path, - folder_id, - task_id, + folder_entity, + task_entity, open_workfile=True, - dst_folder_entity=folder_entity, - dst_task_entity=task_entity, ) elif representation_entity: host.copy_workfile_representation( representation_project_name, - representation_entity["id"], + representation_entity, workfile_path, - folder_id, - task_id, + folder_entity, + task_entity, open_workfile=True, - folder_entity=folder_entity, - task_entity=task_entity, - src_representation_entity=representation_entity, src_representation_path=representation_path, anatomy=anatomy, ) else: host.save_workfile_with_context( workfile_path, - folder_id, - task_id, + folder_entity, + task_entity, open_workfile=True, - folder_entity=folder_entity, - task_entity=task_entity, ) if not description: diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index d9a217653e..d13bfa248f 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -770,11 +770,9 @@ class WorkfilesModel: items = self._host.list_workfiles( self._project_name, - folder_id, - task_id, + folder_entity, + task_entity, project_entity=project_entity, - folder_entity=folder_entity, - task_entity=task_entity, anatomy=anatomy, template_key=template_key, project_settings=project_settings, From 70f3c05d0793f9f04f8a5890cecbe8ea31a0b4e1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 19 May 2025 10:45:49 +0200 Subject: [PATCH 058/312] linting fixes --- client/ayon_core/host/interfaces/workfiles.py | 5 ++--- client/ayon_core/pipeline/workfile/utils.py | 3 +-- client/ayon_core/tools/workfiles/control.py | 2 -- client/ayon_core/tools/workfiles/models/workfiles.py | 1 - client/ayon_core/tools/workfiles/widgets/side_panel.py | 4 +++- 5 files changed, 6 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index de4c096237..f416d19aa0 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -243,13 +243,12 @@ class IWorkfileHost: Args: filepath (str): Path to workfile. - folder_id (str): Folder id. - task_id (str): Task id. + folder_entity (dict[str, Any]): Folder id. + task_entity (dict[str, Any]): Task id. """ self.open_workfile(filepath) - def list_workfiles( self, project_name: str, diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index c5b6b16e2a..a7a1436522 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -564,7 +564,6 @@ def _save_workfile( current_folder_path = context["folder_path"] current_task_name = context["task_name"] - folder_id = folder_entity["id"] task_name = task_entity["name"] task_type = task_entity["taskType"] task_id = task_entity["id"] @@ -764,4 +763,4 @@ def _create_workfile_info_entity( project_name, "workfile", workfile_info ) session.commit() - return workfile_info \ No newline at end of file + return workfile_info diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index faab199c9f..ab6b12e4f4 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -1,6 +1,4 @@ import os -import shutil -from typing import Optional import ayon_api diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index d13bfa248f..4f5fb9890d 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -4,7 +4,6 @@ import copy import uuid import platform import typing -import shutil from typing import Optional, Any import ayon_api diff --git a/client/ayon_core/tools/workfiles/widgets/side_panel.py b/client/ayon_core/tools/workfiles/widgets/side_panel.py index 2e146fddbe..b1b91d9721 100644 --- a/client/ayon_core/tools/workfiles/widgets/side_panel.py +++ b/client/ayon_core/tools/workfiles/widgets/side_panel.py @@ -48,7 +48,9 @@ class SidePanelWidget(QtWidgets.QWidget): description_widget = QtWidgets.QWidget(self) description_label = QtWidgets.QLabel("Artist note", description_widget) description_input = QtWidgets.QPlainTextEdit(description_widget) - btn_description_save = QtWidgets.QPushButton("Save note", description_widget) + btn_description_save = QtWidgets.QPushButton( + "Save note", description_widget + ) description_layout = QtWidgets.QVBoxLayout(description_widget) description_layout.setContentsMargins(0, 0, 0, 0) From a159e02f873a9176fbe1ac351035a152615dd3a0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 21 May 2025 16:51:22 +0200 Subject: [PATCH 059/312] fix arguments --- client/ayon_core/pipeline/workfile/utils.py | 1 - client/ayon_core/tools/workfiles/control.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index a7a1436522..f36e349841 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -620,7 +620,6 @@ def _save_workfile( workfile_path, folder_entity, task_entity, - open_workfile=True, ) if not description: diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index ab6b12e4f4..4391e6b5fd 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -531,6 +531,7 @@ class BaseWorkfileController( folder_id, task_id, rootless_workdir, + workdir, filename, version, comment, From edb371fb38b6c0d7e57f3450608db833080cb4f7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 22 May 2025 12:35:38 +0200 Subject: [PATCH 060/312] Remove outdated todo --- client/ayon_core/pipeline/workfile/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index f36e349841..d37287b330 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -171,7 +171,6 @@ def save_workfile_info( username: Optional[str] = None, workfile_entities: Optional[list[dict[str, Any]]] = None, ): - # TODO create pipeline function for this if workfile_entities is None: workfile_entities = list(ayon_api.get_workfiles_info( project_name, From e97f7f1d20e07727411308b9b7cb73e4a328e10f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 26 May 2025 15:11:34 +0200 Subject: [PATCH 061/312] added docstring to dataclasses --- client/ayon_core/host/interfaces/workfiles.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index f416d19aa0..7d924dff3a 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -16,6 +16,29 @@ if typing.TYPE_CHECKING: @dataclass class WorkfileInfo: + """Information about workfile. + + Host can open, copy and use the workfile using this information object. + + Attributes: + filepath (str): Path to the workfile. + rootless_path (str): Path to the workfile without root. And without + backslashes on Windows. + file_size (Optional[float]): Size of the workfile in bytes. + file_created (Optional[float]): Timestamp when the workfile was + created on the filesystem. + file_modified (Optional[float]): Timestamp when the workfile was + modified on the filesystem. + workfile_entity_id (Optional[str]): Workfile entity id. If None then + the workfile is not in the database. + description (str): Description of the workfile. + created_by (Optional[str]): User id of the user who created the + workfile entity. + updated_by (Optional[str]): User id of the user who updated the + workfile entity. + available (bool): True if workfile is available on the machine. + + """ filepath: str rootless_path: str file_size: Optional[float] @@ -81,6 +104,27 @@ class WorkfileInfo: @dataclass class PublishedWorkfileInfo: + """Information about published workfile. + + Host can copy and use the workfile using this information object. + + Attributes: + project_name (str): Name of the project where workfile lives. + folder_id (str): Folder id under which is workfile stored. + task_id (Optional[str]): Task id under which is workfile stored. + representation_id (str): Representation id of the workfile. + filepath (str): Path to the workfile. + created_at (float): Timestamp when the workfile representation + was created. + author (str): Author of the workfile representation. + available (bool): True if workfile is available on the machine. + file_size (Optional[float]): Size of the workfile in bytes. + file_created (Optional[float]): Timestamp when the workfile was + created on the filesystem. + file_modified (Optional[float]): Timestamp when the workfile was + modified on the filesystem. + + """ project_name: str folder_id: str task_id: Optional[str] From c83bae2605f60bc6aa555d542b5783171cb0c01a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:03:13 +0200 Subject: [PATCH 062/312] move context change responsibility to host --- client/ayon_core/host/host.py | 224 ++++++++++++++++++++- client/ayon_core/pipeline/context_tools.py | 106 +++++----- 2 files changed, 262 insertions(+), 68 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 3333cf3778..d451b768c1 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -1,10 +1,20 @@ +from __future__ import annotations + import os import logging import contextlib from abc import ABC, abstractmethod +import typing +from typing import Optional, Any -# NOTE can't import 'typing' because of issues in Maya 2020 -# - shiboken crashes on 'typing' module import +import ayon_api + +from ayon_core.lib import emit_event + +from .interfaces import IWorkfileHost + +if typing.TYPE_CHECKING: + from ayon_core.pipeline import Anatomy class HostBase(ABC): @@ -94,12 +104,12 @@ class HostBase(ABC): @property @abstractmethod - def name(self): + def name(self) -> str: """Host name.""" pass - def get_current_project_name(self): + def get_current_project_name(self) -> str: """ Returns: Union[str, None]: Current project name. @@ -107,7 +117,7 @@ class HostBase(ABC): return os.environ.get("AYON_PROJECT_NAME") - def get_current_folder_path(self): + def get_current_folder_path(self) -> Optional[str]: """ Returns: Union[str, None]: Current asset name. @@ -115,7 +125,7 @@ class HostBase(ABC): return os.environ.get("AYON_FOLDER_PATH") - def get_current_task_name(self): + def get_current_task_name(self) -> Optional[str]: """ Returns: Union[str, None]: Current task name. @@ -123,7 +133,7 @@ class HostBase(ABC): return os.environ.get("AYON_TASK_NAME") - def get_current_context(self): + def get_current_context(self) -> dict[str, Optional[str]]: """Get current context information. This method should be used to get current context of host. Usage of @@ -142,6 +152,88 @@ class HostBase(ABC): "task_name": self.get_current_task_name() } + def set_current_context( + self, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + reason: Optional[str] = None, + workdir: Optional[str] = None, + project_entity: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + ): + """Set current context information. + + This method should be used to set current context of host. Usage of + this method can be crucial for host implementations in DCCs where + can be opened multiple workfiles at one moment and change of context + can't be caught properly. + + Notes: + This method should not care about change of workdir and expect any + of the arguments. + + Args: + folder_entity (Optional[dict[str, Any]]): Folder entity. + task_entity (Optional[dict[str, Any]]): Task entity. + reason (Optional[str]): Reason for context change. + workdir (Optional[str]): Work directory path. + project_entity (Optional[dict[str, Any]]): Project entity data. + project_settings (Optional[dict[str, Any]]): Project settings data. + anatomy (Optional[Anatomy]): Anatomy instance for the project. + + """ + from ayon_core.pipeline import Anatomy + + folder_path = folder_entity["path"] + task_name = task_entity["name"] + + context = self.get_current_context() + # Don't do anything if context did not change + if ( + context["folder_path"] == folder_path + and context["task_name"] == task_name + ): + return context + + project_name = self.get_current_project_name() + if project_entity is None: + project_entity = ayon_api.get_project(project_name) + + if anatomy is None: + anatomy = Anatomy(project_name, project_entity=project_entity) + + self._before_context_change( + project_entity, + folder_entity, + task_entity, + anatomy, + reason, + ) + self._set_current_context( + project_entity, + folder_entity, + task_entity, + reason, + workdir, + anatomy, + project_settings, + ) + self._after_context_change( + project_entity, + folder_entity, + task_entity, + anatomy, + reason, + ) + + return self._emit_context_change_event( + project_name, + folder_path, + task_name, + ) + def get_context_title(self): """Context title shown for UI purposes. @@ -188,3 +280,121 @@ class HostBase(ABC): yield finally: pass + + def _emit_context_change_event( + self, + project_name: str, + folder_path: Optional[str], + task_name: Optional[str], + ): + """Emit context change event. + + Args: + project_name (str): Name of the project. + folder_path (Optional[str]): Path of the folder. + task_name (Optional[str]): Name of the task. + + """ + data = { + "project_name": project_name, + "folder_path": folder_path, + "task_name": task_name, + } + emit_event("taskChanged", data) + return data + + def _set_current_context( + self, + project_entity: dict[str, Any], + folder_entity: Optional[dict[str, Any]], + task_entity: Optional[dict[str, Any]], + reason: Optional[str], + workdir: Optional[str], + anatomy: Optional["Anatomy"], + project_settings: Optional[dict[str, Any]], + ): + from ayon_core.pipeline.workfile import get_workdir + + project_name = self.get_current_project_name() + folder_path = None + task_name = None + if folder_entity: + folder_path = folder_entity["path"] + if task_entity: + task_name = task_entity["name"] + + if ( + workdir is None + and isinstance(self, IWorkfileHost) + and folder_entity + ): + if project_entity is None: + project_entity = ayon_api.get_project(project_name) + + workdir = get_workdir( + project_entity, + folder_entity, + task_entity, + self.name, + anatomy=anatomy, + project_settings=project_settings, + ) + + envs = { + "AYON_PROJECT_NAME": project_name, + "AYON_FOLDER_PATH": folder_path, + "AYON_TASK_NAME": task_name, + "AYON_WORKDIR": workdir, + } + + # Update the Session and environments. Pop from environments all keys with + # value set to None. + for key, value in envs.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + def _before_context_change( + self, + project_entity: dict[str, Any], + folder_entity: Optional[dict[str, Any]], + task_entity: Optional[dict[str, Any]], + anatomy: "Anatomy", + reason: Optional[str], + ): + """Before context is changed. + + This method is called before the context is changed in the host. + + Can be overriden to implement host specific logic. + + Args: + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + reason (Optional[str]): Reason for context change. + + """ + pass + + def _after_context_change( + self, + project_entity: dict[str, Any], + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + anatomy: "Anatomy", + reason: Optional[str], + ): + """After context is changed. + + This method is called after the context is changed in the host. + + Can be overriden to implement host specific logic. + + Args: + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + reason (Optional[str]): Reason for context change. + + """ + pass diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 0c6e86ef4b..c51f0ad0d9 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -1,20 +1,21 @@ """Core pipeline functionality""" - +from __future__ import annotations import os import logging import platform import uuid +import warnings +from typing import Optional, Any import ayon_api import pyblish.api from pyblish.lib import MessageHandler from ayon_core import AYON_CORE_ROOT -from ayon_core.host import HostBase, IWorkfileHost +from ayon_core.host import HostBase from ayon_core.lib import ( is_in_tests, initialize_ayon_connection, - emit_event, version_up ) from ayon_core.addon import load_addons, AddonsManager @@ -24,7 +25,6 @@ from .publish.lib import filter_pyblish_plugins from .anatomy import Anatomy from .template_data import get_template_data_with_names from .workfile import ( - get_workdir, get_custom_workfile_template_by_string_context, get_workfile_template_key_from_context, get_last_workfile, @@ -505,14 +505,19 @@ def get_current_context_custom_workfile_template(project_settings=None): ) +_PLACEHOLDER = object() + + def change_current_context( - folder_entity, - task_entity, - template_key=None, - workdir=None, - anatomy=None, - project_entity=None, - project_settings=None, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + template_key: Optional[str] = _PLACEHOLDER, + workdir: Optional[str] = _PLACEHOLDER, + reason: Optional[str] = None, + project_entity: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + anatomy: Optional[Anatomy] = None, ): """Update active Session to a new task work area. @@ -529,9 +534,10 @@ def change_current_context( Args: folder_entity (Dict[str, Any]): Folder entity to set. task_entity (Dict[str, Any]): Task entity to set. - template_key (Optional[str]): Prepared template key to be used for - workfile template in Anatomy. - workdir (Optional[str]): Workdir to set. + template_key (Optional[str]): DEPRECATED: Prepared template key to + be used for workfile template in Anatomy. + workdir (Optional[str]): DEPRECATED: Workdir to set. + reason (Optional[str]): Reason for changing context. anatomy (Optional[Anatomy]): Anatomy object used for workdir calculation. project_entity (Optional[dict[str, Any]]): Project entity used for @@ -540,58 +546,36 @@ def change_current_context( workdir calculation. Returns: - Dict[str, str]: The changed key, values in the current Session. + Dict[str, str]: New context data. """ - host = registered_host() - project_name = host.get_current_project_name() - folder_path = None - task_name = None - if folder_entity: - folder_path = folder_entity["path"] - if task_entity: - task_name = task_entity["name"] + depr_args = [] + if template_key is not _PLACEHOLDER: + depr_args.append("'template_key'") - if isinstance(host, IWorkfileHost) and workdir is None and folder_entity: - if project_entity is None: - project_entity = ayon_api.get_project(project_name) - workdir = get_workdir( - project_entity, - folder_entity, - task_entity, - host.name, - anatomy=anatomy, - template_key=template_key, - project_settings=project_settings, + if workdir is not _PLACEHOLDER: + depr_args.append("'workdir'") + + if depr_args: + ending = "s" if len(depr_args) > 1 else "" + depr_args = ", ".join(depr_args) + warnings.warn( + ( + f"Used deprecated argument{ending} {depr_args}." + " To change " + ), + DeprecationWarning, ) - envs = { - "AYON_PROJECT_NAME": project_name, - "AYON_FOLDER_PATH": folder_path, - "AYON_TASK_NAME": task_name, - "AYON_WORKDIR": workdir, - } - - # Update the Session and environments. Pop from environments all keys with - # value set to None. - for key, value in envs.items(): - if value is None: - os.environ.pop(key, None) - else: - os.environ[key] = value - - data = envs.copy() - - # Convert env keys to human readable keys - data["project_name"] = project_name - data["folder_path"] = folder_path - data["task_name"] = task_name - data["workdir_path"] = workdir - - # Emit session change - emit_event("taskChanged", data) - - return data + host = registered_host() + return host.set_current_context( + folder_entity, + task_entity, + reason=reason, + anatomy=anatomy, + project_entity=project_entity, + project_settings=project_settings, + ) def get_process_id(): From a879d11ac6c76199e3fbc76bf4aad017735c9971 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:03:34 +0200 Subject: [PATCH 063/312] get_ayon_username is using cached values --- client/ayon_core/lib/local_settings.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index d994145d4b..91b881cf57 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -15,6 +15,10 @@ import ayon_api _PLACEHOLDER = object() +class _Cache: + username = None + + def _get_ayon_appdirs(*args): return os.path.join( platformdirs.user_data_dir("AYON", "Ynput"), @@ -591,10 +595,26 @@ def get_local_site_id(): def get_ayon_username(): """AYON username used for templates and publishing. - Uses curet ayon api username. + Uses current ayon api username. Returns: str: Username. """ - return ayon_api.get_user()["name"] + # Look for username in the connection stack + # - this is used when service is working as other user + # (e.g. in background sync) + # TODO @iLLiCiTiT - do not use private attribute of 'ServerAPI', rather + # use public method to get username from connection stack. + con = ayon_api.get_server_api_connection() + user_stack = getattr(con, "_as_user_stack", None) + if user_stack is not None: + username = user_stack.username + if username is not None: + return username + + # Cache the username to avoid multiple API calls + # - it is not expected that user would change + if _Cache.username is None: + _Cache.username = ayon_api.get_user()["name"] + return _Cache.username From 8bda7dd93b3a63eb02e3b3db9844533ae61ae04d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:05:15 +0200 Subject: [PATCH 064/312] move all the responsibility about workfiles to IWorkfileHost --- client/ayon_core/host/interfaces/workfiles.py | 499 +++++++++++++++++- 1 file changed, 482 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 7d924dff3a..0c8ceb872b 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -10,10 +10,16 @@ from typing import Optional, Any import ayon_api import arrow +from ayon_core.lib import emit_event + if typing.TYPE_CHECKING: from ayon_core.pipeline import Anatomy +WORKFILE_OPEN_REASON = "workfile.opened" +WORKFILE_SAVE_REASON = "workfile.saved" + + @dataclass class WorkfileInfo: """Information about workfile. @@ -256,8 +262,17 @@ class IWorkfileHost: def save_workfile_with_context( self, filepath: str, - folder_entity: dict[str, Any] = None, - task_entity: dict[str, Any] = None, + folder_entity: Optional[dict[str, Any]], + task_entity: Optional[dict[str, Any]], + *, + version: Optional[int], + comment: Optional[str] = None, + description: Optional[str] = None, + rootless_path: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, + project_settings: Optional[dict[str, Any]] = None, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, ): """Save current workfile with context. @@ -268,15 +283,72 @@ class IWorkfileHost: filepath (str): Where the current scene should be saved. folder_entity (dict[str, Any]): Folder entity. task_entity (dict[str, Any]): Task entity. + version (Optional[int]): Version of the workfile. + comment (Optional[str]): Comment for the workfile. + description (Optional[str]): Description for the workfile. + rootless_path (Optional[str]): Rootless path of the workfile. + workfile_entities (Optional[list[dict[str, Any]]]): Workfile + project_settings (Optional[dict[str, Any]]): Project settings. + project_entity (Optional[dict[str, Any]]): Project entity. + anatomy (Optional[Anatomy]): Project anatomy. """ + self._before_workfile_save( + filepath, + folder_entity, + task_entity, + ) + event_data = self._get_workfile_event_data( + self.get_current_project_name(), + folder_entity, + task_entity, + filepath, + ) + self._emit_workfile_save_event(event_data, after_open=False) + + workdir = os.path.dirname(filepath) + + self.set_current_context( + folder_entity, + task_entity, + reason=WORKFILE_SAVE_REASON, + workdir=workdir, + project_entity=project_entity, + project_settings=project_settings, + anatomy=anatomy, + ) + self.save_workfile(filepath) + self._save_workfile_entity( + filepath, + folder_entity, + task_entity, + version, + comment, + description, + rootless_path, + workfile_entities, + project_settings, + project_entity, + anatomy, + ) + self._after_workfile_save( + filepath, + folder_entity, + task_entity, + ) + self._emit_workfile_save_event(event_data) + def open_workfile_with_context( self, filepath: str, folder_entity: dict[str, Any], task_entity: dict[str, Any], + *, + project_entity: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, ): """Open passed filepath in the host with context. @@ -289,10 +361,42 @@ class IWorkfileHost: filepath (str): Path to workfile. folder_entity (dict[str, Any]): Folder id. task_entity (dict[str, Any]): Task id. + project_entity (Optional[dict[str, Any]]): Project entity. + project_settings (Optional[dict[str, Any]]): Project settings. + anatomy (Optional[Anatomy]): Project anatomy. """ + context = self.get_current_context() + project_name = context["project_name"] + current_folder_path = context["folder_path"] + current_task_name = context["task_name"] + + workdir = os.path.dirname(filepath) + # Set 'AYON_WORKDIR' environment variable + os.environ["AYON_WORKDIR"] = workdir + + event_data = self._get_workfile_event_data( + project_name, folder_entity, task_entity, filepath + ) + + self._before_workfile_open(folder_entity, task_entity, filepath) + self._emit_workfile_open_event(event_data, after_open=False) + + self.set_current_context( + folder_entity, + task_entity, + reason=WORKFILE_OPEN_REASON, + workdir=workdir, + project_entity=project_entity, + project_settings=project_settings, + anatomy=anatomy, + ) + self.open_workfile(filepath) + self._after_workfile_open(folder_entity, task_entity, filepath) + self._emit_workfile_open_event(event_data) + def list_workfiles( self, project_name: str, @@ -414,6 +518,7 @@ class IWorkfileHost: self, project_name: str, folder_id: str, + *, anatomy: Optional["Anatomy"] = None, version_entities: Optional[list[dict[str, Any]]] = None, repre_entities: Optional[list[dict[str, Any]]] = None, @@ -524,7 +629,16 @@ class IWorkfileHost: dst_path: str, folder_entity: dict[str, Any], task_entity: dict[str, Any], - open_workfile: bool = False, + *, + version: Optional[int], + comment: Optional[str] = None, + description: Optional[str] = None, + rootless_path: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, + project_settings: Optional[dict[str, Any]] = None, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + open_workfile: bool = True, ): """Save workfile path with target folder and task context. @@ -536,21 +650,68 @@ class IWorkfileHost: dst_path (str): Where the scene should be saved. folder_entity (dict[str, Any]): Folder entity. task_entity (dict[str, Any]): Task entity. + version (Optional[int]): Version of the workfile. + comment (Optional[str]): Comment for the workfile. + description (Optional[str]): Description for the workfile. + rootless_path (Optional[str]): Rootless path of the workfile. + workfile_entities (Optional[list[dict[str, Any]]]): Workfile + entities to be saved with the workfile. + project_settings (Optional[dict[str, Any]]): Project settings. + project_entity (Optional[dict[str, Any]]): Project entity. + anatomy (Optional[Anatomy]): Project anatomy. open_workfile (bool): Open workfile when copied. """ - # TODO We might need option to open file once copied as some DCC might - # actually need to open the workfile to copy it. + self._before_workfile_copy( + src_path, + dst_path, + folder_entity, + task_entity, + open_workfile, + ) + event_data = self._get_workfile_event_data( + self.get_current_project_name(), + folder_entity, + task_entity, + dst_path, + ) + self._emit_workfile_save_event(event_data, after_open=False) + dst_dir = os.path.dirname(dst_path) if not os.path.exists(dst_dir): os.makedirs(dst_dir, exist_ok=True) shutil.copy(src_path, dst_path) - if open_workfile: - self.open_workfile_with_context( - dst_path, - folder_entity, - task_entity, - ) + + self._save_workfile_entity( + dst_path, + folder_entity, + task_entity, + version, + comment, + description, + rootless_path, + workfile_entities, + project_settings, + project_entity, + anatomy, + ) + self._after_workfile_copy( + src_path, + dst_path, + folder_entity, + task_entity, + open_workfile, + ) + self._emit_workfile_save_event(event_data) + + if not open_workfile: + return + + self.open_workfile_with_context( + dst_path, + folder_entity, + task_entity, + ) def copy_workfile_representation( self, @@ -559,8 +720,17 @@ class IWorkfileHost: dst_path: str, folder_entity: dict[str, Any], task_entity: dict[str, Any], - open_workfile: bool = False, - anatomy: Optional[Anatomy] = None, + *, + version: Optional[int], + comment: Optional[str] = None, + description: Optional[str] = None, + rootless_path: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, + project_settings: Optional[dict[str, Any]] = None, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + open_workfile: bool = True, + src_anatomy: Optional["Anatomy"] = None, src_representation_path: Optional[str] = None, ): """Copy workfile representation. @@ -574,8 +744,17 @@ class IWorkfileHost: dst_path (str): Where the scene should be saved. folder_entity (dict[str, Any): Folder entity. task_entity (dict[str, Any]): Task entity. - open_workfile (bool): Open workfile when copied. + version (Optional[int]): Version of the workfile. + comment (Optional[str]): Comment for the workfile. + description (Optional[str]): Description for the workfile. + rootless_path (Optional[str]): Rootless path of the workfile. + workfile_entities (Optional[list[dict[str, Any]]]): Workfile + entities to be saved with the workfile. + project_settings (Optional[dict[str, Any]]): Project settings. + project_entity (Optional[dict[str, Any]]): Project entity. anatomy (Optional[Anatomy]): Project anatomy. + open_workfile (bool): Open workfile when copied. + src_anatomy (Optional[Anatomy]): Anatomy of the source src_representation_path (Optional[str]): Representation path. """ @@ -586,12 +765,27 @@ class IWorkfileHost: get_representation_path_with_anatomy ) - if src_representation_path is None: + project_name = self.get_current_project_name() + # Re-use Anatomy or project entity if source context is same + if project_name == src_project_name: + if src_anatomy is None and anatomy is not None: + src_anatomy = anatomy + elif anatomy is None and src_anatomy is not None: + anatomy = src_anatomy + elif not project_entity: + project_entity = ayon_api.get_project(project_name) + if anatomy is None: - anatomy = Anatomy(src_project_name) + anatomy = src_anatomy = Anatomy( + project_name, project_entity=project_entity + ) + + if src_representation_path is None: + if src_anatomy is None: + src_anatomy = Anatomy(src_project_name) src_representation_path = get_representation_path_with_anatomy( src_representation_entity, - anatomy, + src_anatomy, ) self.copy_workfile( @@ -599,6 +793,14 @@ class IWorkfileHost: dst_path, folder_entity, task_entity, + version=version, + comment=comment, + description=description, + rootless_path=rootless_path, + workfile_entities=workfile_entities, + project_settings=project_settings, + project_entity=project_entity, + anatomy=anatomy, open_workfile=open_workfile, ) @@ -696,3 +898,266 @@ class IWorkfileHost: )) return version_entities, repre_entities + + def _save_workfile_entity( + self, + workfile_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + version: Optional[int], + comment: Optional[str], + description: Optional[str], + rootless_path: Optional[str], + workfile_entities: Optional[list[dict[str, Any]]] = None, + project_settings: Optional[dict[str, Any]] = None, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + ): + from ayon_core.pipeline.workfile.utils import ( + save_workfile_info, + find_workfile_rootless_path, + ) + + project_name = self.get_current_project_name() + if not description: + description = None + + if not comment: + comment = None + + if rootless_path is None: + rootless_path = find_workfile_rootless_path( + workfile_path, + project_name, + folder_entity, + task_entity, + self.name, + project_entity=project_entity, + project_settings=project_settings, + anatomy=anatomy, + ) + + # It is not possible to create workfile infor without rootless path + workfile_info = None + if not rootless_path: + return workfile_info + + if platform.system().lower() == "windows": + rootless_path = rootless_path.replace("\\", "/") + + workfile_info = save_workfile_info( + project_name, + task_entity["id"], + rootless_path, + self.name, + version, + comment, + description, + workfile_entities=workfile_entities, + ) + return workfile_info + + def _create_extra_folders(self, folder_entity, task_entity, workdir): + from ayon_core.pipeline.workfile.path_resolving import ( + create_workdir_extra_folders + ) + + project_name = self.get_current_project_name() + + # Create extra folders + create_workdir_extra_folders( + workdir, + self.name, + task_entity["taskType"], + task_entity["name"], + project_name + ) + + def _get_workfile_event_data( + self, + project_name: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + filepath: str, + ): + workdir, filename = os.path.split(filepath) + return { + "project_name": project_name, + "folder_id": folder_entity["id"], + "folder_path": folder_entity["path"], + "task_id": task_entity["id"], + "task_name": task_entity["name"], + "host_name": self.name, + "filepath": filepath, + "filename": filename, + "workdir_path": workdir, + } + + def _before_workfile_open( + self, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + filepath: str, + ): + """Before workfile is opened. + + This method is called before the workfile is opened in the host. + + Can be overriden to implement host specific logic. + + Args: + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + filepath (str): Path to the workfile. + + """ + pass + + def _after_workfile_open( + self, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + filepath: str, + ): + """After workfile is opened. + + This method is called after the workfile is opened in the host. + + Can be overriden to implement host specific logic. + + Args: + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + filepath (str): Path to the workfile. + + """ + pass + + + def _before_workfile_save( + self, + filepath: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + ): + """Before workfile is saved. + + This method is called before the workfile is saved in the host. + + Can be overriden to implement host specific logic. + + Args: + filepath (str): Path to the workfile. + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + + """ + pass + + def _after_workfile_save( + self, + filepath: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + ): + """After workfile is saved. + + This method is called after the workfile is saved in the host. + + Can be overriden to implement host specific logic. + + Args: + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + filepath (str): Path to the workfile. + + """ + workdir = os.path.dirname(filepath) + self._create_extra_folders(folder_entity, task_entity, workdir) + + def _before_workfile_copy( + self, + src_path: str, + dst_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + open_workfile: bool = True, + ): + """Before workfile is copied. + + This method is called before the workfile is copied by host + integration. + + Can be overriden to implement host specific logic. + + Args: + src_path (str): Path to the source workfile. + dst_path (str): Path to the destination workfile. + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + open_workfile (bool): Should be the path opened once copy is + finished. + + """ + pass + + def _after_workfile_copy( + self, + src_path: str, + dst_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + open_workfile: bool = True, + ): + """After workfile is copied. + + This method is called after the workfile is copied by host + integration. + + Can be overriden to implement host specific logic. + + Args: + src_path (str): Path to the source workfile. + dst_path (str): Path to the destination workfile. + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + open_workfile (bool): Should be the path opened once copy is + finished. + + """ + workdir = os.path.dirname(dst_path) + self._create_extra_folders(folder_entity, task_entity, workdir) + + def _emit_workfile_open_event( + self, + event_data: dict[str, Optional[str]], + after_open: bool = True, + ): + topics = [] + topic_end = "before" + if after_open: + topics.append("workfile.opened") + topic_end = "after" + + # Keep backwards compatible event topic + topics.append(f"workfile.open.{topic_end}") + + for topic in topics: + emit_event(topic, event_data) + + def _emit_workfile_save_event( + self, + event_data: dict[str, Optional[str]], + after_open: bool = True, + ): + topics = [] + topic_end = "before" + if after_open: + topics.append("workfile.saved") + topic_end = "after" + + # Keep backwards compatible event topic + topics.append(f"workfile.save.{topic_end}") + + for topic in topics: + emit_event(topic, event_data) From c52f300b878b8f23d418872b2ff02048ddba0960 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:05:39 +0200 Subject: [PATCH 065/312] use host methods to work with workfiles and add helper functions --- .../ayon_core/pipeline/workfile/__init__.py | 4 + client/ayon_core/pipeline/workfile/utils.py | 426 ++++++------------ 2 files changed, 131 insertions(+), 299 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/__init__.py b/client/ayon_core/pipeline/workfile/__init__.py index cc081d676b..52acb035b1 100644 --- a/client/ayon_core/pipeline/workfile/__init__.py +++ b/client/ayon_core/pipeline/workfile/__init__.py @@ -26,6 +26,8 @@ from .utils import ( save_current_workfile_to, copy_and_open_workfile, copy_and_open_workfile_representation, + save_workfile_info, + find_workfile_rootless_path, ) from .build_workfile import BuildWorkfile @@ -50,6 +52,7 @@ __all__ = ( "get_last_workfile_from_paths", "get_last_workfile_with_version", "get_last_workfile", + "find_workfile_rootless_path", "get_custom_workfile_template", "get_custom_workfile_template_by_string_context", @@ -66,6 +69,7 @@ __all__ = ( "save_current_workfile_to", "copy_and_open_workfile", "copy_and_open_workfile_representation", + "save_workfile_info", "BuildWorkfile", diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index d37287b330..7ed2ee4739 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -11,10 +11,7 @@ from ayon_api.operations import OperationsSession from ayon_core.lib import filter_profiles, emit_event, get_ayon_username from ayon_core.settings import get_project_settings -from .path_resolving import ( - create_workdir_extra_folders, - get_workfile_template_key, -) +from .path_resolving import get_workfile_template_key if typing.TYPE_CHECKING: from ayon_core.pipeline import Anatomy @@ -25,13 +22,60 @@ class MissingWorkdirError(Exception): pass +def get_workfiles_info( + workfile_path: str, + project_name: str, + task_id: str, + *, + anatomy: Optional["Anatomy"] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, +) -> Optional[dict[str, Any]]: + """Find workfile info entity for a workfile path. + + Args: + workfile_path (str): Workfile path. + project_name (str): The name of the project. + task_id (str): Task id under which is workfile created. + anatomy (Optional[Anatomy]): Project anatomy used to get roots. + workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched + workfile entities related to task. + + Returns: + Optional[dict[str, Any]]: Workfile info entity if found, otherwise + `None`. + + """ + if anatomy is None: + anatomy = Anatomy(project_name) + + if workfile_entities is None: + workfile_entities = list(ayon_api.get_workfiles_info( + project_name, + task_ids=[task_id], + )) + + if platform.system().lower() == "windows": + workfile_path = workfile_path.replace("\\", "/") + workfile_path = workfile_path.lower() + + for workfile_entity in workfile_entities: + path = workfile_entity["path"] + filled_path = anatomy.fill_root(path) + if platform.system().lower() == "windows": + filled_path = filled_path.replace("\\", "/") + filled_path = filled_path.lower() + if filled_path == workfile_path: + return workfile_entity + return None + + def should_use_last_workfile_on_launch( - project_name, - host_name, - task_name, - task_type, - default_output=False, - project_settings=None, + project_name: str, + host_name: str, + task_name: str, + task_type: str, + default_output: bool = False, + project_settings: Optional[dict[str, Any]] = None, ): """Define if host should start last version workfile if possible. @@ -144,30 +188,14 @@ def should_open_workfiles_tool_on_launch( return output -def _get_event_context_data( - project_name: str, - folder_entity: dict[str, Any], - task_entity: dict[str, Any], - host_name: str, -): - return { - "project_name": project_name, - "folder_id": folder_entity["id"], - "folder_path": folder_entity["path"], - "task_id": task_entity["id"], - "task_name": task_entity["name"], - "host_name": host_name, - } - - def save_workfile_info( project_name: str, task_id: str, rootless_path: str, host_name: str, - version: Optional[int], - comment: Optional[str], - description: Optional[str], + version: Optional[int] = None, + comment: Optional[str] = None, + description: Optional[str] = None, username: Optional[str] = None, workfile_entities: Optional[list[dict[str, Any]]] = None, ): @@ -255,63 +283,38 @@ def open_workfile( filepath: str, folder_entity: dict[str, Any], task_entity: dict[str, Any], + project_entity: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, ): - from ayon_core.pipeline.context_tools import ( - registered_host, change_current_context - ) + from ayon_core.pipeline.context_tools import registered_host # Trigger before save event host = registered_host() - context = host.get_current_context() - project_name = context["project_name"] - current_folder_path = context["folder_path"] - current_task_name = context["task_name"] - host_name = host.name - - # TODO move to workfiles pipeline - event_data = _get_event_context_data( - project_name, folder_entity, task_entity, host_name - ) - event_data["filepath"] = filepath - - emit_event("workfile.open.before", event_data, source="workfiles.tool") - - # Change context - if ( - folder_entity["path"] != current_folder_path - or task_entity["name"] != current_task_name - ): - change_current_context( - project_name, - folder_entity, - task_entity, - workdir=os.path.dirname(filepath) - ) - host.open_workfile_with_context( filepath, folder_entity, task_entity, + project_entity=project_entity, + project_settings=project_settings, + anatomy=anatomy, ) - emit_event("workfile.open.after", event_data, source="workfiles.tool") - def save_current_workfile_to( workfile_path: str, folder_entity: dict[str, Any], task_entity: dict[str, Any], - version: Optional[int], + *, + version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, - source: Optional[str] = None, rootless_path: Optional[str] = None, workfile_entities: Optional[list[dict[str, Any]]] = None, - username: Optional[str] = None, project_entity: Optional[dict[str, Any]] = None, project_settings: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, -) -> dict[str, Any]: +): """Save current workfile to new location or context. Args: @@ -321,12 +324,10 @@ def save_current_workfile_to( version (Optional[int]): Workfile version. comment (optional[str]): Workfile comment. description (Optional[str]): Workfile description. - source (Optional[str]): Source of the save action. rootless_path (Optional[str]): Rootless path of the workfile. Is calculated if not passed in. workfile_entities (Optional[list[dict[str, Any]]]): List of workfile - username (Optional[str]): Username of the user saving the workfile. - Current user is used if not passed. + entities related to task. project_entity (Optional[dict[str, Any]]): Project entity used for rootless path calculation. project_settings (Optional[dict[str, Any]]): Project settings used for @@ -338,25 +339,22 @@ def save_current_workfile_to( dict[str, Any]: Workfile info entity. """ - print("save_current_workfile_to") - return _save_workfile( - None, - None, - None, - None, + from ayon_core.pipeline.context_tools import registered_host + + # Trigger before save event + host = registered_host() + host.save_workfile_with_context( workfile_path, folder_entity, task_entity, version, comment, description, - source, - rootless_path, - workfile_entities, - username, - project_entity, - project_settings, - anatomy, + rootless_path=rootless_path, + workfile_entities=workfile_entities, + project_entity=project_entity, + project_settings=project_settings, + anatomy=anatomy, ) @@ -365,17 +363,16 @@ def copy_and_open_workfile( workfile_path: str, folder_entity: dict[str, Any], task_entity: dict[str, Any], - version: Optional[int], + *, + version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, - source: Optional[str] = None, rootless_path: Optional[str] = None, workfile_entities: Optional[list[dict[str, Any]]] = None, - username: Optional[str] = None, project_entity: Optional[dict[str, Any]] = None, project_settings: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, -) -> dict[str, Any]: +): """Copy workfile to new location and open it. Args: @@ -386,12 +383,9 @@ def copy_and_open_workfile( version (Optional[int]): Workfile version. comment (optional[str]): Workfile comment. description (Optional[str]): Workfile description. - source (Optional[str]): Source of the save action. rootless_path (Optional[str]): Rootless path of the workfile. Is calculated if not passed in. workfile_entities (Optional[list[dict[str, Any]]]): List of workfile - username (Optional[str]): Username of the user saving the workfile. - Current user is used if not passed. project_entity (Optional[dict[str, Any]]): Project entity used for rootless path calculation. project_settings (Optional[dict[str, Any]]): Project settings used for @@ -403,51 +397,49 @@ def copy_and_open_workfile( dict[str, Any]: Workfile info entity. """ - print("copy_and_open_workfile") - return _save_workfile( + from ayon_core.pipeline.context_tools import registered_host + + host = registered_host() + host.copy_workfile( src_workfile_path, - None, - None, - None, workfile_path, folder_entity, task_entity, - version, - comment, - description, - source, - rootless_path, - workfile_entities, - username, - project_entity, - project_settings, - anatomy, + version=version, + comment=comment, + description=description, + rootless_path=rootless_path, + workfile_entities=workfile_entities, + project_entity=project_entity, + project_settings=project_settings, + anatomy=anatomy, + open_workfile=True, ) def copy_and_open_workfile_representation( - project_name: str, + src_project_name: str, representation_id: str, workfile_path: str, folder_entity: dict[str, Any], task_entity: dict[str, Any], - version: Optional[int], + *, + version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, - source: Optional[str] = None, rootless_path: Optional[str] = None, representation_entity: Optional[dict[str, Any]] = None, representation_path: Optional[str] = None, workfile_entities: Optional[list[dict[str, Any]]] = None, - username: Optional[str] = None, project_entity: Optional[dict[str, Any]] = None, project_settings: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, -) -> dict[str, Any]: + src_anatomy: Optional["Anatomy"] = None, +): """Copy workfile to new location and open it. Args: - project_name (str): Project name where representation is stored. + src_project_name (str): Project name where representation is stored. representation_id (str): Source representation id. workfile_path (str): Destination workfile path. folder_entity (dict[str, Any]): Target folder entity. @@ -455,12 +447,13 @@ def copy_and_open_workfile_representation( version (Optional[int]): Workfile version. comment (optional[str]): Workfile comment. description (Optional[str]): Workfile description. - source (Optional[str]): Source of the save action. rootless_path (Optional[str]): Rootless path of the workfile. Is calculated if not passed in. + representation_entity (Optional[dict[str, Any]]): Representation + entity. If not provided, it will be fetched from the server. + representation_path (Optional[str]): Path to the representation. + Calculated if not provided. workfile_entities (Optional[list[dict[str, Any]]]): List of workfile - username (Optional[str]): Username of the user saving the workfile. - Current user is used if not passed. project_entity (Optional[dict[str, Any]]): Project entity used for rootless path calculation. project_settings (Optional[dict[str, Any]]): Project settings used for @@ -472,209 +465,42 @@ def copy_and_open_workfile_representation( dict[str, Any]: Workfile info entity. """ - print("copy_and_open_workfile_representation") + from ayon_core.pipeline.context_tools import registered_host + if representation_entity is None: representation_entity = ayon_api.get_representation_by_id( - project_name, + src_project_name, representation_id, ) - return _save_workfile( - None, - project_name, + host = registered_host() + host.copy_workfile_representation( + src_project_name, representation_entity, - representation_path, workfile_path, folder_entity, task_entity, - version, - comment, - description, - source, - rootless_path, - workfile_entities, - username, - project_entity, - project_settings, - anatomy, + version=version, + comment=comment, + description=description, + rootless_path=rootless_path, + workfile_entities=workfile_entities, + project_settings=project_settings, + project_entity=project_entity, + anatomy=anatomy, + src_anatomy=src_anatomy, + src_representation_path=representation_path, + open_workfile=open_workfile, ) -def _save_workfile( - src_workfile_path: Optional[str], - representation_project_name: Optional[str], - representation_entity: Optional[dict[str, Any]], - representation_path: Optional[str], - workfile_path: str, - folder_entity: dict[str, Any], - task_entity: dict[str, Any], - version: Optional[int], - comment: Optional[str], - description: Optional[str], - source: Optional[str], - rootless_path: Optional[str], - workfile_entities: Optional[list[dict[str, Any]]], - username: Optional[str], - project_entity: Optional[dict[str, Any]], - project_settings: Optional[dict[str, Any]], - anatomy: Optional["Anatomy"], -) -> dict[str, Any]: - """Function used to save workfile to new location and context. - - Because the functionality for 'save_current_workfile_to' and - 'copy_and_open_workfile' is currently the same, except for used - function on host it is easier to create this wrapper function. - - Args: - src_workfile_path (Optional[str]): Source workfile path. - representation_entity (Optional[dict[str, Any]]): Representation used - as source for workfile. - workfile_path (str): Destination workfile path. - folder_entity (dict[str, Any]): Target folder entity. - task_entity (dict[str, Any]): Target task entity. - version (Optional[int]): Workfile version. - comment (optional[str]): Workfile comment. - description (Optional[str]): Workfile description. - source (Optional[str]): Source of the save action. - rootless_path (Optional[str]): Rootless path of the workfile. Is - calculated if not passed in. - workfile_entities (Optional[list[dict[str, Any]]]): List of workfile - username (Optional[str]): Username of the user saving the workfile. - Current user is used if not passed. - project_entity (Optional[dict[str, Any]]): Project entity used for - rootless path calculation. - project_settings (Optional[dict[str, Any]]): Project settings used for - rootless path calculation. - anatomy (Optional[Anatomy]): Project anatomy used for rootless - path calculation. - - Returns: - dict[str, Any]: Workfile info entity. - - """ - from ayon_core.pipeline.context_tools import ( - registered_host, change_current_context - ) - - # Trigger before save event - host = registered_host() - context = host.get_current_context() - project_name = context["project_name"] - current_folder_path = context["folder_path"] - current_task_name = context["task_name"] - - task_name = task_entity["name"] - task_type = task_entity["taskType"] - task_id = task_entity["id"] - host_name = host.name - - workdir, filename = os.path.split(workfile_path) - - # QUESTION should the data be different for 'before' and 'after'? - event_data = _get_event_context_data( - project_name, folder_entity, task_entity, host_name - ) - event_data.update({ - "filename": filename, - "workdir_path": workdir, - }) - - emit_event("workfile.save.before", event_data, source=source) - - # Change context - if ( - folder_entity["path"] != current_folder_path - or task_entity["name"] != current_task_name - ): - change_current_context( - folder_entity, - task_entity, - workdir=workdir, - anatomy=anatomy, - project_entity=project_entity, - project_settings=project_settings, - ) - - if src_workfile_path: - host.copy_workfile( - src_workfile_path, - workfile_path, - folder_entity, - task_entity, - open_workfile=True, - ) - elif representation_entity: - host.copy_workfile_representation( - representation_project_name, - representation_entity, - workfile_path, - folder_entity, - task_entity, - open_workfile=True, - src_representation_path=representation_path, - anatomy=anatomy, - ) - else: - host.save_workfile_with_context( - workfile_path, - folder_entity, - task_entity, - ) - - if not description: - description = None - - if not comment: - comment = None - - if rootless_path is None: - rootless_path = _find_rootless_path( - workfile_path, - project_name, - task_type, - host_name, - project_entity, - project_settings, - anatomy, - ) - - # It is not possible to create workfile infor without rootless path - workfile_info = None - if rootless_path: - if platform.system().lower() == "windows": - rootless_path = rootless_path.replace("\\", "/") - - workfile_info = save_workfile_info( - project_name, - task_id, - rootless_path, - host_name, - version, - comment, - description, - username=username, - workfile_entities=workfile_entities, - ) - - # Create extra folders - create_workdir_extra_folders( - workdir, - host.name, - task_entity["taskType"], - task_name, - project_name - ) - - # Trigger after save events - emit_event("workfile.save.after", event_data, source=source) - return workfile_info - - -def _find_rootless_path( +def find_workfile_rootless_path( workfile_path: str, project_name: str, - task_type: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], host_name: str, + *, project_entity: Optional[dict[str, Any]] = None, project_settings: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, @@ -684,6 +510,8 @@ def _find_rootless_path( from ayon_core.pipeline import Anatomy anatomy = Anatomy(project_name, project_entity=project_entity) + + task_type = task_entity["taskType"] template_key = get_workfile_template_key( project_name, task_type, From e08c5f243b7b1e171ff66b717639c5a1d07f6480 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:06:04 +0200 Subject: [PATCH 066/312] modified workfiles model to use api functions --- .../tools/workfiles/models/workfiles.py | 226 +++++------------- 1 file changed, 55 insertions(+), 171 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 4f5fb9890d..f2977be973 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -35,6 +35,7 @@ from ayon_core.pipeline.workfile import ( save_current_workfile_to, copy_and_open_workfile, copy_and_open_workfile_representation, + save_workfile_info, ) from ayon_core.pipeline.version_start import get_versioning_start from ayon_core.tools.workfiles.abstract import ( @@ -149,20 +150,18 @@ class WorkfilesModel: task_entity = self._controller.get_task_entity( project_name, task_id ) - workfile_entities = self.get_workfile_entities(task_id) + failed = False try: - workfile_info = save_current_workfile_to( + save_current_workfile_to( filepath, folder_entity, task_entity, - version, - comment, - description, - source="workfiles.tool", + version=version, + comment=comment, + description=description, rootless_path=rootless_path, - workfile_entities=workfile_entities, - username=self._get_current_username(), + workfile_entities=self.get_workfile_entities(task_id), project_entity=self._controller.get_project_entity( project_name ), @@ -170,7 +169,7 @@ class WorkfilesModel: anatomy=self._controller.project_anatomy, ) self._update_workfile_info( - task_id, rootless_path, description, workfile_info + task_id, rootless_path, description ) self._update_current_context( folder_id, folder_entity["path"], task_entity["name"] @@ -212,8 +211,9 @@ class WorkfilesModel: rootless_path = f"{rootless_workdir}/{filename}" failed = False + workfile_entities = self.get_workfile_entities(task_id) try: - workfile_info = copy_and_open_workfile_representation( + copy_and_open_workfile_representation( project_name, representation_id, dst_filepath, @@ -225,8 +225,7 @@ class WorkfilesModel: rootless_path=rootless_path, representation_entity=repre_entity, representation_path=representation_filepath, - workfile_entities=self.get_workfile_entities(task_id), - username=self._get_current_username(), + workfile_entities=workfile_entities, project_entity=self._controller.get_project_entity( project_name ), @@ -234,7 +233,7 @@ class WorkfilesModel: anatomy=self._controller.project_anatomy, ) self._update_workfile_info( - task_id, rootless_path, description, workfile_info + task_id, rootless_path, description ) self._update_current_context( folder_id, folder_entity["path"], task_entity["name"] @@ -281,13 +280,11 @@ class WorkfilesModel: workfile_path, folder_entity, task_entity, - version, - comment, - description, - source="workfiles.tool", + version=version, + comment=comment, + description=description, rootless_path=rootless_path, workfile_entities=workfile_entities, - username=self._get_current_username(), project_entity=project_entity, project_settings=self._controller.project_settings, anatomy=self._controller.project_anatomy, @@ -608,7 +605,9 @@ class WorkfilesModel: cache.update_data(self._host.list_published_workfiles( project_name, folder_id, - anatomy, + anatomy=anatomy, + version_entities=version_entities, + repre_entities=repre_entities, )) items = cache.get_data() @@ -638,31 +637,6 @@ class WorkfilesModel: return self._current_username # --- Host --- - def _get_event_context_data( - self, - project_name: str, - folder_id: str, - task_id: str, - folder_entity: Optional[dict[str, Any]] = None, - task_entity: Optional[dict[str, Any]] = None, - ): - if folder_entity is None: - folder_entity = self._controller.get_folder_entity( - project_name, folder_id - ) - if task_entity is None: - task_entity = self._controller.get_task_entity( - project_name, task_id - ) - return { - "project_name": project_name, - "folder_id": folder_id, - "folder_path": folder_entity["path"], - "task_id": task_id, - "task_name": task_entity["name"], - "host_name": self._host_name, - } - def _open_workfile(self, folder_id: str, task_id: str, filepath: str): # TODO move to workfiles pipeline project_name = self._project_name @@ -849,23 +823,26 @@ class WorkfilesModel: task_id: str, rootless_path: str, description: str, - workfile_entity: dict[str, Any], ): self._update_file_description(task_id, rootless_path, description) - workfile_entities = self.get_workfile_entities(task_id) - target_idx = None - for idx, workfile_entity in enumerate(workfile_entities): - if workfile_entity["path"] == rootless_path: - target_idx = idx - break - - if target_idx is None: - workfile_entities.append(workfile_entity) - else: - workfile_entities[target_idx] = workfile_entity - self._reset_workarea_file_items(task_id) + # Update workfile entity cache if are cached + if task_id in self._workfile_entities_by_task_id: + workfile_entities = self.get_workfile_entities(task_id) + + target_workfile_entity = None + for workfile_entity in workfile_entities: + if rootless_path == workfile_entity["path"]: + target_workfile_entity = workfile_entity + break + + if target_workfile_entity is None: + self._workfile_entities_by_task_id.pop(task_id, None) + self.get_workfile_entities(task_id) + else: + target_workfile_entity["attrib"]["description"] = description + def _update_file_description( self, task_id: str, rootless_path: str, description: str ): @@ -885,119 +862,26 @@ class WorkfilesModel: comment: Optional[str], description: Optional[str], ): - # TODO create pipeline function for this + workfile_entity = save_workfile_info( + self._controller.get_current_project_name(), + task_id, + rootless_path, + self._controller.get_host_name(), + version=version, + comment=comment, + description=description, + workfile_entities=self.get_workfile_entities(task_id), + ) + # Update cache workfile_entities = self.get_workfile_entities(task_id) - workfile_entity = next( - ( - _ent - for _ent in workfile_entities - if _ent["path"] == rootless_path - ), - None - ) - if not workfile_entity: - workfile_entity = self._create_workfile_info_entity( - task_id, - rootless_path, - version, - comment, - description, - ) + match_idx = None + for idx, entity in enumerate(workfile_entities): + if entity["id"] == workfile_entity["id"]: + # Update existing entity + match_idx = idx + break + + if match_idx is None: workfile_entities.append(workfile_entity) - return - - data = {} - for key, value in ( - ("host_name", self._host_name), - ("version", version), - ("comment", comment), - ): - if value is not None: - data[key] = value - - old_data = workfile_entity["data"] - - changed_data = {} - for key, value in data.items(): - if key not in old_data or old_data[key] != value: - changed_data[key] = value - - update_data = {} - if changed_data: - update_data["data"] = changed_data - - old_description = workfile_entity["attrib"].get("description") - if description is not None and old_description != description: - update_data["attrib"] = {"description": description} - workfile_entity["attrib"]["description"] = description - - username = self._get_current_username() - # Automatically fix 'createdBy' and 'updatedBy' fields - # NOTE both fields were not automatically filled by server - # until 1.1.3 release. - if workfile_entity.get("createdBy") is None: - update_data["createdBy"] = username - workfile_entity["createdBy"] = username - - if workfile_entity.get("updatedBy") != username: - update_data["updatedBy"] = username - workfile_entity["updatedBy"] = username - - if not update_data: - return - - session = OperationsSession() - session.update_entity( - self._project_name, - "workfile", - workfile_entity["id"], - update_data, - ) - session.commit() - - def _create_workfile_info_entity( - self, - task_id: str, - rootless_path: str, - version: Optional[int], - comment: Optional[str], - description: str, - ) -> dict[str, Any]: - extension = os.path.splitext(rootless_path)[1] - - attrib = {} - for key, value in ( - ("extension", extension), - ("description", description), - ): - if value is not None: - attrib[key] = value - - data = {} - for key, value in ( - ("host_name", self._host_name), - ("version", version), - ("comment", comment), - ): - if value is not None: - data[key] = value - - username = self._get_current_username() - workfile_info = { - "id": uuid.uuid4().hex, - "path": rootless_path, - "taskId": task_id, - "attrib": attrib, - "data": data, - # TODO remove 'createdBy' and 'updatedBy' fields when server is - # or above 1.1.3 . - "createdBy": username, - "updatedBy": username, - } - - session = OperationsSession() - session.create_entity( - self._project_name, "workfile", workfile_info - ) - session.commit() - return workfile_info + else: + workfile_entities[match_idx] = workfile_entity From 406f43a13f2ac075c45605beebb4c1f3708fd335 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:10:26 +0200 Subject: [PATCH 067/312] formatting cleanup --- client/ayon_core/host/host.py | 4 ++-- client/ayon_core/host/interfaces/workfiles.py | 3 --- client/ayon_core/pipeline/workfile/utils.py | 6 +++--- client/ayon_core/tools/workfiles/models/workfiles.py | 2 -- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index d451b768c1..5e5e8ac79f 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -347,8 +347,8 @@ class HostBase(ABC): "AYON_WORKDIR": workdir, } - # Update the Session and environments. Pop from environments all keys with - # value set to None. + # Update the Session and environments. Pop from environments all + # keys with value set to None. for key, value in envs.items(): if value is None: os.environ.pop(key, None) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 0c8ceb872b..47a0cb0277 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -368,8 +368,6 @@ class IWorkfileHost: """ context = self.get_current_context() project_name = context["project_name"] - current_folder_path = context["folder_path"] - current_task_name = context["task_name"] workdir = os.path.dirname(filepath) # Set 'AYON_WORKDIR' environment variable @@ -1033,7 +1031,6 @@ class IWorkfileHost: """ pass - def _before_workfile_save( self, filepath: str, diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 7ed2ee4739..c45163e7a1 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -8,7 +8,7 @@ from typing import Optional, Any import ayon_api from ayon_api.operations import OperationsSession -from ayon_core.lib import filter_profiles, emit_event, get_ayon_username +from ayon_core.lib import filter_profiles, get_ayon_username from ayon_core.settings import get_project_settings from .path_resolving import get_workfile_template_key @@ -54,7 +54,7 @@ def get_workfiles_info( task_ids=[task_id], )) - if platform.system().lower() == "windows": + if platform.system().lower() == "windows": workfile_path = workfile_path.replace("\\", "/") workfile_path = workfile_path.lower() @@ -398,7 +398,7 @@ def copy_and_open_workfile( """ from ayon_core.pipeline.context_tools import registered_host - + host = registered_host() host.copy_workfile( src_workfile_path, diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index f2977be973..23521dc3f6 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -1,13 +1,11 @@ from __future__ import annotations import os import copy -import uuid import platform import typing from typing import Optional, Any import ayon_api -from ayon_api.operations import OperationsSession from ayon_core.lib import ( get_ayon_username, From 7cb22fbe1fed55b3f2edb178ba0fbd1ab1b54265 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 13:27:35 +0200 Subject: [PATCH 068/312] add arrow to dependencies --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index f919a9589b..1ca75cd6b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ python = ">=3.9.1,<3.10" pytest = "^8.0" pytest-print = "^1.0" ayon-python-api = "^1.0" +arrow = "0.17.0" # linting dependencies ruff = "0.11.7" pre-commit = "^3.6.2" From df55a32b95995ccbf9c5292b4a4991f1939d010f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 13:32:26 +0200 Subject: [PATCH 069/312] fix 'PublishedWorkfileInfo' --- client/ayon_core/host/interfaces/workfiles.py | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 47a0cb0277..8cf904e5d3 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -1,10 +1,11 @@ from __future__ import annotations + import os import platform import shutil +import typing from abc import abstractmethod from dataclasses import dataclass, asdict -import typing from typing import Optional, Any import ayon_api @@ -57,7 +58,13 @@ class WorkfileInfo: available: bool @classmethod - def new(cls, filepath, rootless_path, available, workfile_entity): + def new( + cls, + filepath: str, + rootless_path: str, + available: bool, + workfile_entity: dict[str, Any], + ): file_size = file_modified = file_created = None if filepath and os.path.exists(filepath): filestat = os.stat(filepath) @@ -85,7 +92,7 @@ class WorkfileInfo: available=available, ) - def to_data(self): + def to_data(self) -> dict[str, Any]: """Converts file item to data. Returns: @@ -95,7 +102,7 @@ class WorkfileInfo: return asdict(self) @classmethod - def from_data(cls, data): + def from_data(cls, data: dict[str, Any]) -> "WorkfileInfo": """Converts data to workfile item. Args: @@ -173,7 +180,7 @@ class PublishedWorkfileInfo: file_modified=file_modified, ) - def to_data(self): + def to_data(self) -> dict[str, Any]: """Converts file item to data. Returns: @@ -183,7 +190,7 @@ class PublishedWorkfileInfo: return asdict(self) @classmethod - def from_data(cls, data): + def from_data(cls, data: dict[str, Any]) -> "PublishedWorkfileInfo": """Converts data to workfile item. Args: @@ -193,7 +200,7 @@ class PublishedWorkfileInfo: WorkfileInfo: File item. """ - return WorkfileInfo(**data) + return PublishedWorkfileInfo(**data) class IWorkfileHost: From f4545a6f9798651e6a6fafe6d65d9cd7e5556797 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 13:48:51 +0200 Subject: [PATCH 070/312] some formatting changes --- .../ayon_core/host/interfaces/exceptions.py | 2 +- client/ayon_core/host/interfaces/workfiles.py | 61 +++++++++++-------- .../pipeline/workfile/path_resolving.py | 2 +- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/client/ayon_core/host/interfaces/exceptions.py b/client/ayon_core/host/interfaces/exceptions.py index c6b4cef4b4..eec4564142 100644 --- a/client/ayon_core/host/interfaces/exceptions.py +++ b/client/ayon_core/host/interfaces/exceptions.py @@ -1,5 +1,5 @@ class MissingMethodsError(ValueError): - """Exception when host miss some required methods for specific workflow. + """Exception when host miss some required methods for a specific workflow. Args: host (HostBase): Host implementation where are missing methods. diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 8cf904e5d3..a081999823 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -197,22 +197,26 @@ class PublishedWorkfileInfo: data (dict[str, Any]): Workfile item data. Returns: - WorkfileInfo: File item. + PublishedWorkfileInfo: File item. """ return PublishedWorkfileInfo(**data) class IWorkfileHost: - """Implementation requirements to be able use workfile utils and tool.""" + """Implementation requirements to be able to use workfiles utils and tool. + Some of the methods are pre-implemented as they generally do the same in + all host integrations. + + """ @abstractmethod def save_workfile(self, dst_path: Optional[str] = None): - """Save currently opened scene. + """Save the currently opened scene. Args: dst_path (str): Where the current scene should be saved. Or use - current path if 'None' is passed. + the current path if 'None' is passed. """ pass @@ -229,10 +233,10 @@ class IWorkfileHost: @abstractmethod def get_current_workfile(self) -> Optional[str]: - """Retrieve path to current opened file. + """Retrieve a path to current opened file. Returns: - Optional[str]: Path to file which is currently opened. None if + Optional[str]: Path to the file which is currently opened. None if nothing is opened. """ @@ -241,8 +245,8 @@ class IWorkfileHost: def workfile_has_unsaved_changes(self) -> Optional[bool]: """Currently opened scene is saved. - Not all hosts can know if current scene is saved because the API of - DCC does not support it. + Not all hosts can know if the current scene is saved because the API + of DCC does not support it. Returns: Optional[bool]: True if scene is saved and False if has unsaved @@ -253,7 +257,7 @@ class IWorkfileHost: return None def get_workfile_extensions(self) -> list[str]: - """Extensions that can be used as save workfile. + """Extensions that can be used to save the workfile to. Notes: Method may not be used if 'list_workfiles' and @@ -269,8 +273,8 @@ class IWorkfileHost: def save_workfile_with_context( self, filepath: str, - folder_entity: Optional[dict[str, Any]], - task_entity: Optional[dict[str, Any]], + folder_entity: dict[str, Any], + task_entity: dict[str, Any], *, version: Optional[int], comment: Optional[str] = None, @@ -281,7 +285,7 @@ class IWorkfileHost: project_entity: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, ): - """Save current workfile with context. + """Save the current workfile with context. Notes: Should this method care about context change? @@ -415,7 +419,7 @@ class IWorkfileHost: ) -> list[WorkfileInfo]: """List workfiles in the given folder. - NOTES: + Notes: - Better method name? - This method is pre-implemented as the logic can be shared across 95% of host integrations. Ad-hoc implementation to give host @@ -423,7 +427,7 @@ class IWorkfileHost: - Should this method also handle workfiles based on workfile entities? Args: - project_name (str): Name of project. + project_name (str): Project name. folder_entity (dict[str, Any]): Folder entity. task_entity (dict[str, Any]): Task entity. project_entity (Optional[dict[str, Any]]): Project entity. @@ -505,7 +509,10 @@ class IWorkfileHost: rootless_path, None ) items.append(WorkfileInfo.new( - filepath, rootless_path, True, workfile_entity + filepath, + rootless_path, + available=True, + workfile_entity=workfile_entity, )) for workfile_entity in workfile_entities_by_path.values(): @@ -514,7 +521,10 @@ class IWorkfileHost: rootless_path = workfile_entity["path"] filepath = anatomy.fill_root(rootless_path) items.append(WorkfileInfo.new( - filepath, rootless_path, False, workfile_entity + filepath, + rootless_path, + available=False, + workfile_entity=workfile_entity, )) return items @@ -528,9 +538,9 @@ class IWorkfileHost: version_entities: Optional[list[dict[str, Any]]] = None, repre_entities: Optional[list[dict[str, Any]]] = None, ) -> list[PublishedWorkfileInfo]: - """List published workfiles for given folder. + """List published workfiles for the given folder. - Default implementation looks for products with 'workfile' + The default implementation looks for products with the 'workfile' product type. Pre-fetched entities have mandatory fields to be fetched. @@ -548,7 +558,7 @@ class IWorkfileHost: Returns: list[PublishedWorkfileInfo]: Published workfile information for - given context. + the given context. """ from ayon_core.pipeline import Anatomy @@ -601,7 +611,9 @@ class IWorkfileHost: try: workfile_path = workfile_path.format(root=anatomy.roots) except Exception as exc: - print(f"Failed to format workfile path: {exc}") + self.log.warning( + f"Failed to format workfile path.", exc_info=True + ) is_available = False file_size = file_modified = file_created = None @@ -647,8 +659,8 @@ class IWorkfileHost: ): """Save workfile path with target folder and task context. - It is expected that workfile is saved to current project, but can be - copied from other project. + It is expected that workfile is saved to the current project, but + can be copied from the other project. Args: src_path (str): Path to the source scene. @@ -763,8 +775,6 @@ class IWorkfileHost: src_representation_path (Optional[str]): Representation path. """ - # TODO We might need option to open file once copied as some DCC might - # actually need to open the workfile to copy it. from ayon_core.pipeline import Anatomy from ayon_core.pipeline.load import ( get_representation_path_with_anatomy @@ -815,6 +825,7 @@ class IWorkfileHost: Todo: Remove when all usages are replaced. + """ return self.get_workfile_extensions() @@ -823,8 +834,8 @@ class IWorkfileHost: Todo: Remove when all usages are replaced. - """ + """ self.save_workfile(dst_path) def open_file(self, filepath): diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index ac915060eb..2bb94d5c06 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -737,7 +737,7 @@ def get_comments_from_workfile_paths( extensions (set[str]): Set of file extensions. file_template (StringTemplate): Workfile file template. template_data (dict[str, Any]): Data to fill the template with. - current_filename (str): Filename to check for current comment. + current_filename (str): Filename to check for the current comment. Returns: tuple[list[str], str]: List of comments and the current comment. From e6bb395d67e6bff6f2e912d893a5d630791a25b5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 13:56:37 +0200 Subject: [PATCH 071/312] set AYON_WORKDIR when workfile is opened --- client/ayon_core/host/interfaces/workfiles.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index a081999823..ab61e608f8 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -319,6 +319,9 @@ class IWorkfileHost: workdir = os.path.dirname(filepath) + # Set 'AYON_WORKDIR' environment variable + os.environ["AYON_WORKDIR"] = workdir + self.set_current_context( folder_entity, task_entity, From 524ed034238f86e3625ea7047a8af78db5c297ba Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 13:58:22 +0200 Subject: [PATCH 072/312] removed unnecessary workdir handling from set current context --- client/ayon_core/host/host.py | 50 +++++++------------ client/ayon_core/host/interfaces/workfiles.py | 4 -- client/ayon_core/pipeline/context_tools.py | 9 ++-- 3 files changed, 23 insertions(+), 40 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 5e5e8ac79f..45123d74a8 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -158,9 +158,7 @@ class HostBase(ABC): task_entity: dict[str, Any], *, reason: Optional[str] = None, - workdir: Optional[str] = None, project_entity: Optional[dict[str, Any]] = None, - project_settings: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, ): """Set current context information. @@ -178,9 +176,7 @@ class HostBase(ABC): folder_entity (Optional[dict[str, Any]]): Folder entity. task_entity (Optional[dict[str, Any]]): Task entity. reason (Optional[str]): Reason for context change. - workdir (Optional[str]): Work directory path. project_entity (Optional[dict[str, Any]]): Project entity data. - project_settings (Optional[dict[str, Any]]): Project settings data. anatomy (Optional[Anatomy]): Anatomy instance for the project. """ @@ -208,24 +204,22 @@ class HostBase(ABC): project_entity, folder_entity, task_entity, - anatomy, reason, + anatomy, ) self._set_current_context( project_entity, folder_entity, task_entity, reason, - workdir, anatomy, - project_settings, ) self._after_context_change( project_entity, folder_entity, task_entity, - anatomy, reason, + anatomy, ) return self._emit_context_change_event( @@ -309,10 +303,22 @@ class HostBase(ABC): folder_entity: Optional[dict[str, Any]], task_entity: Optional[dict[str, Any]], reason: Optional[str], - workdir: Optional[str], - anatomy: Optional["Anatomy"], - project_settings: Optional[dict[str, Any]], + anatomy: "Anatomy", ): + """Method that changes the context in host. + + Can be overriden for hosts that do need different handling of context + than using environment variables. + + Args: + project_entity (dict[str, Any]): Project entity. + folder_entity (dict[str, Any]): Folder entity of new context. + task_entity (dict[str, Any]): Task entity of new context. + reason (Optional[str]): Reason why change happened. Currently + known reasons are that workfile is being opened or saved. + anatomy (Anatomy): Project anatomy. + + """ from ayon_core.pipeline.workfile import get_workdir project_name = self.get_current_project_name() @@ -323,28 +329,10 @@ class HostBase(ABC): if task_entity: task_name = task_entity["name"] - if ( - workdir is None - and isinstance(self, IWorkfileHost) - and folder_entity - ): - if project_entity is None: - project_entity = ayon_api.get_project(project_name) - - workdir = get_workdir( - project_entity, - folder_entity, - task_entity, - self.name, - anatomy=anatomy, - project_settings=project_settings, - ) - envs = { "AYON_PROJECT_NAME": project_name, "AYON_FOLDER_PATH": folder_path, "AYON_TASK_NAME": task_name, - "AYON_WORKDIR": workdir, } # Update the Session and environments. Pop from environments all @@ -360,8 +348,8 @@ class HostBase(ABC): project_entity: dict[str, Any], folder_entity: Optional[dict[str, Any]], task_entity: Optional[dict[str, Any]], - anatomy: "Anatomy", reason: Optional[str], + anatomy: "Anatomy", ): """Before context is changed. @@ -382,8 +370,8 @@ class HostBase(ABC): project_entity: dict[str, Any], folder_entity: dict[str, Any], task_entity: dict[str, Any], - anatomy: "Anatomy", reason: Optional[str], + anatomy: "Anatomy", ): """After context is changed. diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index ab61e608f8..97f71690f5 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -326,9 +326,7 @@ class IWorkfileHost: folder_entity, task_entity, reason=WORKFILE_SAVE_REASON, - workdir=workdir, project_entity=project_entity, - project_settings=project_settings, anatomy=anatomy, ) @@ -398,9 +396,7 @@ class IWorkfileHost: folder_entity, task_entity, reason=WORKFILE_OPEN_REASON, - workdir=workdir, project_entity=project_entity, - project_settings=project_settings, anatomy=anatomy, ) diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index c51f0ad0d9..5043902b67 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -524,11 +524,11 @@ def change_current_context( This updates the live Session to a different task under folder. Notes: - This function does a lot of things related to workfiles which + * This function does a lot of things related to workfiles which extends arguments options a lot. - We might want to implement 'set_current_context' on host integration + * We might want to implement 'set_current_context' on host integration instead. But `AYON_WORKDIR`, which is related to 'IWorkfileHost', - would not be available in that case which might be break some + would not be available in that case which might break some logic. Args: @@ -572,9 +572,8 @@ def change_current_context( folder_entity, task_entity, reason=reason, - anatomy=anatomy, project_entity=project_entity, - project_settings=project_settings, + anatomy=anatomy, ) From 63b69a915436370a0e831846a5265a7154605b61 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:02:13 +0200 Subject: [PATCH 073/312] added 'TemplateResult' typint --- .../pipeline/workfile/path_resolving.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 2bb94d5c06..91867cd162 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -3,6 +3,7 @@ import os import re import copy import platform +import typing from typing import Optional, Dict, Any import ayon_api @@ -13,10 +14,12 @@ from ayon_core.lib import ( Logger, StringTemplate, ) -from ayon_core.lib.path_templates import TemplateResult from ayon_core.pipeline import version_start, Anatomy from ayon_core.pipeline.template_data import get_template_data +if typing.TYPE_CHECKING: + from ayon_core.lib.path_templates import TemplateResult + def get_workfile_template_key_from_context( project_name: str, @@ -113,7 +116,7 @@ def get_workdir_with_workdir_data( anatomy=None, template_key=None, project_settings=None -) -> TemplateResult: +) -> "TemplateResult": """Fill workdir path from entered data and project's anatomy. It is possible to pass only project's name instead of project's anatomy but @@ -157,14 +160,14 @@ def get_workdir_with_workdir_data( def get_workdir( - project_entity, - folder_entity, - task_entity, - host_name, + project_entity: dict[str, Any], + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + host_name: str, anatomy=None, template_key=None, project_settings=None -): +) -> "TemplateResult": """Fill workdir path from entered data and project's anatomy. Args: @@ -186,8 +189,8 @@ def get_workdir( Returns: TemplateResult: Workdir path. - """ + """ if not anatomy: anatomy = Anatomy( project_entity["name"], project_entity=project_entity From 0f7921741daa458083bddeed667a9d4d480dc280 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:07:29 +0200 Subject: [PATCH 074/312] remove unused imports --- client/ayon_core/host/host.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 45123d74a8..e997443aa2 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -11,8 +11,6 @@ import ayon_api from ayon_core.lib import emit_event -from .interfaces import IWorkfileHost - if typing.TYPE_CHECKING: from ayon_core.pipeline import Anatomy @@ -319,8 +317,6 @@ class HostBase(ABC): anatomy (Anatomy): Project anatomy. """ - from ayon_core.pipeline.workfile import get_workdir - project_name = self.get_current_project_name() folder_path = None task_name = None From 87832f109d6739c4788a03b67065db0acb386dfc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:27:47 +0200 Subject: [PATCH 075/312] modified change context function --- client/ayon_core/pipeline/context_tools.py | 25 ++++++---------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 5043902b67..b27d2f3920 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -513,15 +513,13 @@ def change_current_context( task_entity: dict[str, Any], *, template_key: Optional[str] = _PLACEHOLDER, - workdir: Optional[str] = _PLACEHOLDER, reason: Optional[str] = None, project_entity: Optional[dict[str, Any]] = None, - project_settings: Optional[dict[str, Any]] = None, anatomy: Optional[Anatomy] = None, -): +) -> dict[str, str]: """Update active Session to a new task work area. - This updates the live Session to a different task under folder. + This updates the live Session to a different task under a folder. Notes: * This function does a lot of things related to workfiles which @@ -536,33 +534,22 @@ def change_current_context( task_entity (Dict[str, Any]): Task entity to set. template_key (Optional[str]): DEPRECATED: Prepared template key to be used for workfile template in Anatomy. - workdir (Optional[str]): DEPRECATED: Workdir to set. reason (Optional[str]): Reason for changing context. anatomy (Optional[Anatomy]): Anatomy object used for workdir calculation. project_entity (Optional[dict[str, Any]]): Project entity used for workdir calculation. - project_settings (Optional[dict[str, Any]]): Project settings used for - workdir calculation. Returns: - Dict[str, str]: New context data. + dict[str, str]: New context data. """ - depr_args = [] if template_key is not _PLACEHOLDER: - depr_args.append("'template_key'") - - if workdir is not _PLACEHOLDER: - depr_args.append("'workdir'") - - if depr_args: - ending = "s" if len(depr_args) > 1 else "" - depr_args = ", ".join(depr_args) warnings.warn( ( - f"Used deprecated argument{ending} {depr_args}." - " To change " + f"Used deprecated argument 'template_key' in" + f" 'change_current_context'." + " It is not necessary to pass it in anymore." ), DeprecationWarning, ) From 77dbf2946bca4809a2b15b386c61e78a2cff91c7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:30:08 +0200 Subject: [PATCH 076/312] added typehints and modify docstrings --- .../pipeline/workfile/path_resolving.py | 65 ++++++++++--------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 91867cd162..21f0571888 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -213,8 +213,11 @@ def get_workdir( def get_last_workfile_with_version_from_paths( - filepaths, file_template, template_data, extensions -): + filepaths: list[str], + file_template: str, + template_data: dict[str, Any], + extensions: set[str], +) -> tuple[Optional[str], Optional[int]]: """Return last workfile version. Using workfile template and it's filling data find most possible last @@ -230,10 +233,10 @@ def get_last_workfile_with_version_from_paths( filepaths (list[str]): Workfile paths. file_template (str): Template of file name. template_data (Dict[str, Any]): Data for filling template. - extensions (Iterable[str]): All allowed file extensions of workfile. + extensions (set[str]): All allowed file extensions of workfile. Returns: - tuple[Union[str, None], Union[int, None]]: Last workfile with version + tuple[Optional[str], Optional[int]]: Last workfile with version if there is any workfile otherwise None for both. """ @@ -295,6 +298,8 @@ def get_last_workfile_with_version_from_paths( if file_version == version: output_filepaths.append(filepath) + # Use file modification time to use most recent file if there are + # multiple workfiles with the same version output_filepath = None last_time = None for _output_filepath in output_filepaths: @@ -316,10 +321,10 @@ def get_last_workfile_from_paths( file_template: str, template_data: dict[str, Any], extensions: set[str], -): - """Return last workfile filename. +) -> Optional[str]: + """Return the last workfile filename. - Returns file with version 1 if there is not workfile yet. + Returns the file with version 1 if there is not workfile yet. Args: filepaths (list[str]): Paths to workfiles. @@ -328,8 +333,7 @@ def get_last_workfile_from_paths( extensions (set[str]): All allowed file extensions of workfile. Returns: - Optional[str]: Last or first workfile as filename of full path - to filename. + Optional[str]: Last workfile path. """ filepath, _version = get_last_workfile_with_version_from_paths( @@ -341,7 +345,7 @@ def get_last_workfile_from_paths( def _filter_dir_files_by_ext( dirpath: str, extensions: set[str], -): +) -> tuple[list[str], set[str]]: """Filter files by extensions. Args: @@ -366,12 +370,15 @@ def _filter_dir_files_by_ext( def get_last_workfile_with_version( - workdir, file_template, fill_data, extensions -): + workdir: str, + file_template: str, + template_data: dict[str, Any], + extensions: set[str], +) -> tuple[Optional[str], Optional[int]]: """Return last workfile version. - Usign workfile template and it's filling data find most possible last - version of workfile which was created for the context. + Using the workfile template and its filling data to find the most possible + last version of workfile which was created for the context. Functionality is fully based on knowing which keys are optional or what values are expected as value. @@ -382,14 +389,14 @@ def get_last_workfile_with_version( Args: workdir (str): Path to dir where workfiles are stored. file_template (str): Template of file name. - fill_data (Dict[str, Any]): Data for filling template. - extensions (Iterable[str]): All allowed file extensions of workfile. + template_data (dict[str, Any]): Data for filling template. + extensions (set[str]): All allowed file extensions of workfile. Returns: - Tuple[Union[str, None], Union[int, None]]: Last workfile with version + tuple[Optional[str], Optional[int]]: Last workfile with version if there is any workfile otherwise None for both. - """ + """ if not os.path.exists(workdir): return None, None @@ -400,7 +407,7 @@ def get_last_workfile_with_version( return get_last_workfile_with_version_from_paths( filepaths, file_template, - fill_data, + template_data, dotted_extensions, ) @@ -408,10 +415,10 @@ def get_last_workfile_with_version( def get_last_workfile( workdir: str, file_template: str, - fill_data: dict[str, Any], + template_data: dict[str, Any], extensions: set[str], - full_path: bool = False -): + full_path: bool = False, +) -> str: """Return last workfile filename. Returns file with version 1 if there is not workfile yet. @@ -419,10 +426,9 @@ def get_last_workfile( Args: workdir (str): Path to dir where workfiles are stored. file_template (str): Template of file name. - fill_data (Dict[str, Any]): Data for filling template. + template_data (Dict[str, Any]): Data for filling template. extensions (Iterable[str]): All allowed file extensions of workfile. - full_path (bool): Full path to file is returned if - set to True. + full_path (bool): Return full path to the file or only filename. Returns: str: Last or first workfile as filename of full path to filename. @@ -434,11 +440,11 @@ def get_last_workfile( filepath = get_last_workfile_from_paths( filepaths, file_template, - fill_data, + template_data, dotted_extensions ) if filepath is None: - data = copy.deepcopy(fill_data) + data = copy.deepcopy(template_data) data["version"] = version_start.get_versioning_start( data["project"]["name"], data["app"], @@ -492,11 +498,10 @@ def get_custom_workfile_template( project_settings(Dict[str, Any]): Preloaded project settings. Returns: - str: Path to template or None if none of profiles match current + Optional[str]: Path to template or None if none of profiles match current context. Existence of formatted path is not validated. - None: If no profile is matching context. - """ + """ log = Logger.get_logger("CustomWorkfileResolve") project_name = project_entity["name"] From 63911161dd33cf612b23272a7b02cb83d192e28f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:30:24 +0200 Subject: [PATCH 077/312] add deprecation warning for 'full_path' argument --- client/ayon_core/pipeline/workfile/path_resolving.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 21f0571888..970b351586 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -434,6 +434,18 @@ def get_last_workfile( str: Last or first workfile as filename of full path to filename. """ + # TODO (iLLiCiTiT): Remove the argument 'full_path' and return only full + # path. As far as I can tell it is always called with 'full_path' set + # to 'True'. + # - it has to be 2 step operation, first warn about having it 'False', and + # then warn about having it filled. + if full_path is False: + warnings.warn( + "Argument 'full_path' will be removed and will return" + " only full path in future.", + DeprecationWarning, + ) + filepaths, dotted_extensions = _filter_dir_files_by_ext( workdir, extensions ) From c8a7e355b4cce797e3f5396dd720a284bcdb9206 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:30:52 +0200 Subject: [PATCH 078/312] iterate over extensions fix --- client/ayon_core/pipeline/workfile/path_resolving.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 970b351586..4dcd4d47f0 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -465,8 +465,8 @@ def get_last_workfile( product_type="workfile" ) data.pop("comment", None) - if not data.get("ext"): - data["ext"] = extensions[0] + if data.get("ext") is None: + data["ext"] = next(iter(extensions), "") data["ext"] = data["ext"].lstrip(".") filename = StringTemplate.format_strict_template(file_template, data) filepath = os.path.join(workdir, filename) From f130f543b8aed6f872ca92455a51d8c52a44e82a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:33:15 +0200 Subject: [PATCH 079/312] add missing import --- client/ayon_core/pipeline/workfile/path_resolving.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 4dcd4d47f0..b95d731809 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -3,6 +3,7 @@ import os import re import copy import platform +import warnings import typing from typing import Optional, Dict, Any From 79922d99b28c1093c729a3d76601861cf069929c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:33:23 +0200 Subject: [PATCH 080/312] update docstrings --- client/ayon_core/pipeline/workfile/path_resolving.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index b95d731809..a177caf7a4 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -221,7 +221,7 @@ def get_last_workfile_with_version_from_paths( ) -> tuple[Optional[str], Optional[int]]: """Return last workfile version. - Using workfile template and it's filling data find most possible last + Using the workfile template and its template data find most possible last version of workfile which was created for the context. Functionality is fully based on knowing which keys are optional or what @@ -511,8 +511,8 @@ def get_custom_workfile_template( project_settings(Dict[str, Any]): Preloaded project settings. Returns: - Optional[str]: Path to template or None if none of profiles match current - context. Existence of formatted path is not validated. + Optional[str]: Path to template or None if none of profiles match + current context. Existence of formatted path is not validated. """ log = Logger.get_logger("CustomWorkfileResolve") @@ -734,7 +734,7 @@ class CommentMatcher: self._fname_regex = re.compile(f"^{fname_pattern}$") def parse_comment(self, filename: str) -> Optional[str]: - """Parse the {comment} part from a filename""" + """Parse the {comment} part from a filename.""" if self._fname_regex: match = self._fname_regex.match(filename) if match: From dd637bb25d4a29eb8919a219fc92e716dcf79481 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:33:31 +0200 Subject: [PATCH 081/312] use comprehention --- client/ayon_core/pipeline/workfile/utils.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index c45163e7a1..c61614205a 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -563,14 +563,15 @@ def _create_workfile_info_entity( if value is not None: attrib[key] = value - data = {} - for key, value in ( - ("host_name", host_name), - ("version", version), - ("comment", comment), - ): - if value is not None: - data[key] = value + data = { + key: value + for key, value in ( + ("host_name", host_name), + ("version", version), + ("comment", comment), + ) + if value is not None + } workfile_info = { "id": uuid.uuid4().hex, From 808712e1148bde74f3ff0005ad8e80245c15e998 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:13:01 +0200 Subject: [PATCH 082/312] Apply suggestions from code review Co-authored-by: Roy Nieterau --- client/ayon_core/host/interfaces/workfiles.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 97f71690f5..e435d5dc7f 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -296,6 +296,7 @@ class IWorkfileHost: task_entity (dict[str, Any]): Task entity. version (Optional[int]): Version of the workfile. comment (Optional[str]): Comment for the workfile. + Usually used in the filename template. description (Optional[str]): Description for the workfile. rootless_path (Optional[str]): Rootless path of the workfile. workfile_entities (Optional[list[dict[str, Any]]]): Workfile From 5baf13c96cfcfb99c610a19d6ed8276e72eda2d8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:16:05 +0200 Subject: [PATCH 083/312] fix formatting --- client/ayon_core/host/interfaces/workfiles.py | 4 ++-- client/ayon_core/pipeline/context_tools.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index e435d5dc7f..f2c5dc89cf 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -610,9 +610,9 @@ class IWorkfileHost: try: workfile_path = workfile_path.format(root=anatomy.roots) - except Exception as exc: + except Exception: self.log.warning( - f"Failed to format workfile path.", exc_info=True + "Failed to format workfile path.", exc_info=True ) is_available = False diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index b27d2f3920..cccdafe6f1 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -547,8 +547,8 @@ def change_current_context( if template_key is not _PLACEHOLDER: warnings.warn( ( - f"Used deprecated argument 'template_key' in" - f" 'change_current_context'." + "Used deprecated argument 'template_key' in" + " 'change_current_context'." " It is not necessary to pass it in anymore." ), DeprecationWarning, From 509543e3690677f8dacd3dce63d5a7f5c87f151a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:21:03 +0200 Subject: [PATCH 084/312] better description of workfile_entities --- client/ayon_core/host/interfaces/workfiles.py | 15 ++++---- client/ayon_core/pipeline/workfile/utils.py | 34 +++++++++++++++---- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index f2c5dc89cf..cbf8a8e8e4 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -299,7 +299,8 @@ class IWorkfileHost: Usually used in the filename template. description (Optional[str]): Description for the workfile. rootless_path (Optional[str]): Rootless path of the workfile. - workfile_entities (Optional[list[dict[str, Any]]]): Workfile + workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched + workfile entities for the task. project_settings (Optional[dict[str, Any]]): Project settings. project_entity (Optional[dict[str, Any]]): Project entity. anatomy (Optional[Anatomy]): Project anatomy. @@ -431,8 +432,8 @@ class IWorkfileHost: folder_entity (dict[str, Any]): Folder entity. task_entity (dict[str, Any]): Task entity. project_entity (Optional[dict[str, Any]]): Project entity. - workfile_entities (Optional[list[dict[str, Any]]]): Workfile - entities. + workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched + workfile entities for the task. template_key (Optional[str]): Template key. project_settings (Optional[dict[str, Any]]): Project settings. anatomy (Anatomy): Project anatomy. @@ -671,8 +672,8 @@ class IWorkfileHost: comment (Optional[str]): Comment for the workfile. description (Optional[str]): Description for the workfile. rootless_path (Optional[str]): Rootless path of the workfile. - workfile_entities (Optional[list[dict[str, Any]]]): Workfile - entities to be saved with the workfile. + workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched + workfile entities for the task. project_settings (Optional[dict[str, Any]]): Project settings. project_entity (Optional[dict[str, Any]]): Project entity. anatomy (Optional[Anatomy]): Project anatomy. @@ -765,8 +766,8 @@ class IWorkfileHost: comment (Optional[str]): Comment for the workfile. description (Optional[str]): Description for the workfile. rootless_path (Optional[str]): Rootless path of the workfile. - workfile_entities (Optional[list[dict[str, Any]]]): Workfile - entities to be saved with the workfile. + workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched + workfile entities for the task. project_settings (Optional[dict[str, Any]]): Project settings. project_entity (Optional[dict[str, Any]]): Project entity. anatomy (Optional[Anatomy]): Project anatomy. diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index c61614205a..1a862d7d92 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -38,7 +38,7 @@ def get_workfiles_info( task_id (str): Task id under which is workfile created. anatomy (Optional[Anatomy]): Project anatomy used to get roots. workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched - workfile entities related to task. + workfile entities related to the task. Returns: Optional[dict[str, Any]]: Workfile info entity if found, otherwise @@ -198,7 +198,25 @@ def save_workfile_info( description: Optional[str] = None, username: Optional[str] = None, workfile_entities: Optional[list[dict[str, Any]]] = None, -): +) -> dict[str, Any]: + """Save workfile info entity for a workfile path. + Args: + project_name (str): The name of the project. + task_id (str): Task id under which is workfile created. + rootless_path (str): Rootless path of the workfile. + host_name (str): Name of host which is saving the workfile. + version (Optional[int]): Workfile version. + comment (Optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + username (Optional[str]): Username of user who saves the workfile. + If not provided, current user is used. + workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched + workfile entities related to task. + + Returns: + dict[str, Any]: Workfile info entity. + + """ if workfile_entities is None: workfile_entities = list(ayon_api.get_workfiles_info( project_name, @@ -266,7 +284,7 @@ def save_workfile_info( workfile_entity["updatedBy"] = username if not update_data: - return + return workfile_entity session = OperationsSession() session.update_entity( @@ -326,8 +344,8 @@ def save_current_workfile_to( description (Optional[str]): Workfile description. rootless_path (Optional[str]): Rootless path of the workfile. Is calculated if not passed in. - workfile_entities (Optional[list[dict[str, Any]]]): List of workfile - entities related to task. + workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched + workfile entities related to the task. project_entity (Optional[dict[str, Any]]): Project entity used for rootless path calculation. project_settings (Optional[dict[str, Any]]): Project settings used for @@ -385,7 +403,8 @@ def copy_and_open_workfile( description (Optional[str]): Workfile description. rootless_path (Optional[str]): Rootless path of the workfile. Is calculated if not passed in. - workfile_entities (Optional[list[dict[str, Any]]]): List of workfile + workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched + workfile entities related to the task. project_entity (Optional[dict[str, Any]]): Project entity used for rootless path calculation. project_settings (Optional[dict[str, Any]]): Project settings used for @@ -453,7 +472,8 @@ def copy_and_open_workfile_representation( entity. If not provided, it will be fetched from the server. representation_path (Optional[str]): Path to the representation. Calculated if not provided. - workfile_entities (Optional[list[dict[str, Any]]]): List of workfile + workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched + workfile entities related to the task. project_entity (Optional[dict[str, Any]]): Project entity used for rootless path calculation. project_settings (Optional[dict[str, Any]]): Project settings used for From e53962dd6e929a7a87f4262c9b41495cf673f0c1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:07:07 +0200 Subject: [PATCH 085/312] let 'version' argument optional --- client/ayon_core/host/interfaces/workfiles.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index cbf8a8e8e4..5d5cb8d740 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -276,7 +276,7 @@ class IWorkfileHost: folder_entity: dict[str, Any], task_entity: dict[str, Any], *, - version: Optional[int], + version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, rootless_path: Optional[str] = None, @@ -648,7 +648,7 @@ class IWorkfileHost: folder_entity: dict[str, Any], task_entity: dict[str, Any], *, - version: Optional[int], + version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, rootless_path: Optional[str] = None, @@ -739,7 +739,7 @@ class IWorkfileHost: folder_entity: dict[str, Any], task_entity: dict[str, Any], *, - version: Optional[int], + version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, rootless_path: Optional[str] = None, From d97829a180ea6fc9390cf36593d28e80949be4d5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:07:18 +0200 Subject: [PATCH 086/312] filter published workfiles by extension --- client/ayon_core/host/interfaces/workfiles.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 5d5cb8d740..5c958ac846 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -520,6 +520,9 @@ class IWorkfileHost: # Workfile entity is not in the filesystem # but it is in the database rootless_path = workfile_entity["path"] + ext = os.path.splitext(rootless_path)[1].lower() + if ext not in extensions: + continue filepath = anatomy.fill_root(rootless_path) items.append(WorkfileInfo.new( filepath, From 26a35a8cb5caf266f5236501802e3400c5636c29 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:07:36 +0200 Subject: [PATCH 087/312] pre-fetch project entity --- client/ayon_core/host/interfaces/workfiles.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 5c958ac846..95ef3f3318 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -311,8 +311,9 @@ class IWorkfileHost: folder_entity, task_entity, ) + project_name = self.get_current_project_name() event_data = self._get_workfile_event_data( - self.get_current_project_name(), + project_name, folder_entity, task_entity, filepath, @@ -324,6 +325,9 @@ class IWorkfileHost: # Set 'AYON_WORKDIR' environment variable os.environ["AYON_WORKDIR"] = workdir + if project_entity is None: + project_entity = ayon_api.get_project(project_name) + self.set_current_context( folder_entity, task_entity, From d3699c348fb1600b82c2589474976af5b7e3a614 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:08:02 +0200 Subject: [PATCH 088/312] modified docstrings --- client/ayon_core/host/interfaces/workfiles.py | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 95ef3f3318..f84322de90 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -287,6 +287,13 @@ class IWorkfileHost: ): """Save the current workfile with context. + Arguments 'rootless_path', 'workfile_entities', 'project_entity' + and 'anatomy' can be filled to enhance efficiency if you already + have access to the values. + + Argument 'project_settings' is used to calculate 'rootless_path' + if it is not provided. + Notes: Should this method care about context change? @@ -294,11 +301,13 @@ class IWorkfileHost: filepath (str): Where the current scene should be saved. folder_entity (dict[str, Any]): Folder entity. task_entity (dict[str, Any]): Task entity. - version (Optional[int]): Version of the workfile. + version (Optional[int]): Version of the workfile. Information + for workfile entity. Recommended to fill. comment (Optional[str]): Comment for the workfile. Usually used in the filename template. - description (Optional[str]): Description for the workfile. - rootless_path (Optional[str]): Rootless path of the workfile. + description (Optional[str]): Artist note for the workfile entity. + rootless_path (Optional[str]): Prepared rootless path of + the workfile. workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched workfile entities for the task. project_settings (Optional[dict[str, Any]]): Project settings. @@ -422,14 +431,16 @@ class IWorkfileHost: project_settings: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, ) -> list[WorkfileInfo]: - """List workfiles in the given folder. + """List workfiles in the given task. + + The method should also return workfiles that are not available on + disk, but are in the AYON database. Notes: - Better method name? - This method is pre-implemented as the logic can be shared across 95% of host integrations. Ad-hoc implementation to give host integration workfile api functionality. - - Should this method also handle workfiles based on workfile entities? Args: project_name (str): Project name. @@ -670,14 +681,22 @@ class IWorkfileHost: It is expected that workfile is saved to the current project, but can be copied from the other project. + Arguments 'rootless_path', 'workfile_entities', 'project_entity' + and 'anatomy' can be filled to enhance efficiency if you already + have access to the values. + + Argument 'project_settings' is used to calculate 'rootless_path' + if it is not provided. + Args: src_path (str): Path to the source scene. dst_path (str): Where the scene should be saved. folder_entity (dict[str, Any]): Folder entity. task_entity (dict[str, Any]): Task entity. - version (Optional[int]): Version of the workfile. + version (Optional[int]): Version of the workfile. Information + for workfile entity. Recommended to fill. comment (Optional[str]): Comment for the workfile. - description (Optional[str]): Description for the workfile. + description (Optional[str]): Artist note for the workfile entity. rootless_path (Optional[str]): Rootless path of the workfile. workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched workfile entities for the task. @@ -762,6 +781,13 @@ class IWorkfileHost: Use representation as source for the workfile. + Arguments 'rootless_path', 'workfile_entities', 'project_entity' + and 'anatomy' can be filled to enhance efficiency if you already + have access to the values. + + Argument 'project_settings' is used to calculate 'rootless_path' + if it is not provided. + Args: src_project_name (str): Project name. src_representation_entity (dict[str, Any]): Representation @@ -769,9 +795,10 @@ class IWorkfileHost: dst_path (str): Where the scene should be saved. folder_entity (dict[str, Any): Folder entity. task_entity (dict[str, Any]): Task entity. - version (Optional[int]): Version of the workfile. + version (Optional[int]): Version of the workfile. Information + for workfile entity. Recommended to fill. comment (Optional[str]): Comment for the workfile. - description (Optional[str]): Description for the workfile. + description (Optional[str]): Artist note for the workfile entity. rootless_path (Optional[str]): Rootless path of the workfile. workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched workfile entities for the task. From 76ceefb6f38fbd67a9403605b13e75e2e6d0173b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:08:29 +0200 Subject: [PATCH 089/312] require kwargs for 'list_workfiles' --- client/ayon_core/host/interfaces/workfiles.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index f84322de90..5a3b9f117f 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -425,6 +425,7 @@ class IWorkfileHost: project_name: str, folder_entity: dict[str, Any], task_entity: dict[str, Any], + *, project_entity: Optional[dict[str, Any]] = None, workfile_entities: Optional[list[dict[str, Any]]] = None, template_key: Optional[str] = None, From 3e6aafae556fae48ded5d32e38e5844f0ee2e556 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:08:59 +0200 Subject: [PATCH 090/312] apply suggestion Co-authored-by: Roy Nieterau --- client/ayon_core/host/interfaces/workfiles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 5a3b9f117f..86b751ba66 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -237,7 +237,7 @@ class IWorkfileHost: Returns: Optional[str]: Path to the file which is currently opened. None if - nothing is opened. + nothing is opened or the current workfile is unsaved. """ return None From 351ab7d56bc2b9add8f193489a0ce4e3ef30098e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:24:32 +0200 Subject: [PATCH 091/312] use 'open_workfile' to open workfile --- client/ayon_core/pipeline/workfile/workfile_template_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 27da278c5e..45edf01172 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -604,7 +604,7 @@ class AbstractTemplateBuilder(ABC): """Open template file with registered host.""" template_preset = self.get_template_preset() template_path = template_preset["path"] - self.host.open_file(template_path) + self.host.open_workfile(template_path) @abstractmethod def import_template(self, template_path): From 762f98620baca0220b463eb8b9d2769063b5dbf5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 16:33:17 +0200 Subject: [PATCH 092/312] use dataclasses to pass information form method to method --- client/ayon_core/host/host.py | 50 ++--- client/ayon_core/host/interfaces/workfiles.py | 172 +++++++++--------- 2 files changed, 103 insertions(+), 119 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index e997443aa2..f9f74e8069 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -4,6 +4,7 @@ import os import logging import contextlib from abc import ABC, abstractmethod +from dataclasses import dataclass import typing from typing import Optional, Any @@ -15,6 +16,16 @@ if typing.TYPE_CHECKING: from ayon_core.pipeline import Anatomy +@dataclass +class ContextChangeData: + project_entity: dict[str, Any] + folder_entity: dict[str, Any] + task_entity: dict[str, Any] + reason: Optional[str] + anatomy: "Anatomy" + + + class HostBase(ABC): """Base of host implementation class. @@ -198,13 +209,14 @@ class HostBase(ABC): if anatomy is None: anatomy = Anatomy(project_name, project_entity=project_entity) - self._before_context_change( + context_change_data = ContextChangeData( project_entity, folder_entity, task_entity, reason, anatomy, ) + self._before_context_change(context_change_data) self._set_current_context( project_entity, folder_entity, @@ -212,13 +224,7 @@ class HostBase(ABC): reason, anatomy, ) - self._after_context_change( - project_entity, - folder_entity, - task_entity, - reason, - anatomy, - ) + self._after_context_change(context_change_data) return self._emit_context_change_event( project_name, @@ -339,14 +345,7 @@ class HostBase(ABC): else: os.environ[key] = value - def _before_context_change( - self, - project_entity: dict[str, Any], - folder_entity: Optional[dict[str, Any]], - task_entity: Optional[dict[str, Any]], - reason: Optional[str], - anatomy: "Anatomy", - ): + def _before_context_change(self, context_change_data: ContextChangeData): """Before context is changed. This method is called before the context is changed in the host. @@ -354,21 +353,13 @@ class HostBase(ABC): Can be overriden to implement host specific logic. Args: - folder_entity (dict[str, Any]): Folder entity. - task_entity (dict[str, Any]): Task entity. - reason (Optional[str]): Reason for context change. + context_change_data (ContextChangeData): Object with information + about context change. """ pass - def _after_context_change( - self, - project_entity: dict[str, Any], - folder_entity: dict[str, Any], - task_entity: dict[str, Any], - reason: Optional[str], - anatomy: "Anatomy", - ): + def _after_context_change(self, context_change_data: ContextChangeData): """After context is changed. This method is called after the context is changed in the host. @@ -376,9 +367,8 @@ class HostBase(ABC): Can be overriden to implement host specific logic. Args: - folder_entity (dict[str, Any]): Folder entity. - task_entity (dict[str, Any]): Task entity. - reason (Optional[str]): Reason for context change. + context_change_data (ContextChangeData): Object with information + about context change. """ pass diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 86b751ba66..53776f1ce8 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -21,6 +21,29 @@ WORKFILE_OPEN_REASON = "workfile.opened" WORKFILE_SAVE_REASON = "workfile.saved" +@dataclass +class WorkfileOpenData: + filepath: str + folder_entity: dict[str, Any] + task_entity: dict[str, Any] + + +@dataclass +class WorkfileSaveData(WorkfileOpenData): + filepath: str + folder_entity: dict[str, Any] + task_entity: dict[str, Any] + + +@dataclass +class WorkfileCopyData: + source_path: str + destination_path: str + folder_entity: dict[str, Any] + task_entity: dict[str, Any] + open_workfile: bool + + @dataclass class WorkfileInfo: """Information about workfile. @@ -315,11 +338,12 @@ class IWorkfileHost: anatomy (Optional[Anatomy]): Project anatomy. """ - self._before_workfile_save( - filepath, - folder_entity, - task_entity, + save_workfile_data = WorkfileSaveData( + folder_entity=folder_entity, + task_entity=task_entity, + filepath=filepath, ) + self._before_workfile_save(save_workfile_data) project_name = self.get_current_project_name() event_data = self._get_workfile_event_data( project_name, @@ -360,11 +384,7 @@ class IWorkfileHost: project_entity, anatomy, ) - self._after_workfile_save( - filepath, - folder_entity, - task_entity, - ) + self._after_workfile_save(save_workfile_data) self._emit_workfile_save_event(event_data) def open_workfile_with_context( @@ -403,8 +423,12 @@ class IWorkfileHost: event_data = self._get_workfile_event_data( project_name, folder_entity, task_entity, filepath ) - - self._before_workfile_open(folder_entity, task_entity, filepath) + open_workfile_data = WorkfileOpenData( + folder_entity=folder_entity, + task_entity=task_entity, + filepath=filepath, + ) + self._before_workfile_open(open_workfile_data) self._emit_workfile_open_event(event_data, after_open=False) self.set_current_context( @@ -417,7 +441,7 @@ class IWorkfileHost: self.open_workfile(filepath) - self._after_workfile_open(folder_entity, task_entity, filepath) + self._after_workfile_open(open_workfile_data) self._emit_workfile_open_event(event_data) def list_workfiles( @@ -707,13 +731,15 @@ class IWorkfileHost: open_workfile (bool): Open workfile when copied. """ - self._before_workfile_copy( - src_path, - dst_path, - folder_entity, - task_entity, - open_workfile, + copy_workfile_data = WorkfileCopyData( + source_path=src_path, + destination_path=dst_path, + folder_entity=folder_entity, + task_entity=task_entity, + open_workfile=open_workfile, + ) + self._before_workfile_copy(copy_workfile_data) event_data = self._get_workfile_event_data( self.get_current_project_name(), folder_entity, @@ -740,13 +766,7 @@ class IWorkfileHost: project_entity, anatomy, ) - self._after_workfile_copy( - src_path, - dst_path, - folder_entity, - task_entity, - open_workfile, - ) + self._after_workfile_copy(copy_workfile_data) self._emit_workfile_save_event(event_data) if not open_workfile: @@ -1046,11 +1066,8 @@ class IWorkfileHost: } def _before_workfile_open( - self, - folder_entity: dict[str, Any], - task_entity: dict[str, Any], - filepath: str, - ): + self, open_workfile_data: WorkfileOpenData + ) -> None: """Before workfile is opened. This method is called before the workfile is opened in the host. @@ -1058,19 +1075,15 @@ class IWorkfileHost: Can be overriden to implement host specific logic. Args: - folder_entity (dict[str, Any]): Folder entity. - task_entity (dict[str, Any]): Task entity. - filepath (str): Path to the workfile. + open_workfile_data (WorkfileOpenData): Context and path of + workfile to open. """ pass def _after_workfile_open( - self, - folder_entity: dict[str, Any], - task_entity: dict[str, Any], - filepath: str, - ): + self, open_workfile_data: WorkfileOpenData + ) -> None: """After workfile is opened. This method is called after the workfile is opened in the host. @@ -1078,19 +1091,15 @@ class IWorkfileHost: Can be overriden to implement host specific logic. Args: - folder_entity (dict[str, Any]): Folder entity. - task_entity (dict[str, Any]): Task entity. - filepath (str): Path to the workfile. + open_workfile_data (WorkfileOpenData): Context and path of + opened workfile. """ pass def _before_workfile_save( - self, - filepath: str, - folder_entity: dict[str, Any], - task_entity: dict[str, Any], - ): + self, save_workfile_data: WorkfileSaveData + ) -> None: """Before workfile is saved. This method is called before the workfile is saved in the host. @@ -1098,19 +1107,15 @@ class IWorkfileHost: Can be overriden to implement host specific logic. Args: - filepath (str): Path to the workfile. - folder_entity (dict[str, Any]): Folder entity. - task_entity (dict[str, Any]): Task entity. + save_workfile_data (WorkfileSaveData): Workfile path with target + folder and task context. """ pass def _after_workfile_save( - self, - filepath: str, - folder_entity: dict[str, Any], - task_entity: dict[str, Any], - ): + self, save_workfile_data: WorkfileSaveData + ) -> None: """After workfile is saved. This method is called after the workfile is saved in the host. @@ -1118,22 +1123,20 @@ class IWorkfileHost: Can be overriden to implement host specific logic. Args: - folder_entity (dict[str, Any]): Folder entity. - task_entity (dict[str, Any]): Task entity. - filepath (str): Path to the workfile. + save_workfile_data (WorkfileSaveData): Workfile path with target + folder and task context. """ - workdir = os.path.dirname(filepath) - self._create_extra_folders(folder_entity, task_entity, workdir) + workdir = os.path.dirname(save_workfile_data.filepath) + self._create_extra_folders( + save_workfile_data.folder_entity, + save_workfile_data.task_entity, + workdir + ) def _before_workfile_copy( - self, - src_path: str, - dst_path: str, - folder_entity: dict[str, Any], - task_entity: dict[str, Any], - open_workfile: bool = True, - ): + self, copy_workfile_data: WorkfileCopyData + ) -> None: """Before workfile is copied. This method is called before the workfile is copied by host @@ -1142,24 +1145,15 @@ class IWorkfileHost: Can be overriden to implement host specific logic. Args: - src_path (str): Path to the source workfile. - dst_path (str): Path to the destination workfile. - folder_entity (dict[str, Any]): Folder entity. - task_entity (dict[str, Any]): Task entity. - open_workfile (bool): Should be the path opened once copy is - finished. + copy_workfile_data (WorkfileCopyData): Source and destination + path with context before workfile is copied. """ pass def _after_workfile_copy( - self, - src_path: str, - dst_path: str, - folder_entity: dict[str, Any], - task_entity: dict[str, Any], - open_workfile: bool = True, - ): + self, copy_workfile_data: WorkfileCopyData + ) -> None: """After workfile is copied. This method is called after the workfile is copied by host @@ -1168,22 +1162,22 @@ class IWorkfileHost: Can be overriden to implement host specific logic. Args: - src_path (str): Path to the source workfile. - dst_path (str): Path to the destination workfile. - folder_entity (dict[str, Any]): Folder entity. - task_entity (dict[str, Any]): Task entity. - open_workfile (bool): Should be the path opened once copy is - finished. + copy_workfile_data (WorkfileCopyData): Source and destination + path with context after workfile is copied. """ - workdir = os.path.dirname(dst_path) - self._create_extra_folders(folder_entity, task_entity, workdir) + workdir = os.path.dirname(copy_workfile_data.destination_path) + self._create_extra_folders( + copy_workfile_data.folder_entity, + copy_workfile_data.task_entity, + workdir, + ) def _emit_workfile_open_event( self, event_data: dict[str, Optional[str]], after_open: bool = True, - ): + ) -> None: topics = [] topic_end = "before" if after_open: @@ -1200,7 +1194,7 @@ class IWorkfileHost: self, event_data: dict[str, Optional[str]], after_open: bool = True, - ): + ) -> None: topics = [] topic_end = "before" if after_open: From 688e5f2104b3563a09724d4b264bb4c4f144973c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 16:55:07 +0200 Subject: [PATCH 093/312] remove unnecessary line --- client/ayon_core/host/interfaces/workfiles.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 53776f1ce8..d5aec5b651 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -737,7 +737,6 @@ class IWorkfileHost: folder_entity=folder_entity, task_entity=task_entity, open_workfile=open_workfile, - ) self._before_workfile_copy(copy_workfile_data) event_data = self._get_workfile_event_data( From da1a39ed6ad15437b946617630f45efedf312e86 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 17:03:25 +0200 Subject: [PATCH 094/312] validate extension earlier Co-authored-by: Roy Nieterau --- client/ayon_core/host/interfaces/workfiles.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index d5aec5b651..f6f6b91f2d 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -539,12 +539,13 @@ class IWorkfileHost: items = [] for filename in filenames: - filepath = os.path.join(workdir, filename) # TODO add 'default' support for folders - ext = os.path.splitext(filepath)[1].lower() + ext = os.path.splitext(filename)[1].lower() if ext not in extensions: continue + filepath = os.path.join(workdir, filename) + rootless_path = f"{rootless_workdir}/{filename}" workfile_entity = workfile_entities_by_path.pop( rootless_path, None From 411c433d50ed8bf4b0ea84d9872e7b07d17d342c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 17:06:58 +0200 Subject: [PATCH 095/312] added typehints --- client/ayon_core/host/host.py | 7 +++++-- client/ayon_core/host/interfaces/workfiles.py | 21 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index f9f74e8069..1c2885b7e6 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -25,7 +25,6 @@ class ContextChangeData: anatomy: "Anatomy" - class HostBase(ABC): """Base of host implementation class. @@ -169,7 +168,7 @@ class HostBase(ABC): reason: Optional[str] = None, project_entity: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, - ): + ) -> dict[str, Optional[str]]: """Set current context information. This method should be used to set current context of host. Usage of @@ -188,6 +187,10 @@ class HostBase(ABC): project_entity (Optional[dict[str, Any]]): Project entity data. anatomy (Optional[Anatomy]): Anatomy instance for the project. + Returns: + dict[str, Optional[str]]: Context information with project name, + folder path and task name. + """ from ayon_core.pipeline import Anatomy diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index f6f6b91f2d..76e91dcd93 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -186,7 +186,7 @@ class PublishedWorkfileInfo: file_size: Optional[float], file_modified: Optional[float], file_created: Optional[float], - ): + ) -> "PublishedWorkfileInfo": created_at = arrow.get(repre_entity["createdAt"]).to("local") return cls( @@ -307,7 +307,7 @@ class IWorkfileHost: project_settings: Optional[dict[str, Any]] = None, project_entity: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, - ): + ) -> None: """Save the current workfile with context. Arguments 'rootless_path', 'workfile_entities', 'project_entity' @@ -396,7 +396,7 @@ class IWorkfileHost: project_entity: Optional[dict[str, Any]] = None, project_settings: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, - ): + ) -> None: """Open passed filepath in the host with context. This function should be used to open workfile in different context. @@ -701,7 +701,7 @@ class IWorkfileHost: project_entity: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, open_workfile: bool = True, - ): + ) -> None: """Save workfile path with target folder and task context. It is expected that workfile is saved to the current project, but @@ -797,7 +797,7 @@ class IWorkfileHost: open_workfile: bool = True, src_anatomy: Optional["Anatomy"] = None, src_representation_path: Optional[str] = None, - ): + ) -> None: """Copy workfile representation. Use representation as source for the workfile. @@ -984,7 +984,7 @@ class IWorkfileHost: project_settings: Optional[dict[str, Any]] = None, project_entity: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, - ): + ) -> Optional[dict[str, Any]]: from ayon_core.pipeline.workfile.utils import ( save_workfile_info, find_workfile_rootless_path, @@ -1029,7 +1029,12 @@ class IWorkfileHost: ) return workfile_info - def _create_extra_folders(self, folder_entity, task_entity, workdir): + def _create_extra_folders( + self, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + workdir: str, + ) -> None: from ayon_core.pipeline.workfile.path_resolving import ( create_workdir_extra_folders ) @@ -1051,7 +1056,7 @@ class IWorkfileHost: folder_entity: dict[str, Any], task_entity: dict[str, Any], filepath: str, - ): + ) -> dict[str, Optional[str]]: workdir, filename = os.path.split(filepath) return { "project_name": project_name, From 0c25defb9d5f3a82597fdfcbc0af6befc20207b6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 17:17:10 +0200 Subject: [PATCH 096/312] added more docstrings --- client/ayon_core/host/interfaces/workfiles.py | 92 +++++++++++++++++-- 1 file changed, 86 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 76e91dcd93..d5f17b9acb 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -351,7 +351,7 @@ class IWorkfileHost: task_entity, filepath, ) - self._emit_workfile_save_event(event_data, after_open=False) + self._emit_workfile_save_event(event_data, after_save=False) workdir = os.path.dirname(filepath) @@ -612,7 +612,7 @@ class IWorkfileHost: ( version_entities, repre_entities - ) = self._fetch_workfile_entities( + ) = self._fetch_published_workfile_entities( project_name, folder_id, version_entities, @@ -746,7 +746,7 @@ class IWorkfileHost: task_entity, dst_path, ) - self._emit_workfile_save_event(event_data, after_open=False) + self._emit_workfile_save_event(event_data, after_save=False) dst_dir = os.path.dirname(dst_path) if not os.path.exists(dst_dir): @@ -921,7 +921,7 @@ class IWorkfileHost: return self.workfile_has_unsaved_changes() - def _fetch_workfile_entities( + def _fetch_published_workfile_entities( self, project_name: str, folder_id: str, @@ -931,6 +931,21 @@ class IWorkfileHost: list[dict[str, Any]], list[dict[str, Any]] ]: + """Fetch integrated workfile entities for the given folder. + + Args: + project_name (str): Project name. + folder_id (str): Folder id. + version_entities (Optional[list[dict[str, Any]]]): Pre-fetched + version entities. + repre_entities (Optional[list[dict[str, Any]]]): Pre-fetched + representation entities. + + Returns: + tuple[list[dict[str, Any]], list[dict[str, Any]]]: + Tuple of version entities and representation entities. + + """ if repre_entities is not None and version_entities is None: # Get versions of representations version_ids = {r["versionId"] for r in repre_entities} @@ -985,6 +1000,27 @@ class IWorkfileHost: project_entity: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, ) -> Optional[dict[str, Any]]: + """Create of update workfile entity to AYON based on provided data. + + Args: + workfile_path (str): Path to the workfile. + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + version (Optional[int]): Version of the workfile. + comment (Optional[str]): Comment for the workfile. + description (Optional[str]): Artist note for the workfile entity. + rootless_path (Optional[str]): Prepared rootless path of + the workfile. + workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched + workfile entities. + project_settings (Optional[dict[str, Any]]): Project settings. + project_entity (Optional[dict[str, Any]]): Project entity. + anatomy (Optional[Anatomy]): Project anatomy. + + Returns: + Optional[dict[str, Any]]: Workfile entity. + + """ from ayon_core.pipeline.workfile.utils import ( save_workfile_info, find_workfile_rootless_path, @@ -1035,6 +1071,16 @@ class IWorkfileHost: task_entity: dict[str, Any], workdir: str, ) -> None: + """Create extra folders in the workdir. + + This method should be called when workfile is saved or copied. + + Args: + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + workdir (str): Workdir where workfile/s will be stored. + + """ from ayon_core.pipeline.workfile.path_resolving import ( create_workdir_extra_folders ) @@ -1057,6 +1103,18 @@ class IWorkfileHost: task_entity: dict[str, Any], filepath: str, ) -> dict[str, Optional[str]]: + """Prepare workfile event data. + + Args: + project_name (str): Name of the project where workfile lives. + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + filepath (str): Path to the workfile. + + Returns: + dict[str, Optional[str]]: Data for workfile event. + + """ workdir, filename = os.path.split(filepath) return { "project_name": project_name, @@ -1183,6 +1241,17 @@ class IWorkfileHost: event_data: dict[str, Optional[str]], after_open: bool = True, ) -> None: + """Emit workfile save event. + + Emit event before and after workfile is opened. + + Other addons can listen to this event and do additional steps. + + Args: + event_data (dict[str, Optional[str]]): Prepare event data. + after_open (bool): Emit event after workfile is opened. + + """ topics = [] topic_end = "before" if after_open: @@ -1198,11 +1267,22 @@ class IWorkfileHost: def _emit_workfile_save_event( self, event_data: dict[str, Optional[str]], - after_open: bool = True, + after_save: bool = True, ) -> None: + """Emit workfile save event. + + Emit event before and after workfile is saved or copied. + + Other addons can listen to this event and do additional steps. + + Args: + event_data (dict[str, Optional[str]]): Prepare event data. + after_save (bool): Emit event after workfile is saved. + + """ topics = [] topic_end = "before" - if after_open: + if after_save: topics.append("workfile.saved") topic_end = "after" From 397bfd23ebc865d74016b1e63bb27f1cebaf6fde Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 18:04:00 +0200 Subject: [PATCH 097/312] added deprecation warnings --- client/ayon_core/host/interfaces/workfiles.py | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index d5f17b9acb..b300adc308 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -4,6 +4,8 @@ import os import platform import shutil import typing +import warnings +import functools from abc import abstractmethod from dataclasses import dataclass, asdict from typing import Optional, Any @@ -21,6 +23,26 @@ WORKFILE_OPEN_REASON = "workfile.opened" WORKFILE_SAVE_REASON = "workfile.saved" +def deprecated(reason): + def decorator(func): + message = f"Call to deprecated function {func.__name__} ({reason})." + + @functools.wraps(func) + def new_func(*args, **kwargs): + warnings.simplefilter("always", DeprecationWarning) + warnings.warn( + message, + category=DeprecationWarning, + stacklevel=2 + ) + warnings.simplefilter("default", DeprecationWarning) + return func(*args, **kwargs) + + return new_func + + return decorator + + @dataclass class WorkfileOpenData: filepath: str @@ -876,6 +898,7 @@ class IWorkfileHost: ) # --- Deprecated method names --- + @deprecated("Use 'get_workfile_extensions' instead") def file_extensions(self): """Deprecated variant of 'get_workfile_extensions'. @@ -885,40 +908,44 @@ class IWorkfileHost: """ return self.get_workfile_extensions() + @deprecated("Use 'save_workfile' instead") def save_file(self, dst_path=None): """Deprecated variant of 'save_workfile'. Todo: - Remove when all usages are replaced. + Remove when all usages are replaced """ self.save_workfile(dst_path) + @deprecated("Use 'open_workfile' instead") def open_file(self, filepath): """Deprecated variant of 'open_workfile'. Todo: Remove when all usages are replaced. - """ + """ return self.open_workfile(filepath) + @deprecated("Use 'get_current_workfile' instead") def current_file(self): """Deprecated variant of 'get_current_workfile'. Todo: Remove when all usages are replaced. - """ + """ return self.get_current_workfile() + @deprecated("Use 'workfile_has_unsaved_changes' instead") def has_unsaved_changes(self): """Deprecated variant of 'workfile_has_unsaved_changes'. Todo: Remove when all usages are replaced. - """ + """ return self.workfile_has_unsaved_changes() def _fetch_published_workfile_entities( From 7eb067a99d917e761093180387805529d754d842 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 18:05:33 +0200 Subject: [PATCH 098/312] remove safe type hint --- client/ayon_core/host/host.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 1c2885b7e6..ad6a805575 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -22,7 +22,7 @@ class ContextChangeData: folder_entity: dict[str, Any] task_entity: dict[str, Any] reason: Optional[str] - anatomy: "Anatomy" + anatomy: Anatomy class HostBase(ABC): @@ -167,7 +167,7 @@ class HostBase(ABC): *, reason: Optional[str] = None, project_entity: Optional[dict[str, Any]] = None, - anatomy: Optional["Anatomy"] = None, + anatomy: Optional[Anatomy] = None, ) -> dict[str, Optional[str]]: """Set current context information. @@ -310,7 +310,7 @@ class HostBase(ABC): folder_entity: Optional[dict[str, Any]], task_entity: Optional[dict[str, Any]], reason: Optional[str], - anatomy: "Anatomy", + anatomy: Anatomy, ): """Method that changes the context in host. From a23678beb1c526b5185dca74dcb30137638d6901 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 18:07:36 +0200 Subject: [PATCH 099/312] use 'ContextChangeData' for '_set_current_context' --- client/ayon_core/host/host.py | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index ad6a805575..c7c2b30323 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -220,13 +220,7 @@ class HostBase(ABC): anatomy, ) self._before_context_change(context_change_data) - self._set_current_context( - project_entity, - folder_entity, - task_entity, - reason, - anatomy, - ) + self._set_current_context(context_change_data) self._after_context_change(context_change_data) return self._emit_context_change_event( @@ -305,13 +299,8 @@ class HostBase(ABC): return data def _set_current_context( - self, - project_entity: dict[str, Any], - folder_entity: Optional[dict[str, Any]], - task_entity: Optional[dict[str, Any]], - reason: Optional[str], - anatomy: Anatomy, - ): + self, context_change_data: ContextChangeData + ) -> None: """Method that changes the context in host. Can be overriden for hosts that do need different handling of context @@ -329,10 +318,10 @@ class HostBase(ABC): project_name = self.get_current_project_name() folder_path = None task_name = None - if folder_entity: - folder_path = folder_entity["path"] - if task_entity: - task_name = task_entity["name"] + if context_change_data.folder_entity: + folder_path = context_change_data.folder_entity["path"] + if context_change_data.task_entity: + task_name = context_change_data.task_entity["name"] envs = { "AYON_PROJECT_NAME": project_name, From 873db37794114f84a32250fe78431c446fe46038 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Jun 2025 18:08:55 +0200 Subject: [PATCH 100/312] don't use safe typehint --- client/ayon_core/host/interfaces/workfiles.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index b300adc308..ce0c680f16 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -147,7 +147,7 @@ class WorkfileInfo: return asdict(self) @classmethod - def from_data(cls, data: dict[str, Any]) -> "WorkfileInfo": + def from_data(cls, data: dict[str, Any]) -> WorkfileInfo: """Converts data to workfile item. Args: @@ -328,7 +328,7 @@ class IWorkfileHost: workfile_entities: Optional[list[dict[str, Any]]] = None, project_settings: Optional[dict[str, Any]] = None, project_entity: Optional[dict[str, Any]] = None, - anatomy: Optional["Anatomy"] = None, + anatomy: Optional[Anatomy] = None, ) -> None: """Save the current workfile with context. @@ -417,7 +417,7 @@ class IWorkfileHost: *, project_entity: Optional[dict[str, Any]] = None, project_settings: Optional[dict[str, Any]] = None, - anatomy: Optional["Anatomy"] = None, + anatomy: Optional[Anatomy] = None, ) -> None: """Open passed filepath in the host with context. @@ -476,7 +476,7 @@ class IWorkfileHost: workfile_entities: Optional[list[dict[str, Any]]] = None, template_key: Optional[str] = None, project_settings: Optional[dict[str, Any]] = None, - anatomy: Optional["Anatomy"] = None, + anatomy: Optional[Anatomy] = None, ) -> list[WorkfileInfo]: """List workfiles in the given task. @@ -601,7 +601,7 @@ class IWorkfileHost: project_name: str, folder_id: str, *, - anatomy: Optional["Anatomy"] = None, + anatomy: Optional[Anatomy] = None, version_entities: Optional[list[dict[str, Any]]] = None, repre_entities: Optional[list[dict[str, Any]]] = None, ) -> list[PublishedWorkfileInfo]: @@ -721,7 +721,7 @@ class IWorkfileHost: workfile_entities: Optional[list[dict[str, Any]]] = None, project_settings: Optional[dict[str, Any]] = None, project_entity: Optional[dict[str, Any]] = None, - anatomy: Optional["Anatomy"] = None, + anatomy: Optional[Anatomy] = None, open_workfile: bool = True, ) -> None: """Save workfile path with target folder and task context. @@ -815,9 +815,9 @@ class IWorkfileHost: workfile_entities: Optional[list[dict[str, Any]]] = None, project_settings: Optional[dict[str, Any]] = None, project_entity: Optional[dict[str, Any]] = None, - anatomy: Optional["Anatomy"] = None, + anatomy: Optional[Anatomy] = None, open_workfile: bool = True, - src_anatomy: Optional["Anatomy"] = None, + src_anatomy: Optional[Anatomy] = None, src_representation_path: Optional[str] = None, ) -> None: """Copy workfile representation. @@ -1025,7 +1025,7 @@ class IWorkfileHost: workfile_entities: Optional[list[dict[str, Any]]] = None, project_settings: Optional[dict[str, Any]] = None, project_entity: Optional[dict[str, Any]] = None, - anatomy: Optional["Anatomy"] = None, + anatomy: Optional[Anatomy] = None, ) -> Optional[dict[str, Any]]: """Create of update workfile entity to AYON based on provided data. From 0bf1e9a9349e352af4bf22982159a6d4fd2b4d51 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 11:42:21 +0200 Subject: [PATCH 101/312] add indentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ondřej Samohel <33513211+antirotor@users.noreply.github.com> --- client/ayon_core/host/interfaces/workfiles.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index ce0c680f16..73f5c2ba37 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -610,9 +610,9 @@ class IWorkfileHost: The default implementation looks for products with the 'workfile' product type. - Pre-fetched entities have mandatory fields to be fetched. - - Version: 'id', 'author', 'taskId' - - Representation: 'id', 'versionId', 'files' + Pre-fetched entities have mandatory fields to be fetched: + - Version: 'id', 'author', 'taskId' + - Representation: 'id', 'versionId', 'files' Args: project_name (str): Project name. From e3114d85b8372d8b03e31920a1fa1aea2d77f8f9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 11:46:05 +0200 Subject: [PATCH 102/312] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ondřej Samohel <33513211+antirotor@users.noreply.github.com> --- client/ayon_core/host/interfaces/workfiles.py | 6 +++--- client/ayon_core/pipeline/workfile/utils.py | 17 +++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 73f5c2ba37..096f39a9f3 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -74,7 +74,7 @@ class WorkfileInfo: Attributes: filepath (str): Path to the workfile. - rootless_path (str): Path to the workfile without root. And without + rootless_path (str): Path to the workfile without the root. And without backslashes on Windows. file_size (Optional[float]): Size of the workfile in bytes. file_created (Optional[float]): Timestamp when the workfile was @@ -256,7 +256,7 @@ class IWorkfileHost: """ @abstractmethod - def save_workfile(self, dst_path: Optional[str] = None): + def save_workfile(self, dst_path: Optional[str] = None) -> None: """Save the currently opened scene. Args: @@ -267,7 +267,7 @@ class IWorkfileHost: pass @abstractmethod - def open_workfile(self, filepath: str): + def open_workfile(self, filepath: str) -> None: """Open passed filepath in the host. Args: diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 1a862d7d92..570d1a1259 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -247,14 +247,15 @@ def save_workfile_info( description, ) - data = {} - for key, value in ( - ("host_name", host_name), - ("version", version), - ("comment", comment), - ): - if value is not None: - data[key] = value + data = { + key: value + for key, value in ( + ("host_name", host_name), + ("version", version), + ("comment", comment), + ) + if value is not None + } old_data = workfile_entity["data"] From 2e798f9ee2610dcfcbd8d7e5999f3ff969f4f2ce Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 10:25:22 +0200 Subject: [PATCH 103/312] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ondřej Samohel <33513211+antirotor@users.noreply.github.com> --- client/ayon_core/host/host.py | 4 ++-- client/ayon_core/host/interfaces/workfiles.py | 14 +++++++------- client/ayon_core/pipeline/workfile/utils.py | 1 + 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index c7c2b30323..191f6d4f4b 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -342,7 +342,7 @@ class HostBase(ABC): This method is called before the context is changed in the host. - Can be overriden to implement host specific logic. + Can be overridden to implement host specific logic. Args: context_change_data (ContextChangeData): Object with information @@ -356,7 +356,7 @@ class HostBase(ABC): This method is called after the context is changed in the host. - Can be overriden to implement host specific logic. + Can be overridden to implement host specific logic. Args: context_change_data (ContextChangeData): Object with information diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 096f39a9f3..36a35f297a 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -822,7 +822,7 @@ class IWorkfileHost: ) -> None: """Copy workfile representation. - Use representation as source for the workfile. + Use representation as a source for the workfile. Arguments 'rootless_path', 'workfile_entities', 'project_entity' and 'anatomy' can be filled to enhance efficiency if you already @@ -1162,7 +1162,7 @@ class IWorkfileHost: This method is called before the workfile is opened in the host. - Can be overriden to implement host specific logic. + Can be overridden to implement host specific logic. Args: open_workfile_data (WorkfileOpenData): Context and path of @@ -1178,7 +1178,7 @@ class IWorkfileHost: This method is called after the workfile is opened in the host. - Can be overriden to implement host specific logic. + Can be overridden to implement host specific logic. Args: open_workfile_data (WorkfileOpenData): Context and path of @@ -1194,7 +1194,7 @@ class IWorkfileHost: This method is called before the workfile is saved in the host. - Can be overriden to implement host specific logic. + Can be overridden to implement host specific logic. Args: save_workfile_data (WorkfileSaveData): Workfile path with target @@ -1210,7 +1210,7 @@ class IWorkfileHost: This method is called after the workfile is saved in the host. - Can be overriden to implement host specific logic. + Can be overridden to implement host specific logic. Args: save_workfile_data (WorkfileSaveData): Workfile path with target @@ -1232,7 +1232,7 @@ class IWorkfileHost: This method is called before the workfile is copied by host integration. - Can be overriden to implement host specific logic. + Can be overridden to implement host specific logic. Args: copy_workfile_data (WorkfileCopyData): Source and destination @@ -1249,7 +1249,7 @@ class IWorkfileHost: This method is called after the workfile is copied by host integration. - Can be overriden to implement host specific logic. + Can be overridden to implement host specific logic. Args: copy_workfile_data (WorkfileCopyData): Source and destination diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 570d1a1259..d3c30d932a 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -200,6 +200,7 @@ def save_workfile_info( workfile_entities: Optional[list[dict[str, Any]]] = None, ) -> dict[str, Any]: """Save workfile info entity for a workfile path. + Args: project_name (str): The name of the project. task_id (str): Task id under which is workfile created. From 34d9289f3c88861242fe79cbbec1239d55787ebb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 14:42:04 +0200 Subject: [PATCH 104/312] remove invalid returns typehints --- client/ayon_core/pipeline/workfile/utils.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index d3c30d932a..87aa06fb87 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -334,7 +334,7 @@ def save_current_workfile_to( project_entity: Optional[dict[str, Any]] = None, project_settings: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, -): +) -> None: """Save current workfile to new location or context. Args: @@ -355,9 +355,6 @@ def save_current_workfile_to( anatomy (Optional[Anatomy]): Project anatomy used for rootless path calculation. - Returns: - dict[str, Any]: Workfile info entity. - """ from ayon_core.pipeline.context_tools import registered_host @@ -392,7 +389,7 @@ def copy_and_open_workfile( project_entity: Optional[dict[str, Any]] = None, project_settings: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, -): +) -> None: """Copy workfile to new location and open it. Args: @@ -414,9 +411,6 @@ def copy_and_open_workfile( anatomy (Optional[Anatomy]): Project anatomy used for rootless path calculation. - Returns: - dict[str, Any]: Workfile info entity. - """ from ayon_core.pipeline.context_tools import registered_host @@ -456,7 +450,7 @@ def copy_and_open_workfile_representation( project_settings: Optional[dict[str, Any]] = None, anatomy: Optional["Anatomy"] = None, src_anatomy: Optional["Anatomy"] = None, -): +) -> None: """Copy workfile to new location and open it. Args: @@ -483,9 +477,6 @@ def copy_and_open_workfile_representation( anatomy (Optional[Anatomy]): Project anatomy used for rootless path calculation. - Returns: - dict[str, Any]: Workfile info entity. - """ from ayon_core.pipeline.context_tools import registered_host From 09858f61e1ceedbf90b400434f211b37d2233517 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 14:48:37 +0200 Subject: [PATCH 105/312] added typeddict for context data --- client/ayon_core/host/host.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 191f6d4f4b..c957f4ee22 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -15,6 +15,13 @@ from ayon_core.lib import emit_event if typing.TYPE_CHECKING: from ayon_core.pipeline import Anatomy + from typing import TypedDict + + class HostContextData(TypedDict): + project_name: str + folder_path: Optional[str] + task_name: Optional[str] + @dataclass class ContextChangeData: @@ -141,7 +148,7 @@ class HostBase(ABC): return os.environ.get("AYON_TASK_NAME") - def get_current_context(self) -> dict[str, Optional[str]]: + def get_current_context(self) -> "HostContextData": """Get current context information. This method should be used to get current context of host. Usage of @@ -168,7 +175,7 @@ class HostBase(ABC): reason: Optional[str] = None, project_entity: Optional[dict[str, Any]] = None, anatomy: Optional[Anatomy] = None, - ) -> dict[str, Optional[str]]: + ) -> "HostContextData": """Set current context information. This method should be used to set current context of host. Usage of @@ -281,7 +288,7 @@ class HostBase(ABC): project_name: str, folder_path: Optional[str], task_name: Optional[str], - ): + ) -> "HostContextData": """Emit context change event. Args: @@ -289,6 +296,9 @@ class HostBase(ABC): folder_path (Optional[str]): Path of the folder. task_name (Optional[str]): Name of the task. + Returns: + HostContextData: Data send to context change event. + """ data = { "project_name": project_name, From a237a2441abb0e121215b77e599ee5f7910214ed Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 17 Jun 2025 09:25:09 +0200 Subject: [PATCH 106/312] change core support for per project bundles --- package.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.py b/package.py index 908d34ffa8..9b4a15d24e 100644 --- a/package.py +++ b/package.py @@ -6,6 +6,8 @@ client_dir = "ayon_core" plugin_for = ["ayon_server"] +project_can_override_addon_version = True + ayon_server_version = ">=1.8.4,<2.0.0" ayon_launcher_version = ">=1.0.2" ayon_required_addons = {} From 76665860174d97715ef352b6111229f62d24e101 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 17 Jun 2025 13:45:30 +0200 Subject: [PATCH 107/312] use kwargs --- client/ayon_core/host/interfaces/workfiles.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 36a35f297a..6b11c2fce6 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -107,6 +107,7 @@ class WorkfileInfo: cls, filepath: str, rootless_path: str, + *, available: bool, workfile_entity: dict[str, Any], ): @@ -202,6 +203,7 @@ class PublishedWorkfileInfo: folder_id: str, task_id: Optional[str], repre_entity: dict[str, Any], + *, filepath: str, author: str, available: bool, @@ -696,12 +698,12 @@ class IWorkfileHost: folder_id, task_id, repre_entity, - workfile_path, - version_entity["author"], - is_available, - file_size, - file_created, - file_modified, + filepath=workfile_path, + author=version_entity["author"], + available=is_available, + file_size=file_size, + file_created=file_created, + file_modified=file_modified, ) items.append(workfile_item) From 0ae72c8e46a2a0aeedb171cdac1abed96f35bcea Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 17 Jun 2025 13:52:28 +0200 Subject: [PATCH 108/312] small enhancmement of docstring --- client/ayon_core/pipeline/workfile/path_resolving.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index a177caf7a4..4f100a219e 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -432,7 +432,8 @@ def get_last_workfile( full_path (bool): Return full path to the file or only filename. Returns: - str: Last or first workfile as filename of full path to filename. + str: Last or first workfile file name or path based on + 'full_path' value. """ # TODO (iLLiCiTiT): Remove the argument 'full_path' and return only full From 48bc7a0769fb6fb19b5696652387653b55279056 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 17 Jun 2025 13:53:28 +0200 Subject: [PATCH 109/312] small clarity enhancement --- client/ayon_core/pipeline/workfile/path_resolving.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 4f100a219e..4e4c70a27c 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -420,9 +420,9 @@ def get_last_workfile( extensions: set[str], full_path: bool = False, ) -> str: - """Return last workfile filename. + """Return last the workfile filename. - Returns file with version 1 if there is not workfile yet. + Returns first file name/path if there are not workfiles yet. Args: workdir (str): Path to dir where workfiles are stored. From 8b35eb38492d21bb00d969164c828f7b8f8c8a0c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 16:29:59 +0200 Subject: [PATCH 110/312] fix kwargs --- client/ayon_core/pipeline/workfile/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 87aa06fb87..3a04424ee4 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -364,9 +364,9 @@ def save_current_workfile_to( workfile_path, folder_entity, task_entity, - version, - comment, - description, + version=version, + comment=comment, + description=description, rootless_path=rootless_path, workfile_entities=workfile_entities, project_entity=project_entity, From 3e5e873ad07afba9ebe9955e552be1936d04ef75 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 16:49:16 +0200 Subject: [PATCH 111/312] added helper function to save current file with current context --- client/ayon_core/pipeline/workfile/utils.py | 65 ++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 3a04424ee4..f64f68850b 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -358,7 +358,6 @@ def save_current_workfile_to( """ from ayon_core.pipeline.context_tools import registered_host - # Trigger before save event host = registered_host() host.save_workfile_with_context( workfile_path, @@ -375,6 +374,70 @@ def save_current_workfile_to( ) +def save_workfile_with_current_context( + workfile_path: str, + *, + version: Optional[int] = None, + comment: Optional[str] = None, + description: Optional[str] = None, + rootless_path: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, + project_entity: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, +) -> None: + """Save current workfile to new location using current context. + + Helper function to save workfile using current context. Calls + 'save_current_workfile_to' at the end. + + Args: + workfile_path (str): Destination workfile path. + version (Optional[int]): Workfile version. + comment (optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + rootless_path (Optional[str]): Rootless path of the workfile. Is + calculated if not passed in. + workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched + workfile entities related to the task. + project_entity (Optional[dict[str, Any]]): Project entity used for + rootless path calculation. + project_settings (Optional[dict[str, Any]]): Project settings used for + rootless path calculation. + anatomy (Optional[Anatomy]): Project anatomy used for rootless + path calculation. + + """ + from ayon_core.pipeline.context_tools import registered_host + + host = registered_host() + context = host.get_current_context() + project_name = context["project_name"] + folder_path = context["folder_path"] + task_name = context["task_name"] + folder_entity = task_entity = None + if folder_path: + folder_entity = ayon_api.get_folder_by_path(project_name, folder_path) + if folder_entity and task_name: + task_entity = ayon_api.get_task_by_name( + project_name, folder_entity["id"], task_name + ) + + save_current_workfile_to( + workfile_path, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + rootless_path=rootless_path, + workfile_entities=workfile_entities, + project_entity=project_entity, + project_settings=project_settings, + anatomy=anatomy, + ) + + def copy_and_open_workfile( src_workfile_path: str, workfile_path: str, From ab363bf77eab85fee7593a719c3853c94d63c3d9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:10:23 +0200 Subject: [PATCH 112/312] remove typehint --- client/ayon_core/host/host.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index c957f4ee22..d562fcbe65 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -124,7 +124,7 @@ class HostBase(ABC): pass - def get_current_project_name(self) -> str: + def get_current_project_name(self): """ Returns: Union[str, None]: Current project name. From 1e7c9db988434bb2b27e7a73b1a4af102497b576 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 26 Jun 2025 09:25:20 +0200 Subject: [PATCH 113/312] define context change reason enum --- client/ayon_core/host/__init__.py | 3 +++ client/ayon_core/host/constants.py | 15 +++++++++++++++ client/ayon_core/host/host.py | 8 +++++--- client/ayon_core/host/interfaces/workfiles.py | 9 +++------ 4 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 client/ayon_core/host/constants.py diff --git a/client/ayon_core/host/__init__.py b/client/ayon_core/host/__init__.py index b252b03d76..ef5c324028 100644 --- a/client/ayon_core/host/__init__.py +++ b/client/ayon_core/host/__init__.py @@ -1,3 +1,4 @@ +from .constants import ContextChangeReason from .host import ( HostBase, ) @@ -15,6 +16,8 @@ from .dirmap import HostDirmap __all__ = ( + "ContextChangeReason", + "HostBase", "IWorkfileHost", diff --git a/client/ayon_core/host/constants.py b/client/ayon_core/host/constants.py new file mode 100644 index 0000000000..2564c5d54d --- /dev/null +++ b/client/ayon_core/host/constants.py @@ -0,0 +1,15 @@ +from enum import Enum + + +class StrEnum(str, Enum): + """A string-based Enum class that allows for string comparison.""" + + def __str__(self) -> str: + return self.value + + +class ContextChangeReason(StrEnum): + """Reasons for context change in the host.""" + undefined = "undefined" + workfile_open = "workfile.opened" + workfile_save = "workfile.saved" diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index d562fcbe65..554b694240 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -12,6 +12,8 @@ import ayon_api from ayon_core.lib import emit_event +from .constants import ContextChangeReason + if typing.TYPE_CHECKING: from ayon_core.pipeline import Anatomy @@ -28,7 +30,7 @@ class ContextChangeData: project_entity: dict[str, Any] folder_entity: dict[str, Any] task_entity: dict[str, Any] - reason: Optional[str] + reason: ContextChangeReason anatomy: Anatomy @@ -172,7 +174,7 @@ class HostBase(ABC): folder_entity: dict[str, Any], task_entity: dict[str, Any], *, - reason: Optional[str] = None, + reason: ContextChangeReason = ContextChangeReason.undefined, project_entity: Optional[dict[str, Any]] = None, anatomy: Optional[Anatomy] = None, ) -> "HostContextData": @@ -190,7 +192,7 @@ class HostBase(ABC): Args: folder_entity (Optional[dict[str, Any]]): Folder entity. task_entity (Optional[dict[str, Any]]): Task entity. - reason (Optional[str]): Reason for context change. + reason (ContextChangeReason): Reason for context change. project_entity (Optional[dict[str, Any]]): Project entity data. anatomy (Optional[Anatomy]): Anatomy instance for the project. diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 6b11c2fce6..559660a6e9 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -14,15 +14,12 @@ import ayon_api import arrow from ayon_core.lib import emit_event +from ayon_core.host.constants import ContextChangeReason if typing.TYPE_CHECKING: from ayon_core.pipeline import Anatomy -WORKFILE_OPEN_REASON = "workfile.opened" -WORKFILE_SAVE_REASON = "workfile.saved" - - def deprecated(reason): def decorator(func): message = f"Call to deprecated function {func.__name__} ({reason})." @@ -388,9 +385,9 @@ class IWorkfileHost: self.set_current_context( folder_entity, task_entity, - reason=WORKFILE_SAVE_REASON, project_entity=project_entity, anatomy=anatomy, + reason=ContextChangeReason.workfile_save, ) self.save_workfile(filepath) @@ -458,9 +455,9 @@ class IWorkfileHost: self.set_current_context( folder_entity, task_entity, - reason=WORKFILE_OPEN_REASON, project_entity=project_entity, anatomy=anatomy, + reason=ContextChangeReason.workfile_open, ) self.open_workfile(filepath) From 1d40243df5325cf639bbdc12b5a0f3d0adfd33ba Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 26 Jun 2025 09:25:26 +0200 Subject: [PATCH 114/312] fix typehint --- client/ayon_core/host/host.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 554b694240..7fc4b19bdd 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -319,12 +319,8 @@ class HostBase(ABC): than using environment variables. Args: - project_entity (dict[str, Any]): Project entity. - folder_entity (dict[str, Any]): Folder entity of new context. - task_entity (dict[str, Any]): Task entity of new context. - reason (Optional[str]): Reason why change happened. Currently - known reasons are that workfile is being opened or saved. - anatomy (Anatomy): Project anatomy. + context_change_data (ContextChangeData): Context change related + data. """ project_name = self.get_current_project_name() From 646f3bedd4a786caf6e2fda7d812dce270966fb1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:18:38 +0200 Subject: [PATCH 115/312] wrap optional arguments into wrappers --- client/ayon_core/host/interfaces/__init__.py | 48 +- client/ayon_core/host/interfaces/workfiles.py | 1060 ++++++++++++----- client/ayon_core/pipeline/workfile/utils.py | 130 +- .../tools/workfiles/models/workfiles.py | 106 +- 4 files changed, 877 insertions(+), 467 deletions(-) diff --git a/client/ayon_core/host/interfaces/__init__.py b/client/ayon_core/host/interfaces/__init__.py index 379d8555fb..8f11ad4e2f 100644 --- a/client/ayon_core/host/interfaces/__init__.py +++ b/client/ayon_core/host/interfaces/__init__.py @@ -1,5 +1,30 @@ from .exceptions import MissingMethodsError -from .workfiles import IWorkfileHost, WorkfileInfo, PublishedWorkfileInfo +from .workfiles import ( + IWorkfileHost, + WorkfileInfo, + PublishedWorkfileInfo, + + OpenWorkfileOptionalData, + ListWorkfilesOptionalData, + ListPublishedWorkfilesOptionalData, + SaveWorkfileOptionalData, + CopyWorkfileOptionalData, + CopyPublishedWorkfileOptionalData, + + get_open_workfile_context, + get_list_workfiles_context, + get_list_published_workfiles_context, + get_save_workfile_context, + get_copy_workfile_context, + get_copy_repre_workfile_context, + + OpenWorkfileContext, + ListWorkfilesContext, + ListPublishedWorkfilesContext, + SaveWorkfileContext, + CopyWorkfileContext, + CopyPublishedWorkfileContext, +) from .interfaces import ( IPublishHost, INewPublisher, @@ -14,6 +39,27 @@ __all__ = ( "WorkfileInfo", "PublishedWorkfileInfo", + "OpenWorkfileOptionalData", + "ListWorkfilesOptionalData", + "ListPublishedWorkfilesOptionalData", + "SaveWorkfileOptionalData", + "CopyWorkfileOptionalData", + "CopyPublishedWorkfileOptionalData", + + "get_open_workfile_context", + "get_list_workfiles_context", + "get_list_published_workfiles_context", + "get_save_workfile_context", + "get_copy_workfile_context", + "get_copy_repre_workfile_context", + + "OpenWorkfileContext", + "ListWorkfilesContext", + "ListPublishedWorkfilesContext", + "SaveWorkfileContext", + "CopyWorkfileContext", + "CopyPublishedWorkfileContext", + "IPublishHost", "INewPublisher", "ILoadHost", diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 559660a6e9..8bc0b1cf85 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -14,6 +14,7 @@ import ayon_api import arrow from ayon_core.lib import emit_event +from ayon_core.settings import get_project_settings from ayon_core.host.constants import ContextChangeReason if typing.TYPE_CHECKING: @@ -40,29 +41,560 @@ def deprecated(reason): return decorator +# Wrappers for optional arguments that might change in future +class _WorkfileOptionalData: + """Base class for optional data used in workfile operations.""" + def __init__( + self, + *, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + project_settings: Optional[dict[str, Any]] = None, + **kwargs + ): + if kwargs: + cls_name = self.__class__.__name__ + keys = ", ".join(['"{}"'.format(k) for k in kwargs.keys()]) + warnings.warn( + f"Unknown keywords passed to {cls_name}: {keys}", + ) + + self.project_entity = project_entity + self.anatomy = anatomy + self.project_settings = project_settings + + def get_project_data( + self, project_name: str + ) -> tuple[dict[str, Any], "Anatomy", dict[str, Any]]: + from ayon_core.pipeline import Anatomy + + project_entity = self.project_entity + anatomy = self.anatomy + project_settings = self.project_settings + + if project_entity is None: + project_entity = ayon_api.get_project(project_name) + + if anatomy is None: + anatomy = Anatomy( + project_name, + project_entity=project_entity + ) + + if project_settings is None: + project_settings = get_project_settings(project_name) + return ( + project_entity, + anatomy, + project_settings, + ) + + +class OpenWorkfileOptionalData(_WorkfileOptionalData): + """Optional data for opening workfile.""" + data_version = 1 + + +class ListWorkfilesOptionalData(_WorkfileOptionalData): + """Optional data to list workfiles.""" + data_version = 1 + + def __init__( + self, + *, + template_key: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, + **kwargs + ): + super().__init__(**kwargs) + self.template_key = template_key + self.workfile_entities = workfile_entities + + def get_template_key( + self, + project_name: str, + task_type: str, + host_name: str, + project_settings: dict[str, Any], + ) -> str: + from ayon_core.pipeline.workfile import get_workfile_template_key + + if self.template_key is not None: + return self.template_key + + return get_workfile_template_key( + project_name=project_name, + task_type=task_type, + host_name=host_name, + project_settings=project_settings, + ) + + def get_workfile_entities( + self, project_name: str, task_id: str + ) -> list[dict[str, Any]]: + """Fill workfile entities if not provided.""" + if self.workfile_entities is not None: + return self.workfile_entities + return list(ayon_api.get_workfiles_info( + project_name, task_ids=[task_id] + )) + + +class ListPublishedWorkfilesOptionalData(_WorkfileOptionalData): + """Optional data to list published workfiles.""" + data_version = 1 + + def __init__( + self, + *, + product_entities: Optional[list[dict[str, Any]]] = None, + version_entities: Optional[list[dict[str, Any]]] = None, + repre_entities: Optional[list[dict[str, Any]]] = None, + **kwargs + ): + super().__init__(**kwargs) + + self.product_entities = product_entities + self.version_entities = version_entities + self.repre_entities = repre_entities + + def get_entities( + self, + project_name: str, + folder_id: str, + ) -> tuple[ + list[dict[str, Any]], + list[dict[str, Any]], + list[dict[str, Any]] + ]: + product_entities = self.product_entities + if product_entities is None: + product_entities = list(ayon_api.get_products( + project_name, + folder_ids={folder_id}, + product_types={"workfile"}, + fields={"id", "name"}, + )) + + version_entities = self.version_entities + if version_entities is None: + product_ids = {p["id"] for p in product_entities} + version_entities = list(ayon_api.get_versions( + project_name, + product_ids=product_ids, + task_ids=task_filters, + fields={"id", "author", "taskId"}, + )) + + repre_entities = self.repre_entities + if repre_entities is None: + version_ids = {v["id"] for v in version_entities} + repre_entities = list(ayon_api.get_representations( + project_name, + version_ids=version_ids, + )) + return product_entities, version_entities, repre_entities + + +class SaveWorkfileOptionalData(_WorkfileOptionalData): + """Optional data to save workfile.""" + data_version = 1 + + def __init__( + self, + *, + rootless_path: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, + **kwargs + ): + super().__init__(**kwargs) + + self.rootless_path = rootless_path + self.workfile_entities = workfile_entities + + def get_workfile_entities(self, project_name: str, task_id: str): + """Fill workfile entities if not provided.""" + if self.workfile_entities is not None: + return self.workfile_entities + return list(ayon_api.get_workfiles_info( + project_name, task_ids=[task_id] + )) + + def get_rootless_path( + self, + workfile_path: str, + project_name: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + host_name: str, + project_entity: dict[str, Any], + project_settings: dict[str, Any], + anatomy: "Anatomy", + ): + from ayon_core.pipeline.workfile.utils import ( + find_workfile_rootless_path + ) + + if self.rootless_path is not None: + return self.rootless_path + + return find_workfile_rootless_path( + workfile_path, + project_name, + folder_entity, + task_entity, + host_name, + project_entity=project_entity, + project_settings=project_settings, + anatomy=anatomy, + ) + + +class CopyWorkfileOptionalData(SaveWorkfileOptionalData): + """Optional data to copy workfile.""" + data_version = 1 + + +class CopyPublishedWorkfileOptionalData(SaveWorkfileOptionalData): + """Optional data to copy published workfile.""" + data_version = 1 + + def __init__( + self, + src_anatomy: Optional["Anatomy"] = None, + src_representation_path: Optional[str] = None, + **kwargs + ): + super().__init__(**kwargs) + self.src_anatomy = src_anatomy + self.src_representation_path = src_representation_path + + def get_source_data( + self, + current_anatomy: Optional["Anatomy"], + project_name: str, + representation_entity: dict[str, Any], + ) -> tuple["Anatomy", str]: + from ayon_core.pipeline import Anatomy + from ayon_core.pipeline.load import ( + get_representation_path_with_anatomy + ) + + src_anatomy = self.src_anatomy + + if ( + src_anatomy is None + and current_anatomy is not None + and current_anatomy.project_name == project_name + ): + src_anatomy = current_anatomy + else: + src_anatomy = Anatomy(project_name) + + repre_path = self.src_representation_path + if repre_path is None: + repre_path = get_representation_path_with_anatomy( + representation_entity, + src_anatomy, + ) + return src_anatomy, repre_path + + +# Dataclasses used during workfile operations @dataclass -class WorkfileOpenData: +class OpenWorkfileContext: + data_version: int + project_name: str filepath: str + project_entity: dict[str, Any] folder_entity: dict[str, Any] task_entity: dict[str, Any] + anatomy: "Anatomy" + project_settings: dict[str, Any] @dataclass -class WorkfileSaveData(WorkfileOpenData): - filepath: str +class ListWorkfilesContext: + data_version: int + project_name: str + project_entity: dict[str, Any] folder_entity: dict[str, Any] task_entity: dict[str, Any] + anatomy: "Anatomy" + project_settings: dict[str, Any] + template_key: str + workfile_entities: list[dict[str, Any]] @dataclass -class WorkfileCopyData: - source_path: str - destination_path: str +class ListPublishedWorkfilesContext: + data_version: int + project_name: str + project_entity: dict[str, Any] + folder_id: str + anatomy: "Anatomy" + project_settings: dict[str, Any] + product_entities: list[dict[str, Any]] + version_entities: list[dict[str, Any]] + repre_entities: list[dict[str, Any]] + + +@dataclass +class SaveWorkfileContext: + data_version: int + project_name: str + project_entity: dict[str, Any] folder_entity: dict[str, Any] task_entity: dict[str, Any] + anatomy: "Anatomy" + project_settings: dict[str, Any] + dst_path: str + rootless_path: str + workfile_entities: list[dict[str, Any]] + + +@dataclass +class CopyWorkfileContext(SaveWorkfileContext): + src_path: str + version: Optional[int] + comment: Optional[str] + description: Optional[str] open_workfile: bool +@dataclass +class CopyPublishedWorkfileContext(CopyWorkfileContext): + src_project_name: str + src_representation_entity: dict[str, Any] + src_anatomy: "Anatomy" + + +def get_open_workfile_context( + project_name: str, + filepath: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + prepared_data: Optional[OpenWorkfileOptionalData], +) -> OpenWorkfileContext: + if prepared_data is None: + prepared_data = OpenWorkfileOptionalData() + ( + project_entity, anatomy, project_settings + ) = prepared_data.get_project_data(project_name) + return OpenWorkfileContext( + data_version=prepared_data.data_version, + filepath=filepath, + folder_entity=folder_entity, + task_entity=task_entity, + project_name=project_name, + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + ) + + +def get_list_workfiles_context( + project_name: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + host_name: str, + prepared_data: Optional[ListWorkfilesOptionalData], +) -> ListWorkfilesContext: + if prepared_data is None: + prepared_data = ListWorkfilesOptionalData() + ( + project_entity, anatomy, project_settings + ) = prepared_data.get_project_data(project_name) + + template_key = prepared_data.get_template_key( + project_name, + task_entity["taskType"], + host_name, + project_settings, + ) + workfile_entities = prepared_data.get_workfile_entities( + project_name, task_entity["id"] + ) + return ListWorkfilesContext( + data_version=prepared_data.data_version, + project_entity=project_entity, + folder_entity=folder_entity, + task_entity=task_entity, + project_name=project_name, + anatomy=anatomy, + project_settings=project_settings, + template_key=template_key, + workfile_entities=workfile_entities, + ) + + +def get_list_published_workfiles_context( + project_name: str, + folder_id: str, + prepared_data: Optional[ListPublishedWorkfilesOptionalData], +) -> ListPublishedWorkfilesContext: + if prepared_data is None: + prepared_data = ListPublishedWorkfilesOptionalData() + ( + project_entity, anatomy, project_settings + ) = prepared_data.get_project_data(project_name) + ( + product_entities, + version_entities, + repre_entities, + ) = prepared_data.get_entities(project_name, folder_id) + + + return ListPublishedWorkfilesContext( + data_version=prepared_data.data_version, + project_name=project_name, + project_entity=project_entity, + folder_id=folder_id, + anatomy=anatomy, + project_settings=project_settings, + product_entities=product_entities, + version_entities=version_entities, + repre_entities=repre_entities, + ) + + +def get_save_workfile_context( + project_name: str, + filepath: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + host_name: str, + prepared_data: Optional[SaveWorkfileOptionalData], +) -> SaveWorkfileContext: + if prepared_data is None: + prepared_data = SaveWorkfileOptionalData() + + ( + project_entity, anatomy, project_settings + ) = prepared_data.get_project_data(project_name) + + rootless_path = prepared_data.get_rootless_path( + filepath, + project_name, + folder_entity, + task_entity, + host_name, + project_entity, + project_settings, + anatomy, + ) + workfile_entities = prepared_data.get_workfile_entities( + project_name, task_entity["id"] + ) + return SaveWorkfileContext( + data_version=prepared_data.data_version, + project_name=project_name, + project_entity=project_entity, + folder_entity=folder_entity, + task_entity=task_entity, + anatomy=anatomy, + project_settings=project_settings, + dst_path=filepath, + rootless_path=rootless_path, + workfile_entities=workfile_entities, + ) + + +def get_copy_workfile_context( + project_name: str, + src_path: str, + dst_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + version: Optional[int], + comment: Optional[str], + description: Optional[str], + open_workfile: bool, + host_name: str, + prepared_data: Optional[CopyWorkfileOptionalData], +) -> CopyWorkfileContext: + if prepared_data is None: + prepared_data = CopyWorkfileOptionalData() + context: SaveWorkfileContext = get_save_workfile_context( + project_name, + dst_path, + folder_entity, + task_entity, + host_name, + prepared_data, + ) + return CopyWorkfileContext( + data_version=prepared_data.data_version, + src_path=src_path, + project_name=context.project_name, + project_entity=context.project_entity, + folder_entity=context.folder_entity, + task_entity=context.task_entity, + version=version, + comment=comment, + description=description, + open_workfile=open_workfile, + anatomy=context.anatomy, + project_settings=context.project_settings, + dst_path=context.dst_path, + rootless_path=context.rootless_path, + workfile_entities=context.workfile_entities, + ) + + +def get_copy_repre_workfile_context( + project_name: str, + src_project_name: str, + src_representation_entity: dict[str, Any], + dst_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + version: Optional[int], + comment: Optional[str], + description: Optional[str], + open_workfile: bool, + host_name: str, + prepared_data: Optional[CopyPublishedWorkfileOptionalData], +) -> CopyPublishedWorkfileContext: + if prepared_data is None: + prepared_data = CopyPublishedWorkfileOptionalData() + + context: SaveWorkfileContext = get_save_workfile_context( + project_name, + dst_path, + folder_entity, + task_entity, + host_name, + prepared_data, + ) + src_anatomy, repre_path = prepared_data.get_source_data( + context.anatomy, + src_project_name, + src_representation_entity, + ) + return CopyPublishedWorkfileContext( + data_version=prepared_data.data_version, + src_project_name=src_project_name, + src_representation_entity=src_representation_entity, + src_path=repre_path, + dst_path=context.dst_path, + project_name=context.project_name, + project_entity=context.project_entity, + folder_entity=context.folder_entity, + task_entity=context.task_entity, + version=version, + comment=comment, + description=description, + open_workfile=open_workfile, + anatomy=context.anatomy, + project_settings=context.project_settings, + rootless_path=context.rootless_path, + workfile_entities=context.workfile_entities, + src_anatomy=src_anatomy, + ) + + @dataclass class WorkfileInfo: """Information about workfile. @@ -323,11 +855,7 @@ class IWorkfileHost: version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, - rootless_path: Optional[str] = None, - workfile_entities: Optional[list[dict[str, Any]]] = None, - project_settings: Optional[dict[str, Any]] = None, - project_entity: Optional[dict[str, Any]] = None, - anatomy: Optional[Anatomy] = None, + prepared_data: Optional[SaveWorkfileOptionalData] = None, ) -> None: """Save the current workfile with context. @@ -350,22 +878,21 @@ class IWorkfileHost: comment (Optional[str]): Comment for the workfile. Usually used in the filename template. description (Optional[str]): Artist note for the workfile entity. - rootless_path (Optional[str]): Prepared rootless path of - the workfile. - workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched - workfile entities for the task. - project_settings (Optional[dict[str, Any]]): Project settings. - project_entity (Optional[dict[str, Any]]): Project entity. - anatomy (Optional[Anatomy]): Project anatomy. + prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data + for speed enhancements. """ - save_workfile_data = WorkfileSaveData( - folder_entity=folder_entity, - task_entity=task_entity, - filepath=filepath, - ) - self._before_workfile_save(save_workfile_data) project_name = self.get_current_project_name() + save_workfile_context = get_save_workfile_context( + project_name, + filepath, + folder_entity, + task_entity, + host_name=self.name, + prepared_data=prepared_data, + ) + + self._before_workfile_save(save_workfile_context) event_data = self._get_workfile_event_data( project_name, folder_entity, @@ -379,33 +906,23 @@ class IWorkfileHost: # Set 'AYON_WORKDIR' environment variable os.environ["AYON_WORKDIR"] = workdir - if project_entity is None: - project_entity = ayon_api.get_project(project_name) - self.set_current_context( folder_entity, task_entity, - project_entity=project_entity, - anatomy=anatomy, reason=ContextChangeReason.workfile_save, + project_entity=save_workfile_context.project_entity, + anatomy=save_workfile_context.anatomy, ) self.save_workfile(filepath) self._save_workfile_entity( - filepath, - folder_entity, - task_entity, + save_workfile_context, version, comment, description, - rootless_path, - workfile_entities, - project_settings, - project_entity, - anatomy, ) - self._after_workfile_save(save_workfile_data) + self._after_workfile_save(save_workfile_context) self._emit_workfile_save_event(event_data) def open_workfile_with_context( @@ -414,9 +931,7 @@ class IWorkfileHost: folder_entity: dict[str, Any], task_entity: dict[str, Any], *, - project_entity: Optional[dict[str, Any]] = None, - project_settings: Optional[dict[str, Any]] = None, - anatomy: Optional[Anatomy] = None, + prepared_data: Optional[OpenWorkfileOptionalData] = None, ) -> None: """Open passed filepath in the host with context. @@ -429,14 +944,21 @@ class IWorkfileHost: filepath (str): Path to workfile. folder_entity (dict[str, Any]): Folder id. task_entity (dict[str, Any]): Task id. - project_entity (Optional[dict[str, Any]]): Project entity. - project_settings (Optional[dict[str, Any]]): Project settings. - anatomy (Optional[Anatomy]): Project anatomy. + prepared_data (Optional[WorkfileOptionalData]): Prepared data + for speed enhancements. """ context = self.get_current_context() project_name = context["project_name"] + open_workfile_context = get_open_workfile_context( + project_name, + filepath, + folder_entity, + task_entity, + prepared_data=prepared_data, + ) + workdir = os.path.dirname(filepath) # Set 'AYON_WORKDIR' environment variable os.environ["AYON_WORKDIR"] = workdir @@ -444,25 +966,20 @@ class IWorkfileHost: event_data = self._get_workfile_event_data( project_name, folder_entity, task_entity, filepath ) - open_workfile_data = WorkfileOpenData( - folder_entity=folder_entity, - task_entity=task_entity, - filepath=filepath, - ) - self._before_workfile_open(open_workfile_data) + self._before_workfile_open(open_workfile_context) self._emit_workfile_open_event(event_data, after_open=False) self.set_current_context( folder_entity, task_entity, - project_entity=project_entity, - anatomy=anatomy, reason=ContextChangeReason.workfile_open, + project_entity=open_workfile_context.project_entity, + anatomy=open_workfile_context.anatomy, ) self.open_workfile(filepath) - self._after_workfile_open(open_workfile_data) + self._after_workfile_open(open_workfile_context) self._emit_workfile_open_event(event_data) def list_workfiles( @@ -471,11 +988,7 @@ class IWorkfileHost: folder_entity: dict[str, Any], task_entity: dict[str, Any], *, - project_entity: Optional[dict[str, Any]] = None, - workfile_entities: Optional[list[dict[str, Any]]] = None, - template_key: Optional[str] = None, - project_settings: Optional[dict[str, Any]] = None, - anatomy: Optional[Anatomy] = None, + prepared_data: Optional[ListWorkfilesOptionalData] = None, ) -> list[WorkfileInfo]: """List workfiles in the given task. @@ -492,18 +1005,13 @@ class IWorkfileHost: project_name (str): Project name. folder_entity (dict[str, Any]): Folder entity. task_entity (dict[str, Any]): Task entity. - project_entity (Optional[dict[str, Any]]): Project entity. - workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched - workfile entities for the task. - template_key (Optional[str]): Template key. - project_settings (Optional[dict[str, Any]]): Project settings. - anatomy (Anatomy): Project anatomy. + prepared_data (Optional[ListWorkfilesOptionalData]): Prepared + data for speed enhancements. Returns: list[WorkfileInfo]: List of workfiles. """ - from ayon_core.pipeline import Anatomy from ayon_core.pipeline.template_data import get_template_data from ayon_core.pipeline.workfile import get_workdir_with_workdir_data @@ -511,25 +1019,21 @@ class IWorkfileHost: if not extensions: return [] - if project_entity is None: - project_entity = ayon_api.get_project(project_name) - - if workfile_entities is None: - task_id = task_entity["id"] - workfile_entities = list(ayon_api.get_workfiles_info( - project_name, task_ids=[task_id] - )) - - if anatomy is None: - anatomy = Anatomy(project_name, project_entity=project_entity) + list_workfiles_context = get_list_workfiles_context( + project_name, + folder_entity, + task_entity, + host_name=self.name, + prepared_data=prepared_data, + ) workfile_entities_by_path = { workfile_entity["path"]: workfile_entity - for workfile_entity in workfile_entities + for workfile_entity in list_workfiles_context.workfile_entities } workdir_data = get_template_data( - project_entity, + list_workfiles_context.project_entity, folder_entity, task_entity, host_name=self.name, @@ -537,9 +1041,9 @@ class IWorkfileHost: workdir = get_workdir_with_workdir_data( workdir_data, project_name, - anatomy=anatomy, - template_key=template_key, - project_settings=project_settings, + anatomy=list_workfiles_context.anatomy, + template_key=list_workfiles_context.template_key, + project_settings=list_workfiles_context.project_settings, ) if platform.system().lower() == "windows": @@ -585,7 +1089,7 @@ class IWorkfileHost: ext = os.path.splitext(rootless_path)[1].lower() if ext not in extensions: continue - filepath = anatomy.fill_root(rootless_path) + filepath = prepared_data.anatomy.fill_root(rootless_path) items.append(WorkfileInfo.new( filepath, rootless_path, @@ -600,9 +1104,7 @@ class IWorkfileHost: project_name: str, folder_id: str, *, - anatomy: Optional[Anatomy] = None, - version_entities: Optional[list[dict[str, Any]]] = None, - repre_entities: Optional[list[dict[str, Any]]] = None, + prepared_data: Optional[ListPublishedWorkfilesOptionalData] = None, ) -> list[PublishedWorkfileInfo]: """List published workfiles for the given folder. @@ -616,45 +1118,32 @@ class IWorkfileHost: Args: project_name (str): Project name. folder_id (str): Folder id. - anatomy (Anatomy): Project anatomy. - version_entities (Optional[list[dict[str, Any]]]): Pre-fetched - version entities. - repre_entities (Optional[list[dict[str, Any]]]): Pre-fetched - representation entities. + prepared_data (Optional[ListPublishedWorkfilesOptionalData]): + Prepared data for speed enhancements. Returns: list[PublishedWorkfileInfo]: Published workfile information for the given context. """ - from ayon_core.pipeline import Anatomy - - # Get all representations of the folder - ( - version_entities, - repre_entities - ) = self._fetch_published_workfile_entities( + list_workfiles_context = get_list_published_workfiles_context( project_name, folder_id, - version_entities, - repre_entities, + prepared_data=prepared_data, ) - if not repre_entities: + if not list_workfiles_context.repre_entities: return [] - if anatomy is None: - anatomy = Anatomy(project_name) - versions_by_id = { version_entity["id"]: version_entity - for version_entity in version_entities + for version_entity in prepared_data.version_entities } extensions = { ext.lstrip(".") for ext in self.get_workfile_extensions() } items = [] - for repre_entity in repre_entities: + for repre_entity in prepared_data.repre_entities: version_id = repre_entity["versionId"] version_entity = versions_by_id[version_id] task_id = version_entity["taskId"] @@ -675,7 +1164,9 @@ class IWorkfileHost: continue try: - workfile_path = workfile_path.format(root=anatomy.roots) + workfile_path = workfile_path.format( + root=prepared_data.anatomy.roots + ) except Exception: self.log.warning( "Failed to format workfile path.", exc_info=True @@ -716,12 +1207,8 @@ class IWorkfileHost: version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, - rootless_path: Optional[str] = None, - workfile_entities: Optional[list[dict[str, Any]]] = None, - project_settings: Optional[dict[str, Any]] = None, - project_entity: Optional[dict[str, Any]] = None, - anatomy: Optional[Anatomy] = None, open_workfile: bool = True, + prepared_data: Optional[CopyWorkfileOptionalData] = None, ) -> None: """Save workfile path with target folder and task context. @@ -744,59 +1231,31 @@ class IWorkfileHost: for workfile entity. Recommended to fill. comment (Optional[str]): Comment for the workfile. description (Optional[str]): Artist note for the workfile entity. - rootless_path (Optional[str]): Rootless path of the workfile. - workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched - workfile entities for the task. - project_settings (Optional[dict[str, Any]]): Project settings. - project_entity (Optional[dict[str, Any]]): Project entity. - anatomy (Optional[Anatomy]): Project anatomy. open_workfile (bool): Open workfile when copied. + prepared_data (Optional[CopyWorkfileOptionalData]): Prepared data + for speed enhancements. """ - copy_workfile_data = WorkfileCopyData( - source_path=src_path, - destination_path=dst_path, - folder_entity=folder_entity, - task_entity=task_entity, + project_name = self.get_current_project_name() + copy_workfile_context: CopyWorkfileContext = get_copy_workfile_context( + project_name, + src_path, + dst_path, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, open_workfile=open_workfile, + host_name=self.name, + prepared_data=prepared_data, ) - self._before_workfile_copy(copy_workfile_data) - event_data = self._get_workfile_event_data( - self.get_current_project_name(), - folder_entity, - task_entity, - dst_path, - ) - self._emit_workfile_save_event(event_data, after_save=False) - - dst_dir = os.path.dirname(dst_path) - if not os.path.exists(dst_dir): - os.makedirs(dst_dir, exist_ok=True) - shutil.copy(src_path, dst_path) - - self._save_workfile_entity( - dst_path, - folder_entity, - task_entity, - version, - comment, - description, - rootless_path, - workfile_entities, - project_settings, - project_entity, - anatomy, - ) - self._after_workfile_copy(copy_workfile_data) - self._emit_workfile_save_event(event_data) - - if not open_workfile: - return - - self.open_workfile_with_context( - dst_path, - folder_entity, - task_entity, + self._copy_workfile( + copy_workfile_context, + version=version, + comment=comment, + description=description, + open_workfile=open_workfile, ) def copy_workfile_representation( @@ -810,14 +1269,8 @@ class IWorkfileHost: version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, - rootless_path: Optional[str] = None, - workfile_entities: Optional[list[dict[str, Any]]] = None, - project_settings: Optional[dict[str, Any]] = None, - project_entity: Optional[dict[str, Any]] = None, - anatomy: Optional[Anatomy] = None, open_workfile: bool = True, - src_anatomy: Optional[Anatomy] = None, - src_representation_path: Optional[str] = None, + prepared_data: Optional[CopyPublishedWorkfileOptionalData] = None, ) -> None: """Copy workfile representation. @@ -841,58 +1294,33 @@ class IWorkfileHost: for workfile entity. Recommended to fill. comment (Optional[str]): Comment for the workfile. description (Optional[str]): Artist note for the workfile entity. - rootless_path (Optional[str]): Rootless path of the workfile. - workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched - workfile entities for the task. - project_settings (Optional[dict[str, Any]]): Project settings. - project_entity (Optional[dict[str, Any]]): Project entity. - anatomy (Optional[Anatomy]): Project anatomy. open_workfile (bool): Open workfile when copied. - src_anatomy (Optional[Anatomy]): Anatomy of the source - src_representation_path (Optional[str]): Representation path. + prepared_data (Optional[CopyPublishedWorkfileOptionalData]): + Prepared data for speed enhancements. """ - from ayon_core.pipeline import Anatomy - from ayon_core.pipeline.load import ( - get_representation_path_with_anatomy - ) - project_name = self.get_current_project_name() - # Re-use Anatomy or project entity if source context is same - if project_name == src_project_name: - if src_anatomy is None and anatomy is not None: - src_anatomy = anatomy - elif anatomy is None and src_anatomy is not None: - anatomy = src_anatomy - elif not project_entity: - project_entity = ayon_api.get_project(project_name) - - if anatomy is None: - anatomy = src_anatomy = Anatomy( - project_name, project_entity=project_entity - ) - - if src_representation_path is None: - if src_anatomy is None: - src_anatomy = Anatomy(src_project_name) - src_representation_path = get_representation_path_with_anatomy( + copy_repre_workfile_context: CopyPublishedWorkfileContext = ( + get_copy_repre_workfile_context( + project_name, + src_project_name, src_representation_entity, - src_anatomy, + dst_path, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + open_workfile=open_workfile, + host_name=self.name, + prepared_data=prepared_data, ) - - self.copy_workfile( - src_representation_path, - dst_path, - folder_entity, - task_entity, + ) + self._copy_workfile( + copy_repre_workfile_context, version=version, comment=comment, description=description, - rootless_path=rootless_path, - workfile_entities=workfile_entities, - project_settings=project_settings, - project_entity=project_entity, - anatomy=anatomy, open_workfile=open_workfile, ) @@ -947,109 +1375,94 @@ class IWorkfileHost: """ return self.workfile_has_unsaved_changes() - def _fetch_published_workfile_entities( + def _copy_workfile( self, - project_name: str, - folder_id: str, - version_entities: Optional[list[dict[str, Any]]], - repre_entities: Optional[list[dict[str, Any]]], - ) -> tuple[ - list[dict[str, Any]], - list[dict[str, Any]] - ]: - """Fetch integrated workfile entities for the given folder. - - Args: - project_name (str): Project name. - folder_id (str): Folder id. - version_entities (Optional[list[dict[str, Any]]]): Pre-fetched - version entities. - repre_entities (Optional[list[dict[str, Any]]]): Pre-fetched - representation entities. - - Returns: - tuple[list[dict[str, Any]], list[dict[str, Any]]]: - Tuple of version entities and representation entities. - - """ - if repre_entities is not None and version_entities is None: - # Get versions of representations - version_ids = {r["versionId"] for r in repre_entities} - version_entities = list(ayon_api.get_versions( - project_name, - version_ids=version_ids, - fields={"id", "author", "taskId"}, - )) - - if version_entities is None: - # Get product entities of folder - product_entities = ayon_api.get_products( - project_name, - folder_ids={folder_id}, - product_types={"workfile"}, - fields={"id", "name"} - ) - - version_entities = [] - product_ids = {product["id"] for product in product_entities} - if product_ids: - # Get version docs of products with their families - version_entities = list(ayon_api.get_versions( - project_name, - product_ids=product_ids, - fields={"id", "author", "taskId"}, - )) - - # Fetch representations of filtered versions and add filter for - # extension - if repre_entities is None: - repre_entities = [] - if version_entities: - repre_entities = list(ayon_api.get_representations( - project_name, - version_ids={v["id"] for v in version_entities} - )) - - return version_entities, repre_entities - - def _save_workfile_entity( - self, - workfile_path: str, - folder_entity: dict[str, Any], - task_entity: dict[str, Any], + copy_workfile_context: CopyWorkfileContext, + *, + version: Optional[int], + comment: Optional[str], + description: Optional[str], + open_workfile: bool, + ) -> None: + """Save workfile path with target folder and task context. + + It is expected that workfile is saved to the current project, but + can be copied from the other project. + + Arguments 'rootless_path', 'workfile_entities', 'project_entity' + and 'anatomy' can be filled to enhance efficiency if you already + have access to the values. + + Argument 'project_settings' is used to calculate 'rootless_path' + if it is not provided. + + Args: + copy_workfile_context (CopyWorkfileContext): Prepared data + for speed enhancements. + version (Optional[int]): Version of the workfile. Information + for workfile entity. Recommended to fill. + comment (Optional[str]): Comment for the workfile. + description (Optional[str]): Artist note for the workfile entity. + open_workfile (bool): Open workfile when copied. + + """ + self._before_workfile_copy(copy_workfile_context) + event_data = self._get_workfile_event_data( + copy_workfile_context.project_name, + copy_workfile_context.folder_entity, + copy_workfile_context.task_entity, + copy_workfile_context.dst_path, + ) + self._emit_workfile_save_event(event_data, after_save=False) + + dst_dir = os.path.dirname(copy_workfile_context.dst_path) + if not os.path.exists(dst_dir): + os.makedirs(dst_dir, exist_ok=True) + shutil.copy( + copy_workfile_context.src_path, + copy_workfile_context.dst_path + ) + + self._save_workfile_entity( + copy_workfile_context, + version, + comment, + description, + ) + self._after_workfile_copy(copy_workfile_context) + self._emit_workfile_save_event(event_data) + + if not open_workfile: + return + + self.open_workfile_with_context( + copy_workfile_context.dst_path, + copy_workfile_context.folder_entity, + copy_workfile_context.task_entity, + ) + + def _save_workfile_entity( + self, + save_workfile_context: SaveWorkfileContext, version: Optional[int], comment: Optional[str], description: Optional[str], - rootless_path: Optional[str], - workfile_entities: Optional[list[dict[str, Any]]] = None, - project_settings: Optional[dict[str, Any]] = None, - project_entity: Optional[dict[str, Any]] = None, - anatomy: Optional[Anatomy] = None, ) -> Optional[dict[str, Any]]: """Create of update workfile entity to AYON based on provided data. Args: - workfile_path (str): Path to the workfile. - folder_entity (dict[str, Any]): Folder entity. - task_entity (dict[str, Any]): Task entity. + save_workfile_context (SaveWorkfileContext): Save workfile + context with all prepared data. version (Optional[int]): Version of the workfile. comment (Optional[str]): Comment for the workfile. description (Optional[str]): Artist note for the workfile entity. - rootless_path (Optional[str]): Prepared rootless path of - the workfile. - workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched - workfile entities. - project_settings (Optional[dict[str, Any]]): Project settings. - project_entity (Optional[dict[str, Any]]): Project entity. - anatomy (Optional[Anatomy]): Project anatomy. Returns: Optional[dict[str, Any]]: Workfile entity. """ from ayon_core.pipeline.workfile.utils import ( - save_workfile_info, - find_workfile_rootless_path, + save_workfile_info ) project_name = self.get_current_project_name() @@ -1059,18 +1472,7 @@ class IWorkfileHost: if not comment: comment = None - if rootless_path is None: - rootless_path = find_workfile_rootless_path( - workfile_path, - project_name, - folder_entity, - task_entity, - self.name, - project_entity=project_entity, - project_settings=project_settings, - anatomy=anatomy, - ) - + rootless_path = save_workfile_context.rootless_path # It is not possible to create workfile infor without rootless path workfile_info = None if not rootless_path: @@ -1081,13 +1483,13 @@ class IWorkfileHost: workfile_info = save_workfile_info( project_name, - task_entity["id"], + save_workfile_context.task_entity["id"], rootless_path, self.name, version, comment, description, - workfile_entities=workfile_entities, + workfile_entities=save_workfile_context.workfile_entities, ) return workfile_info @@ -1155,7 +1557,7 @@ class IWorkfileHost: } def _before_workfile_open( - self, open_workfile_data: WorkfileOpenData + self, open_workfile_context: OpenWorkfileContext ) -> None: """Before workfile is opened. @@ -1164,14 +1566,14 @@ class IWorkfileHost: Can be overridden to implement host specific logic. Args: - open_workfile_data (WorkfileOpenData): Context and path of + open_workfile_context (OpenWorkfileContext): Context and path of workfile to open. """ pass def _after_workfile_open( - self, open_workfile_data: WorkfileOpenData + self, open_workfile_context: OpenWorkfileContext ) -> None: """After workfile is opened. @@ -1180,14 +1582,14 @@ class IWorkfileHost: Can be overridden to implement host specific logic. Args: - open_workfile_data (WorkfileOpenData): Context and path of + open_workfile_context (OpenWorkfileContext): Context and path of opened workfile. """ pass def _before_workfile_save( - self, save_workfile_data: WorkfileSaveData + self, save_workfile_context: SaveWorkfileContext ) -> None: """Before workfile is saved. @@ -1196,14 +1598,14 @@ class IWorkfileHost: Can be overridden to implement host specific logic. Args: - save_workfile_data (WorkfileSaveData): Workfile path with target + save_workfile_context (SaveWorkfileContext): Workfile path with target folder and task context. """ pass def _after_workfile_save( - self, save_workfile_data: WorkfileSaveData + self, save_workfile_context: SaveWorkfileContext ) -> None: """After workfile is saved. @@ -1212,19 +1614,19 @@ class IWorkfileHost: Can be overridden to implement host specific logic. Args: - save_workfile_data (WorkfileSaveData): Workfile path with target + save_workfile_context (SaveWorkfileContext): Workfile path with target folder and task context. """ - workdir = os.path.dirname(save_workfile_data.filepath) + workdir = os.path.dirname(save_workfile_context.dst_path) self._create_extra_folders( - save_workfile_data.folder_entity, - save_workfile_data.task_entity, + save_workfile_context.folder_entity, + save_workfile_context.task_entity, workdir ) def _before_workfile_copy( - self, copy_workfile_data: WorkfileCopyData + self, copy_workfile_context: CopyWorkfileContext ) -> None: """Before workfile is copied. @@ -1234,14 +1636,14 @@ class IWorkfileHost: Can be overridden to implement host specific logic. Args: - copy_workfile_data (WorkfileCopyData): Source and destination + copy_workfile_context (CopyWorkfileContext): Source and destination path with context before workfile is copied. """ pass def _after_workfile_copy( - self, copy_workfile_data: WorkfileCopyData + self, copy_workfile_context: CopyWorkfileContext ) -> None: """After workfile is copied. @@ -1251,14 +1653,14 @@ class IWorkfileHost: Can be overridden to implement host specific logic. Args: - copy_workfile_data (WorkfileCopyData): Source and destination + copy_workfile_context (CopyWorkfileContext): Source and destination path with context after workfile is copied. """ - workdir = os.path.dirname(copy_workfile_data.destination_path) + workdir = os.path.dirname(copy_workfile_context.dst_path) self._create_extra_folders( - copy_workfile_data.folder_entity, - copy_workfile_data.task_entity, + copy_workfile_context.folder_entity, + copy_workfile_context.task_entity, workdir, ) @@ -1271,6 +1673,8 @@ class IWorkfileHost: Emit event before and after workfile is opened. + This method is not meant to be overridden. + Other addons can listen to this event and do additional steps. Args: @@ -1299,6 +1703,8 @@ class IWorkfileHost: Emit event before and after workfile is saved or copied. + This method is not meant to be overridden. + Other addons can listen to this event and do additional steps. Args: diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index f64f68850b..f19c9933a0 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -10,6 +10,12 @@ from ayon_api.operations import OperationsSession from ayon_core.lib import filter_profiles, get_ayon_username from ayon_core.settings import get_project_settings +from ayon_core.host.interfaces import ( + SaveWorkfileOptionalData, + OpenWorkfileOptionalData, + CopyWorkfileOptionalData, + CopyPublishedWorkfileOptionalData, +) from .path_resolving import get_workfile_template_key @@ -303,9 +309,8 @@ def open_workfile( filepath: str, folder_entity: dict[str, Any], task_entity: dict[str, Any], - project_entity: Optional[dict[str, Any]] = None, - project_settings: Optional[dict[str, Any]] = None, - anatomy: Optional["Anatomy"] = None, + *, + prepared_data: Optional[OpenWorkfileOptionalData] = None, ): from ayon_core.pipeline.context_tools import registered_host @@ -315,9 +320,7 @@ def open_workfile( filepath, folder_entity, task_entity, - project_entity=project_entity, - project_settings=project_settings, - anatomy=anatomy, + prepared_data=prepared_data, ) @@ -329,11 +332,7 @@ def save_current_workfile_to( version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, - rootless_path: Optional[str] = None, - workfile_entities: Optional[list[dict[str, Any]]] = None, - project_entity: Optional[dict[str, Any]] = None, - project_settings: Optional[dict[str, Any]] = None, - anatomy: Optional["Anatomy"] = None, + prepared_data: Optional[SaveWorkfileOptionalData] = None, ) -> None: """Save current workfile to new location or context. @@ -344,16 +343,8 @@ def save_current_workfile_to( version (Optional[int]): Workfile version. comment (optional[str]): Workfile comment. description (Optional[str]): Workfile description. - rootless_path (Optional[str]): Rootless path of the workfile. Is - calculated if not passed in. - workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched - workfile entities related to the task. - project_entity (Optional[dict[str, Any]]): Project entity used for - rootless path calculation. - project_settings (Optional[dict[str, Any]]): Project settings used for - rootless path calculation. - anatomy (Optional[Anatomy]): Project anatomy used for rootless - path calculation. + prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data + for speed enhancements. """ from ayon_core.pipeline.context_tools import registered_host @@ -366,11 +357,7 @@ def save_current_workfile_to( version=version, comment=comment, description=description, - rootless_path=rootless_path, - workfile_entities=workfile_entities, - project_entity=project_entity, - project_settings=project_settings, - anatomy=anatomy, + prepared_data=prepared_data, ) @@ -380,11 +367,7 @@ def save_workfile_with_current_context( version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, - rootless_path: Optional[str] = None, - workfile_entities: Optional[list[dict[str, Any]]] = None, - project_entity: Optional[dict[str, Any]] = None, - project_settings: Optional[dict[str, Any]] = None, - anatomy: Optional["Anatomy"] = None, + prepared_data: Optional[SaveWorkfileOptionalData] = None, ) -> None: """Save current workfile to new location using current context. @@ -396,16 +379,8 @@ def save_workfile_with_current_context( version (Optional[int]): Workfile version. comment (optional[str]): Workfile comment. description (Optional[str]): Workfile description. - rootless_path (Optional[str]): Rootless path of the workfile. Is - calculated if not passed in. - workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched - workfile entities related to the task. - project_entity (Optional[dict[str, Any]]): Project entity used for - rootless path calculation. - project_settings (Optional[dict[str, Any]]): Project settings used for - rootless path calculation. - anatomy (Optional[Anatomy]): Project anatomy used for rootless - path calculation. + prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data + for speed enhancements. """ from ayon_core.pipeline.context_tools import registered_host @@ -430,11 +405,7 @@ def save_workfile_with_current_context( version=version, comment=comment, description=description, - rootless_path=rootless_path, - workfile_entities=workfile_entities, - project_entity=project_entity, - project_settings=project_settings, - anatomy=anatomy, + prepared_data=prepared_data, ) @@ -447,11 +418,7 @@ def copy_and_open_workfile( version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, - rootless_path: Optional[str] = None, - workfile_entities: Optional[list[dict[str, Any]]] = None, - project_entity: Optional[dict[str, Any]] = None, - project_settings: Optional[dict[str, Any]] = None, - anatomy: Optional["Anatomy"] = None, + prepared_data: Optional[CopyWorkfileOptionalData] = None, ) -> None: """Copy workfile to new location and open it. @@ -463,16 +430,8 @@ def copy_and_open_workfile( version (Optional[int]): Workfile version. comment (optional[str]): Workfile comment. description (Optional[str]): Workfile description. - rootless_path (Optional[str]): Rootless path of the workfile. Is - calculated if not passed in. - workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched - workfile entities related to the task. - project_entity (Optional[dict[str, Any]]): Project entity used for - rootless path calculation. - project_settings (Optional[dict[str, Any]]): Project settings used for - rootless path calculation. - anatomy (Optional[Anatomy]): Project anatomy used for rootless - path calculation. + prepared_data (Optional[CopyWorkfileOptionalData]): Prepared data + for speed enhancements. """ from ayon_core.pipeline.context_tools import registered_host @@ -486,18 +445,14 @@ def copy_and_open_workfile( version=version, comment=comment, description=description, - rootless_path=rootless_path, - workfile_entities=workfile_entities, - project_entity=project_entity, - project_settings=project_settings, - anatomy=anatomy, open_workfile=True, + prepared_data=prepared_data, ) def copy_and_open_workfile_representation( src_project_name: str, - representation_id: str, + representation_entity: dict[str, Any], workfile_path: str, folder_entity: dict[str, Any], task_entity: dict[str, Any], @@ -505,50 +460,25 @@ def copy_and_open_workfile_representation( version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, - rootless_path: Optional[str] = None, - representation_entity: Optional[dict[str, Any]] = None, - representation_path: Optional[str] = None, - workfile_entities: Optional[list[dict[str, Any]]] = None, - project_entity: Optional[dict[str, Any]] = None, - project_settings: Optional[dict[str, Any]] = None, - anatomy: Optional["Anatomy"] = None, - src_anatomy: Optional["Anatomy"] = None, + prepared_data: Optional[CopyPublishedWorkfileOptionalData] = None, ) -> None: """Copy workfile to new location and open it. Args: src_project_name (str): Project name where representation is stored. - representation_id (str): Source representation id. + representation_entity (dict[str, Any]): Representation entity. workfile_path (str): Destination workfile path. folder_entity (dict[str, Any]): Target folder entity. task_entity (dict[str, Any]): Target task entity. version (Optional[int]): Workfile version. comment (optional[str]): Workfile comment. description (Optional[str]): Workfile description. - rootless_path (Optional[str]): Rootless path of the workfile. Is - calculated if not passed in. - representation_entity (Optional[dict[str, Any]]): Representation - entity. If not provided, it will be fetched from the server. - representation_path (Optional[str]): Path to the representation. - Calculated if not provided. - workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched - workfile entities related to the task. - project_entity (Optional[dict[str, Any]]): Project entity used for - rootless path calculation. - project_settings (Optional[dict[str, Any]]): Project settings used for - rootless path calculation. - anatomy (Optional[Anatomy]): Project anatomy used for rootless - path calculation. + prepared_data (Optional[CopyPublishedWorkfileOptionalData]): Prepared data + for speed enhancements. """ from ayon_core.pipeline.context_tools import registered_host - if representation_entity is None: - representation_entity = ayon_api.get_representation_by_id( - src_project_name, - representation_id, - ) - host = registered_host() host.copy_workfile_representation( src_project_name, @@ -559,14 +489,8 @@ def copy_and_open_workfile_representation( version=version, comment=comment, description=description, - rootless_path=rootless_path, - workfile_entities=workfile_entities, - project_settings=project_settings, - project_entity=project_entity, - anatomy=anatomy, - src_anatomy=src_anatomy, - src_representation_path=representation_path, open_workfile=open_workfile, + prepared_data=prepared_data, ) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 23521dc3f6..5e4e5db808 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -19,6 +19,14 @@ from ayon_core.host import ( WorkfileInfo, PublishedWorkfileInfo, ) +from ayon_core.host.interfaces import ( + OpenWorkfileOptionalData, + ListWorkfilesOptionalData, + ListPublishedWorkfilesOptionalData, + SaveWorkfileOptionalData, + CopyWorkfileOptionalData, + CopyPublishedWorkfileOptionalData, +) from ayon_core.pipeline.template_data import ( get_template_data, get_task_template_data, @@ -142,6 +150,7 @@ class WorkfilesModel: filepath = os.path.join(workdir, filename) rootless_path = f"{rootless_workdir}/{filename}" project_name = self._controller.get_current_project_name() + project_entity = self._controller.get_project_entity(project_name) folder_entity = self._controller.get_folder_entity( project_name, folder_id ) @@ -149,6 +158,13 @@ class WorkfilesModel: project_name, task_id ) + prepared_data = SaveWorkfileOptionalData( + project_entity=project_entity, + anatomy=self._controller.project_anatomy, + project_settings=self._controller.project_settings, + rootless_path=rootless_path, + workfile_entities=self.get_workfile_entities(task_id), + ) failed = False try: save_current_workfile_to( @@ -158,13 +174,7 @@ class WorkfilesModel: version=version, comment=comment, description=description, - rootless_path=rootless_path, - workfile_entities=self.get_workfile_entities(task_id), - project_entity=self._controller.get_project_entity( - project_name - ), - project_settings=self._controller.project_settings, - anatomy=self._controller.project_anatomy, + prepared_data=prepared_data, ) self._update_workfile_info( task_id, rootless_path, description @@ -198,37 +208,38 @@ class WorkfilesModel: self._emit_event("copy_representation.started") project_name = self._project_name + project_entity = self._controller.get_project_entity(project_name) folder_entity = self._controller.get_folder_entity( - self._project_name, folder_id + project_name, folder_id ) task_entity = self._controller.get_task_entity( - self._project_name, task_id + project_name, task_id ) repre_entity = self._repre_by_id.get(representation_id) dst_filepath = os.path.join(workdir, filename) rootless_path = f"{rootless_workdir}/{filename}" + prepared_data = CopyPublishedWorkfileOptionalData( + project_entity=project_entity, + anatomy=self._controller.project_anatomy, + project_settings=self._controller.project_settings, + rootless_path=rootless_path, + representation_path=representation_filepath, + workfile_entities=self.get_workfile_entities(task_id), + src_anatomy=self._controller.project_anatomy, + ) failed = False - workfile_entities = self.get_workfile_entities(task_id) try: copy_and_open_workfile_representation( project_name, - representation_id, + repre_entity, dst_filepath, folder_entity, task_entity, version=version, comment=comment, description=description, - rootless_path=rootless_path, - representation_entity=repre_entity, - representation_path=representation_filepath, - workfile_entities=workfile_entities, - project_entity=self._controller.get_project_entity( - project_name - ), - project_settings=self._controller.project_settings, - anatomy=self._controller.project_anatomy, + prepared_data=prepared_data, ) self._update_workfile_info( task_id, rootless_path, description @@ -271,6 +282,14 @@ class WorkfilesModel: workfile_entities = self.get_workfile_entities(task_id) rootless_path = f"{rootless_workdir}/{filename}" workfile_path = os.path.join(workdir, filename) + + prepared_data = CopyWorkfileOptionalData( + project_entity=project_entity, + project_settings=self._controller.project_settings, + anatomy=self._controller.project_anatomy, + rootless_path=rootless_path, + workfile_entities=workfile_entities, + ) failed = False try: copy_and_open_workfile( @@ -281,11 +300,7 @@ class WorkfilesModel: version=version, comment=comment, description=description, - rootless_path=rootless_path, - workfile_entities=workfile_entities, - project_entity=project_entity, - project_settings=self._controller.project_settings, - anatomy=self._controller.project_anatomy, + prepared_data=prepared_data, ) except Exception: @@ -571,12 +586,12 @@ class WorkfilesModel: project_name = self._project_name anatomy = self._controller.project_anatomy - product_entities = ayon_api.get_products( + product_entities = list(ayon_api.get_products( project_name, folder_ids={folder_id}, product_types={"workfile"}, fields={"id", "name"} - ) + )) version_entities = [] product_ids = {product["id"] for product in product_entities} @@ -599,13 +614,20 @@ class WorkfilesModel: repre_entity["id"]: repre_entity for repre_entity in repre_entities }) + project_entity = self._controller.get_project_entity(project_name) + prepared_data = ListPublishedWorkfilesOptionalData( + project_entity=project_entity, + anatomy=anatomy, + project_settings=self._controller.project_settings, + product_entities=product_entities, + version_entities=version_entities, + repre_entities=repre_entities, + ) cache.update_data(self._host.list_published_workfiles( project_name, folder_id, - anatomy=anatomy, - version_entities=version_entities, - repre_entities=repre_entities, + prepared_data=prepared_data, )) items = cache.get_data() @@ -638,13 +660,21 @@ class WorkfilesModel: def _open_workfile(self, folder_id: str, task_id: str, filepath: str): # TODO move to workfiles pipeline project_name = self._project_name + project_entity = self._controller.get_project_entity(project_name) folder_entity = self._controller.get_folder_entity( project_name, folder_id ) task_entity = self._controller.get_task_entity( project_name, task_id ) - open_workfile(filepath, folder_entity, task_entity) + prepared_data = OpenWorkfileOptionalData( + project_entity=project_entity, + anatomy=self._controller.project_anatomy, + project_settings=self._controller.project_settings, + ) + open_workfile( + filepath, folder_entity, task_entity, prepared_data=prepared_data + ) self._update_current_context( folder_id, folder_entity["path"], task_entity["name"] ) @@ -739,15 +769,19 @@ class WorkfilesModel: fill_data = self._prepare_fill_data(folder_id, task_id) template_key = self._get_template_key(fill_data) + prepared_data = ListWorkfilesOptionalData( + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + template_key=template_key, + workfile_entities=workfile_entities, + ) + items = self._host.list_workfiles( self._project_name, folder_entity, task_entity, - project_entity=project_entity, - anatomy=anatomy, - template_key=template_key, - project_settings=project_settings, - workfile_entities=workfile_entities, + prepared_data=prepared_data, ) cache.update_data(items) From 91377aa40011a22593b6cb41694c96747f7fcf33 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:20:56 +0200 Subject: [PATCH 116/312] formatting fixes --- client/ayon_core/host/interfaces/workfiles.py | 10 ++++------ client/ayon_core/pipeline/workfile/utils.py | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 8bc0b1cf85..4eb8b08719 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -182,7 +182,6 @@ class ListPublishedWorkfilesOptionalData(_WorkfileOptionalData): version_entities = list(ayon_api.get_versions( project_name, product_ids=product_ids, - task_ids=task_filters, fields={"id", "author", "taskId"}, )) @@ -444,7 +443,6 @@ def get_list_published_workfiles_context( repre_entities, ) = prepared_data.get_entities(project_name, folder_id) - return ListPublishedWorkfilesContext( data_version=prepared_data.data_version, project_name=project_name, @@ -1598,8 +1596,8 @@ class IWorkfileHost: Can be overridden to implement host specific logic. Args: - save_workfile_context (SaveWorkfileContext): Workfile path with target - folder and task context. + save_workfile_context (SaveWorkfileContext): Workfile path with + target folder and task context. """ pass @@ -1614,8 +1612,8 @@ class IWorkfileHost: Can be overridden to implement host specific logic. Args: - save_workfile_context (SaveWorkfileContext): Workfile path with target - folder and task context. + save_workfile_context (SaveWorkfileContext): Workfile path with + target folder and task context. """ workdir = os.path.dirname(save_workfile_context.dst_path) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index f19c9933a0..0ac294c82a 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -473,8 +473,8 @@ def copy_and_open_workfile_representation( version (Optional[int]): Workfile version. comment (optional[str]): Workfile comment. description (Optional[str]): Workfile description. - prepared_data (Optional[CopyPublishedWorkfileOptionalData]): Prepared data - for speed enhancements. + prepared_data (Optional[CopyPublishedWorkfileOptionalData]): Prepared + data for speed enhancements. """ from ayon_core.pipeline.context_tools import registered_host From dc476dea9234e2e430ccd5795bd29e1d05b65c6f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:57:27 +0200 Subject: [PATCH 117/312] fix missing argument --- client/ayon_core/pipeline/workfile/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 0ac294c82a..177eb69694 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -489,7 +489,7 @@ def copy_and_open_workfile_representation( version=version, comment=comment, description=description, - open_workfile=open_workfile, + open_workfile=True, prepared_data=prepared_data, ) From b142bc4d457dd7665d19ba5f09247bb6619fc2a9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 2 Jul 2025 09:49:44 +0200 Subject: [PATCH 118/312] expect folder path and task name in 'save_current_workfile_to' --- client/ayon_core/pipeline/workfile/utils.py | 18 +++++++++++++----- .../tools/workfiles/models/workfiles.py | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 177eb69694..a1371a4956 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -326,8 +326,8 @@ def open_workfile( def save_current_workfile_to( workfile_path: str, - folder_entity: dict[str, Any], - task_entity: dict[str, Any], + folder_path: str, + task_name: str, *, version: Optional[int] = None, comment: Optional[str] = None, @@ -338,8 +338,8 @@ def save_current_workfile_to( Args: workfile_path (str): Destination workfile path. - folder_entity (dict[str, Any]): Target folder entity. - task_entity (dict[str, Any]): Target task entity. + folder_path (str): Target folder path. + task_name (str): Target task name. version (Optional[int]): Workfile version. comment (optional[str]): Workfile comment. description (Optional[str]): Workfile description. @@ -350,6 +350,14 @@ def save_current_workfile_to( from ayon_core.pipeline.context_tools import registered_host host = registered_host() + context = host.get_current_context() + project_name = context["project_name"] + folder_entity = ayon_api.get_folder_by_path( + project_name, folder_path + ) + task_entity = ayon_api.get_task_by_name( + project_name, folder_entity["id"], task_name + ) host.save_workfile_with_context( workfile_path, folder_entity, @@ -398,7 +406,7 @@ def save_workfile_with_current_context( project_name, folder_entity["id"], task_name ) - save_current_workfile_to( + host.save_workfile_with_context( workfile_path, folder_entity, task_entity, diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 5e4e5db808..0a581d6498 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -167,7 +167,7 @@ class WorkfilesModel: ) failed = False try: - save_current_workfile_to( + self._host.save_workfile_with_context( filepath, folder_entity, task_entity, From bc9a1b6526fc939239719738adfab6c2ea172ddd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 2 Jul 2025 09:53:46 +0200 Subject: [PATCH 119/312] remove unused import --- client/ayon_core/tools/workfiles/models/workfiles.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 0a581d6498..a1cca07178 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -38,7 +38,6 @@ from ayon_core.pipeline.workfile import ( get_last_workfile_with_version_from_paths, get_comments_from_workfile_paths, open_workfile, - save_current_workfile_to, copy_and_open_workfile, copy_and_open_workfile_representation, save_workfile_info, From 7006ed8940e62eacfae2d247f4d08b0988bb7201 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:17:55 +0200 Subject: [PATCH 120/312] fix not existing dirs --- client/ayon_core/pipeline/workfile/path_resolving.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 4e4c70a27c..b318137a5d 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -362,6 +362,10 @@ def _filter_dir_files_by_ext( if not ext.startswith("."): ext = f".{ext}" dotted_extensions.add(ext) + + if not os.path.exists(dirpath): + return [], dotted_extensions + filtered_paths = [ os.path.join(dirpath, filename) for filename in os.listdir(dirpath) From 206eb45cf49fc5dec1ae56200c1c18294f800914 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:24:12 +0200 Subject: [PATCH 121/312] added 'save_workfile_with_current_context' to workfile init --- client/ayon_core/pipeline/workfile/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/pipeline/workfile/__init__.py b/client/ayon_core/pipeline/workfile/__init__.py index 52acb035b1..51327b8c09 100644 --- a/client/ayon_core/pipeline/workfile/__init__.py +++ b/client/ayon_core/pipeline/workfile/__init__.py @@ -24,6 +24,7 @@ from .utils import ( open_workfile, save_current_workfile_to, + save_workfile_with_current_context, copy_and_open_workfile, copy_and_open_workfile_representation, save_workfile_info, @@ -67,6 +68,7 @@ __all__ = ( "open_workfile", "save_current_workfile_to", + "save_workfile_with_current_context", "copy_and_open_workfile", "copy_and_open_workfile_representation", "save_workfile_info", From 5ff56aeda59cb89c1e4e8e4029a01888a7398ced Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:24:25 +0200 Subject: [PATCH 122/312] use host methods in workfiles tool --- client/ayon_core/tools/workfiles/models/workfiles.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index a1cca07178..35ffc3102c 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -37,9 +37,6 @@ from ayon_core.pipeline.workfile import ( get_workfile_template_key, get_last_workfile_with_version_from_paths, get_comments_from_workfile_paths, - open_workfile, - copy_and_open_workfile, - copy_and_open_workfile_representation, save_workfile_info, ) from ayon_core.pipeline.version_start import get_versioning_start @@ -229,7 +226,7 @@ class WorkfilesModel: ) failed = False try: - copy_and_open_workfile_representation( + self._host.copy_workfile_representation( project_name, repre_entity, dst_filepath, @@ -291,7 +288,7 @@ class WorkfilesModel: ) failed = False try: - copy_and_open_workfile( + self._host.copy_workfile( src_filepath, workfile_path, folder_entity, @@ -671,7 +668,7 @@ class WorkfilesModel: anatomy=self._controller.project_anatomy, project_settings=self._controller.project_settings, ) - open_workfile( + self._host.open_workfile_with_context( filepath, folder_entity, task_entity, prepared_data=prepared_data ) self._update_current_context( From 43d9324e1028971dfb5b7b6a098422535cca89eb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:26:27 +0200 Subject: [PATCH 123/312] remove unnecessary functions --- .../ayon_core/pipeline/workfile/__init__.py | 6 - client/ayon_core/pipeline/workfile/utils.py | 104 ------------------ 2 files changed, 110 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/__init__.py b/client/ayon_core/pipeline/workfile/__init__.py index 51327b8c09..c6a0e0d80b 100644 --- a/client/ayon_core/pipeline/workfile/__init__.py +++ b/client/ayon_core/pipeline/workfile/__init__.py @@ -22,11 +22,8 @@ from .utils import ( should_open_workfiles_tool_on_launch, MissingWorkdirError, - open_workfile, save_current_workfile_to, save_workfile_with_current_context, - copy_and_open_workfile, - copy_and_open_workfile_representation, save_workfile_info, find_workfile_rootless_path, ) @@ -66,11 +63,8 @@ __all__ = ( "should_open_workfiles_tool_on_launch", "MissingWorkdirError", - "open_workfile", "save_current_workfile_to", "save_workfile_with_current_context", - "copy_and_open_workfile", - "copy_and_open_workfile_representation", "save_workfile_info", "BuildWorkfile", diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index a1371a4956..fd5cc4538e 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -305,25 +305,6 @@ def save_workfile_info( return workfile_entity -def open_workfile( - filepath: str, - folder_entity: dict[str, Any], - task_entity: dict[str, Any], - *, - prepared_data: Optional[OpenWorkfileOptionalData] = None, -): - from ayon_core.pipeline.context_tools import registered_host - - # Trigger before save event - host = registered_host() - host.open_workfile_with_context( - filepath, - folder_entity, - task_entity, - prepared_data=prepared_data, - ) - - def save_current_workfile_to( workfile_path: str, folder_path: str, @@ -417,91 +398,6 @@ def save_workfile_with_current_context( ) -def copy_and_open_workfile( - src_workfile_path: str, - workfile_path: str, - folder_entity: dict[str, Any], - task_entity: dict[str, Any], - *, - version: Optional[int] = None, - comment: Optional[str] = None, - description: Optional[str] = None, - prepared_data: Optional[CopyWorkfileOptionalData] = None, -) -> None: - """Copy workfile to new location and open it. - - Args: - src_workfile_path (str): Source workfile path. - workfile_path (str): Destination workfile path. - folder_entity (dict[str, Any]): Target folder entity. - task_entity (dict[str, Any]): Target task entity. - version (Optional[int]): Workfile version. - comment (optional[str]): Workfile comment. - description (Optional[str]): Workfile description. - prepared_data (Optional[CopyWorkfileOptionalData]): Prepared data - for speed enhancements. - - """ - from ayon_core.pipeline.context_tools import registered_host - - host = registered_host() - host.copy_workfile( - src_workfile_path, - workfile_path, - folder_entity, - task_entity, - version=version, - comment=comment, - description=description, - open_workfile=True, - prepared_data=prepared_data, - ) - - -def copy_and_open_workfile_representation( - src_project_name: str, - representation_entity: dict[str, Any], - workfile_path: str, - folder_entity: dict[str, Any], - task_entity: dict[str, Any], - *, - version: Optional[int] = None, - comment: Optional[str] = None, - description: Optional[str] = None, - prepared_data: Optional[CopyPublishedWorkfileOptionalData] = None, -) -> None: - """Copy workfile to new location and open it. - - Args: - src_project_name (str): Project name where representation is stored. - representation_entity (dict[str, Any]): Representation entity. - workfile_path (str): Destination workfile path. - folder_entity (dict[str, Any]): Target folder entity. - task_entity (dict[str, Any]): Target task entity. - version (Optional[int]): Workfile version. - comment (optional[str]): Workfile comment. - description (Optional[str]): Workfile description. - prepared_data (Optional[CopyPublishedWorkfileOptionalData]): Prepared - data for speed enhancements. - - """ - from ayon_core.pipeline.context_tools import registered_host - - host = registered_host() - host.copy_workfile_representation( - src_project_name, - representation_entity, - workfile_path, - folder_entity, - task_entity, - version=version, - comment=comment, - description=description, - open_workfile=True, - prepared_data=prepared_data, - ) - - def find_workfile_rootless_path( workfile_path: str, project_name: str, From 9c1aa9bfeffcd6af9569b6e3304113f84ed59dc9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:02:41 +0200 Subject: [PATCH 124/312] remove unused import --- client/ayon_core/pipeline/workfile/utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index fd5cc4538e..9e4194ccf6 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -12,9 +12,6 @@ from ayon_core.lib import filter_profiles, get_ayon_username from ayon_core.settings import get_project_settings from ayon_core.host.interfaces import ( SaveWorkfileOptionalData, - OpenWorkfileOptionalData, - CopyWorkfileOptionalData, - CopyPublishedWorkfileOptionalData, ) from .path_resolving import get_workfile_template_key From cd6136ba00667adfe1c810e9e3986604066e229b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:32:02 +0200 Subject: [PATCH 125/312] show all arguments in IDE --- client/ayon_core/host/interfaces/workfiles.py | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 4eb8b08719..3c86d9caa3 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -102,11 +102,19 @@ class ListWorkfilesOptionalData(_WorkfileOptionalData): def __init__( self, *, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + project_settings: Optional[dict[str, Any]] = None, template_key: Optional[str] = None, workfile_entities: Optional[list[dict[str, Any]]] = None, **kwargs ): - super().__init__(**kwargs) + super().__init__( + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + **kwargs + ) self.template_key = template_key self.workfile_entities = workfile_entities @@ -147,12 +155,20 @@ class ListPublishedWorkfilesOptionalData(_WorkfileOptionalData): def __init__( self, *, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + project_settings: Optional[dict[str, Any]] = None, product_entities: Optional[list[dict[str, Any]]] = None, version_entities: Optional[list[dict[str, Any]]] = None, repre_entities: Optional[list[dict[str, Any]]] = None, **kwargs ): - super().__init__(**kwargs) + super().__init__( + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + **kwargs + ) self.product_entities = product_entities self.version_entities = version_entities @@ -202,11 +218,19 @@ class SaveWorkfileOptionalData(_WorkfileOptionalData): def __init__( self, *, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + project_settings: Optional[dict[str, Any]] = None, rootless_path: Optional[str] = None, workfile_entities: Optional[list[dict[str, Any]]] = None, **kwargs ): - super().__init__(**kwargs) + super().__init__( + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + **kwargs + ) self.rootless_path = rootless_path self.workfile_entities = workfile_entities @@ -260,11 +284,23 @@ class CopyPublishedWorkfileOptionalData(SaveWorkfileOptionalData): def __init__( self, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional["Anatomy"] = None, + project_settings: Optional[dict[str, Any]] = None, + rootless_path: Optional[str] = None, + workfile_entities: Optional[list[dict[str, Any]]] = None, src_anatomy: Optional["Anatomy"] = None, src_representation_path: Optional[str] = None, **kwargs ): - super().__init__(**kwargs) + super().__init__( + rootless_path=rootless_path, + workfile_entities=workfile_entities, + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + **kwargs + ) self.src_anatomy = src_anatomy self.src_representation_path = src_representation_path From c8b2ad1ce23ed3de9958317a3b309bbcc57d5e15 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:42:28 +0200 Subject: [PATCH 126/312] use 'AnatomyTemplateResult' for workdir output typehint --- client/ayon_core/pipeline/anatomy/__init__.py | 4 ++++ .../ayon_core/pipeline/workfile/path_resolving.py | 14 +++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/anatomy/__init__.py b/client/ayon_core/pipeline/anatomy/__init__.py index 7000f51495..36bc2a138d 100644 --- a/client/ayon_core/pipeline/anatomy/__init__.py +++ b/client/ayon_core/pipeline/anatomy/__init__.py @@ -6,6 +6,7 @@ from .exceptions import ( AnatomyTemplateUnsolved, ) from .anatomy import Anatomy +from .templates import AnatomyTemplateResult, AnatomyStringTemplate __all__ = ( @@ -16,4 +17,7 @@ __all__ = ( "AnatomyTemplateUnsolved", "Anatomy", + + "AnatomyTemplateResult", + "AnatomyStringTemplate", ) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index b318137a5d..0e364ebc01 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -19,7 +19,7 @@ from ayon_core.pipeline import version_start, Anatomy from ayon_core.pipeline.template_data import get_template_data if typing.TYPE_CHECKING: - from ayon_core.lib.path_templates import TemplateResult + from ayon_core.pipeline.anatomy import AnatomyTemplateResult def get_workfile_template_key_from_context( @@ -117,7 +117,7 @@ def get_workdir_with_workdir_data( anatomy=None, template_key=None, project_settings=None -) -> "TemplateResult": +) -> "AnatomyTemplateResult": """Fill workdir path from entered data and project's anatomy. It is possible to pass only project's name instead of project's anatomy but @@ -136,7 +136,7 @@ def get_workdir_with_workdir_data( if 'template_key' is not passed. Returns: - TemplateResult: Workdir path. + AnatomyTemplateResult: Workdir path. """ if not anatomy: @@ -153,7 +153,7 @@ def get_workdir_with_workdir_data( template_obj = anatomy.get_template_item( "work", template_key, "directory" ) - # Output is TemplateResult object which contain useful data + # Output is AnatomyTemplateResult object which contain useful data output = template_obj.format_strict(workdir_data) if output: return output.normalized() @@ -168,7 +168,7 @@ def get_workdir( anatomy=None, template_key=None, project_settings=None -) -> "TemplateResult": +) -> "AnatomyTemplateResult": """Fill workdir path from entered data and project's anatomy. Args: @@ -189,7 +189,7 @@ def get_workdir( if 'template_key' is not passed. Returns: - TemplateResult: Workdir path. + AnatomyTemplateResult: Workdir path. """ if not anatomy: @@ -203,7 +203,7 @@ def get_workdir( task_entity, host_name, ) - # Output is TemplateResult object which contain useful data + # Output is AnatomyTemplateResult object which contain useful data return get_workdir_with_workdir_data( workdir_data, anatomy.project_name, From b70385ab3ab3a871e7c528b5c3ee55a2faf977ef Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:42:40 +0200 Subject: [PATCH 127/312] implemented save next version helper --- client/ayon_core/pipeline/workfile/utils.py | 134 +++++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 9e4194ccf6..0e24b33555 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -12,9 +12,16 @@ from ayon_core.lib import filter_profiles, get_ayon_username from ayon_core.settings import get_project_settings from ayon_core.host.interfaces import ( SaveWorkfileOptionalData, + ListWorkfilesOptionalData, ) +from ayon_core.pipeline.version_start import get_versioning_start +from ayon_core.pipeline.template_data import get_template_data -from .path_resolving import get_workfile_template_key +from .path_resolving import ( + get_workdir, + get_workfile_template_key, + get_last_workfile_with_version_from_paths, +) if typing.TYPE_CHECKING: from ayon_core.pipeline import Anatomy @@ -395,6 +402,131 @@ def save_workfile_with_current_context( ) +def save_next_version( + version: Optional[int] = None, + comment: Optional[str] = None, + description: Optional[str] = None, +) -> None: + """Save workfile using current context, version and comment. + + Helper function to save workfile using current context. Last workfile + version + 1 is used if is not passed in. + + Args: + version (Optional[int]): Workfile version that will be used. Last + version + 1 is used if is not passed in. + comment (optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + + """ + from ayon_core.pipeline import Anatomy + from ayon_core.pipeline.context_tools import registered_host + + host = registered_host() + + context = host.get_current_context() + project_name = context["project_name"] + folder_path = context["folder_path"] + task_name = context["task_name"] + project_entity = ayon_api.get_project(project_name) + project_settings = get_project_settings(project_name) + anatomy = Anatomy(project_name, project_entity=project_entity) + folder_entity = ayon_api.get_folder_by_path(project_name, folder_path) + task_entity = ayon_api.get_task_by_name( + project_name, folder_entity["id"], task_name + ) + + template_key = get_workfile_template_key( + project_name, + task_entity["taskType"], + host.name, + project_settings=project_settings + ) + file_template = anatomy.get_template_item("work", template_key, "file") + template_data = get_template_data( + project_entity, + folder_entity, + task_entity, + host.name, + project_settings, + ) + workdir = get_workdir( + project_entity, + folder_entity, + task_entity, + host.name, + anatomy=anatomy, + template_key=template_key, + project_settings=project_settings, + ) + rootless_dir = workdir.rootless + if version is None: + workfile_extensions = host.get_workfile_extensions() + if not workfile_extensions: + raise ValueError("Host does not have defined file extensions") + workfiles = host.list_workfiles( + project_name, folder_entity, task_entity, + prepared_data=ListWorkfilesOptionalData( + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + template_key=template_key, + ) + ) + filepaths = [ + workfile.filepath + for workfile in workfiles + ] + + dotted_extensions = set() + for ext in workfile_extensions: + if not ext.startswith("."): + ext = f".{ext}" + dotted_extensions.add(ext) + + last_path, last_version = get_last_workfile_with_version_from_paths( + filepaths, + file_template, + template_data, + dotted_extensions, + ) + if last_path is None: + version = get_versioning_start( + project_name, + host.name, + task_name=task_entity["name"], + task_type=task_entity["taskType"], + product_type="workfile" + ) + else: + version = last_version + 1 + + template_data["version"] = version + template_data["comment"] = comment + + filename = file_template.format_strict(template_data) + workfile_path = os.path.join(workdir, filename) + rootless_path = f"{rootless_dir}/{filename}" + if platform.system().lower() == "windows": + rootless_path = rootless_path.replace("\\", "/") + + prepared_data = SaveWorkfileOptionalData( + project_entity=project_entity, + anatomy=anatomy, + project_settings=project_settings, + rootless_path=rootless_path, + ) + host.save_workfile_with_context( + workfile_path, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + prepared_data=prepared_data, + ) + + def find_workfile_rootless_path( workfile_path: str, project_name: str, From 4517d55c456f28565ab7f8340b4c754d2bb839ee Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:16:23 +0200 Subject: [PATCH 128/312] implemented helper function to parse data from filename using template --- .../pipeline/workfile/path_resolving.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 0e364ebc01..ed7ac03e81 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -6,6 +6,7 @@ import platform import warnings import typing from typing import Optional, Dict, Any +from dataclasses import dataclass import ayon_api @@ -213,6 +214,85 @@ def get_workdir( ) +@dataclass +class WorkfileParsedData: + version: Optional[int] = None + comment: Optional[str] = None + ext: Optional[str] = None + + +class WorkfileDataParser: + """Parse dynamic data from existing filenames based on template. + + Args: + file_template (str): Workfile file template. + data (dict[str, Any]): Data to fill the template with. + + """ + def __init__( + self, + file_template: str, + data: dict[str, Any], + ): + data = copy.deepcopy(data) + file_template = str(file_template) + # Use placeholders that will never be in the filename + ext_replacement = "CIextID" + version_replacement = "CIversionID" + comment_replacement = "CIcommentID" + data["version"] = version_replacement + data["comment"] = comment_replacement + for pattern, replacement in ( + # Replace `.{ext}` with `{ext}` so we are sure dot is not at the end + (r"\.?{ext}", ext_replacement), + ): + file_template = re.sub(pattern, replacement, file_template) + + file_template = StringTemplate(file_template) + comment_template = re.escape(str(file_template.format_strict(data))) + data.pop("comment") + file_template = re.escape(str(file_template.format_strict(data))) + for src, replacement in ( + (ext_replacement, r"(?P\..*)"), + (version_replacement, r"(?P[0-9]+)"), + (comment_replacement, r"(?P.+?)"), + ): + comment_template = comment_template.replace(src, replacement) + file_template = file_template.replace(src, replacement) + + kwargs = {} + if platform.system().lower() == "windows": + kwargs["flags"] = re.IGNORECASE + + # Match from beginning to end of string to be safe + self._comment_template = re.compile(f"^{comment_template}$", **kwargs) + self._file_template = re.compile(f"^{file_template}$", **kwargs) + + def parse_data(self, filename: str) -> WorkfileParsedData: + """Parse the dynamic data from a filename.""" + match = self._comment_template.match(filename) + if not match: + match = self._file_template.match(filename) + + if not match: + return WorkfileParsedData() + + kwargs = match.groupdict() + version = kwargs.get("version") + if version is not None: + kwargs["version"] = int(version) + return WorkfileParsedData(**kwargs) + + +def parse_data_from_workfile( + filename: str, + file_template: str, + template_data: dict[str, Any], +) -> WorkfileParsedData: + parser = WorkfileDataParser(file_template, template_data) + return parser.parse_data(filename) + + def get_last_workfile_with_version_from_paths( filepaths: list[str], file_template: str, From e09f87262d06696ea1e4c4b74268240fecd6d682 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:16:43 +0200 Subject: [PATCH 129/312] store version and comment from filename to WorkfileInfo --- client/ayon_core/host/interfaces/workfiles.py | 59 +++++++++++++++---- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 3c86d9caa3..53ec02ac57 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -639,6 +639,8 @@ class WorkfileInfo: filepath (str): Path to the workfile. rootless_path (str): Path to the workfile without the root. And without backslashes on Windows. + version (Optional[int]): Version of the workfile. + comment (Optional[str]): Comment of the workfile. file_size (Optional[float]): Size of the workfile in bytes. file_created (Optional[float]): Timestamp when the workfile was created on the filesystem. @@ -656,6 +658,8 @@ class WorkfileInfo: """ filepath: str rootless_path: str + version: Optional[int] + comment: Optional[str] file_size: Optional[float] file_created: Optional[float] file_modified: Optional[float] @@ -671,6 +675,8 @@ class WorkfileInfo: filepath: str, rootless_path: str, *, + version: Optional[int], + comment: Optional[str], available: bool, workfile_entity: dict[str, Any], ): @@ -691,6 +697,8 @@ class WorkfileInfo: return cls( filepath=filepath, rootless_path=rootless_path, + version=version, + comment=comment, file_size=file_size, file_created=file_created, file_modified=file_modified, @@ -1047,7 +1055,10 @@ class IWorkfileHost: """ from ayon_core.pipeline.template_data import get_template_data - from ayon_core.pipeline.workfile import get_workdir_with_workdir_data + from ayon_core.pipeline.workfile.path_resolving import ( + get_workdir_with_workdir_data, + WorkfileDataParser, + ) extensions = self.get_workfile_extensions() if not extensions: @@ -1080,22 +1091,18 @@ class IWorkfileHost: project_settings=list_workfiles_context.project_settings, ) + file_template = list_workfiles_context.anatomy.get_template_item( + "work", list_workfiles_context.template_key, "file" + ) + rootless_workdir = workdir.rootless if platform.system().lower() == "windows": - rootless_workdir = workdir.replace("\\", "/") - else: - rootless_workdir = workdir - - used_roots = workdir.used_values.get("root") - if used_roots: - used_root_name = next(iter(used_roots)) - root_value = used_roots[used_root_name] - workdir_end = rootless_workdir[len(root_value):].lstrip("/") - rootless_workdir = f"{{root[{used_root_name}]}}/{workdir_end}" + rootless_workdir = rootless_workdir.replace("\\", "/") filenames = [] if os.path.exists(workdir): filenames = list(os.listdir(workdir)) + data_parser = WorkfileDataParser(file_template, workdir_data) items = [] for filename in filenames: # TODO add 'default' support for folders @@ -1109,12 +1116,26 @@ class IWorkfileHost: workfile_entity = workfile_entities_by_path.pop( rootless_path, None ) - items.append(WorkfileInfo.new( + version = comment = None + if workfile_entity: + _data = workfile_entity["data"] + version = _data.get("version") + comment = _data.get("comment") + + if version is None: + parsed_data = data_parser.parse_data(filename) + version = parsed_data.version + comment = parsed_data.comment + + item = WorkfileInfo.new( filepath, rootless_path, + version=version, + comment=comment, available=True, workfile_entity=workfile_entity, - )) + ) + items.append(item) for workfile_entity in workfile_entities_by_path.values(): # Workfile entity is not in the filesystem @@ -1123,10 +1144,22 @@ class IWorkfileHost: ext = os.path.splitext(rootless_path)[1].lower() if ext not in extensions: continue + + _data = workfile_entity["data"] + version = _data.get("version") + comment = _data.get("comment") + if version is None: + filename = os.path.basename(rootless_path) + parsed_data = data_parser.parse_data(filename) + version = parsed_data.version + comment = parsed_data.comment + filepath = prepared_data.anatomy.fill_root(rootless_path) items.append(WorkfileInfo.new( filepath, rootless_path, + version=version, + comment=comment, available=False, workfile_entity=workfile_entity, )) From eeb839a65c0fa159bdf71b0fdf6771b38c8e8682 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:17:17 +0200 Subject: [PATCH 130/312] use version on workfile info to find last version --- client/ayon_core/pipeline/workfile/utils.py | 27 ++++++--------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 0e24b33555..82326754b8 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -20,7 +20,6 @@ from ayon_core.pipeline.template_data import get_template_data from .path_resolving import ( get_workdir, get_workfile_template_key, - get_last_workfile_with_version_from_paths, ) if typing.TYPE_CHECKING: @@ -473,24 +472,16 @@ def save_next_version( template_key=template_key, ) ) - filepaths = [ - workfile.filepath + versions = { + workfile.version for workfile in workfiles - ] + if workfile.version is not None + } + version = None + if versions: + version = max(versions) + 1 - dotted_extensions = set() - for ext in workfile_extensions: - if not ext.startswith("."): - ext = f".{ext}" - dotted_extensions.add(ext) - - last_path, last_version = get_last_workfile_with_version_from_paths( - filepaths, - file_template, - template_data, - dotted_extensions, - ) - if last_path is None: + if version is None: version = get_versioning_start( project_name, host.name, @@ -498,8 +489,6 @@ def save_next_version( task_type=task_entity["taskType"], product_type="workfile" ) - else: - version = last_version + 1 template_data["version"] = version template_data["comment"] = comment From bc433532f02d192902663d556faf97bca955b413 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:17:28 +0200 Subject: [PATCH 131/312] always store all data even if are set to None --- client/ayon_core/pipeline/workfile/utils.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 82326754b8..0c3a50446d 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -586,13 +586,9 @@ def _create_workfile_info_entity( attrib[key] = value data = { - key: value - for key, value in ( - ("host_name", host_name), - ("version", version), - ("comment", comment), - ) - if value is not None + "host_name": host_name, + "version": version, + "comment": comment, } workfile_info = { From b9cad42dc28b7e1367a0dea119dc37f4849851e2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:17:47 +0200 Subject: [PATCH 132/312] use data from WorkfileInfo in workfiles tool --- .../tools/workfiles/models/workfiles.py | 66 ++++++++----------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 35ffc3102c..b08a138cc2 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -466,19 +466,20 @@ class WorkfilesModel: template_has_comment = "{comment" in file_template_str file_items = self.get_workarea_file_items(folder_id, task_id) - filepaths = [ - item.filepath - for item in file_items - ] - comment_hints, comment = get_comments_from_workfile_paths( - filepaths, - extensions, - file_template, - fill_data, - current_filename, - ) + comment_hints = set() + comment = None + for item in file_items: + filepath = item.filepath + filename = os.path.basename(filepath) + if filename == current_filename: + comment = item.comment + + if item.comment: + comment_hints.add(item.comment) + comment_hints = list(comment_hints) + last_version = self._get_last_workfile_version( - filepaths, file_template_str, fill_data, extensions + file_items, task_entity ) return { @@ -530,15 +531,11 @@ class WorkfilesModel: if use_last_version: file_items = self.get_workarea_file_items(folder_id, task_id) - filepaths = [ - item.filepath - for item in file_items - ] + task_entity = self._controller.get_task_entity( + self._project_name, task_id + ) version = self._get_last_workfile_version( - filepaths, - file_template.template, - fill_data, - self._extensions + file_items, task_entity ) fill_data["version"] = version fill_data["ext"] = extension.lstrip(".") @@ -800,11 +797,7 @@ class WorkfilesModel: ) def _get_last_workfile_version( - self, - filepaths: list[str], - file_template: str, - fill_data: dict[str, Any], - extensions: set[str] + self, file_items: list[WorkfileInfo], task_entity: dict[str, Any] ) -> int: """ @@ -813,27 +806,26 @@ class WorkfilesModel: last version + 1 which might be wrong. Args: - filepaths (list[str]): Workfile paths. - file_template (str): File template. - fill_data (dict[str, Any]): Fill data. - extensions (set[str]): Extensions. + file_items (list[WorkfileInfo]): Workfile items. + task_entity (dict[str, Any]): Task entity. Returns: int: Next workfile version. """ - version = get_last_workfile_with_version_from_paths( - filepaths, file_template, fill_data, extensions - )[1] - if version is not None: - return version + 1 + versions = { + item.version + for item in file_items + if item.version is not None + } + if versions: + return max(versions) + 1 - task_info = fill_data.get("task", {}) return get_versioning_start( self._project_name, self._host_name, - task_name=task_info.get("name"), - task_type=task_info.get("type"), + task_name=task_entity["name"], + task_type=task_entity["taskType"], product_type="workfile", project_settings=self._controller.project_settings, ) From 6fd5ee7ed0e5ff274cd814f41c8ff31ccdf4a926 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:06:18 +0200 Subject: [PATCH 133/312] use context instead of prepared data --- client/ayon_core/host/interfaces/workfiles.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 53ec02ac57..b6c33337e9 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -1154,7 +1154,7 @@ class IWorkfileHost: version = parsed_data.version comment = parsed_data.comment - filepath = prepared_data.anatomy.fill_root(rootless_path) + filepath = list_workfiles_context.anatomy.fill_root(rootless_path) items.append(WorkfileInfo.new( filepath, rootless_path, @@ -1203,14 +1203,14 @@ class IWorkfileHost: versions_by_id = { version_entity["id"]: version_entity - for version_entity in prepared_data.version_entities + for version_entity in list_workfiles_context.version_entities } extensions = { ext.lstrip(".") for ext in self.get_workfile_extensions() } items = [] - for repre_entity in prepared_data.repre_entities: + for repre_entity in list_workfiles_context.repre_entities: version_id = repre_entity["versionId"] version_entity = versions_by_id[version_id] task_id = version_entity["taskId"] @@ -1232,7 +1232,7 @@ class IWorkfileHost: try: workfile_path = workfile_path.format( - root=prepared_data.anatomy.roots + root=list_workfiles_context.anatomy.roots ) except Exception: self.log.warning( From 77a31cb5e95003fd6e97e7d01e5aa036bc3b07e2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:14:58 +0200 Subject: [PATCH 134/312] added helper functions to parse dynamic data from workfile --- .../pipeline/workfile/path_resolving.py | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index ed7ac03e81..b750e3bf47 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -284,15 +284,54 @@ class WorkfileDataParser: return WorkfileParsedData(**kwargs) -def parse_data_from_workfile( +def parse_dynamic_data_from_workfile( filename: str, file_template: str, template_data: dict[str, Any], ) -> WorkfileParsedData: + """Parse dynamic data from a workfile filename. + + Dynamic data are 'version', 'comment' and 'ext'. + + Args: + filename (str): Workfile filename. + file_template (str): Workfile file template. + template_data (dict[str, Any]): Data to fill the template with. + + Returns: + WorkfileParsedData: Dynamic data parsed from the filename. + + """ parser = WorkfileDataParser(file_template, template_data) return parser.parse_data(filename) +def parse_dynamic_data_from_workfiles( + filenames: list[str], + file_template: str, + template_data: dict[str, Any], +) -> dict[str, WorkfileParsedData]: + """Parse dynamic data from a workfiles filenames. + + Dynamic data are 'version', 'comment' and 'ext'. + + Args: + filenames (list[str]): Workfiles filenames. + file_template (str): Workfile file template. + template_data (dict[str, Any]): Data to fill the template with. + + Returns: + dict[str, WorkfileParsedData]: Dynamic data parsed from the filenames + by filename. + + """ + parser = WorkfileDataParser(file_template, template_data) + return { + filename: parser.parse_data(filename) + for filename in filenames + } + + def get_last_workfile_with_version_from_paths( filepaths: list[str], file_template: str, From 567c8ed650bf523c724a6ddc6d9e34cc830f238c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:15:12 +0200 Subject: [PATCH 135/312] added warnings to comment matcher function and class --- .../ayon_core/pipeline/workfile/path_resolving.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index b750e3bf47..3f7d23f07f 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -824,6 +824,12 @@ class CommentMatcher: file_template: StringTemplate, data: dict[str, Any] ): + warnings.warn( + "Class 'CommentMatcher' is deprecated. Please" + " use 'parse_dynamic_data_from_workfiles' instead.", + DeprecationWarning, + stacklevel=2, + ) self._fname_regex = None if "{comment}" not in file_template: @@ -873,7 +879,7 @@ def get_comments_from_workfile_paths( template_data: dict[str, Any], current_filename: Optional[str] = None, ) -> tuple[list[str], str]: - """Collect comments from workfile filenames. + """DEPRECATED Collect comments from workfile filenames. Based on 'current_filename' is also returned "current comment". @@ -888,6 +894,12 @@ def get_comments_from_workfile_paths( tuple[list[str], str]: List of comments and the current comment. """ + warnings.warn( + "Function 'get_comments_from_workfile_paths' is deprecated. Please" + " use 'parse_dynamic_data_from_workfiles' instead.", + DeprecationWarning, + stacklevel=2, + ) current_comment = "" if not filepaths: return [], current_comment From bb4b975bf59692bf752bbedcd88c850e13988e6b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:17:56 +0200 Subject: [PATCH 136/312] added explaining comment --- client/ayon_core/pipeline/workfile/path_resolving.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 3f7d23f07f..a13fe6b5cc 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -249,7 +249,14 @@ class WorkfileDataParser: file_template = re.sub(pattern, replacement, file_template) file_template = StringTemplate(file_template) + # Prepare template that does contain 'comment' comment_template = re.escape(str(file_template.format_strict(data))) + # Prepare template that does not contain 'comment' + # - comment is usually marked as optional and in that case the regex + # to find the comment is different based on the filename + # - if filename contains comment then 'comment_template' will match + # - if filename does not contain comment then 'file_template' will + # match data.pop("comment") file_template = re.escape(str(file_template.format_strict(data))) for src, replacement in ( From 86ce8d799bb12326c226a88032ec9d7f48ab39e4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:22:02 +0200 Subject: [PATCH 137/312] remove unused imports --- client/ayon_core/tools/workfiles/models/workfiles.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index b08a138cc2..d33a532222 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -35,8 +35,6 @@ from ayon_core.pipeline.template_data import ( from ayon_core.pipeline.workfile import ( get_workdir_with_workdir_data, get_workfile_template_key, - get_last_workfile_with_version_from_paths, - get_comments_from_workfile_paths, save_workfile_info, ) from ayon_core.pipeline.version_start import get_versioning_start From e9e6c68523dd2da20c031381006375427630f24c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:22:11 +0200 Subject: [PATCH 138/312] fix line length --- client/ayon_core/pipeline/workfile/path_resolving.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index a13fe6b5cc..b806f1ebf0 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -243,7 +243,8 @@ class WorkfileDataParser: data["version"] = version_replacement data["comment"] = comment_replacement for pattern, replacement in ( - # Replace `.{ext}` with `{ext}` so we are sure dot is not at the end + # Replace `.{ext}` with `{ext}` so we are sure dot is not + # at the end (r"\.?{ext}", ext_replacement), ): file_template = re.sub(pattern, replacement, file_template) From 02ed6cb9e080a1f3f0e751dd046fe14470edb1f7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:34:33 +0200 Subject: [PATCH 139/312] remove double slashes --- client/ayon_core/host/interfaces/workfiles.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index b6c33337e9..193d59322b 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -1113,6 +1113,11 @@ class IWorkfileHost: filepath = os.path.join(workdir, filename) rootless_path = f"{rootless_workdir}/{filename}" + # Double slashes can happen when root leads to root of disk or + # when task exists on root folder + # - '/{hierarchy}/{folder[name]}' -> '//some_folder' + while "//" in rootless_path: + rootless_path = rootless_path.replace("//", "/") workfile_entity = workfile_entities_by_path.pop( rootless_path, None ) From 6b452e49291db234a0bc8d37105baf4c5571139e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Jul 2025 17:50:35 +0200 Subject: [PATCH 140/312] fix the slashes issue at the root --- client/ayon_core/host/interfaces/workfiles.py | 5 ----- client/ayon_core/lib/path_templates.py | 7 ++++++- client/ayon_core/pipeline/anatomy/templates.py | 10 ++++++++++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 193d59322b..b6c33337e9 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -1113,11 +1113,6 @@ class IWorkfileHost: filepath = os.path.join(workdir, filename) rootless_path = f"{rootless_workdir}/{filename}" - # Double slashes can happen when root leads to root of disk or - # when task exists on root folder - # - '/{hierarchy}/{folder[name]}' -> '//some_folder' - while "//" in rootless_path: - rootless_path = rootless_path.replace("//", "/") workfile_entity = workfile_entities_by_path.pop( rootless_path, None ) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index 9e3e455a6c..c6e9e14eac 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -3,6 +3,7 @@ import re import copy import numbers import warnings +import platform from string import Formatter import typing from typing import List, Dict, Any, Set @@ -12,6 +13,7 @@ if typing.TYPE_CHECKING: SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)") OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") +_IS_WINDOWS = platform.system().lower() == "windows" class TemplateUnsolved(Exception): @@ -277,8 +279,11 @@ class TemplateResult(str): """Convert to normalized path.""" cls = self.__class__ + path = str(self) + if _IS_WINDOWS: + path = path.replace("\\", "/") return cls( - os.path.normpath(self.replace("\\", "/")), + os.path.normpath(path), self.template, self.solved, self.used_values, diff --git a/client/ayon_core/pipeline/anatomy/templates.py b/client/ayon_core/pipeline/anatomy/templates.py index d89b70719e..e3ec005089 100644 --- a/client/ayon_core/pipeline/anatomy/templates.py +++ b/client/ayon_core/pipeline/anatomy/templates.py @@ -1,6 +1,7 @@ import os import re import copy +import platform import collections import numbers @@ -15,6 +16,7 @@ from .exceptions import ( AnatomyTemplateUnsolved, ) +_IS_WINDOWS = platform.system().lower() == "windows" _PLACEHOLDER = object() @@ -526,6 +528,14 @@ class AnatomyTemplates: root_key = "{" + root_key + "}" output = output.replace(str(used_value), root_key) + # Make sure rootless path is with forward slashes + if _IS_WINDOWS: + output.replace("\\", "/") + + # Make sure there are no double slashes + while "//" in output: + output = output.replace("//", "/") + return output def format(self, data, strict=True): From 11c7119aa550cbd6ed9758d5f1793ec1af0cdd9a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 9 Jul 2025 18:53:06 +0200 Subject: [PATCH 141/312] Add photoshop review to be handled by global extract_review --- client/ayon_core/plugins/publish/extract_review.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index a5f541225c..7aa40a17a4 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -162,6 +162,7 @@ class ExtractReview(pyblish.api.InstancePlugin): "flame", "unreal", "circuit", + "photoshop" ] # Supported extensions From de0e069a419c78af7916a1d45283ee19df95ea49 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 9 Jul 2025 18:53:28 +0200 Subject: [PATCH 142/312] Add photoshop thumbnails to be handled by global extract_thumbnail --- client/ayon_core/plugins/publish/extract_thumbnail.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 69bb9007f9..66acb15312 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -38,6 +38,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): "substancedesigner", "nuke", "aftereffects", + "photoshop", "unreal", "houdini", "circuit", From 50ae9ee4189bd9482d9ed1c9d10a34bd21d3af81 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 9 Jul 2025 18:54:25 +0200 Subject: [PATCH 143/312] Added photoshop specific defaults to ExtractReview More closely follow what PS was doing internally. --- server/settings/publish_plugins.py | 97 ++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index d690d79607..b14f43e48a 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1442,6 +1442,103 @@ DEFAULT_PUBLISH_VALUES = { "fill_missing_frames": "closest_existing" } ] + }, + { + "product_types": [], + "hosts": ["photoshop"], + "outputs": [ + { + "name": "jpg", + "ext": "jpg", + "tags": [ + "ftrackreview", + "kitsureview", + "webreview" + ], + "burnins": [], + "ffmpeg_args": { + "video_filters": [], + "audio_filters": [], + "input": [], + "output": [] + }, + "filter": { + "families": [ + "render", + "review", + "ftrack" + ], + "product_names": [], + "custom_tags": [], + "single_frame_filter": "single_frame" + }, + "overscan_crop": "", + # "overscan_color": [0, 0, 0], + "overscan_color": [0, 0, 0, 0.0], + "width": 1920, + "height": 1080, + "scale_pixel_aspect": True, + "bg_color": [0, 0, 0, 0.0], + "letter_box": { + "enabled": False, + "ratio": 0.0, + "fill_color": [0, 0, 0, 1.0], + "line_thickness": 0, + "line_color": [255, 0, 0, 1.0] + }, + "fill_missing_frames": "closest_existing" + }, + { + "name": "mov", + "ext": "mov", + "tags": [ + "ftrackreview", + "kitsureview", + "webreview" + ], + "burnins": [], + "ffmpeg_args": { + "video_filters": [], + "audio_filters": [], + "input": [ + "-apply_trc gamma22" + ], + "output": [ + "-pix_fmt yuv420p", + "-crf 18", + "-c:a aac", + "-b:a 192k", + "-g 1", + "-movflags faststart" + ] + }, + "filter": { + "families": [ + "render", + "review", + "ftrack" + ], + "product_names": [], + "custom_tags": [], + "single_frame_filter": "multi_frame" + }, + "overscan_crop": "", + # "overscan_color": [0, 0, 0], + "overscan_color": [0, 0, 0, 0.0], + "width": 0, + "height": 0, + "scale_pixel_aspect": True, + "bg_color": [0, 0, 0, 0.0], + "letter_box": { + "enabled": False, + "ratio": 0.0, + "fill_color": [0, 0, 0, 1.0], + "line_thickness": 0, + "line_color": [255, 0, 0, 1.0] + }, + "fill_missing_frames": "closest_existing" + } + ] } ] }, From dce81ba92d66adb9aafe67ad4c3df38f17708727 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 14 Jul 2025 12:03:38 +0200 Subject: [PATCH 144/312] If an instance is not set to `integrate` then skip the validation check against it. This should fix the issue described [here](https://community.ynput.io/t/houdini-local-render-and-publish-existing-frames-error/2647/7?u=bigroy) where a matching "render instance" is generated for the local rendering spawning off from an initial instance - where the initial instance becomes set to not integrate (integrate=False) but remain available for further validations (publish=True). --- client/ayon_core/plugins/publish/validate_unique_subsets.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/validate_unique_subsets.py b/client/ayon_core/plugins/publish/validate_unique_subsets.py index 4067dd75a5..e91cd16374 100644 --- a/client/ayon_core/plugins/publish/validate_unique_subsets.py +++ b/client/ayon_core/plugins/publish/validate_unique_subsets.py @@ -34,7 +34,11 @@ class ValidateProductUniqueness(pyblish.api.ContextPlugin): for instance in context: # Ignore disabled instances - if not instance.data.get('publish', True): + if not instance.data.get("publish", True): + continue + + # Ignore disabled instances + if not instance.data.get("integrate", True): continue # Ignore instance without folder data From 1e98481a10a58c433ef8d4dd0ac68a8099f28cca Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 14 Jul 2025 12:06:07 +0200 Subject: [PATCH 145/312] Tweak comment --- client/ayon_core/plugins/publish/validate_unique_subsets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/validate_unique_subsets.py b/client/ayon_core/plugins/publish/validate_unique_subsets.py index e91cd16374..26c9ada116 100644 --- a/client/ayon_core/plugins/publish/validate_unique_subsets.py +++ b/client/ayon_core/plugins/publish/validate_unique_subsets.py @@ -37,7 +37,7 @@ class ValidateProductUniqueness(pyblish.api.ContextPlugin): if not instance.data.get("publish", True): continue - # Ignore disabled instances + # Ignore instances not marked to integrate if not instance.data.get("integrate", True): continue From 37980d2299909b3b108f41f5998e231b43e25dab Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 14 Jul 2025 14:54:58 +0200 Subject: [PATCH 146/312] Fix all characters of report being printed to new lines --- client/ayon_core/pipeline/publish/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 49143c4426..fb84417730 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -1048,7 +1048,7 @@ def main_cli_publish( discover_result = publish_plugins_discover() publish_plugins = discover_result.plugins - print("\n".join(discover_result.get_report(only_errors=False))) + print(discover_result.get_report(only_errors=False)) # Error exit as soon as any error occurs. error_format = ("Failed {plugin.__name__}: " From 6064f095c8db0ef8a7d7137c0947275cd8603e6b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 14 Jul 2025 17:11:19 +0200 Subject: [PATCH 147/312] added base of instance parenting --- client/ayon_core/pipeline/create/structures.py | 4 ++++ client/ayon_core/tools/publisher/models/create.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index a4c68d2502..3048ae2829 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -653,6 +653,10 @@ class CreatedInstance: def product_name(self): return self._data["productName"] + @property + def parent_instance_id(self) -> Optional[str]: + return self._data.get("parentInstanceId") + @property def label(self): label = self._data.get("label") diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 75ed2c73fe..058077aadd 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -219,6 +219,7 @@ class InstanceItem: is_active: bool, is_mandatory: bool, has_promised_context: bool, + parent_instance_id: Optional[str], ): self._instance_id: str = instance_id self._creator_identifier: str = creator_identifier @@ -232,6 +233,7 @@ class InstanceItem: self._is_active: bool = is_active self._is_mandatory: bool = is_mandatory self._has_promised_context: bool = has_promised_context + self._parent_instance_id: Optional[str] = parent_instance_id @property def id(self): @@ -261,6 +263,10 @@ class InstanceItem: def has_promised_context(self): return self._has_promised_context + @property + def parent_instance_id(self): + return self._parent_instance_id + def get_variant(self): return self._variant @@ -312,6 +318,7 @@ class InstanceItem: instance["active"], instance.is_mandatory, instance.has_promised_context, + instance.parent_instance_id, ) From 9792be3c849c840f5fca8ade4f54bb3942af81fa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 14 Jul 2025 17:14:16 +0200 Subject: [PATCH 148/312] modified instances view to show parenting hierarchy --- .../publisher/widgets/list_view_widgets.py | 488 +++++++++--------- 1 file changed, 249 insertions(+), 239 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 969bec11e5..9fb0402810 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -22,12 +22,15 @@ selection can be enabled disabled using checkbox or keyboard key presses: ... ``` """ +from __future__ import annotations + import collections +import typing from qtpy import QtWidgets, QtCore, QtGui from ayon_core.style import get_objected_colors -from ayon_core.tools.utils import NiceCheckbox +from ayon_core.tools.utils import NiceCheckbox, BaseClickableFrame from ayon_core.tools.utils.lib import html_escape, checkstate_int_to_enum from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend @@ -44,6 +47,9 @@ from ayon_core.tools.publisher.constants import ( from .widgets import AbstractInstanceView +if typing.TYPE_CHECKING: + from ayon_core.tools.publisher.abstract import InstanceItem + class ListItemDelegate(QtWidgets.QStyledItemDelegate): """Generic delegate for instance group. @@ -135,8 +141,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): active_checkbox.setVisible(not instance.is_mandatory) layout = QtWidgets.QHBoxLayout(self) - content_margins = layout.contentsMargins() - layout.setContentsMargins(content_margins.left() + 2, 0, 2, 0) + layout.setContentsMargins(2, 0, 2, 0) layout.addWidget(product_name_label) layout.addStretch(1) layout.addWidget(active_checkbox) @@ -194,6 +199,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): def update_instance(self, instance, context_info): """Update instance object.""" # Check product name + self._instance_id = instance.id label = instance.label if label != self._instance_label_widget.text(): self._instance_label_widget.setText(html_escape(label)) @@ -241,43 +247,33 @@ class ListContextWidget(QtWidgets.QFrame): self.double_clicked.emit() -class InstanceListGroupWidget(QtWidgets.QFrame): +class InstanceListGroupWidget(BaseClickableFrame): """Widget representing group of instances. - Has collapse/expand indicator, label of group and checkbox modifying all - of its children. + Has label of group and checkbox modifying all of its children. """ - expand_changed = QtCore.Signal(str, bool) toggle_requested = QtCore.Signal(str, int) + expand_change_requested = QtCore.Signal(str) def __init__(self, group_name, parent): super().__init__(parent) self.setObjectName("InstanceListGroupWidget") self.group_name = group_name - self._expanded = False - - expand_btn = QtWidgets.QToolButton(self) - expand_btn.setObjectName("ArrowBtn") - expand_btn.setArrowType(QtCore.Qt.RightArrow) - expand_btn.setMaximumWidth(14) name_label = QtWidgets.QLabel(group_name, self) toggle_checkbox = NiceCheckbox(parent=self) layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(5, 0, 2, 0) - layout.addWidget(expand_btn) + layout.setContentsMargins(2, 0, 2, 0) layout.addWidget( name_label, 1, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter ) layout.addWidget(toggle_checkbox, 0) name_label.setAttribute(QtCore.Qt.WA_TranslucentBackground) - expand_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground) - expand_btn.clicked.connect(self._on_expand_clicked) toggle_checkbox.stateChanged.connect(self._on_checkbox_change) self._ignore_state_change = False @@ -285,7 +281,6 @@ class InstanceListGroupWidget(QtWidgets.QFrame): self._expected_checkstate = None self.name_label = name_label - self.expand_btn = expand_btn self.toggle_checkbox = toggle_checkbox def set_checkstate(self, state): @@ -307,26 +302,15 @@ class InstanceListGroupWidget(QtWidgets.QFrame): return self.toggle_checkbox.checkState() + def set_active_toggle_enabled(self, enabled): + self.toggle_checkbox.setEnabled(enabled) + def _on_checkbox_change(self, state): if not self._ignore_state_change: self.toggle_requested.emit(self.group_name, state) - def _on_expand_clicked(self): - self.expand_changed.emit(self.group_name, not self._expanded) - - def set_expanded(self, expanded): - """Change icon of collapse/expand identifier.""" - if self._expanded == expanded: - return - - self._expanded = expanded - if expanded: - self.expand_btn.setArrowType(QtCore.Qt.DownArrow) - else: - self.expand_btn.setArrowType(QtCore.Qt.RightArrow) - - def set_active_toggle_enabled(self, enabled): - self.toggle_checkbox.setEnabled(enabled) + def _mouse_release_callback(self): + self.expand_change_requested.emit(self.group_name) class InstanceTreeView(QtWidgets.QTreeView): @@ -339,24 +323,11 @@ class InstanceTreeView(QtWidgets.QTreeView): self.setObjectName("InstanceListView") self.setHeaderHidden(True) - self.setIndentation(0) self.setExpandsOnDoubleClick(False) self.setSelectionMode( QtWidgets.QAbstractItemView.ExtendedSelection ) self.viewport().setMouseTracking(True) - self._pressed_group_index = None - - def _expand_item(self, index, expand=None): - is_expanded = self.isExpanded(index) - if expand is None: - expand = not is_expanded - - if expand != is_expanded: - if expand: - self.expand(index) - else: - self.collapse(index) def get_selected_instance_ids(self): """Ids of selected instances.""" @@ -388,53 +359,6 @@ class InstanceTreeView(QtWidgets.QTreeView): return super().event(event) - def _mouse_press(self, event): - """Store index of pressed group. - - This is to be able to change state of group and process mouse - "double click" as 2x "single click". - """ - if event.button() != QtCore.Qt.LeftButton: - return - - pressed_group_index = None - pos_index = self.indexAt(event.pos()) - if pos_index.data(IS_GROUP_ROLE): - pressed_group_index = pos_index - - self._pressed_group_index = pressed_group_index - - def mousePressEvent(self, event): - self._mouse_press(event) - super().mousePressEvent(event) - - def mouseDoubleClickEvent(self, event): - self._mouse_press(event) - super().mouseDoubleClickEvent(event) - - def _mouse_release(self, event, pressed_index): - if event.button() != QtCore.Qt.LeftButton: - return False - - pos_index = self.indexAt(event.pos()) - if not pos_index.data(IS_GROUP_ROLE) or pressed_index != pos_index: - return False - - if self.state() == QtWidgets.QTreeView.State.DragSelectingState: - indexes = self.selectionModel().selectedIndexes() - if len(indexes) != 1 or indexes[0] != pos_index: - return False - - self._expand_item(pos_index) - return True - - def mouseReleaseEvent(self, event): - pressed_index = self._pressed_group_index - self._pressed_group_index = None - result = self._mouse_release(event, pressed_index) - if not result: - super().mouseReleaseEvent(event) - class InstanceListView(AbstractInstanceView): """Widget providing abstract methods of AbstractInstanceView for list view. @@ -472,18 +396,19 @@ class InstanceListView(AbstractInstanceView): instance_view.selectionModel().selectionChanged.connect( self._on_selection_change ) - instance_view.collapsed.connect(self._on_collapse) - instance_view.expanded.connect(self._on_expand) instance_view.toggle_requested.connect(self._on_toggle_request) instance_view.double_clicked.connect(self.double_clicked) self._group_items = {} self._group_widgets = {} - self._widgets_by_id = {} + self._widgets_by_id: dict[str, InstanceListItemWidget] = {} + self._items_by_id = {} + self._parent_id_by_id = {} # Group by instance id for handling of active state self._group_by_instance_id = {} self._context_item = None self._context_widget = None + self._missing_parent_item = None self._convertor_group_item = None self._convertor_group_widget = None @@ -496,22 +421,6 @@ class InstanceListView(AbstractInstanceView): self._active_toggle_enabled = True - def _on_expand(self, index): - self._update_widget_expand_state(index, True) - - def _on_collapse(self, index): - self._update_widget_expand_state(index, False) - - def _update_widget_expand_state(self, index, expanded): - group_name = index.data(GROUP_ROLE) - if group_name == CONVERTOR_ITEM_GROUP: - group_widget = self._convertor_group_widget - else: - group_widget = self._group_widgets.get(group_name) - - if group_widget: - group_widget.set_expanded(expanded) - def _on_toggle_request(self, toggle): if not self._active_toggle_enabled: return @@ -583,85 +492,94 @@ class InstanceListView(AbstractInstanceView): self._update_convertor_items_group() context_info_by_id = self._controller.get_instances_context_info() - + instance_items = self._controller.get_instance_items() # Prepare instances by their groups instances_by_group_name = collections.defaultdict(list) + instances_by_parent_id = collections.defaultdict(list) group_names = set() - for instance in self._controller.get_instance_items(): + instance_ids = set() + for instance in instance_items: + instance_ids.add(instance.id) + if instance.parent_instance_id: + instances_by_parent_id[instance.parent_instance_id].append( + instance + ) + continue + group_label = instance.group_label group_names.add(group_label) instances_by_group_name[group_label].append(instance) + missing_parent_ids = set(instances_by_parent_id) - instance_ids + for instance_id in missing_parent_ids: + for instance in instances_by_parent_id[instance_id]: + group_label = instance.group_label + group_names.add(group_label) + instances_by_group_name[group_label].append(instance) + # Create new groups based on prepared `instances_by_group_name` if self._make_sure_groups_exists(group_names): sort_at_the_end = True # Remove groups that are not available anymore self._remove_groups_except(group_names) + self._remove_instances_except(instance_items) - # Store which groups should be expanded at the end expand_groups = set() + expand_to_items = [] + widgets_by_id = {} + # Process changes in each group item # - create new instance, update existing and remove not existing for group_name, group_item in self._group_items.items(): - # Instance items to remove - # - will contain all existing instance ids at the start - # - instance ids may be removed when existing instances are checked - to_remove = set() - # Mapping of existing instances under group item - existing_mapping = {} - - # Get group index to be able to get children indexes - group_index = self._instance_model.index( - group_item.row(), group_item.column() - ) - - # Iterate over children indexes of group item - for idx in range(group_item.rowCount()): - index = self._instance_model.index(idx, 0, group_index) - instance_id = index.data(INSTANCE_ID_ROLE) - # Add all instance into `to_remove` set - to_remove.add(instance_id) - existing_mapping[instance_id] = idx - # Collect all new instances that are not existing under group # New items - new_items = [] - # Tuples of new instance and instance itself - new_items_with_instance = [] + new_items = collections.defaultdict(list) + # Tuples of model item and instance itself + items_with_instance = [] # Group activity (should be {-1;0;1} at the end) # - 0 when all instances are disabled # - 1 when all instances are enabled # - -1 when it's mixed activity = None for instance in instances_by_group_name[group_name]: - instance_id = instance.id - # Handle group activity - if activity is None: - activity = int(instance.is_active) - elif activity == -1: - pass - elif activity != instance.is_active: - activity = -1 + _queue = collections.deque() + _queue.append((instance, group_item, None)) + while _queue: + instance, parent_item, parent_id = _queue.popleft() + instance_id = instance.id + # Handle group activity + if activity is None: + activity = int(instance.is_active) + elif activity == -1: + pass + elif activity != instance.is_active: + activity = -1 - context_info = context_info_by_id[instance_id] + self._group_by_instance_id[instance_id] = group_name - self._group_by_instance_id[instance_id] = group_name - # Remove instance id from `to_remove` if already exists and - # trigger update of widget - if instance_id in to_remove: - to_remove.remove(instance_id) - widget = self._widgets_by_id[instance_id] - widget.update_instance(instance, context_info) - continue + # Create new item and store it as new + item = self._items_by_id.get(instance_id) + if item is None: + item = QtGui.QStandardItem() + item.setData(instance_id, INSTANCE_ID_ROLE) + self._items_by_id[instance_id] = item + new_items[parent_id].append(item) + elif parent_id != self._parent_id_by_id.get(instance_id): + new_items[parent_id].append(item) - # Create new item and store it as new - item = QtGui.QStandardItem() - item.setData(instance.product_name, SORT_VALUE_ROLE) - item.setData(instance.product_name, GROUP_ROLE) - item.setData(instance_id, INSTANCE_ID_ROLE) - new_items.append(item) - new_items_with_instance.append((item, instance)) + self._parent_id_by_id[instance_id] = parent_id + + children = instances_by_parent_id.pop(instance_id, []) + items_with_instance.append( + (item, instance, bool(children)) + ) + + item.setData(instance.product_name, SORT_VALUE_ROLE) + item.setData(instance.product_name, GROUP_ROLE) + + for child in children: + _queue.append((child, item, instance_id)) # Set checkstate of group checkbox state = QtCore.Qt.PartiallyChecked @@ -670,23 +588,9 @@ class InstanceListView(AbstractInstanceView): elif activity == 1: state = QtCore.Qt.Checked - widget = self._group_widgets[group_name] - widget.set_checkstate(state) - - # Remove items that were not found - idx_to_remove = [] - for instance_id in to_remove: - idx_to_remove.append(existing_mapping[instance_id]) - - # Remove them in reverse order to prevent row index changes - for idx in reversed(sorted(idx_to_remove)): - group_item.removeRows(idx, 1) - - # Cleanup instance related widgets - for instance_id in to_remove: - self._group_by_instance_id.pop(instance_id) - widget = self._widgets_by_id.pop(instance_id) - widget.deleteLater() + if group_name is not None: + widget = self._group_widgets[group_name] + widget.set_checkstate(state) # Process new instance items and add them to model and create # their widgets @@ -695,40 +599,76 @@ class InstanceListView(AbstractInstanceView): sort_at_the_end = True # Add items under group item - group_item.appendRows(new_items) + for parent_id, items in new_items.items(): + if parent_id is None: + parent_item = group_item + else: + parent_item = self._items_by_id[parent_id] - for item, instance in new_items_with_instance: - context_info = context_info_by_id[instance.id] - if not context_info.is_valid: - expand_groups.add(group_name) - item_index = self._instance_model.index( - item.row(), - item.column(), - group_index - ) - proxy_index = self._proxy_model.mapFromSource(item_index) + parent_item.appendRows(items) + + for item, instance, has_children in items_with_instance: + context_info = context_info_by_id[instance.id] + # TODO expand all parents + if not context_info.is_valid: + expand_groups.add(group_name) + expand_to_items.append(item) + item_index = self._instance_model.indexFromItem(item) + proxy_index = self._proxy_model.mapFromSource(item_index) + widget = self._instance_view.indexWidget(proxy_index) + if isinstance(widget, InstanceListItemWidget): + widget.update_instance(instance, context_info) + else: widget = InstanceListItemWidget( instance, context_info, self._instance_view ) - widget.set_active_toggle_enabled( - self._active_toggle_enabled - ) widget.active_changed.connect(self._on_active_changed) widget.double_clicked.connect(self.double_clicked) self._instance_view.setIndexWidget(proxy_index, widget) - self._widgets_by_id[instance.id] = widget + widget.set_active_toggle_enabled( + self._active_toggle_enabled + ) - # Trigger sort at the end of refresh - if sort_at_the_end: - self._proxy_model.sort(0) + widgets_by_id[instance.id] = widget + self._widgets_by_id.pop(instance.id, None) - # Expand groups marked for expanding - for group_name in expand_groups: - group_item = self._group_items[group_name] - proxy_index = self._proxy_model.mapFromSource(group_item.index()) + for widget in self._widgets_by_id.values(): + widget.setVisible(False) + widget.deleteLater() + self._widgets_by_id = widgets_by_id + + # Expand items marked for expanding + items_to_expand = [ + self._group_items[group_name] + for group_name in expand_groups + ] + _marked_ids = set() + for item in expand_to_items: + parent = item.parent() + _items = [] + while True: + # Parent is not set or is group (groups are separate) + if parent is None or parent.data(IS_GROUP_ROLE): + break + instance_id = parent.data(INSTANCE_ID_ROLE) + # Parent was already marked for expanding + if instance_id in _marked_ids: + break + _marked_ids.add(instance_id) + _items.append(parent) + parent = parent.parent() + + items_to_expand.extend(reversed(_items)) + + for item in items_to_expand: + proxy_index = self._proxy_model.mapFromSource(item.index()) self._instance_view.expand(proxy_index) + # Trigger sort at the end of refresh + if sort_at_the_end: + self._proxy_model.sort(0) + def _make_sure_context_item_exists(self): if self._context_item is not None: return False @@ -761,7 +701,7 @@ class InstanceListView(AbstractInstanceView): root_item = self._instance_model.invisibleRootItem() if not convertor_items_by_id: - root_item.removeRow(group_item.row()) + root_item.takeRow(group_item.row()) self._convertor_group_widget.deleteLater() self._convertor_group_widget = None self._convertor_items_by_id = {} @@ -785,9 +725,7 @@ class InstanceListView(AbstractInstanceView): CONVERTOR_ITEM_GROUP, self._instance_view ) widget.toggle_checkbox.setVisible(False) - widget.expand_changed.connect( - self._on_convertor_group_expand_request - ) + self._instance_view.setIndexWidget(proxy_index, widget) self._convertor_group_item = group_item @@ -798,7 +736,7 @@ class InstanceListView(AbstractInstanceView): child_identifier = child_item.data(CONVERTER_IDENTIFIER_ROLE) if child_identifier not in convertor_items_by_id: self._convertor_items_by_id.pop(child_identifier, None) - group_item.removeRows(row, 1) + group_item.takeRow(row) new_items = [] for identifier, convertor_item in convertor_items_by_id.items(): @@ -853,8 +791,10 @@ class InstanceListView(AbstractInstanceView): widget.set_active_toggle_enabled( self._active_toggle_enabled ) - widget.expand_changed.connect(self._on_group_expand_request) widget.toggle_requested.connect(self._on_group_toggle_request) + widget.expand_change_requested.connect( + self._on_expand_toggle_request + ) self._group_widgets[group_name] = widget self._instance_view.setIndexWidget(proxy_index, widget) @@ -868,10 +808,84 @@ class InstanceListView(AbstractInstanceView): continue group_item = self._group_items.pop(group_name) - root_item.removeRow(group_item.row()) + root_item.takeRow(group_item.row()) widget = self._group_widgets.pop(group_name) + widget.setVisible(False) widget.deleteLater() + def _remove_instances_except(self, instance_items: list[InstanceItem]): + parent_id_by_id = { + item.id: item.parent_instance_id + for item in instance_items + } + instance_ids = set(parent_id_by_id) + all_removed_ids = set(self._items_by_id) - instance_ids + queue = collections.deque() + for group_item in self._group_items.values(): + queue.append((group_item, None)) + while queue: + parent_item, parent_id = queue.popleft() + children = [ + parent_item.child(row) + for row in range(parent_item.rowCount()) + ] + for child in children: + instance_id = child.data(INSTANCE_ID_ROLE) + if instance_id not in parent_id_by_id: + parent_item.takeRow(child.row()) + elif parent_id != parent_id_by_id[instance_id]: + parent_item.takeRow(child.row()) + + queue.append((child, instance_id)) + + for instance_id in all_removed_ids: + self._items_by_id.pop(instance_id) + self._group_by_instance_id.pop(instance_id) + self._parent_id_by_id.pop(instance_id) + widget = self._widgets_by_id.pop(instance_id, None) + if widget is not None: + widget.setVisible(False) + widget.deleteLater() + + def _add_missing_parent_item(self): + label = "! Orphaned instances !" + if self._missing_parent_item is None: + item = QtGui.QStandardItem() + item.setData(label, GROUP_ROLE) + item.setData("_", SORT_VALUE_ROLE) + item.setData(True, IS_GROUP_ROLE) + item.setFlags(QtCore.Qt.ItemIsEnabled) + self._missing_parent_item = item + + if self._missing_parent_item.parent() is None: + root_item = self._instance_model.invisibleRootItem() + root_item.appendRow(self._missing_parent_item) + index = self._missing_parent_item.index() + proxy_index = self._proxy_model.mapFromSource(index) + widget = InstanceListGroupWidget(label, self._instance_view) + widget.toggle_checkbox.setVisible(False) + self._instance_view.setIndexWidget(proxy_index, widget) + return self._missing_parent_item + + def _remove_missing_parent_item(self): + if self._missing_parent_item is None: + return + + row = self._missing_parent_item.row() + if row < 0: + return + + parent = self._missing_parent_item.parent() + if parent is None: + parent = self._instance_model.invisibleRootItem() + index = self._missing_parent_item.index() + proxy_index = self._proxy_model.mapFromSource(index) + widget = self._instance_view.indexWidget(proxy_index) + if widget is not None: + widget.setVisible(False) + widget.deleteLater() + parent.takeRow(self._missing_parent_item.row()) + def refresh_instance_states(self, instance_ids=None): """Trigger update of all instances.""" if instance_ids is not None: @@ -925,26 +939,13 @@ class InstanceListView(AbstractInstanceView): def _on_selection_change(self, *_args): self.selection_changed.emit() - def _on_group_expand_request(self, group_name, expanded): + def _on_expand_toggle_request(self, group_name): group_item = self._group_items.get(group_name) if not group_item: return - - group_index = self._instance_model.index( - group_item.row(), group_item.column() - ) - proxy_index = self._proxy_model.mapFromSource(group_index) - self._instance_view.setExpanded(proxy_index, expanded) - - def _on_convertor_group_expand_request(self, _, expanded): - group_item = self._convertor_group_item - if not group_item: - return - group_index = self._instance_model.index( - group_item.row(), group_item.column() - ) - proxy_index = self._proxy_model.mapFromSource(group_index) - self._instance_view.setExpanded(proxy_index, expanded) + proxy_index = self._proxy_model.mapFromSource(group_item.index()) + new_state = not self._instance_view.isExpanded(proxy_index) + self._instance_view.setExpanded(proxy_index, new_state) def _on_group_toggle_request(self, group_name, state): state = checkstate_int_to_enum(state) @@ -962,24 +963,33 @@ class InstanceListView(AbstractInstanceView): active_by_id = {} all_changed = True - for row in range(group_item.rowCount()): - item = group_item.child(row) - instance_id = item.data(INSTANCE_ID_ROLE) - widget = self._widgets_by_id.get(instance_id) - if widget is None: - continue - if widget.is_checkbox_enabled(): - active_by_id[instance_id] = active - else: - all_changed = False + items_to_expand = [group_item] + _queue = collections.deque() + _queue.append(group_item) + while _queue: + item = _queue.popleft() + for row in range(item.rowCount()): + child = item.child(row) + instance_id = child.data(INSTANCE_ID_ROLE) + if child.hasChildren(): + items_to_expand.append(child) + _queue.append(child) + widget = self._widgets_by_id.get(instance_id) + if widget is None: + continue + if widget.is_checkbox_enabled(): + active_by_id[instance_id] = active + else: + all_changed = False self._controller.set_instances_active_state(active_by_id) self._change_active_instances(active_by_id, active) - proxy_index = self._proxy_model.mapFromSource(group_item.index()) - if not self._instance_view.isExpanded(proxy_index): - self._instance_view.expand(proxy_index) + for item in items_to_expand: + proxy_index = self._proxy_model.mapFromSource(item.index()) + if not self._instance_view.isExpanded(proxy_index): + self._instance_view.expand(proxy_index) if not all_changed: # If not all instances were changed, update group checkstate From 0a75ab09c509fecfb180fd38513920a3be564c6a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 14 Jul 2025 21:59:28 +0200 Subject: [PATCH 149/312] Report the actual class name --- client/ayon_core/pipeline/plugin_discover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/plugin_discover.py b/client/ayon_core/pipeline/plugin_discover.py index f531600276..03da7fce79 100644 --- a/client/ayon_core/pipeline/plugin_discover.py +++ b/client/ayon_core/pipeline/plugin_discover.py @@ -51,7 +51,7 @@ class DiscoverResult: "*** Discovered {} plugins".format(len(self.plugins)) ) for cls in self.plugins: - lines.append("- {}".format(cls.__class__.__name__)) + lines.append("- {}".format(cls.__name__)) # Plugin that were defined to be ignored if self.ignored_plugins or full_report: From baba4e4d7d6dcb85c8df9cd970914a08fd34996f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Jul 2025 12:43:54 +0200 Subject: [PATCH 150/312] Revert "Added photoshop specific defaults to ExtractReview" This reverts commit 50ae9ee4189bd9482d9ed1c9d10a34bd21d3af81. --- server/settings/publish_plugins.py | 97 ------------------------------ 1 file changed, 97 deletions(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index b14f43e48a..d690d79607 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1442,103 +1442,6 @@ DEFAULT_PUBLISH_VALUES = { "fill_missing_frames": "closest_existing" } ] - }, - { - "product_types": [], - "hosts": ["photoshop"], - "outputs": [ - { - "name": "jpg", - "ext": "jpg", - "tags": [ - "ftrackreview", - "kitsureview", - "webreview" - ], - "burnins": [], - "ffmpeg_args": { - "video_filters": [], - "audio_filters": [], - "input": [], - "output": [] - }, - "filter": { - "families": [ - "render", - "review", - "ftrack" - ], - "product_names": [], - "custom_tags": [], - "single_frame_filter": "single_frame" - }, - "overscan_crop": "", - # "overscan_color": [0, 0, 0], - "overscan_color": [0, 0, 0, 0.0], - "width": 1920, - "height": 1080, - "scale_pixel_aspect": True, - "bg_color": [0, 0, 0, 0.0], - "letter_box": { - "enabled": False, - "ratio": 0.0, - "fill_color": [0, 0, 0, 1.0], - "line_thickness": 0, - "line_color": [255, 0, 0, 1.0] - }, - "fill_missing_frames": "closest_existing" - }, - { - "name": "mov", - "ext": "mov", - "tags": [ - "ftrackreview", - "kitsureview", - "webreview" - ], - "burnins": [], - "ffmpeg_args": { - "video_filters": [], - "audio_filters": [], - "input": [ - "-apply_trc gamma22" - ], - "output": [ - "-pix_fmt yuv420p", - "-crf 18", - "-c:a aac", - "-b:a 192k", - "-g 1", - "-movflags faststart" - ] - }, - "filter": { - "families": [ - "render", - "review", - "ftrack" - ], - "product_names": [], - "custom_tags": [], - "single_frame_filter": "multi_frame" - }, - "overscan_crop": "", - # "overscan_color": [0, 0, 0], - "overscan_color": [0, 0, 0, 0.0], - "width": 0, - "height": 0, - "scale_pixel_aspect": True, - "bg_color": [0, 0, 0, 0.0], - "letter_box": { - "enabled": False, - "ratio": 0.0, - "fill_color": [0, 0, 0, 1.0], - "line_thickness": 0, - "line_color": [255, 0, 0, 1.0] - }, - "fill_missing_frames": "closest_existing" - } - ] } ] }, From b87dbabd142764e27890283201819b77d00747f0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Jul 2025 16:01:11 +0200 Subject: [PATCH 151/312] Added task_types to ExtractReview profile in Settings --- server/settings/publish_plugins.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index d690d79607..ee422a0acf 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -747,6 +747,11 @@ class ExtractReviewProfileModel(BaseSettingsModel): hosts: list[str] = SettingsField( default_factory=list, title="Host names" ) + task_types: list[str] = SettingsField( + default_factory=list, + title="Task Types", + enum_resolver=task_types_enum, + ) outputs: list[ExtractReviewOutputDefModel] = SettingsField( default_factory=list, title="Output Definitions" ) @@ -1348,6 +1353,7 @@ DEFAULT_PUBLISH_VALUES = { { "product_types": [], "hosts": [], + "task_types": [], "outputs": [ { "name": "png", From ff92960be6a8b16919321d4b598be980db1ba2ac Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Jul 2025 16:01:31 +0200 Subject: [PATCH 152/312] Added task_types to ExtractReview profile --- client/ayon_core/plugins/publish/extract_review.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 7aa40a17a4..1e4997cfb4 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -203,15 +203,21 @@ class ExtractReview(pyblish.api.InstancePlugin): def _get_outputs_for_instance(self, instance): host_name = instance.context.data["hostName"] product_type = instance.data["productType"] + task_type = None + task_entity = instance.data.get("taskEntity") + if task_entity: + task_type = task_entity["taskType"] self.log.debug("Host: \"{}\"".format(host_name)) self.log.debug("Product type: \"{}\"".format(product_type)) + self.log.debug("Task type: \"{}\"".format(task_type)) profile = filter_profiles( self.profiles, { "hosts": host_name, "product_types": product_type, + "task_types": task_type }, logger=self.log) if not profile: From 260aad8c1fb19db519f8f64814b6e20030b36b1b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 21 Jul 2025 15:30:58 +0200 Subject: [PATCH 153/312] implemented 'copy_workfile_to_context' --- client/ayon_core/pipeline/workfile/utils.py | 119 ++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 0c3a50446d..29d636fd7d 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -13,6 +13,7 @@ from ayon_core.settings import get_project_settings from ayon_core.host.interfaces import ( SaveWorkfileOptionalData, ListWorkfilesOptionalData, + CopyWorkfileOptionalData, ) from ayon_core.pipeline.version_start import get_versioning_start from ayon_core.pipeline.template_data import get_template_data @@ -516,6 +517,124 @@ def save_next_version( ) +def copy_workfile_to_context( + src_workfile_path: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + version: Optional[int] = None, + comment: Optional[str] = None, + description: Optional[str] = None, + open_workfile: bool = True, + prepared_data: Optional[CopyWorkfileOptionalData] = None, +) -> None: + """Copy workfile to a context. + + Copy workfile to a specified folder and task. Destination path is + calculated based on passed information. + + Args: + src_workfile_path (str): Source workfile path. + folder_entity (dict[str, Any]): Target folder entity. + task_entity (dict[str, Any]): Target task entity. + version (Optional[int]): Workfile version. Use next version if not + passed. + comment (optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + prepared_data (Optional[CopyWorkfileOptionalData]): Prepared data + for speed enhancements. Rootless path is calculated in this + function. + + """ + from ayon_core.pipeline import Anatomy + from ayon_core.pipeline.context_tools import registered_host + + host = registered_host() + project_name = host.get_current_project_name() + + anatomy = prepared_data.anatomy + if anatomy is None: + if prepared_data.project_entity is None: + prepared_data.project_entity = ayon_api.get_project( + project_name + ) + anatomy = Anatomy( + project_name, project_entity=prepared_data.project_entity + ) + prepared_data.anatomy = anatomy + + project_settings = prepared_data.project_settings + if project_settings is None: + project_settings = get_project_settings(project_name) + prepared_data.project_settings = project_settings + + if version is None: + list_prepared_data = None + if prepared_data is not None: + list_prepared_data = ListWorkfilesOptionalData( + project_entity=prepared_data.project_entity, + anatomy=prepared_data.anatomy, + project_settings=prepared_data.project_settings, + workfile_entities=prepared_data.workfile_entities, + ) + + workfiles = host.list_workfiles( + project_name, + folder_entity, + task_entity, + prepared_data=list_prepared_data + ) + if workfiles: + version = max( + workfile.version + for workfile in workfiles + ) + 1 + else: + version = get_versioning_start( + project_name, + host.name, + task_name=task_entity["name"], + task_type=task_entity["taskType"], + product_type="workfile" + ) + + task_type = task_entity["taskType"] + template_key = get_workfile_template_key( + project_name, + task_type, + host.name, + project_settings=prepared_data.project_settings + ) + + template_data = get_template_data( + prepared_data.project_entity, + folder_entity, + task_entity, + host.name, + prepared_data.project_settings, + ) + template_data["version"] = version + if comment: + template_data["comment"] = comment + + workfile_template = anatomy.get_template_item( + "work", template_key, "path" + ) + workfile_path = workfile_template.format_strict(template_data) + prepared_data.rootless_path = workfile_path.rootless + host.copy_workfile( + src_workfile_path, + workfile_path, + folder_entity, + task_entity, + version=version, + comment=comment, + description=description, + open_workfile=open_workfile, + prepared_data=prepared_data, + ) + + def find_workfile_rootless_path( workfile_path: str, project_name: str, From 4462e218cd317765cfea7c635b0fd60de0c048bf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:10:58 +0200 Subject: [PATCH 154/312] make sure the save is enabled --- client/ayon_core/tools/workfiles/widgets/files_widget.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/workfiles/widgets/files_widget.py b/client/ayon_core/tools/workfiles/widgets/files_widget.py index 012a12ab17..a1993c078b 100644 --- a/client/ayon_core/tools/workfiles/widgets/files_widget.py +++ b/client/ayon_core/tools/workfiles/widgets/files_widget.py @@ -333,7 +333,9 @@ class FilesWidget(QtWidgets.QWidget): ) def _on_save_as_request(self): - self._on_published_save_clicked() + # Make sure the save is enabled + if self._is_save_enabled and self._valid_selected_context: + self._on_published_save_clicked() def _set_select_contex_mode(self, enabled): if self._select_context_mode is enabled: From a1a4066bc60faf1ba017b9130dc4402d105891ab Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:11:06 +0200 Subject: [PATCH 155/312] fix typo --- client/ayon_core/tools/workfiles/widgets/files_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/workfiles/widgets/files_widget.py b/client/ayon_core/tools/workfiles/widgets/files_widget.py index a1993c078b..0c8ad392e2 100644 --- a/client/ayon_core/tools/workfiles/widgets/files_widget.py +++ b/client/ayon_core/tools/workfiles/widgets/files_widget.py @@ -201,7 +201,7 @@ class FilesWidget(QtWidgets.QWidget): def _on_current_open_requests(self): # TODO validate if item under mouse is enabled - # - thi uses selected item, but that does not have to be the one + # - this uses selected item, but that does not have to be the one # under mouse self._on_workarea_open_clicked() From 55dedaef8012f0d4787f3548a76c18bdf15a3a09 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:25:59 +0200 Subject: [PATCH 156/312] allow to pass prepared data --- client/ayon_core/pipeline/workfile/utils.py | 33 +++++++++++++++------ 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 29d636fd7d..28614bbb37 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -406,6 +406,8 @@ def save_next_version( version: Optional[int] = None, comment: Optional[str] = None, description: Optional[str] = None, + *, + prepared_data: Optional[SaveWorkfileOptionalData] = None, ) -> None: """Save workfile using current context, version and comment. @@ -417,6 +419,8 @@ def save_next_version( version + 1 is used if is not passed in. comment (optional[str]): Workfile comment. description (Optional[str]): Workfile description. + prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data + for speed enhancements. """ from ayon_core.pipeline import Anatomy @@ -428,9 +432,25 @@ def save_next_version( project_name = context["project_name"] folder_path = context["folder_path"] task_name = context["task_name"] - project_entity = ayon_api.get_project(project_name) - project_settings = get_project_settings(project_name) - anatomy = Anatomy(project_name, project_entity=project_entity) + if prepared_data is None: + prepared_data = SaveWorkfileOptionalData() + + project_entity = prepared_data.project_entity + anatomy = prepared_data.anatomy + project_settings = prepared_data.project_settings + + if project_entity is None: + project_entity = ayon_api.get_project(project_name) + prepared_data.project_entity = project_entity + + if project_settings is None: + project_settings = get_project_settings(project_name) + prepared_data.project_settings = project_settings + + if anatomy is None: + anatomy = Anatomy(project_name, project_entity=project_entity) + prepared_data.anatomy = anatomy + folder_entity = ayon_api.get_folder_by_path(project_name, folder_path) task_entity = ayon_api.get_task_by_name( project_name, folder_entity["id"], task_name @@ -499,13 +519,8 @@ def save_next_version( rootless_path = f"{rootless_dir}/{filename}" if platform.system().lower() == "windows": rootless_path = rootless_path.replace("\\", "/") + prepared_data.rootless_path = rootless_path - prepared_data = SaveWorkfileOptionalData( - project_entity=project_entity, - anatomy=anatomy, - project_settings=project_settings, - rootless_path=rootless_path, - ) host.save_workfile_with_context( workfile_path, folder_entity, From 84e88f0cf3c328883c2267df3e0a0e8448ec6093 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:51:43 +0200 Subject: [PATCH 157/312] add docstring --- client/ayon_core/pipeline/workfile/utils.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 28614bbb37..aee304d1d3 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -709,6 +709,22 @@ def _create_workfile_info_entity( comment: Optional[str], description: Optional[str], ) -> dict[str, Any]: + """Create workfile entity data. + + Args: + project_name (str): Project name. + task_id (str): Task id. + host_name (str): Host name. + rootless_path (str): Rootless workfile path. + username (str): Username. + version (Optional[int]): Workfile version. + comment (Optional[str]): Workfile comment. + description (Optional[str]): Workfile description. + + Returns: + dict[str, Any]: Created workfile entity data. + + """ extension = os.path.splitext(rootless_path)[1] attrib = {} From 71dc4ca70670173eec8bb2bbd738a3a1a2371a17 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:51:53 +0200 Subject: [PATCH 158/312] added return type hint --- client/ayon_core/pipeline/workfile/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index aee304d1d3..77c1953e4d 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -86,7 +86,7 @@ def should_use_last_workfile_on_launch( task_type: str, default_output: bool = False, project_settings: Optional[dict[str, Any]] = None, -): +) -> bool: """Define if host should start last version workfile if possible. Default output is `False`. Can be overridden with environment variable From 60f1fa8961154e81c47c31c2db04719f5ea28fd7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 21 Jul 2025 19:21:30 +0200 Subject: [PATCH 159/312] remove bundle names from environment variables --- client/ayon_core/tools/launcher/models/actions.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index adb8d371ed..1945019fef 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -513,7 +513,12 @@ class ActionsModel: uri = payload["uri"] else: uri = data["uri"] - run_detached_ayon_launcher_process(uri) + + # Remove bundles from environment variables + env = os.environ.copy() + env.pop("AYON_BUNDLE_NAME", None) + env.pop("AYON_STUDIO_BUNDLE_NAME", None) + run_detached_ayon_launcher_process(uri, env=env) elif response_type in ("query", "navigate"): response.error_message = ( From ec3eaeb75180595fddd494cbf9b3cce97486d94e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 21 Jul 2025 19:26:47 +0200 Subject: [PATCH 160/312] added log --- client/ayon_core/tools/tray/ui/tray.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index f090be063e..cea8d4f747 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -243,6 +243,11 @@ class TrayManager: project_bundle = os.getenv("AYON_BUNDLE_NAME") studio_bundle = os.getenv("AYON_STUDIO_BUNDLE_NAME") if studio_bundle and project_bundle != studio_bundle: + self.log.info( + f"Project bundle '{project_bundle}' is defined, but tray" + " cannot be running in project scope. Restarting tray to use" + " studio bundle." + ) self.restart() def get_services_submenu(self): From 3c8f3224bce64895c97f26a250e952f828b1da16 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:58:20 +0200 Subject: [PATCH 161/312] filter instances without active parents --- .../publish/collect_from_create_context.py | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_from_create_context.py b/client/ayon_core/plugins/publish/collect_from_create_context.py index b99866fed9..8383dfaa96 100644 --- a/client/ayon_core/plugins/publish/collect_from_create_context.py +++ b/client/ayon_core/plugins/publish/collect_from_create_context.py @@ -2,6 +2,8 @@ """ import os +import collections + import pyblish.api from ayon_core.host import IPublishHost @@ -36,18 +38,42 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): if project_name: context.data["projectName"] = project_name + # Filter active instances and skip instances which have disabled + # parent instance + instances_by_parent_id = collections.defaultdict(list) + filtered_instances = [] for created_instance in create_context.instances: + if not created_instance["active"]: + continue + parent_id = created_instance.parent_instance_id + if parent_id is None: + filtered_instances.append(created_instance) + else: + instances_by_parent_id[parent_id].append(created_instance) + + parent_ids_queue = collections.deque() + parent_ids_queue.extend( + instance.id for instance in filtered_instances + ) + while parent_ids_queue: + parent_id = parent_ids_queue.popleft() + children = instances_by_parent_id[parent_id] + if not children: + continue + filtered_instances.extend(children) + parent_ids_queue.extend(instance.id for instance in children) + + for created_instance in filtered_instances: instance_data = created_instance.data_to_store() - if instance_data["active"]: - thumbnail_path = thumbnail_paths_by_instance_id.get( - created_instance.id - ) - self.create_instance( - context, - instance_data, - created_instance.transient_data, - thumbnail_path - ) + thumbnail_path = thumbnail_paths_by_instance_id.get( + created_instance.id + ) + self.create_instance( + context, + instance_data, + created_instance.transient_data, + thumbnail_path + ) # Update global data to context context.data.update(create_context.context_data_to_store()) From 25aac472ab10c9575cbde22870dd5878233709f1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:58:40 +0200 Subject: [PATCH 162/312] added disable state to list view widget --- client/ayon_core/style/data.json | 1 + client/ayon_core/style/style.css | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/client/ayon_core/style/data.json b/client/ayon_core/style/data.json index 24629ec085..56d2190e09 100644 --- a/client/ayon_core/style/data.json +++ b/client/ayon_core/style/data.json @@ -97,6 +97,7 @@ }, "publisher": { "error": "#AA5050", + "disabled": "#5b6779", "crash": "#FF6432", "success": "#458056", "warning": "#ffc671", diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index b26d36fb7e..0d057beb7b 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -1153,6 +1153,10 @@ PixmapButton:disabled { color: {color:publisher:error}; } +#ListViewProductName[state="disabled"] { + color: {color:publisher:disabled}; +} + #PublishInfoFrame { background: {color:bg}; border-radius: 0.3em; From b50070937965c4a46482947262480f55f992bd08 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:58:56 +0200 Subject: [PATCH 163/312] added 'InstanceContextInfo' to create imports --- client/ayon_core/pipeline/create/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/pipeline/create/__init__.py b/client/ayon_core/pipeline/create/__init__.py index ced43528eb..cbe009d95e 100644 --- a/client/ayon_core/pipeline/create/__init__.py +++ b/client/ayon_core/pipeline/create/__init__.py @@ -27,6 +27,7 @@ from .structures import ( CreatorAttributeValues, PublishAttributeValues, PublishAttributes, + InstanceContextInfo, ) from .utils import ( get_last_versions_for_instances, @@ -91,6 +92,7 @@ __all__ = ( "CreatorAttributeValues", "PublishAttributeValues", "PublishAttributes", + "InstanceContextInfo", "get_last_versions_for_instances", "get_next_versions_for_instances", From c8eb0faf3cf300605fbf2854ffbc5f0ef8de4cb1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Jul 2025 15:31:57 +0200 Subject: [PATCH 164/312] visualize instance parenting in list view --- .../publisher/widgets/list_view_widgets.py | 501 ++++++++++++------ 1 file changed, 331 insertions(+), 170 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 9fb0402810..65bc531d27 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -25,7 +25,7 @@ selection can be enabled disabled using checkbox or keyboard key presses: from __future__ import annotations import collections -import typing +from typing import Optional from qtpy import QtWidgets, QtCore, QtGui @@ -33,7 +33,14 @@ from ayon_core.style import get_objected_colors from ayon_core.tools.utils import NiceCheckbox, BaseClickableFrame from ayon_core.tools.utils.lib import html_escape, checkstate_int_to_enum +from ayon_core.pipeline.create import ( + InstanceContextInfo, +) + from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend +from ayon_core.tools.publisher.models.create import ( + InstanceItem, +) from ayon_core.tools.publisher.constants import ( INSTANCE_ID_ROLE, SORT_VALUE_ROLE, @@ -47,9 +54,6 @@ from ayon_core.tools.publisher.constants import ( from .widgets import AbstractInstanceView -if typing.TYPE_CHECKING: - from ayon_core.tools.publisher.abstract import InstanceItem - class ListItemDelegate(QtWidgets.QStyledItemDelegate): """Generic delegate for instance group. @@ -121,7 +125,13 @@ class InstanceListItemWidget(QtWidgets.QWidget): active_changed = QtCore.Signal(str, bool) double_clicked = QtCore.Signal() - def __init__(self, instance, context_info, parent): + def __init__( + self, + instance: InstanceItem, + context_info: InstanceContextInfo, + parent_is_active: bool, + parent: QtWidgets.QWidget, + ): super().__init__(parent) self._instance_id = instance.id @@ -137,8 +147,6 @@ class InstanceListItemWidget(QtWidgets.QWidget): product_name_label.setObjectName("ListViewProductName") active_checkbox = NiceCheckbox(parent=self) - active_checkbox.setChecked(instance.is_active) - active_checkbox.setVisible(not instance.is_mandatory) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(2, 0, 2, 0) @@ -146,20 +154,32 @@ class InstanceListItemWidget(QtWidgets.QWidget): layout.addStretch(1) layout.addWidget(active_checkbox) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - product_name_label.setAttribute(QtCore.Qt.WA_TranslucentBackground) - active_checkbox.setAttribute(QtCore.Qt.WA_TranslucentBackground) + for widget in ( + self, + product_name_label, + active_checkbox, + ): + widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) active_checkbox.stateChanged.connect(self._on_active_change) self._instance_label_widget = product_name_label self._active_checkbox = active_checkbox - self._has_valid_context = None + # Instance info + self._has_valid_context = context_info.is_valid + self._is_mandatory = instance.is_mandatory + self._instance_is_active = instance.is_active - self._checkbox_enabled = not instance.is_mandatory + # Parent active state is fluent and can change + self._parent_is_active = parent_is_active - self._set_valid_property(context_info.is_valid) + # Widget logic info + self._state = None + self._toggle_is_enabled = True + + self._update_style_state() + self._update_checkbox_state() def mouseDoubleClickEvent(self, event): widget = self.childAt(event.pos()) @@ -167,60 +187,108 @@ class InstanceListItemWidget(QtWidgets.QWidget): if widget is not self._active_checkbox: self.double_clicked.emit() - def _set_valid_property(self, valid): - if self._has_valid_context == valid: - return - self._has_valid_context = valid - state = "" - if not valid: - state = "invalid" - self._instance_label_widget.setProperty("state", state) - self._instance_label_widget.style().polish(self._instance_label_widget) - - def is_active(self): + def is_active(self) -> bool: """Instance is activated.""" return self._active_checkbox.isChecked() - def set_active(self, new_value): - """Change active state of instance and checkbox.""" - old_value = self.is_active() - if new_value is None: - new_value = not old_value - - if new_value != old_value: - self._active_checkbox.blockSignals(True) - self._active_checkbox.setChecked(new_value) - self._active_checkbox.blockSignals(False) - def is_checkbox_enabled(self) -> bool: """Checkbox can be changed by user.""" - return self._checkbox_enabled + return ( + self._parent_is_active + and not self._is_mandatory + ) - def update_instance(self, instance, context_info): + def set_active_toggle_enabled(self, enabled: bool) -> None: + """Toggle can be available for user.""" + self._toggle_is_enabled = enabled + self._update_checkbox_state() + + def set_active(self, new_value: Optional[bool]) -> None: + """Change active state of instance and checkbox by user interaction. + + Args: + new_value (Optional[bool]): New active state of instance. Toggle + if is 'None'. + + """ + # Do not allow to change state if is mandatory or parent is not active + if not self.is_checkbox_enabled(): + return + + if new_value is None: + new_value = not self._active_checkbox.isChecked() + # Update instance active state + self._instance_is_active = new_value + self._set_checked(new_value) + + def update_instance( + self, + instance: InstanceItem, + context_info: InstanceContextInfo, + parent_is_active: bool, + ) -> None: """Update instance object.""" # Check product name self._instance_id = instance.id label = instance.label if label != self._instance_label_widget.text(): self._instance_label_widget.setText(html_escape(label)) - # Check active state - self.set_active(instance.is_active) - self._set_is_mandatory(instance.is_mandatory) - # Check valid states - self._set_valid_property(context_info.is_valid) + + self._is_mandatory = instance.is_mandatory + self._instance_is_active = instance.is_active + self._has_valid_context = context_info.is_valid + self._parent_is_active = parent_is_active + + self._update_checkbox_state() + self._update_style_state() + + def set_parent_is_active(self, active: bool) -> None: + if self._parent_is_active is active: + return + self._parent_is_active = active + self._update_style_state() + self._update_checkbox_state() + + def _set_checked(self, checked: bool) -> None: + """Change checked state in UI without triggering checkstate change.""" + old_value = self._active_checkbox.isChecked() + if checked is not old_value: + self._active_checkbox.blockSignals(True) + self._active_checkbox.setChecked(checked) + self._active_checkbox.blockSignals(False) + + def _update_style_state(self) -> None: + state = "" + if not self._parent_is_active: + state = "disabled" + elif not self._has_valid_context: + state = "invalid" + + if state == self._state: + return + self._state = state + self._instance_label_widget.setProperty("state", state) + self._instance_label_widget.style().polish(self._instance_label_widget) + + def _update_checkbox_state(self) -> None: + self._active_checkbox.setEnabled( + self._toggle_is_enabled + and not self._is_mandatory + and self._parent_is_active + ) + # Hide checkbox for mandatory instances + self._active_checkbox.setVisible(not self._is_mandatory) + + # Visually disable instance if parent is disabled + checked = self._parent_is_active and self._instance_is_active + if checked is not self._active_checkbox.isChecked(): + self._active_checkbox.setChecked(checked) def _on_active_change(self): self.active_changed.emit( self._instance_id, self._active_checkbox.isChecked() ) - def set_active_toggle_enabled(self, enabled): - self._active_checkbox.setEnabled(enabled) - - def _set_is_mandatory(self, is_mandatory: bool) -> None: - self._checkbox_enabled = not is_mandatory - self._active_checkbox.setVisible(not is_mandatory) - class ListContextWidget(QtWidgets.QFrame): """Context (or global attributes) widget.""" @@ -421,7 +489,7 @@ class InstanceListView(AbstractInstanceView): self._active_toggle_enabled = True - def _on_toggle_request(self, toggle): + def _on_toggle_request(self, toggle: int) -> None: if not self._active_toggle_enabled: return @@ -432,20 +500,7 @@ class InstanceListView(AbstractInstanceView): active = True else: active = False - - group_names = set() - for instance_id in selected_instance_ids: - widget = self._widgets_by_id.get(instance_id) - if widget is None: - continue - - widget.set_active(active) - group_name = self._group_by_instance_id.get(instance_id) - if group_name is not None: - group_names.add(group_name) - - for group_name in group_names: - self._update_group_checkstate(group_name) + self._toggle_active_state(selected_instance_ids, active) def _update_group_checkstate(self, group_name): """Update checkstate of one group.""" @@ -454,8 +509,10 @@ class InstanceListView(AbstractInstanceView): return activity = None - for instance_id, _group_name in self._group_by_instance_id.items(): - if _group_name != group_name: + for ( + instance_id, instance_group_name + ) in self._group_by_instance_id.items(): + if instance_group_name != group_name: continue instance_widget = self._widgets_by_id.get(instance_id) @@ -509,13 +566,7 @@ class InstanceListView(AbstractInstanceView): group_label = instance.group_label group_names.add(group_label) instances_by_group_name[group_label].append(instance) - - missing_parent_ids = set(instances_by_parent_id) - instance_ids - for instance_id in missing_parent_ids: - for instance in instances_by_parent_id[instance_id]: - group_label = instance.group_label - group_names.add(group_label) - instances_by_group_name[group_label].append(instance) + self._group_by_instance_id[instance.id] = group_label # Create new groups based on prepared `instances_by_group_name` if self._make_sure_groups_exists(group_names): @@ -525,15 +576,42 @@ class InstanceListView(AbstractInstanceView): self._remove_groups_except(group_names) self._remove_instances_except(instance_items) - expand_groups = set() expand_to_items = [] widgets_by_id = {} + group_items = [ + ( + self._group_widgets[group_name], + instances_by_group_name[group_name], + group_item, + ) + for group_name, group_item in self._group_items.items() + ] + + # Handle orphaned instances + missing_parent_ids = set(instances_by_parent_id) - instance_ids + if not missing_parent_ids: + # Make sure the item is not in view if there are no orhpaned items + self._remove_missing_parent_item() + else: + # Add orphaned group item and append them to 'group_items' + orphans_item = self._add_missing_parent_item() + for instance_id in missing_parent_ids: + group_items.append(( + None, + instances_by_parent_id[instance_id], + orphans_item, + )) # Process changes in each group item # - create new instance, update existing and remove not existing - for group_name, group_item in self._group_items.items(): - # Collect all new instances that are not existing under group - # New items + for group_widget, group_instances, group_item in group_items: + # Group widget is not set if is orphaned + # - This might need to be changed in future if widget could + # be 'None' + is_orpaned_item = group_widget is None + + # Collect all new instances by parent id + # - 'None' is used if parent is group item new_items = collections.defaultdict(list) # Tuples of model item and instance itself items_with_instance = [] @@ -542,7 +620,7 @@ class InstanceListView(AbstractInstanceView): # - 1 when all instances are enabled # - -1 when it's mixed activity = None - for instance in instances_by_group_name[group_name]: + for instance in group_instances: _queue = collections.deque() _queue.append((instance, group_item, None)) while _queue: @@ -556,7 +634,9 @@ class InstanceListView(AbstractInstanceView): elif activity != instance.is_active: activity = -1 - self._group_by_instance_id[instance_id] = group_name + # Remove group name from groups mapping + if parent_id is not None: + self._group_by_instance_id.pop(instance_id, None) # Create new item and store it as new item = self._items_by_id.get(instance_id) @@ -572,7 +652,13 @@ class InstanceListView(AbstractInstanceView): children = instances_by_parent_id.pop(instance_id, []) items_with_instance.append( - (item, instance, bool(children)) + ( + item, + instance, + parent_id, + is_orpaned_item, + bool(children) + ) ) item.setData(instance.product_name, SORT_VALUE_ROLE) @@ -582,15 +668,13 @@ class InstanceListView(AbstractInstanceView): _queue.append((child, item, instance_id)) # Set checkstate of group checkbox - state = QtCore.Qt.PartiallyChecked - if activity == 0: - state = QtCore.Qt.Unchecked - elif activity == 1: - state = QtCore.Qt.Checked - - if group_name is not None: - widget = self._group_widgets[group_name] - widget.set_checkstate(state) + if group_widget is not None: + state = QtCore.Qt.PartiallyChecked + if activity == 0: + state = QtCore.Qt.Unchecked + elif activity == 1: + state = QtCore.Qt.Checked + group_widget.set_checkstate(state) # Process new instance items and add them to model and create # their widgets @@ -607,20 +691,38 @@ class InstanceListView(AbstractInstanceView): parent_item.appendRows(items) - for item, instance, has_children in items_with_instance: + for ( + item, instance, parent_id, is_orpaned_item, has_children + ) in items_with_instance: context_info = context_info_by_id[instance.id] # TODO expand all parents if not context_info.is_valid: - expand_groups.add(group_name) expand_to_items.append(item) + + parent_active = True + if is_orpaned_item: + parent_active = False + + if parent_id: + parent_widget = widgets_by_id.get(parent_id) + parent_active = False + if parent_widget is not None: + parent_active = parent_widget.is_active() item_index = self._instance_model.indexFromItem(item) proxy_index = self._proxy_model.mapFromSource(item_index) widget = self._instance_view.indexWidget(proxy_index) if isinstance(widget, InstanceListItemWidget): - widget.update_instance(instance, context_info) + widget.update_instance( + instance, + context_info, + parent_active, + ) else: widget = InstanceListItemWidget( - instance, context_info, self._instance_view + instance, + context_info, + parent_active, + self._instance_view ) widget.active_changed.connect(self._on_active_changed) widget.double_clicked.connect(self.double_clicked) @@ -639,10 +741,7 @@ class InstanceListView(AbstractInstanceView): self._widgets_by_id = widgets_by_id # Expand items marked for expanding - items_to_expand = [ - self._group_items[group_name] - for group_name in expand_groups - ] + items_to_expand = [] _marked_ids = set() for item in expand_to_items: parent = item.parent() @@ -669,7 +768,7 @@ class InstanceListView(AbstractInstanceView): if sort_at_the_end: self._proxy_model.sort(0) - def _make_sure_context_item_exists(self): + def _make_sure_context_item_exists(self) -> bool: if self._context_item is not None: return False @@ -692,7 +791,7 @@ class InstanceListView(AbstractInstanceView): self._context_item = context_item return True - def _update_convertor_items_group(self): + def _update_convertor_items_group(self) -> bool: created_new_items = False convertor_items_by_id = self._controller.get_convertor_items() group_item = self._convertor_group_item @@ -758,7 +857,7 @@ class InstanceListView(AbstractInstanceView): return created_new_items - def _make_sure_groups_exists(self, group_names): + def _make_sure_groups_exists(self, group_names: set[str]) -> bool: new_group_items = [] for group_name in group_names: if group_name in self._group_items: @@ -800,7 +899,7 @@ class InstanceListView(AbstractInstanceView): return True - def _remove_groups_except(self, group_names): + def _remove_groups_except(self, group_names: set[str]) -> None: # Remove groups that are not available anymore root_item = self._instance_model.invisibleRootItem() for group_name in tuple(self._group_items.keys()): @@ -840,14 +939,14 @@ class InstanceListView(AbstractInstanceView): for instance_id in all_removed_ids: self._items_by_id.pop(instance_id) - self._group_by_instance_id.pop(instance_id) self._parent_id_by_id.pop(instance_id) + self._group_by_instance_id.pop(instance_id, None) widget = self._widgets_by_id.pop(instance_id, None) if widget is not None: widget.setVisible(False) widget.deleteLater() - def _add_missing_parent_item(self): + def _add_missing_parent_item(self) -> QtGui.QStandardItem: label = "! Orphaned instances !" if self._missing_parent_item is None: item = QtGui.QStandardItem() @@ -857,7 +956,7 @@ class InstanceListView(AbstractInstanceView): item.setFlags(QtCore.Qt.ItemIsEnabled) self._missing_parent_item = item - if self._missing_parent_item.parent() is None: + if self._missing_parent_item.row() < 0: root_item = self._instance_model.invisibleRootItem() root_item.appendRow(self._missing_parent_item) index = self._missing_parent_item.index() @@ -867,7 +966,7 @@ class InstanceListView(AbstractInstanceView): self._instance_view.setIndexWidget(proxy_index, widget) return self._missing_parent_item - def _remove_missing_parent_item(self): + def _remove_missing_parent_item(self) -> None: if self._missing_parent_item is None: return @@ -890,34 +989,130 @@ class InstanceListView(AbstractInstanceView): """Trigger update of all instances.""" if instance_ids is not None: instance_ids = set(instance_ids) - context_info_by_id = self._controller.get_instances_context_info() + + context_info_by_id = self._controller.get_instances_context_info( + instance_ids + ) instance_items_by_id = self._controller.get_instance_items_by_id( instance_ids ) - for instance_id, widget in self._widgets_by_id.items(): - if instance_ids is not None and instance_id not in instance_ids: + instance_ids = set(instance_items_by_id) + + group_items = list(self._group_items.values()) + if self._missing_parent_item is not None: + group_items.append(self._missing_parent_item) + + _queue = collections.deque() + for group_item in group_items: + if not group_item.hasChildren(): continue - widget.update_instance( - instance_items_by_id[instance_id], - context_info_by_id[instance_id], - ) + + children = [ + group_item.child(row) + for row in range(group_item.rowCount()) + ] + _queue.append((children, True)) + + while _queue: + if not instance_ids: + break + + children, parent_active = _queue.popleft() + for child in children: + instance_id = child.data(INSTANCE_ID_ROLE) + widget = self._widgets_by_id[instance_id] + if instance_id in instance_ids: + instance_ids.discard(instance_id) + widget.update_instance( + instance_items_by_id[instance_id], + context_info_by_id[instance_id], + parent_active, + ) + if not instance_ids: + break + + if not child.hasChildren(): + continue + + children = [ + child.child(row) + for row in range(child.rowCount()) + ] + _queue.append((children, widget.is_active())) def _on_active_changed(self, changed_instance_id, new_value): selected_instance_ids, _, _ = self.get_selected_items() + if changed_instance_id not in selected_instance_ids: + selected_instance_ids = {changed_instance_id} + self._toggle_active_state( + set(selected_instance_ids), + new_value, + changed_instance_id + ) + + def _toggle_active_state( + self, + instance_ids: set[str], + new_value: Optional[bool], + active_id: Optional[str] = None, + ) -> None: + active_widget = None + if active_id: + active_widget = self._widgets_by_id[active_id] active_by_id = {} - found = False - for instance_id in selected_instance_ids: - active_by_id[instance_id] = new_value - if not found and instance_id == changed_instance_id: - found = True + if active_id and active_id not in instance_ids: + if not active_widget.is_checkbox_enabled(): + return + if new_value is None: + new_value = not active_widget.is_active() + active_by_id[active_id] = new_value + active_widget.set_active(new_value) + else: + # First make sure that the item under mouse is changed if possible + if active_widget and active_widget.is_checkbox_enabled(): + value = new_value + if value is None: + value = not active_widget.is_active() - if not found: - active_by_id = {changed_instance_id: new_value} + active_by_id[active_id] = value + active_widget.set_active(new_value) + instance_ids.discard(active_id) + + # Change the states from top to bottom + group_items = list(self._group_items.values()) + if self._missing_parent_item is not None: + group_items.append(self._missing_parent_item) + + _queue = collections.deque() + for group_item in group_items: + children = [ + group_item.child(row) + for row in range(group_item.rowCount()) + ] + _queue.append((children, True)) + + while _queue: + children, parent_active = _queue.popleft() + for child in children: + instance_id = child.data(INSTANCE_ID_ROLE) + widget = self._widgets_by_id[instance_id] + widget.set_parent_is_active(parent_active) + if parent_active and instance_id in instance_ids: + value = new_value + if value is None: + value = not widget.is_active() + widget.set_active(value) + active_by_id[instance_id] = value + + children = [ + child.child(row) + for row in range(child.rowCount()) + ] + _queue.append((children, widget.is_active())) self._controller.set_instances_active_state(active_by_id) - self._change_active_instances(active_by_id, new_value) group_names = set() for instance_id in active_by_id: group_name = self._group_by_instance_id.get(instance_id) @@ -927,15 +1122,6 @@ class InstanceListView(AbstractInstanceView): for group_name in group_names: self._update_group_checkstate(group_name) - def _change_active_instances(self, instance_ids, new_value): - if not instance_ids: - return - - for instance_id in instance_ids: - widget = self._widgets_by_id.get(instance_id) - if widget: - widget.set_active(new_value) - def _on_selection_change(self, *_args): self.selection_changed.emit() @@ -952,64 +1138,39 @@ class InstanceListView(AbstractInstanceView): if state == QtCore.Qt.PartiallyChecked: return - if state == QtCore.Qt.Checked: - active = True - else: - active = False - group_item = self._group_items.get(group_name) if not group_item: return - active_by_id = {} - all_changed = True - items_to_expand = [group_item] - _queue = collections.deque() - _queue.append(group_item) - while _queue: - item = _queue.popleft() - for row in range(item.rowCount()): - child = item.child(row) - instance_id = child.data(INSTANCE_ID_ROLE) - if child.hasChildren(): - items_to_expand.append(child) - _queue.append(child) - widget = self._widgets_by_id.get(instance_id) - if widget is None: - continue - if widget.is_checkbox_enabled(): - active_by_id[instance_id] = active - else: - all_changed = False + active = state == QtCore.Qt.Checked - self._controller.set_instances_active_state(active_by_id) + instance_ids = set() + for row in range(group_item.rowCount()): + child = group_item.child(row) + instance_id = child.data(INSTANCE_ID_ROLE) + instance_ids.add(instance_id) - self._change_active_instances(active_by_id, active) + self._toggle_active_state(instance_ids, active) - for item in items_to_expand: - proxy_index = self._proxy_model.mapFromSource(item.index()) - if not self._instance_view.isExpanded(proxy_index): - self._instance_view.expand(proxy_index) + proxy_index = self._proxy_model.mapFromSource(group_item.index()) + if not self._instance_view.isExpanded(proxy_index): + self._instance_view.expand(proxy_index) - if not all_changed: - # If not all instances were changed, update group checkstate - self._update_group_checkstate(group_name) - - def has_items(self): + def has_items(self) -> bool: if self._convertor_group_widget is not None: return True if self._group_items: return True return False - def get_selected_items(self): + def get_selected_items(self) -> tuple[list[str], bool, list[str]]: """Get selected instance ids and context selection. Returns: - tuple: Selected instance ids and boolean if context - is selected. - """ + tuple[list[str], bool, list[str]]: Selected instance ids, + boolean if context is selected and selected convertor ids. + """ instance_ids = [] convertor_identifiers = [] context_selected = False @@ -1133,7 +1294,7 @@ class InstanceListView(AbstractInstanceView): | QtCore.QItemSelectionModel.Rows ) - def set_active_toggle_enabled(self, enabled): + def set_active_toggle_enabled(self, enabled: bool) -> bool: if self._active_toggle_enabled is enabled: return From 293e5fe2e9d759e970d17781cde9c24c1b585c6f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Jul 2025 01:14:11 +0200 Subject: [PATCH 165/312] Use `save_next_version` from Workfiles API https://github.com/ynput/ayon-core/pull/1275 in `version_up_current_workfile` --- client/ayon_core/pipeline/context_tools.py | 53 ++-------------------- 1 file changed, 3 insertions(+), 50 deletions(-) diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index cccdafe6f1..308dd1bf44 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -580,53 +580,6 @@ def get_process_id(): def version_up_current_workfile(): - """Function to increment and save workfile - """ - host = registered_host() - - project_name = get_current_project_name() - folder_path = get_current_folder_path() - task_name = get_current_task_name() - host_name = get_current_host_name() - - template_key = get_workfile_template_key_from_context( - project_name, - folder_path, - task_name, - host_name, - ) - anatomy = Anatomy(project_name) - - data = get_template_data_with_names( - project_name, folder_path, task_name, host_name - ) - data["root"] = anatomy.roots - - work_template = anatomy.get_template_item("work", template_key) - - # Define saving file extension - extensions = host.get_workfile_extensions() - current_file = host.get_current_workfile() - if current_file: - extensions = [os.path.splitext(current_file)[-1]] - - work_root = work_template["directory"].format_strict(data) - file_template = work_template["file"].template - last_workfile_path = get_last_workfile( - work_root, file_template, data, extensions, True - ) - # `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) + """Function to increment and save workfile""" + from ayon_core.pipeline.workfile.utils import save_next_version + save_next_version() From 4d142ab6289f7fafc3e6ca8d43110998eb00dab6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 23 Jul 2025 09:48:49 +0200 Subject: [PATCH 166/312] fill extension in template data --- client/ayon_core/pipeline/workfile/utils.py | 51 ++++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 77c1953e4d..d5c717bd6d 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -411,8 +411,8 @@ def save_next_version( ) -> None: """Save workfile using current context, version and comment. - Helper function to save workfile using current context. Last workfile - version + 1 is used if is not passed in. + Helper function to save a workfile using the current context. Last + workfile version + 1 is used if is not passed in. Args: version (Optional[int]): Workfile version that will be used. Last @@ -480,10 +480,8 @@ def save_next_version( project_settings=project_settings, ) rootless_dir = workdir.rootless + last_workfile = None if version is None: - workfile_extensions = host.get_workfile_extensions() - if not workfile_extensions: - raise ValueError("Host does not have defined file extensions") workfiles = host.list_workfiles( project_name, folder_entity, task_entity, prepared_data=ListWorkfilesOptionalData( @@ -493,14 +491,18 @@ def save_next_version( template_key=template_key, ) ) - versions = { - workfile.version - for workfile in workfiles - if workfile.version is not None - } + for workfile in workfiles: + if workfile.version is None: + continue + if ( + last_workfile is None + or last_workfile.version < workfile.version + ): + last_workfile = workfile + version = None - if versions: - version = max(versions) + 1 + if last_workfile is not None: + version = last_workfile.version + 1 if version is None: version = get_versioning_start( @@ -514,6 +516,26 @@ def save_next_version( template_data["version"] = version template_data["comment"] = comment + # Resolve extension + # - Don't fill any if the host does not have defined any -> e.g. if host + # uses directory instead of a file. + # 1. Use the current file extension. + # 2. Use the last known workfile extension. + # 3. Use the first extensions from 'get_workfile_extensions'. + ext = None + workfile_extensions = host.get_workfile_extensions() + if workfile_extensions: + current_path = host.get_current_workfile() + if current_path: + ext = os.path.splitext(current_path)[1].lstrip(".") + elif last_workfile is not None: + ext = os.path.splitext(last_workfile.filepath)[1].lstrip(".") + else: + ext = next(iter(workfile_extensions), None) + + if ext: + template_data["ext"] = ext + filename = file_template.format_strict(template_data) workfile_path = os.path.join(workdir, filename) rootless_path = f"{rootless_dir}/{filename}" @@ -632,6 +654,11 @@ def copy_workfile_to_context( if comment: template_data["comment"] = comment + workfile_extensions = host.get_workfile_extensions() + if workfile_extensions: + ext = os.path.splitext(src_workfile_path)[1].lstrip(".") + template_data["ext"] = ext + workfile_template = anatomy.get_template_item( "work", template_key, "path" ) From 94b96345552beaccf6a16530d7a8b89f9035235d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Jul 2025 11:25:55 +0200 Subject: [PATCH 167/312] Fix logged warnings --- .../plugins/publish/collect_scene_loaded_versions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index 1abb8e29d2..c8d9747091 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -27,12 +27,13 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): def process(self, context): host = registered_host() if host is None: - self.log.warn("No registered host.") + self.log.warning("No registered host.") return if not hasattr(host, "ls"): host_name = host.__name__ - self.log.warn("Host %r doesn't have ls() implemented." % host_name) + self.log.warning( + f"Host {host_name} doesn't have ls() implemented.") return loaded_versions = [] From f0ea841ebf339a14940792d268e46918c2c60a3c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Jul 2025 11:29:23 +0200 Subject: [PATCH 168/312] Use `ILoadHost.get_containers()` when available --- .../publish/collect_scene_loaded_versions.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index c8d9747091..34d3e5b136 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -1,7 +1,9 @@ import ayon_api import ayon_api.utils +from ayon_core.host import ILoadHost from ayon_core.pipeline import registered_host + import pyblish.api @@ -30,14 +32,19 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): self.log.warning("No registered host.") return - if not hasattr(host, "ls"): + if isinstance(host, ILoadHost): + containers = list(host.get_containers()) + elif hasattr(host, "ls"): + # Backwards compatibility for legacy host implementations + containers = list(host.ls()) + else: host_name = host.__name__ self.log.warning( - f"Host {host_name} doesn't have ls() implemented.") + f"Host {host_name} does not implement ILoadHost " + f"nor does it have ls() implemented. Skipping querying of " + f"loaded versions in scene.") return - loaded_versions = [] - containers = list(host.ls()) repre_ids = { container["representation"] for container in containers @@ -62,6 +69,7 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): # QUESTION should we add same representation id when loaded multiple # times? + loaded_versions = [] for con in containers: repre_id = con["representation"] repre_entity = repre_entities_by_id.get(repre_id) From 5a44efd2ad60cbd380c706217f896106958afb1c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Jul 2025 11:31:29 +0200 Subject: [PATCH 169/312] Opt-out early if there are no containers in the scene file --- .../plugins/publish/collect_scene_loaded_versions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index 34d3e5b136..e3e938b65b 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -45,6 +45,11 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): f"loaded versions in scene.") return + if not containers: + # Opt out early if there are no containers + self.log.debug("No loaded containers found in scene.") + return + repre_ids = { container["representation"] for container in containers From 8b8cff8ea5036e7a49e3b61e036f7beec35642fb Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Jul 2025 11:32:48 +0200 Subject: [PATCH 170/312] Add debug log --- .../ayon_core/plugins/publish/collect_scene_loaded_versions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index e3e938b65b..ea949eb087 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -94,4 +94,5 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): } loaded_versions.append(version) + self.log.debug(f"Collected {len(loaded_versions)} loaded versions.") context.data["loadedVersions"] = loaded_versions From 6def9655f07f8c1dcb80d38a1cb52ff617f03e2e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Jul 2025 11:39:08 +0200 Subject: [PATCH 171/312] Do not use deprecated `Logger.warn`, use `Logger.warning` instead --- client/ayon_core/plugins/publish/integrate_inputlinks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate_inputlinks.py b/client/ayon_core/plugins/publish/integrate_inputlinks.py index a3b6a228d6..be399a95fc 100644 --- a/client/ayon_core/plugins/publish/integrate_inputlinks.py +++ b/client/ayon_core/plugins/publish/integrate_inputlinks.py @@ -105,7 +105,7 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin): created links by its type """ if workfile_instance is None: - self.log.warn("No workfile in this publish session.") + self.log.warning("No workfile in this publish session.") return workfile_version_id = workfile_instance.data["versionEntity"]["id"] From ecd3538dfd481d7fe3c4a4388d3affe7f7d4b615 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Jul 2025 11:46:52 +0200 Subject: [PATCH 172/312] Update client/ayon_core/plugins/publish/collect_scene_loaded_versions.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../ayon_core/plugins/publish/collect_scene_loaded_versions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index ea949eb087..9574c8c211 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -34,9 +34,6 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): if isinstance(host, ILoadHost): containers = list(host.get_containers()) - elif hasattr(host, "ls"): - # Backwards compatibility for legacy host implementations - containers = list(host.ls()) else: host_name = host.__name__ self.log.warning( From 737f3acde17b3d5fb344b7bdeb67cd7deb22c210 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 23 Jul 2025 11:47:19 +0200 Subject: [PATCH 173/312] parent instance id is handled with special attributes --- client/ayon_core/pipeline/create/context.py | 44 +++++++++++++++ .../ayon_core/pipeline/create/structures.py | 54 +++++++++++++++++-- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 929cc59d2a..f2bca97cfe 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -80,6 +80,7 @@ INSTANCE_ADDED_TOPIC = "instances.added" INSTANCE_REMOVED_TOPIC = "instances.removed" VALUE_CHANGED_TOPIC = "values.changed" INSTANCE_REQUIREMENT_CHANGED_TOPIC = "instance.requirement.changed" +INSTANCE_PARENT_CHANGED_TOPIC = "instance.parent.changed" PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC = "pre.create.attr.defs.changed" CREATE_ATTR_DEFS_CHANGED_TOPIC = "create.attr.defs.changed" PUBLISH_ATTR_DEFS_CHANGED_TOPIC = "publish.attr.defs.changed" @@ -262,6 +263,8 @@ class CreateContext: # - right now used only for 'mandatory' but can be extended # in future "requirement_change": BulkInfo(), + # Instance parent changed + "parent_change": BulkInfo(), } self._bulk_order = [] @@ -1364,6 +1367,13 @@ class CreateContext: ) as bulk_info: yield bulk_info + @contextmanager + def bulk_instance_parent_change(self, sender: Optional[str] = None): + with self._bulk_context( + "parent_change", sender + ) as bulk_info: + yield bulk_info + @contextmanager def bulk_publish_attr_defs_change(self, sender: Optional[str] = None): with self._bulk_context("publish_attrs_change", sender) as bulk_info: @@ -1444,6 +1454,19 @@ class CreateContext: with self.bulk_instance_requirement_change() as bulk_item: bulk_item.append(instance_id) + def instance_parent_changed(self, instance_id: str) -> None: + """Instance parent changed. + + Triggered by `CreatedInstance`. + + Args: + instance_id (Optional[str]): Instance id. + + """ + if self._is_instance_events_ready(instance_id): + with self.bulk_instance_parent_change() as bulk_item: + bulk_item.append(instance_id) + # --- context change callbacks --- def publish_attribute_value_changed( self, plugin_name: str, value: dict[str, Any] @@ -2305,6 +2328,8 @@ class CreateContext: self._bulk_publish_attrs_change_finished(data, sender) elif key == "requirement_change": self._bulk_instance_requirement_change_finished(data, sender) + elif key == "parent_change": + self._bulk_instance_parent_change_finished(data, sender) def _bulk_add_instances_finished( self, @@ -2518,3 +2543,22 @@ class CreateContext: {"instances": instances}, sender, ) + + def _bulk_instance_parent_change_finished( + self, + instance_ids: list[str], + sender: Optional[str], + ): + if not instance_ids: + return + + instances = [ + self.get_instance_by_id(instance_id) + for instance_id in set(instance_ids) + ] + + self._emit_event( + INSTANCE_PARENT_CHANGED_TOPIC, + {"instances": instances}, + sender, + ) \ No newline at end of file diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 3048ae2829..562a3a581d 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -1,6 +1,7 @@ import copy import collections from uuid import uuid4 +from enum import Enum import typing from typing import Optional, Dict, List, Any @@ -22,6 +23,18 @@ if typing.TYPE_CHECKING: from .creator_plugins import BaseCreator +class IntEnum(int, Enum): + """An int-based Enum class that allows for int comparison.""" + + def __int__(self) -> int: + return self.value + + +class ParentFlags(IntEnum): + # Delete instance if parent is deleted + parent_lifetime = 1 + + class ConvertorItem: """Item representing convertor plugin. @@ -507,7 +520,9 @@ class CreatedInstance: if transient_data is None: transient_data = {} self._transient_data = transient_data - self._is_mandatory = False + self._is_mandatory: bool = False + self._parent_instance_id: Optional[str] = None + self._parent_flags: int = 0 # Create a copy of passed data to avoid changing them on the fly data = copy.deepcopy(data or {}) @@ -653,10 +668,6 @@ class CreatedInstance: def product_name(self): return self._data["productName"] - @property - def parent_instance_id(self) -> Optional[str]: - return self._data.get("parentInstanceId") - @property def label(self): label = self._data.get("label") @@ -756,6 +767,39 @@ class CreatedInstance: self["active"] = True self._create_context.instance_requirement_changed(self.id) + @property + def parent_instance_id(self) -> Optional[str]: + return self._parent_instance_id + + @property + def parent_flags(self) -> int: + return self._parent_flags + + def set_parent( + self, instance_id: Optional[str], flags: int + ) -> None: + """Set parent instance id and parenting flags. + + Args: + instance_id (Optional[str]): Parent instance id. + flags (int): Parenting flags. + + """ + changed = False + if instance_id != self._parent_instance_id: + changed = True + self._parent_instance_id = instance_id + + if flags is None: + flags = 0 + + if self._parent_flags != flags: + self._parent_flags = flags + changed = True + + if changed: + self._create_context.instance_parent_changed(self.id) + def changes(self): """Calculate and return changes.""" From 654833054901c0e53b9e2683328fad46610446cb Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Jul 2025 11:47:38 +0200 Subject: [PATCH 174/312] Reformat code --- .../plugins/publish/collect_scene_loaded_versions.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index 9574c8c211..ee448e7911 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -32,16 +32,15 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): self.log.warning("No registered host.") return - if isinstance(host, ILoadHost): - containers = list(host.get_containers()) - else: + if not isinstance(host, ILoadHost): host_name = host.__name__ self.log.warning( - f"Host {host_name} does not implement ILoadHost " - f"nor does it have ls() implemented. Skipping querying of " - f"loaded versions in scene.") + f"Host {host_name} does not implement ILoadHost. " + "Skipping querying of loaded versions in scene." + ) return + containers = list(host.get_containers()) if not containers: # Opt out early if there are no containers self.log.debug("No loaded containers found in scene.") From ff7a63099be3a8437cce18e310b89d1476a7ba7b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 23 Jul 2025 11:47:57 +0200 Subject: [PATCH 175/312] handle parent lifetime flag --- client/ayon_core/pipeline/create/__init__.py | 2 + client/ayon_core/pipeline/create/context.py | 130 ++++++++++++------- 2 files changed, 86 insertions(+), 46 deletions(-) diff --git a/client/ayon_core/pipeline/create/__init__.py b/client/ayon_core/pipeline/create/__init__.py index cbe009d95e..c8c780504f 100644 --- a/client/ayon_core/pipeline/create/__init__.py +++ b/client/ayon_core/pipeline/create/__init__.py @@ -21,6 +21,7 @@ from .exceptions import ( TemplateFillError, ) from .structures import ( + ParentFlags, CreatedInstance, ConvertorItem, AttributeValues, @@ -86,6 +87,7 @@ __all__ = ( "TaskNotSetError", "TemplateFillError", + "ParentFlags", "CreatedInstance", "ConvertorItem", "AttributeValues", diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index f2bca97cfe..1cf8f08eff 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -41,7 +41,12 @@ from .exceptions import ( HostMissRequiredMethod, ) from .changes import TrackChangesItem -from .structures import PublishAttributes, ConvertorItem, InstanceContextInfo +from .structures import ( + PublishAttributes, + ConvertorItem, + InstanceContextInfo, + ParentFlags, +) from .creator_plugins import ( Creator, AutoCreator, @@ -2069,63 +2074,96 @@ class CreateContext: sender (Optional[str]): Sender of the event. """ + instance_ids_by_parent_id = collections.defaultdict(set) + for instance in self.instances: + instance_ids_by_parent_id[instance.parent_instance_id].add( + instance.id + ) + + instances_to_remove = list(instances) + ids_to_remove = { + instance.id + for instance in instances_to_remove + } + _queue = collections.deque() + _queue.extend(instances_to_remove) + while _queue: + instance = _queue.popleft() + ids_to_remove.add(instance.id) + children_ids = instance_ids_by_parent_id[instance.id] + for children_id in children_ids: + if children_id in ids_to_remove: + continue + instance = self._instances_by_id[children_id] + if instance.parent_flags & ParentFlags.parent_lifetime: + instances_to_remove.append(instance) + ids_to_remove.add(instance.id) + _queue.append(instance) + instances_by_identifier = collections.defaultdict(list) - for instance in instances: + for instance in instances_to_remove: identifier = instance.creator_identifier instances_by_identifier[identifier].append(instance) # Just remove instances from context if creator is not available missing_creators = set(instances_by_identifier) - set(self.creators) - instances = [] + miss_creator_instances = [] for identifier in missing_creators: - instances.extend( - instance - for instance in instances_by_identifier[identifier] - ) + miss_creator_instances.extend(instances_by_identifier[identifier]) - self._remove_instances(instances, sender) + with self.bulk_remove_instances(sender): + self._remove_instances(miss_creator_instances, sender) - error_message = "Instances removement of creator \"{}\" failed. {}" - failed_info = [] - # Remove instances by creator plugin order - for creator in self.get_sorted_creators( - instances_by_identifier.keys() - ): - identifier = creator.identifier - creator_instances = instances_by_identifier[identifier] + error_message = "Instances removement of creator \"{}\" failed. {}" + failed_info = [] + # Remove instances by creator plugin order + for creator in self.get_sorted_creators( + instances_by_identifier.keys() + ): + identifier = creator.identifier + # Filter instances by current state of 'CreateContext' + # - in case instances were already removed as subroutine of + # previous create plugin. + creator_instances = [ + instance + for instance in instances_by_identifier[identifier] + if instance.id in self._instances_by_id + ] + if not creator_instances: + continue - label = creator.label - failed = False - add_traceback = False - exc_info = None - try: - creator.remove_instances(creator_instances) + label = creator.label + failed = False + add_traceback = False + exc_info = None + try: + creator.remove_instances(creator_instances) - except CreatorError: - failed = True - exc_info = sys.exc_info() - self.log.warning( - error_message.format(identifier, exc_info[1]) - ) - - except (KeyboardInterrupt, SystemExit): - raise - - except: # noqa: E722 - failed = True - add_traceback = True - exc_info = sys.exc_info() - self.log.warning( - error_message.format(identifier, ""), - exc_info=True - ) - - if failed: - failed_info.append( - prepare_failed_creator_operation_info( - identifier, label, exc_info, add_traceback + except CreatorError: + failed = True + exc_info = sys.exc_info() + self.log.warning( + error_message.format(identifier, exc_info[1]) + ) + + except (KeyboardInterrupt, SystemExit): + raise + + except: # noqa: E722 + failed = True + add_traceback = True + exc_info = sys.exc_info() + self.log.warning( + error_message.format(identifier, ""), + exc_info=True + ) + + if failed: + failed_info.append( + prepare_failed_creator_operation_info( + identifier, label, exc_info, add_traceback + ) ) - ) if failed_info: raise CreatorsRemoveFailed(failed_info) From 3941040d23e14aebfaa7b8c1d561ea011d8b34eb Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Jul 2025 12:22:28 +0200 Subject: [PATCH 176/312] Update client/ayon_core/plugins/publish/collect_scene_loaded_versions.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../ayon_core/plugins/publish/collect_scene_loaded_versions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index ee448e7911..524381f656 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -33,7 +33,7 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): return if not isinstance(host, ILoadHost): - host_name = host.__name__ + host_name = host.name self.log.warning( f"Host {host_name} does not implement ILoadHost. " "Skipping querying of loaded versions in scene." From 55a7db79899be57494591e434b447bc3319245c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 23 Jul 2025 16:59:20 +0200 Subject: [PATCH 177/312] :recycle: revive linked assets/folders in template builder Adding back linked assets/folder feature that was there in template builder in OpenPype. This is now working with template type links of AYON. --- .../workfile/workfile_template_builder.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index b0fad8d2a1..276f90af80 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -313,7 +313,8 @@ class AbstractTemplateBuilder(ABC): if not folder_entity: return [] links = get_folder_links( - project_name, folder_entity["id"], link_direction="in" + project_name, + folder_entity["id"], link_types=["template"], link_direction="in" ) linked_folder_ids = { link["entityId"] @@ -1429,8 +1430,7 @@ class PlaceholderLoadMixin(object): builder_type_enum_items = [ {"label": "Current folder", "value": "context_folder"}, - # TODO implement linked folders - # {"label": "Linked folders", "value": "linked_folders"}, + {"label": "Linked folders", "value": "linked_folders"}, {"label": "All folders", "value": "all_folders"}, ] build_type_label = "Folder Builder Type" @@ -1607,10 +1607,7 @@ class PlaceholderLoadMixin(object): builder_type = placeholder.data["builder_type"] folder_ids = [] - if builder_type == "context_folder": - folder_ids = [current_folder_entity["id"]] - - elif builder_type == "all_folders": + if builder_type == "all_folders": folder_ids = { folder_entity["id"] for folder_entity in get_folders( @@ -1620,6 +1617,19 @@ class PlaceholderLoadMixin(object): ) } + elif builder_type == "context_folder": + folder_ids = [current_folder_entity["id"]] + + elif builder_type == "linked_folders": + # Get all linked folders for the current folder + if hasattr(self, "builder") and isinstance( + self.builder, AbstractTemplateBuilder): + # self.builder: AbstractTemplateBuilder + folder_ids = [ + linked_folder_entity["id"] + for linked_folder_entity in self.builder.linked_folder_entities # noqa: E501 + ] + if not folder_ids: return [] From cc9be12d22e7f5eb524d5f6eabfdb1ee9a049f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 23 Jul 2025 17:16:11 +0200 Subject: [PATCH 178/312] :recycle: break the long line --- .../ayon_core/pipeline/workfile/workfile_template_builder.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 276f90af80..bfa192d834 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -1627,7 +1627,8 @@ class PlaceholderLoadMixin(object): # self.builder: AbstractTemplateBuilder folder_ids = [ linked_folder_entity["id"] - for linked_folder_entity in self.builder.linked_folder_entities # noqa: E501 + for linked_folder_entity in ( + self.builder.linked_folder_entities) ] if not folder_ids: From 50e6c541f982a52ede822f70eb72074888f038c6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Jul 2025 10:59:34 +0200 Subject: [PATCH 179/312] reuse comment from last workfile --- client/ayon_core/pipeline/workfile/utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index d5c717bd6d..36e72bb55a 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -417,7 +417,9 @@ def save_next_version( Args: version (Optional[int]): Workfile version that will be used. Last version + 1 is used if is not passed in. - comment (optional[str]): Workfile comment. + comment (optional[str]): Workfile comment. Pass '""' to clear comment. + The last workfile comment is used if it is not passed in and + passed 'version' is 'None'. description (Optional[str]): Workfile description. prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data for speed enhancements. @@ -513,6 +515,9 @@ def save_next_version( product_type="workfile" ) + if comment is None and last_workfile is not None: + comment = last_workfile.comment + template_data["version"] = version template_data["comment"] = comment From eea1f4cb6a9057a1cf5c4a00e5ba26ecd07985d8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:05:41 +0200 Subject: [PATCH 180/312] re-use comment from current file --- client/ayon_core/pipeline/workfile/utils.py | 24 +++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 36e72bb55a..a6e4dad2b4 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -429,6 +429,11 @@ def save_next_version( from ayon_core.pipeline.context_tools import registered_host host = registered_host() + current_path = host.get_current_workfile() + if not current_path: + current_path = None + else: + current_path = os.path.normpath(current_path) context = host.get_current_context() project_name = context["project_name"] @@ -483,6 +488,7 @@ def save_next_version( ) rootless_dir = workdir.rootless last_workfile = None + current_workfile = None if version is None: workfiles = host.list_workfiles( project_name, folder_entity, task_entity, @@ -496,6 +502,10 @@ def save_next_version( for workfile in workfiles: if workfile.version is None: continue + + if current_workfile is None and workfile.filepath == current_path: + current_workfile = workfile + if ( last_workfile is None or last_workfile.version < workfile.version @@ -515,11 +525,18 @@ def save_next_version( product_type="workfile" ) - if comment is None and last_workfile is not None: - comment = last_workfile.comment + # Re-use comment if is not set + if comment is None: + if current_workfile is not None: + # Use 'comment' from the current workfile if is set + comment = current_workfile.comment + elif last_workfile is not None: + # Use 'comment' from the last workfile + comment = last_workfile.comment template_data["version"] = version - template_data["comment"] = comment + if comment: + template_data["comment"] = comment # Resolve extension # - Don't fill any if the host does not have defined any -> e.g. if host @@ -530,7 +547,6 @@ def save_next_version( ext = None workfile_extensions = host.get_workfile_extensions() if workfile_extensions: - current_path = host.get_current_workfile() if current_path: ext = os.path.splitext(current_path)[1].lstrip(".") elif last_workfile is not None: From 4b5431f26718169a93cda20706b37f343f441e8b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:07:09 +0200 Subject: [PATCH 181/312] added helper functions to workfile __init__.py --- client/ayon_core/pipeline/workfile/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/__init__.py b/client/ayon_core/pipeline/workfile/__init__.py index c6a0e0d80b..7acaf69a7c 100644 --- a/client/ayon_core/pipeline/workfile/__init__.py +++ b/client/ayon_core/pipeline/workfile/__init__.py @@ -22,9 +22,11 @@ from .utils import ( should_open_workfiles_tool_on_launch, MissingWorkdirError, + save_workfile_info, save_current_workfile_to, save_workfile_with_current_context, - save_workfile_info, + save_next_version, + copy_workfile_to_context, find_workfile_rootless_path, ) @@ -63,9 +65,11 @@ __all__ = ( "should_open_workfiles_tool_on_launch", "MissingWorkdirError", + "save_workfile_info", "save_current_workfile_to", "save_workfile_with_current_context", - "save_workfile_info", + "save_next_version", + "copy_workfile_to_context", "BuildWorkfile", From 15854f07060838d9e3c5008feeb4551d5da898c5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:49:00 +0200 Subject: [PATCH 182/312] revert some of the logic --- client/ayon_core/pipeline/workfile/utils.py | 43 +++++++++------------ 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index a6e4dad2b4..3812fb6471 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -418,8 +418,7 @@ def save_next_version( version (Optional[int]): Workfile version that will be used. Last version + 1 is used if is not passed in. comment (optional[str]): Workfile comment. Pass '""' to clear comment. - The last workfile comment is used if it is not passed in and - passed 'version' is 'None'. + The current workfile comment is used if it is not passed. description (Optional[str]): Workfile description. prepared_data (Optional[SaveWorkfileOptionalData]): Prepared data for speed enhancements. @@ -489,7 +488,7 @@ def save_next_version( rootless_dir = workdir.rootless last_workfile = None current_workfile = None - if version is None: + if version is None or comment is None: workfiles = host.list_workfiles( project_name, folder_entity, task_entity, prepared_data=ListWorkfilesOptionalData( @@ -500,39 +499,33 @@ def save_next_version( ) ) for workfile in workfiles: - if workfile.version is None: - continue - if current_workfile is None and workfile.filepath == current_path: current_workfile = workfile + if workfile.version is None: + continue + if ( last_workfile is None or last_workfile.version < workfile.version ): last_workfile = workfile - version = None - if last_workfile is not None: - version = last_workfile.version + 1 + if version is None and last_workfile is not None: + version = last_workfile.version + 1 - if version is None: - version = get_versioning_start( - project_name, - host.name, - task_name=task_entity["name"], - task_type=task_entity["taskType"], - product_type="workfile" - ) + if version is None: + version = get_versioning_start( + project_name, + host.name, + task_name=task_entity["name"], + task_type=task_entity["taskType"], + product_type="workfile" + ) - # Re-use comment if is not set - if comment is None: - if current_workfile is not None: - # Use 'comment' from the current workfile if is set - comment = current_workfile.comment - elif last_workfile is not None: - # Use 'comment' from the last workfile - comment = last_workfile.comment + # Re-use comment from the current workfile if is not passed in + if comment is None and current_workfile is not None: + comment = current_workfile.comment template_data["version"] = version if comment: From 583dae949dabe0abd75fb1bb311dbe6547a1730d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:57:25 +0200 Subject: [PATCH 183/312] strip dot of extension --- client/ayon_core/pipeline/workfile/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 3812fb6471..354449bd3e 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -541,11 +541,12 @@ def save_next_version( workfile_extensions = host.get_workfile_extensions() if workfile_extensions: if current_path: - ext = os.path.splitext(current_path)[1].lstrip(".") + ext = os.path.splitext(current_path)[1] elif last_workfile is not None: - ext = os.path.splitext(last_workfile.filepath)[1].lstrip(".") + ext = os.path.splitext(last_workfile.filepath)[1] else: ext = next(iter(workfile_extensions), None) + ext = ext.lstrip(".") if ext: template_data["ext"] = ext From 4204de3ab2482903f1c84b597e29487dfbadf6a4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Jul 2025 12:34:53 +0200 Subject: [PATCH 184/312] pass variant to actions list --- client/ayon_core/tools/launcher/models/actions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index adb8d371ed..1a8e423751 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -399,7 +399,11 @@ class ActionsModel: return cache.get_data() try: - response = ayon_api.post("actions/list", **request_data) + # 'variant' query is supported since AYON backend 1.10.4 + query = urlencode({"variant": self._variant}) + response = ayon_api.post( + f"actions/list?{query}", **request_data + ) response.raise_for_status() except Exception: self.log.warning("Failed to collect webactions.", exc_info=True) From a4ec6c4a774008dd66af401957f05d1e55569e8c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Jul 2025 12:40:59 +0200 Subject: [PATCH 185/312] Remove redundant default value Co-authored-by: Roy Nieterau --- client/ayon_core/pipeline/workfile/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 354449bd3e..6666853998 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -545,7 +545,7 @@ def save_next_version( elif last_workfile is not None: ext = os.path.splitext(last_workfile.filepath)[1] else: - ext = next(iter(workfile_extensions), None) + ext = next(iter(workfile_extensions)) ext = ext.lstrip(".") if ext: From 351167a8d62fc59ddd693b9ab45ecad65eecc177 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 24 Jul 2025 13:33:35 +0200 Subject: [PATCH 186/312] Remove unused imports --- client/ayon_core/pipeline/context_tools.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 308dd1bf44..b06d34b26f 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -16,7 +16,6 @@ from ayon_core.host import HostBase from ayon_core.lib import ( is_in_tests, initialize_ayon_connection, - version_up ) from ayon_core.addon import load_addons, AddonsManager from ayon_core.settings import get_project_settings @@ -24,12 +23,7 @@ from ayon_core.settings import get_project_settings from .publish.lib import filter_pyblish_plugins from .anatomy import Anatomy from .template_data import get_template_data_with_names -from .workfile import ( - get_custom_workfile_template_by_string_context, - get_workfile_template_key_from_context, - get_last_workfile, - MissingWorkdirError, -) +from .workfile import get_custom_workfile_template_by_string_context from . import ( register_loader_plugin_path, register_inventory_action_path, From 2d3259aac6170d82106a8f469376cd076b22baea Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 24 Jul 2025 13:36:14 +0200 Subject: [PATCH 187/312] Import from a level up --- client/ayon_core/pipeline/context_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index b06d34b26f..9b29fa0b3a 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -575,5 +575,5 @@ def get_process_id(): def version_up_current_workfile(): """Function to increment and save workfile""" - from ayon_core.pipeline.workfile.utils import save_next_version + from ayon_core.pipeline.workfile import save_next_version save_next_version() From 39c72809b9c13d773fc1abb217658c3d495bff9f Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 24 Jul 2025 12:05:37 +0000 Subject: [PATCH 188/312] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 509c4a8d14..5e5ea1ca3a 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.4.1+dev" +__version__ = "1.5.0" diff --git a/package.py b/package.py index 039bf0379c..f10bbe29cb 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.4.1+dev" +version = "1.5.0" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 68e1ed39a3..9e1046dc43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.4.1+dev" +version = "1.5.0" description = "" authors = ["Ynput Team "] readme = "README.md" From 34d2c6e6e1f21c452e0c50524b33eefcb6087fd3 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 24 Jul 2025 12:06:16 +0000 Subject: [PATCH 189/312] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 5e5ea1ca3a..7f55a17a01 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.5.0" +__version__ = "1.5.0+dev" diff --git a/package.py b/package.py index f10bbe29cb..807e4e4b35 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.5.0" +version = "1.5.0+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 9e1046dc43..e7977a5579 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.5.0" +version = "1.5.0+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 7382eb338c82618a7ab2161630a8be176c66fa3a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 24 Jul 2025 12:07:11 +0000 Subject: [PATCH 190/312] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9fb6ee645d..9202190f8b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.5.0 - 1.4.1 - 1.4.0 - 1.3.2 From 3cba26a85f4ca068a1229a146a36d981a388a31b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:21:19 +0200 Subject: [PATCH 191/312] moved 'get_current_project_settings' to pipeline context tools --- client/ayon_core/pipeline/context_tools.py | 18 +++++++++++++ client/ayon_core/settings/lib.py | 30 +++++++++++++--------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 9b29fa0b3a..0877f2f049 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -360,6 +360,24 @@ def get_current_task_name(): return get_global_context()["task_name"] +def get_current_project_settings() -> dict[str, Any]: + """Project settings for the current context project. + + Returns: + dict[str, Any]: Project settings for the current context project. + + Raises: + ValueError: If current project is not set. + + """ + project_name = get_current_project_name() + if not project_name: + raise ValueError( + "Current project is not set. Can't get project settings." + ) + return get_project_settings(project_name) + + def get_current_project_entity(fields=None): """Helper function to get project document based on global Session. diff --git a/client/ayon_core/settings/lib.py b/client/ayon_core/settings/lib.py index 72af07799f..fbbd860397 100644 --- a/client/ayon_core/settings/lib.py +++ b/client/ayon_core/settings/lib.py @@ -4,6 +4,7 @@ import logging import collections import copy import time +import warnings import ayon_api @@ -175,17 +176,22 @@ def get_project_environments(project_name, project_settings=None): def get_current_project_settings(): - """Project settings for current context project. + """DEPRECATE Project settings for current context project. + + Function requires access to pipeline context which is in + 'ayon_core.pipeline'. + + Returns: + dict[str, Any]: Project settings for current context project. - Project name should be stored in environment variable `AYON_PROJECT_NAME`. - This function should be used only in host context where environment - variable must be set and should not happen that any part of process will - change the value of the environment variable. """ - project_name = os.environ.get("AYON_PROJECT_NAME") - if not project_name: - raise ValueError( - "Missing context project in environment" - " variable `AYON_PROJECT_NAME`." - ) - return get_project_settings(project_name) + warnings.warn( + "Used deprecated function 'get_current_project_settings' in" + " 'ayon_core.settings'. The function was moved to" + " 'ayon_core.pipeline.context_tools'.", + DeprecationWarning, + stacklevel=2 + ) + from ayon_core.pipeline.context_tools import get_current_project_settings + + return get_current_project_settings() From 7f4f7be8b36b5a24ad63a58ba9a05036ef40e443 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:22:51 +0200 Subject: [PATCH 192/312] use anatomy if roots are not filled --- client/ayon_core/pipeline/load/utils.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 3c50d76fb5..836fc5e096 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -720,11 +720,13 @@ def get_representation_path(representation, root=None): str: fullpath of the representation """ - if root is None: - from ayon_core.pipeline import registered_root + from ayon_core.pipeline import get_current_project_name, Anatomy - root = registered_root() + anatomy = Anatomy(get_current_project_name()) + return get_representation_path_with_anatomy( + representation, anatomy + ) def path_from_representation(): try: @@ -772,7 +774,7 @@ def get_representation_path(representation, root=None): dir_path, file_name = os.path.split(path) if not os.path.exists(dir_path): - return + return None base_name, ext = os.path.splitext(file_name) file_name_items = None @@ -782,7 +784,7 @@ def get_representation_path(representation, root=None): file_name_items = base_name.split("%") if not file_name_items: - return + return None filename_start = file_name_items[0] From 97cd8a2ec960bbca158dad49c2522a2828011fc3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:23:07 +0200 Subject: [PATCH 193/312] mark registered root as deprecated --- client/ayon_core/pipeline/context_tools.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 0877f2f049..89963a6205 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -69,7 +69,7 @@ def _get_addons_manager(): def register_root(path): - """Register currently active root""" + """DEPRECATED Register currently active root.""" log.info("Registering root: %s" % path) _registered_root["_"] = path @@ -88,8 +88,14 @@ def registered_root(): Returns: dict[str, str]: Root paths. - """ + """ + warnings.warn( + "Used deprecated function 'registered_root'. Please use 'Anatomy'" + " to get roots.", + DeprecationWarning, + stacklevel=2, + ) return _registered_root["_"] From 2d341f6e552c76604a31df60ac875a7dbd7ce1b1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:23:26 +0200 Subject: [PATCH 194/312] use 'get_current_host_name' to get host name --- client/ayon_core/pipeline/context_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 89963a6205..2b4f9d45b8 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -183,7 +183,7 @@ def install_ayon_plugins(project_name=None, host_name=None): register_inventory_action_path(INVENTORY_PATH) if host_name is None: - host_name = os.environ.get("AYON_HOST_NAME") + host_name = get_current_host_name() addons_manager = _get_addons_manager() publish_plugin_dirs = addons_manager.collect_publish_plugin_paths( From 28eac4b18bc69ca4117d0cd6bdf6a9e2363d7e38 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:24:16 +0200 Subject: [PATCH 195/312] added HostBase validation --- client/ayon_core/pipeline/context_tools.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 2b4f9d45b8..e8a770ec54 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -99,13 +99,18 @@ def registered_root(): return _registered_root["_"] -def install_host(host): +def install_host(host: HostBase) -> None: """Install `host` into the running Python session. Args: host (HostBase): A host interface object. """ + if not isinstance(host, HostBase): + log.error( + f"Host must be a subclass of 'HostBase', got '{type(host)}'." + ) + global _is_installed _is_installed = True From ab60d611105a58901bdf8467a9d59ff34598ed13 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:24:31 +0200 Subject: [PATCH 196/312] mark 'version_up_current_workfile' as deprecated --- client/ayon_core/pipeline/context_tools.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index e8a770ec54..423e8f7216 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -1,5 +1,6 @@ """Core pipeline functionality""" from __future__ import annotations + import os import logging import platform @@ -575,6 +576,7 @@ def change_current_context( " It is not necessary to pass it in anymore." ), DeprecationWarning, + stacklevel=2, ) host = registered_host() @@ -603,6 +605,16 @@ def get_process_id(): def version_up_current_workfile(): - """Function to increment and save workfile""" + """DEPRECATED Function to increment and save workfile. + + Please use 'save_next_version' from 'ayon_core.pipeline.workfile' instead. + + """ + warnings.warn( + "Used deprecated 'version_up_current_workfile' please use" + " 'save_next_version' from 'ayon_core.pipeline.workfile' instead.", + DeprecationWarning, + stacklevel=2, + ) from ayon_core.pipeline.workfile import save_next_version save_next_version() From 32ea97af45bef30951c65e235f541cc2f2827e46 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Jul 2025 16:04:03 +0200 Subject: [PATCH 197/312] define settings category on core plugins --- client/ayon_core/plugins/publish/cleanup.py | 2 ++ client/ayon_core/plugins/publish/cleanup_farm.py | 2 ++ client/ayon_core/plugins/publish/collect_audio.py | 1 + client/ayon_core/plugins/publish/collect_frames_fix.py | 1 + client/ayon_core/plugins/publish/collect_scene_version.py | 3 ++- client/ayon_core/plugins/publish/extract_burnin.py | 1 + client/ayon_core/plugins/publish/extract_color_transcode.py | 2 ++ client/ayon_core/plugins/publish/extract_review.py | 1 + client/ayon_core/plugins/publish/extract_thumbnail.py | 1 + .../plugins/publish/extract_usd_layer_contributions.py | 6 +++++- client/ayon_core/plugins/publish/integrate_hero_version.py | 2 ++ client/ayon_core/plugins/publish/integrate_product_group.py | 2 ++ .../publish/preintegrate_thumbnail_representation.py | 2 ++ client/ayon_core/plugins/publish/validate_containers.py | 1 + client/ayon_core/plugins/publish/validate_intent.py | 2 ++ client/ayon_core/plugins/publish/validate_version.py | 1 + 16 files changed, 28 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/cleanup.py b/client/ayon_core/plugins/publish/cleanup.py index 681fe700a3..03eaaf9c6e 100644 --- a/client/ayon_core/plugins/publish/cleanup.py +++ b/client/ayon_core/plugins/publish/cleanup.py @@ -38,6 +38,8 @@ class CleanUp(pyblish.api.InstancePlugin): "webpublisher", "shell" ] + settings_category = "core" + exclude_families = ["clip"] optional = True active = True diff --git a/client/ayon_core/plugins/publish/cleanup_farm.py b/client/ayon_core/plugins/publish/cleanup_farm.py index e655437ced..8d1c8de425 100644 --- a/client/ayon_core/plugins/publish/cleanup_farm.py +++ b/client/ayon_core/plugins/publish/cleanup_farm.py @@ -13,6 +13,8 @@ class CleanUpFarm(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder + 11 label = "Clean Up Farm" + + settings_category = "core" enabled = True # Keep "filesequence" for backwards compatibility of older jobs diff --git a/client/ayon_core/plugins/publish/collect_audio.py b/client/ayon_core/plugins/publish/collect_audio.py index 57c69ef2b2..c0b263fa6f 100644 --- a/client/ayon_core/plugins/publish/collect_audio.py +++ b/client/ayon_core/plugins/publish/collect_audio.py @@ -41,6 +41,7 @@ class CollectAudio(pyblish.api.ContextPlugin): "max", "circuit", ] + settings_category = "core" audio_product_name = "audioMain" diff --git a/client/ayon_core/plugins/publish/collect_frames_fix.py b/client/ayon_core/plugins/publish/collect_frames_fix.py index 0f7d5b692a..4270af5541 100644 --- a/client/ayon_core/plugins/publish/collect_frames_fix.py +++ b/client/ayon_core/plugins/publish/collect_frames_fix.py @@ -23,6 +23,7 @@ class CollectFramesFixDef( targets = ["local"] hosts = ["nuke"] families = ["render", "prerender"] + settings_category = "core" rewrite_version_enable = False diff --git a/client/ayon_core/plugins/publish/collect_scene_version.py b/client/ayon_core/plugins/publish/collect_scene_version.py index 7979b66abe..e6e81ea074 100644 --- a/client/ayon_core/plugins/publish/collect_scene_version.py +++ b/client/ayon_core/plugins/publish/collect_scene_version.py @@ -12,9 +12,10 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): """ order = pyblish.api.CollectorOrder - label = 'Collect Scene Version' + label = "Collect Scene Version" # configurable in Settings hosts = ["*"] + settings_category = "core" # in some cases of headless publishing (for example webpublisher using PS) # you want to ignore version from name and let integrate use next version diff --git a/client/ayon_core/plugins/publish/extract_burnin.py b/client/ayon_core/plugins/publish/extract_burnin.py index fa7fd4e504..f962032680 100644 --- a/client/ayon_core/plugins/publish/extract_burnin.py +++ b/client/ayon_core/plugins/publish/extract_burnin.py @@ -57,6 +57,7 @@ class ExtractBurnin(publish.Extractor): "unreal", "circuit", ] + settings_category = "core" 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 8a276cf608..bbb6f9585b 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -55,6 +55,8 @@ class ExtractOIIOTranscode(publish.Extractor): label = "Transcode color spaces" order = pyblish.api.ExtractorOrder + 0.019 + settings_category = "core" + optional = True # Supported extensions diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 1e4997cfb4..377010d9e0 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -165,6 +165,7 @@ class ExtractReview(pyblish.api.InstancePlugin): "photoshop" ] + settings_category = "core" # Supported extensions image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"} video_exts = {"mov", "mp4"} diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 66acb15312..5d9f83fb42 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -43,6 +43,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): "houdini", "circuit", ] + settings_category = "core" enabled = False integrate_thumbnail = False diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index ec1fddc6b1..c2fa61e1fe 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -255,7 +255,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, order = pyblish.api.CollectorOrder + 0.35 label = "Collect USD Layer Contributions (Asset/Shot)" families = ["usd"] - enabled = True + settings_category = "core" # A contribution defines a contribution into a (department) layer which # will get layered into the target product, usually the asset or shot. @@ -633,6 +633,8 @@ class ExtractUSDLayerContribution(publish.Extractor): label = "Extract USD Layer Contributions (Asset/Shot)" order = pyblish.api.ExtractorOrder + 0.45 + settings_category = "core" + use_ayon_entity_uri = False def process(self, instance): @@ -795,6 +797,8 @@ class ExtractUSDAssetContribution(publish.Extractor): label = "Extract USD Asset/Shot Contributions" order = ExtractUSDLayerContribution.order + 0.01 + settings_category = "core" + use_ayon_entity_uri = False def process(self, instance): diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 43f93da293..90e6f15568 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -61,6 +61,8 @@ class IntegrateHeroVersion( # Must happen after IntegrateNew order = pyblish.api.IntegratorOrder + 0.1 + settings_category = "core" + optional = True active = True diff --git a/client/ayon_core/plugins/publish/integrate_product_group.py b/client/ayon_core/plugins/publish/integrate_product_group.py index 90887a359d..8904d21d69 100644 --- a/client/ayon_core/plugins/publish/integrate_product_group.py +++ b/client/ayon_core/plugins/publish/integrate_product_group.py @@ -24,6 +24,8 @@ class IntegrateProductGroup(pyblish.api.InstancePlugin): order = pyblish.api.IntegratorOrder - 0.1 label = "Product Group" + settings_category = "core" + # Attributes set by settings product_grouping_profiles = None diff --git a/client/ayon_core/plugins/publish/preintegrate_thumbnail_representation.py b/client/ayon_core/plugins/publish/preintegrate_thumbnail_representation.py index 8bd67c0183..900febc236 100644 --- a/client/ayon_core/plugins/publish/preintegrate_thumbnail_representation.py +++ b/client/ayon_core/plugins/publish/preintegrate_thumbnail_representation.py @@ -22,6 +22,8 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin): label = "Override Integrate Thumbnail Representations" order = pyblish.api.IntegratorOrder - 0.1 + settings_category = "core" + integrate_profiles = [] def process(self, instance): diff --git a/client/ayon_core/plugins/publish/validate_containers.py b/client/ayon_core/plugins/publish/validate_containers.py index 520e7a7ce9..fda3d93627 100644 --- a/client/ayon_core/plugins/publish/validate_containers.py +++ b/client/ayon_core/plugins/publish/validate_containers.py @@ -31,6 +31,7 @@ class ValidateOutdatedContainers( label = "Validate Outdated Containers" order = pyblish.api.ValidatorOrder + settings_category = "core" optional = True actions = [ShowInventory] diff --git a/client/ayon_core/plugins/publish/validate_intent.py b/client/ayon_core/plugins/publish/validate_intent.py index 71df652e92..fa5e5af093 100644 --- a/client/ayon_core/plugins/publish/validate_intent.py +++ b/client/ayon_core/plugins/publish/validate_intent.py @@ -14,6 +14,8 @@ class ValidateIntent(pyblish.api.ContextPlugin): order = pyblish.api.ValidatorOrder label = "Validate Intent" + settings_category = "core" + enabled = False # Can be modified by settings diff --git a/client/ayon_core/plugins/publish/validate_version.py b/client/ayon_core/plugins/publish/validate_version.py index 0359f8fb53..d63c4e1f03 100644 --- a/client/ayon_core/plugins/publish/validate_version.py +++ b/client/ayon_core/plugins/publish/validate_version.py @@ -17,6 +17,7 @@ class ValidateVersion(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin): order = pyblish.api.ValidatorOrder label = "Validate Version" + settings_category = "core" optional = False active = True From 2e345fb297604b9bff86c8c124e50eb723b1b04e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Jul 2025 16:35:59 +0200 Subject: [PATCH 198/312] warn if 'settings_category' is not filled but settings are received --- client/ayon_core/pipeline/publish/lib.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index fb84417730..cd6a7bca75 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -354,12 +354,17 @@ def get_plugin_settings(plugin, project_settings, log, category=None): # Use project settings based on a category name if category: try: - return ( + output = ( project_settings [category] ["publish"] [plugin.__name__] ) + warnings.warn( + f"Please fill 'settings_category' for plugin '{plugin.__name__}'.", + DeprecationWarning + ) + return output except KeyError: pass @@ -384,12 +389,17 @@ def get_plugin_settings(plugin, project_settings, log, category=None): category_from_file = "core" try: - return ( + output = ( project_settings [category_from_file] [plugin_kind] [plugin.__name__] ) + warnings.warn( + f"Please fill 'settings_category' for plugin '{plugin.__name__}'.", + DeprecationWarning + ) + return output except KeyError: pass return {} From 1bdd64ae3de81473796ed52ac3f59dde47fca55b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:03:17 +0200 Subject: [PATCH 199/312] allow path to python file --- client/ayon_core/pipeline/publish/lib.py | 37 +++++++++++++----------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index fb84417730..ddb1c46def 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -243,32 +243,35 @@ def publish_plugins_discover( for path in paths: path = os.path.normpath(path) - if not os.path.isdir(path): - continue + filenames = [] + if os.path.isdir(path): + filenames.extend( + name + for name in os.listdir(path) + if ( + os.path.isfile(os.path.join(path, name)) + and not name.startswith("_") + ) + ) + else: + filenames.append(os.path.basename(path)) + path = os.path.dirname(path) - for fname in os.listdir(path): - if fname.startswith("_"): - continue - - abspath = os.path.join(path, fname) - - if not os.path.isfile(abspath): - continue - - mod_name, mod_ext = os.path.splitext(fname) - - if mod_ext != ".py": + for filename in filenames: + mod_name, mod_ext = os.path.splitext(filename) + if mod_ext.lower() != ".py": continue + filepath = os.path.join(path, filename) try: module = import_filepath( - abspath, mod_name, sys_module_name=mod_name) + filepath, mod_name, sys_module_name=mod_name except Exception as err: # noqa: BLE001 # we need broad exception to catch all possible errors. - result.crashed_file_paths[abspath] = sys.exc_info() + result.crashed_file_paths[filepath] = sys.exc_info() - log.debug('Skipped: "%s" (%s)', mod_name, err) + log.debug('Skipped: "%s" (%s)', filepath, err) continue for plugin in pyblish.plugin.plugins_from_module(module): From c398e1fca35c5c379acb53b1c47bce8a493affb9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:03:37 +0200 Subject: [PATCH 200/312] hash dirpath for sys modules --- client/ayon_core/pipeline/publish/lib.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index ddb1c46def..c0b138c7f2 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -5,6 +5,7 @@ import sys import inspect import copy import warnings +import hashlib import xml.etree.ElementTree from typing import TYPE_CHECKING, Optional, Union, List @@ -257,15 +258,18 @@ def publish_plugins_discover( filenames.append(os.path.basename(path)) path = os.path.dirname(path) + dirpath_hash = hashlib.md5(path.encode("utf-8")).hexdigest() for filename in filenames: mod_name, mod_ext = os.path.splitext(filename) if mod_ext.lower() != ".py": continue filepath = os.path.join(path, filename) + sys_module_name = f"{dirpath_hash}.{mod_name}" try: module = import_filepath( - filepath, mod_name, sys_module_name=mod_name + filepath, mod_name, sys_module_name=sys_module_name + ) except Exception as err: # noqa: BLE001 # we need broad exception to catch all possible errors. From cdb719494ef86728f22440e137320e2aa91c6216 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:19:26 +0200 Subject: [PATCH 201/312] use same name for both sys module and module --- client/ayon_core/pipeline/publish/lib.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index c0b138c7f2..d360526024 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -260,15 +260,15 @@ def publish_plugins_discover( dirpath_hash = hashlib.md5(path.encode("utf-8")).hexdigest() for filename in filenames: - mod_name, mod_ext = os.path.splitext(filename) - if mod_ext.lower() != ".py": + basename, ext = os.path.splitext(filename) + if ext.lower() != ".py": continue filepath = os.path.join(path, filename) - sys_module_name = f"{dirpath_hash}.{mod_name}" + module_name = f"{dirpath_hash}.{basename}" try: module = import_filepath( - filepath, mod_name, sys_module_name=sys_module_name + filepath, module_name, sys_module_name=module_name ) except Exception as err: # noqa: BLE001 From feece4a7c30f32603c58d11d201aa73d494ed040 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:29:12 +0200 Subject: [PATCH 202/312] fix line length --- client/ayon_core/pipeline/publish/lib.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index cd6a7bca75..dfaba0e7a9 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -361,7 +361,8 @@ def get_plugin_settings(plugin, project_settings, log, category=None): [plugin.__name__] ) warnings.warn( - f"Please fill 'settings_category' for plugin '{plugin.__name__}'.", + "Please fill 'settings_category'" + f" for plugin '{plugin.__name__}'.", DeprecationWarning ) return output @@ -396,7 +397,8 @@ def get_plugin_settings(plugin, project_settings, log, category=None): [plugin.__name__] ) warnings.warn( - f"Please fill 'settings_category' for plugin '{plugin.__name__}'.", + "Please fill 'settings_category'" + f" for plugin '{plugin.__name__}'.", DeprecationWarning ) return output From 4e39e86037d878eed6e5359f1891587b5d909ec5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:28:29 +0200 Subject: [PATCH 203/312] Add 'enabled' attribute back --- .../ayon_core/plugins/publish/extract_usd_layer_contributions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index c2fa61e1fe..0dc9a5e34d 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -255,6 +255,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, order = pyblish.api.CollectorOrder + 0.35 label = "Collect USD Layer Contributions (Asset/Shot)" families = ["usd"] + enabled = True settings_category = "core" # A contribution defines a contribution into a (department) layer which From 8553e44e13f83a4f1ba61d5f9d3c56c9463fb355 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Jul 2025 11:48:22 +0200 Subject: [PATCH 204/312] added share active flag --- client/ayon_core/pipeline/create/context.py | 1 + .../ayon_core/pipeline/create/structures.py | 5 +++ .../publish/collect_from_create_context.py | 45 +++++++++++-------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 1cf8f08eff..383247ecb4 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -2087,6 +2087,7 @@ class CreateContext: } _queue = collections.deque() _queue.extend(instances_to_remove) + # Add children with parent lifetime flag while _queue: instance = _queue.popleft() ids_to_remove.add(instance.id) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 562a3a581d..b2be377b42 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -33,6 +33,11 @@ class IntEnum(int, Enum): class ParentFlags(IntEnum): # Delete instance if parent is deleted parent_lifetime = 1 + # Active state is propagated from parent to children + # - the active state is propagated in collection phase + # NOTE It might be helpful to have a function that would return "real" + # active state for instances + share_active = 1 << 1 class ConvertorItem: diff --git a/client/ayon_core/plugins/publish/collect_from_create_context.py b/client/ayon_core/plugins/publish/collect_from_create_context.py index 8383dfaa96..7b8aeee457 100644 --- a/client/ayon_core/plugins/publish/collect_from_create_context.py +++ b/client/ayon_core/plugins/publish/collect_from_create_context.py @@ -8,7 +8,7 @@ import pyblish.api from ayon_core.host import IPublishHost from ayon_core.pipeline import registered_host -from ayon_core.pipeline.create import CreateContext +from ayon_core.pipeline.create import CreateContext, ParentFlags class CollectFromCreateContext(pyblish.api.ContextPlugin): @@ -38,30 +38,39 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): if project_name: context.data["projectName"] = project_name - # Filter active instances and skip instances which have disabled - # parent instance + # Separate root instances and parented instances instances_by_parent_id = collections.defaultdict(list) - filtered_instances = [] + root_instances = [] for created_instance in create_context.instances: - if not created_instance["active"]: - continue parent_id = created_instance.parent_instance_id if parent_id is None: - filtered_instances.append(created_instance) + root_instances.append(created_instance) else: instances_by_parent_id[parent_id].append(created_instance) - parent_ids_queue = collections.deque() - parent_ids_queue.extend( - instance.id for instance in filtered_instances - ) - while parent_ids_queue: - parent_id = parent_ids_queue.popleft() - children = instances_by_parent_id[parent_id] - if not children: - continue - filtered_instances.extend(children) - parent_ids_queue.extend(instance.id for instance in children) + # Traverse instances from top to bottom + # - All instances without an existing parent are automatically + # eliminated + filtered_instances = [] + _queue = collections.deque() + _queue.append((root_instances, True)) + while _queue: + created_instances, parent_is_active = _queue.popleft() + for created_instance in created_instances: + is_active = created_instance["active"] + # Use a parent's active state if parent flags defines that + if ( + is_active + and created_instance.parent_flags & ParentFlags.share_active + ): + is_active = parent_is_active + + if is_active: + filtered_instances.append(created_instance) + + children = instances_by_parent_id[created_instance.id] + if children: + _queue.append((children, is_active)) for created_instance in filtered_instances: instance_data = created_instance.data_to_store() From e80ba294fb5b184ccb4bf349b23bbb0f551cbd34 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:21:35 +0200 Subject: [PATCH 205/312] added parent changes callback --- client/ayon_core/pipeline/create/context.py | 29 +++++++++++++++++++ .../tools/publisher/models/create.py | 13 +++++++++ 2 files changed, 42 insertions(+) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 383247ecb4..5e069cd62e 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1091,6 +1091,35 @@ class CreateContext: INSTANCE_REQUIREMENT_CHANGED_TOPIC, callback ) + def add_instance_parent_change_callback( + self, callback: Callable + ) -> "EventCallback": + """Register callback to listen to instance parent changes. + + Instance changed parent or parent flags. + + Data structure of event: + + ```python + { + "instances": [CreatedInstance, ...], + "create_context": CreateContext + } + ``` + + Args: + callback (Callable): Callback function that will be called when + instance requirement changed. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ + return self._event_hub.add_callback( + INSTANCE_PARENT_CHANGED_TOPIC, callback + ) + def context_data_to_store(self) -> dict[str, Any]: """Data that should be stored by host function. diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 058077aadd..15addd06b8 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -493,6 +493,9 @@ class CreateModel: self._create_context.add_instance_requirement_change_callback( self._cc_instance_requirement_changed ) + self._create_context.add_instance_parent_change_callback( + self._cc_instance_parent_changed + ) self._create_context.reset_finalization() @@ -1198,6 +1201,16 @@ class CreateModel: {"instance_ids": instance_ids}, ) + def _cc_instance_parent_changed(self, event): + instance_ids = { + instance.id + for instance in event.data["instances"] + } + self._emit_event( + "create.model.instance.parent.changed", + {"instance_ids": instance_ids}, + ) + def _get_allowed_creators_pattern(self) -> Union[Pattern, None]: """Provide regex pattern for configured creator labels in this context From 9ad1a5e830993401f1653adb38866e4ab318b014 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:21:52 +0200 Subject: [PATCH 206/312] added parent flags to UI --- client/ayon_core/tools/publisher/models/create.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 15addd06b8..0b0d287448 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -220,6 +220,7 @@ class InstanceItem: is_mandatory: bool, has_promised_context: bool, parent_instance_id: Optional[str], + parent_flags: int, ): self._instance_id: str = instance_id self._creator_identifier: str = creator_identifier @@ -234,6 +235,7 @@ class InstanceItem: self._is_mandatory: bool = is_mandatory self._has_promised_context: bool = has_promised_context self._parent_instance_id: Optional[str] = parent_instance_id + self._parent_flags: int = parent_flags @property def id(self): @@ -267,6 +269,10 @@ class InstanceItem: def parent_instance_id(self): return self._parent_instance_id + @property + def parent_flags(self) -> int: + return self._parent_flags + def get_variant(self): return self._variant @@ -319,6 +325,7 @@ class InstanceItem: instance.is_mandatory, instance.has_promised_context, instance.parent_instance_id, + instance.parent_flags, ) From 3e0705aad8b831ccbc62ee484cacf2de3a4fb8f4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:31:20 +0200 Subject: [PATCH 207/312] handle add parent flags handling --- .../publisher/widgets/list_view_widgets.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 65bc531d27..21762eed64 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -35,6 +35,7 @@ from ayon_core.tools.utils.lib import html_escape, checkstate_int_to_enum from ayon_core.pipeline.create import ( InstanceContextInfo, + ParentFlags, ) from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend @@ -170,6 +171,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): self._has_valid_context = context_info.is_valid self._is_mandatory = instance.is_mandatory self._instance_is_active = instance.is_active + self._parent_flags = instance.parent_flags # Parent active state is fluent and can change self._parent_is_active = parent_is_active @@ -238,10 +240,20 @@ class InstanceListItemWidget(QtWidgets.QWidget): self._instance_is_active = instance.is_active self._has_valid_context = context_info.is_valid self._parent_is_active = parent_is_active + self._parent_flags = instance.parent_flags self._update_checkbox_state() self._update_style_state() + def is_parent_active(self) -> bool: + return self._parent_is_active + + def _used_parent_active(self): + parent_enabled = True + if self._parent_flags & ParentFlags.share_active: + parent_enabled = self._parent_is_active + return parent_enabled + def set_parent_is_active(self, active: bool) -> None: if self._parent_is_active is active: return @@ -259,7 +271,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): def _update_style_state(self) -> None: state = "" - if not self._parent_is_active: + if not self._used_parent_active(): state = "disabled" elif not self._has_valid_context: state = "invalid" @@ -271,16 +283,18 @@ class InstanceListItemWidget(QtWidgets.QWidget): self._instance_label_widget.style().polish(self._instance_label_widget) def _update_checkbox_state(self) -> None: + parent_enabled = self._used_parent_active() + self._active_checkbox.setEnabled( self._toggle_is_enabled and not self._is_mandatory - and self._parent_is_active + and parent_enabled ) # Hide checkbox for mandatory instances self._active_checkbox.setVisible(not self._is_mandatory) # Visually disable instance if parent is disabled - checked = self._parent_is_active and self._instance_is_active + checked = parent_enabled and self._instance_is_active if checked is not self._active_checkbox.isChecked(): self._active_checkbox.setChecked(checked) From 205277f05242d390e885c2a6603010cdac7582df Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:31:33 +0200 Subject: [PATCH 208/312] listen to parent changes --- .../ayon_core/tools/publisher/widgets/overview_widget.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index 46395328e0..d78b143ce6 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -159,6 +159,10 @@ class OverviewWidget(QtWidgets.QFrame): "create.model.instance.requirement.changed", self._on_instance_requirement_changed ) + controller.register_event_callback( + "create.model.instance.parent.changed", + self._on_instance_parent_changed + ) self._product_content_widget = product_content_widget self._product_content_layout = product_content_layout @@ -361,6 +365,9 @@ class OverviewWidget(QtWidgets.QFrame): def _on_instance_requirement_changed(self, event): self._refresh_instance_states(event["instance_ids"]) + def _on_instance_parent_changed(self, event): + self._refresh_instance_states(event["instance_ids"]) + def _refresh_instance_states(self, instance_ids): current_idx = self._product_views_layout.currentIndex() for idx in range(self._product_views_layout.count()): From 6c12e1973d352a309599a89d070a6ffadd8e7f59 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:37:14 +0200 Subject: [PATCH 209/312] take all children from missing parent item --- .../tools/publisher/widgets/list_view_widgets.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 21762eed64..c7203351de 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -998,6 +998,16 @@ class InstanceListView(AbstractInstanceView): widget.setVisible(False) widget.deleteLater() parent.takeRow(self._missing_parent_item.row()) + _queue = collections.deque() + _queue.append(self._missing_parent_item) + while _queue: + item = _queue.popleft() + for _ in range(item.rowCount()): + child = item.child(0) + _queue.append(child) + item.takeRow(0) + + self._missing_parent_item = None def refresh_instance_states(self, instance_ids=None): """Trigger update of all instances.""" From 42a2c2da5992c94cd576b41003f5f06af3b6dc0b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:37:30 +0200 Subject: [PATCH 210/312] fix parenting changes propagation --- .../publisher/widgets/list_view_widgets.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index c7203351de..798e382fcf 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -659,7 +659,8 @@ class InstanceListView(AbstractInstanceView): item.setData(instance_id, INSTANCE_ID_ROLE) self._items_by_id[instance_id] = item new_items[parent_id].append(item) - elif parent_id != self._parent_id_by_id.get(instance_id): + + elif item.parent() is not parent_item: new_items[parent_id].append(item) self._parent_id_by_id[instance_id] = parent_id @@ -1037,6 +1038,7 @@ class InstanceListView(AbstractInstanceView): ] _queue.append((children, True)) + discarted_ids = set() while _queue: if not instance_ids: break @@ -1045,15 +1047,20 @@ class InstanceListView(AbstractInstanceView): for child in children: instance_id = child.data(INSTANCE_ID_ROLE) widget = self._widgets_by_id[instance_id] + # Add children ids to 'instance_ids' to traverse them too + add_children = False if instance_id in instance_ids: instance_ids.discard(instance_id) + discarted_ids.add(instance_id) + # Parent active state changed -> traverse children too + add_children = ( + parent_active is not widget.is_parent_active() + ) widget.update_instance( instance_items_by_id[instance_id], context_info_by_id[instance_id], parent_active, ) - if not instance_ids: - break if not child.hasChildren(): continue @@ -1062,6 +1069,15 @@ class InstanceListView(AbstractInstanceView): child.child(row) for row in range(child.rowCount()) ] + if add_children: + for new_child in children: + instance_id = new_child.data(INSTANCE_ID_ROLE) + if instance_id not in discarted_ids: + instance_ids.add(instance_id) + + if not instance_ids: + break + _queue.append((children, widget.is_active())) def _on_active_changed(self, changed_instance_id, new_value): From ed6fd25a0409ab255ccbb1819b2b08f070be7339 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:45:56 +0200 Subject: [PATCH 211/312] re-order imports --- client/ayon_core/tools/publisher/widgets/list_view_widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 798e382fcf..4dc7bf1322 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -30,14 +30,14 @@ from typing import Optional from qtpy import QtWidgets, QtCore, QtGui from ayon_core.style import get_objected_colors -from ayon_core.tools.utils import NiceCheckbox, BaseClickableFrame -from ayon_core.tools.utils.lib import html_escape, checkstate_int_to_enum from ayon_core.pipeline.create import ( InstanceContextInfo, ParentFlags, ) +from ayon_core.tools.utils import NiceCheckbox, BaseClickableFrame +from ayon_core.tools.utils.lib import html_escape, checkstate_int_to_enum from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend from ayon_core.tools.publisher.models.create import ( InstanceItem, From bfc82a07fd58914933f7d1757dc48f3a6e1601c8 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 28 Jul 2025 11:30:57 -0400 Subject: [PATCH 212/312] Detect rounding issues in media available_range when extracting (OTIO). --- client/ayon_core/pipeline/editorial.py | 4 ++ .../publish/extract_otio_audio_tracks.py | 9 +++++ .../plugins/publish/extract_otio_review.py | 39 +++++++++++++++---- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 8b6cfc52f1..b553fae3fb 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -7,6 +7,10 @@ import opentimelineio as otio from opentimelineio import opentime as _ot +# https://github.com/AcademySoftwareFoundation/OpenTimelineIO/issues/1822 +OTIO_EPSILON = 1e-9 + + def otio_range_to_frame_range(otio_range): start = _ot.to_frames( otio_range.start_time, otio_range.start_time.rate) diff --git a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py index 472694d334..2aec4a5415 100644 --- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py +++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py @@ -7,6 +7,7 @@ from ayon_core.lib import ( get_ffmpeg_tool_args, run_subprocess ) +from ayon_core.pipeline import editorial class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): @@ -172,6 +173,14 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): clip_start = otio_clip.source_range.start_time fps = clip_start.rate conformed_av_start = media_av_start.rescaled_to(fps) + + # Avoid rounding issue on media available range. + if clip_start.almost_equal( + conformed_av_start, + editorial.OTIO_EPSILON + ): + conformed_av_start = clip_start + # ffmpeg ignores embedded tc start = clip_start - conformed_av_start duration = otio_clip.source_range.duration diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index f217be551c..74cf45e474 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -23,7 +23,11 @@ from ayon_core.lib import ( get_ffmpeg_tool_args, run_subprocess, ) -from ayon_core.pipeline import publish +from ayon_core.pipeline import ( + KnownPublishError, + editorial, + publish, +) class ExtractOTIOReview( @@ -97,8 +101,11 @@ class ExtractOTIOReview( # skip instance if no reviewable data available if ( - not isinstance(otio_review_clips[0], otio.schema.Clip) - and len(otio_review_clips) == 1 + len(otio_review_clips) == 1 + and ( + not isinstance(otio_review_clips[0], otio.schema.Clip) + or otio_review_clips[0].media_reference.is_missing_reference + ) ): self.log.warning( "Instance `{}` has nothing to process".format(instance)) @@ -248,7 +255,7 @@ class ExtractOTIOReview( # Single video way. # Extraction via FFmpeg. - else: + elif hasattr(media_ref, "target_url"): path = media_ref.target_url # Set extract range from 0 (FFmpeg ignores # embedded timecode). @@ -370,6 +377,13 @@ class ExtractOTIOReview( avl_start = avl_range.start_time + # Avoid rounding issue on media available range. + if start.almost_equal( + avl_start, + editorial.OTIO_EPSILON + ): + avl_start = start + # An additional gap is required before the available # range to conform source start point and head handles. if start < avl_start: @@ -388,6 +402,14 @@ class ExtractOTIOReview( # (media duration is shorter then clip requirement). end_point = start + duration avl_end_point = avl_range.end_time_exclusive() + + # Avoid rounding issue on media available range. + if end_point.almost_equal( + avl_end_point, + editorial.OTIO_EPSILON + ): + avl_end_point = end_point + if end_point > avl_end_point: gap_duration = end_point - avl_end_point duration -= gap_duration @@ -444,7 +466,7 @@ class ExtractOTIOReview( command = get_ffmpeg_tool_args("ffmpeg") input_extension = None - if sequence: + if sequence is not None: input_dir, collection, sequence_fps = sequence in_frame_start = min(collection.indexes) @@ -478,7 +500,7 @@ class ExtractOTIOReview( "-i", input_path ]) - elif video: + elif video is not None: video_path, otio_range = video frame_start = otio_range.start_time.value input_fps = otio_range.start_time.rate @@ -496,7 +518,7 @@ class ExtractOTIOReview( "-i", video_path ]) - elif gap: + elif gap is not None: sec_duration = frames_to_seconds(gap, self.actual_fps) # form command for rendering gap files @@ -510,6 +532,9 @@ class ExtractOTIOReview( "-tune", "stillimage" ]) + else: + raise KnownPublishError("Sequence, video or gap is required.") + if video or sequence: command.extend([ "-vf", f"scale={self.to_width}:{self.to_height}:flags=lanczos", From 1ef49e4d08f157860f1b673b5f16683cdf1f7e5b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Jul 2025 15:37:37 +0200 Subject: [PATCH 213/312] fix 'is_checkbox_enabled' --- client/ayon_core/tools/publisher/widgets/list_view_widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 4dc7bf1322..f0fb5dcf82 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -196,7 +196,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): def is_checkbox_enabled(self) -> bool: """Checkbox can be changed by user.""" return ( - self._parent_is_active + self._used_parent_active() and not self._is_mandatory ) @@ -248,7 +248,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): def is_parent_active(self) -> bool: return self._parent_is_active - def _used_parent_active(self): + def _used_parent_active(self) -> bool: parent_enabled = True if self._parent_flags & ParentFlags.share_active: parent_enabled = self._parent_is_active From 7df97e5503c66d766961e841092a820d032c667f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Jul 2025 15:39:55 +0200 Subject: [PATCH 214/312] base of card widget implementation --- .../publisher/widgets/card_view_widgets.py | 180 ++++++++++++++---- 1 file changed, 148 insertions(+), 32 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 8a4eddf058..1a2855888a 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -19,6 +19,7 @@ Only one item can be selected at a time. └──────────────────────┘ ``` """ +from __future__ import annotations import re import collections @@ -26,11 +27,13 @@ from typing import Dict from qtpy import QtWidgets, QtCore -from ayon_core.tools.utils import NiceCheckbox +from ayon_core.pipeline.create import ( + InstanceContextInfo, + ParentFlags, +) -from ayon_core.tools.utils import BaseClickableFrame +from ayon_core.tools.utils import BaseClickableFrame, NiceCheckbox from ayon_core.tools.utils.lib import html_escape - from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend from ayon_core.tools.publisher.constants import ( CONTEXT_ID, @@ -38,7 +41,9 @@ from ayon_core.tools.publisher.constants import ( CONTEXT_GROUP, CONVERTOR_ITEM_GROUP, ) - +from ayon_core.tools.publisher.models.create import ( + InstanceItem, +) from .widgets import ( AbstractInstanceView, ContextWarningLabel, @@ -219,7 +224,11 @@ class InstanceGroupWidget(BaseGroupWidget): self._group_icons = group_icons def update_instance_values( - self, context_info_by_id, instance_items_by_id, instance_ids + self, + context_info_by_id, + instance_items_by_id, + instance_ids, + parent_is_active_by_id, ): """Trigger update on instance widgets.""" @@ -228,17 +237,24 @@ class InstanceGroupWidget(BaseGroupWidget): continue widget.update_instance( instance_items_by_id[instance_id], - context_info_by_id[instance_id] + context_info_by_id[instance_id], + parent_is_active_by_id[instance_id], ) - def update_instances(self, instances, context_info_by_id): + def update_instances( + self, + instances: list[InstanceItem], + context_info_by_id: dict[str, InstanceContextInfo], + parent_active_by_id: dict[str, bool] + ): """Update instances for the group. Args: instances (list[InstanceItem]): List of instances in CreateContext. - context_info_by_id (Dict[str, InstanceContextInfo]): Instance + context_info_by_id (dict[str, InstanceContextInfo]): Instance context info by instance id. + parent_active_by_id (dict[str, bool]): Instance has active parent. """ # Store instances by id and by product name @@ -260,13 +276,20 @@ class InstanceGroupWidget(BaseGroupWidget): for product_names in sorted_product_names: for instance in instances_by_product_name[product_names]: context_info = context_info_by_id[instance.id] + parent_is_active = parent_active_by_id[instance.id] if instance.id in self._widgets_by_id: widget = self._widgets_by_id[instance.id] - widget.update_instance(instance, context_info) + widget.update_instance( + instance, context_info, parent_is_active + ) else: group_icon = self._group_icons[instance.creator_identifier] widget = InstanceCardWidget( - instance, context_info, group_icon, self + instance, + context_info, + parent_is_active, + group_icon, + self ) widget.selected.connect(self._on_widget_selection) widget.active_changed.connect(self._on_active_changed) @@ -406,14 +429,23 @@ class InstanceCardWidget(CardWidget): active_changed = QtCore.Signal(str, bool) - def __init__(self, instance, context_info, group_icon, parent): + def __init__( + self, + instance, + context_info, + parent_is_active: bool, + group_icon, + parent: BaseGroupWidget, + ): super().__init__(parent) + self.instance = instance + self._id = instance.id self._group_identifier = instance.group_label self._group_icon = group_icon - - self.instance = instance + self._parent_is_active = parent_is_active + self._toggle_is_enabled = True self._last_product_name = None self._last_variant = None @@ -467,28 +499,29 @@ class InstanceCardWidget(CardWidget): self._active_checkbox = active_checkbox self._expand_btn = expand_btn - self._update_instance_values(context_info) + self._update_instance_values(context_info, parent_is_active) def set_active_toggle_enabled(self, enabled): - self._active_checkbox.setEnabled(enabled) + if self._toggle_is_enabled is enabled: + return + self._toggle_is_enabled = enabled + self._update_checkbox_state() @property def is_active(self): return self._active_checkbox.isChecked() - def _set_active(self, new_value): - """Set instance as active.""" - checkbox_value = self._active_checkbox.isChecked() - if checkbox_value != new_value: - self._active_checkbox.setChecked(new_value) + def is_checkbox_enabled(self) -> bool: + """Checkbox can be changed by user.""" + return ( + self._used_parent_active() + and not self.instance.is_mandatory + ) - def _set_is_mandatory(self, is_mandatory: bool) -> None: - self._active_checkbox.setVisible(not is_mandatory) - - def update_instance(self, instance, context_info): + def update_instance(self, instance, context_info, parent_is_active): """Update instance object and update UI.""" self.instance = instance - self._update_instance_values(context_info) + self._update_instance_values(context_info, parent_is_active) def _validate_context(self, context_info): valid = context_info.is_valid @@ -499,6 +532,9 @@ class InstanceCardWidget(CardWidget): variant = self.instance.variant product_name = self.instance.product_name label = self.instance.label + + parent_is_enabled = self._used_parent_active() + self._label_widget.setEnabled(parent_is_enabled) if ( variant == self._last_variant and product_name == self._last_product_name @@ -524,13 +560,36 @@ class InstanceCardWidget(CardWidget): QtCore.Qt.NoTextInteraction ) - def _update_instance_values(self, context_info): + def _update_instance_values(self, context_info, parent_is_active): """Update instance data""" + self._parent_is_active = parent_is_active self._update_product_name() - self._set_active(self.instance.is_active) - self._set_is_mandatory(self.instance.is_mandatory) + self._update_checkbox_state() self._validate_context(context_info) + def _update_checkbox_state(self): + parent_is_enabled = self._used_parent_active() + self._active_checkbox.setEnabled( + self._toggle_is_enabled + and not self.instance.is_mandatory + and parent_is_enabled + ) + # Hide checkbox for mandatory instances + self._active_checkbox.setVisible(not self.instance.is_mandatory) + + # Visually disable instance if parent is disabled + checked = parent_is_enabled and self.instance.is_active + if checked is not self._active_checkbox.isChecked(): + self._active_checkbox.blockSignals(True) + self._active_checkbox.setChecked(checked) + self._active_checkbox.blockSignals(False) + + def _used_parent_active(self) -> bool: + parent_enabled = True + if self.instance.parent_flags & ParentFlags.share_active: + parent_enabled = self._parent_is_active + return parent_enabled + def _set_expanded(self, expanded=None): if expanded is None: expanded = not self.detail_widget.isVisible() @@ -601,6 +660,8 @@ class InstanceCardView(AbstractInstanceView): self._widgets_by_group: Dict[str, InstanceGroupWidget] = {} self._ordered_groups = [] + self._instance_ids_by_parent_id = collections.defaultdict(set) + self._explicitly_selected_instance_ids = [] self._explicitly_selected_groups = [] @@ -705,12 +766,43 @@ class InstanceCardView(AbstractInstanceView): # Prepare instances by group and identifiers by group instances_by_group = collections.defaultdict(list) identifiers_by_group = collections.defaultdict(set) - for instance in self._controller.get_instance_items(): + instances_by_id = {} + instance_ids_by_parent_id = collections.defaultdict(set) + instance_items = self._controller.get_instance_items() + for instance in instance_items: group_name = instance.group_label instances_by_group[group_name].append(instance) identifiers_by_group[group_name].add( instance.creator_identifier ) + instances_by_id[instance.id] = instance + instance_ids_by_parent_id[instance.parent_instance_id].add( + instance.id + ) + + parent_active_by_id = { + instance_id: False + for instance_id in instances_by_id + } + _queue = collections.deque() + _queue.append((None, True)) + while _queue: + parent_id, parent_is_active = _queue.popleft() + for instance_id in instance_ids_by_parent_id[parent_id]: + instance_item = instances_by_id[instance_id] + is_active = instance_item.is_active + if ( + not parent_is_active + and instance_item.parent_flags & ParentFlags.share_active + ): + is_active = False + + parent_active_by_id[instance_id] = parent_is_active + _queue.append( + (instance_id, is_active) + ) + + self._instance_ids_by_parent_id = instance_ids_by_parent_id # Remove groups that were not found in apassed instances for group_name in tuple(self._widgets_by_group.keys()): @@ -755,7 +847,9 @@ class InstanceCardView(AbstractInstanceView): widget_idx += 1 group_widget.update_instances( - instances_by_group[group_name], context_info_by_id + instances_by_group[group_name], + context_info_by_id, + parent_active_by_id ) group_widget.set_active_toggle_enabled( self._active_toggle_enabled @@ -763,7 +857,7 @@ class InstanceCardView(AbstractInstanceView): self._update_ordered_group_names() - def has_items(self): + def has_items(self) -> bool: if self._convertor_items_group is not None: return True if self._widgets_by_group: @@ -828,9 +922,31 @@ class InstanceCardView(AbstractInstanceView): instance_items_by_id = self._controller.get_instance_items_by_id( instance_ids ) + instance_ids = set(instance_items_by_id) + + parent_is_active_by_id = { + instance_id: False + for instance_id in instance_ids + } + + discarted_ids = set() + _queue = collections.deque() + _queue.append((None, True)) + while _queue: + parent_id, parent_is_active = _queue.pop() + for instance_id in self._instance_ids_by_parent_id[parent_id]: + if instance_id in instance_ids: + instance_ids.discard(instance_id) + discarted_ids.add(instance_id) + # TODO there is no way how to get current state + parent_is_active_by_id[instance_id] = parent_is_active + for widget in self._widgets_by_group.values(): widget.update_instance_values( - context_info_by_id, instance_items_by_id, instance_ids + context_info_by_id, + instance_items_by_id, + instance_ids, + parent_is_active_by_id, ) def _on_active_changed(self, group_name, instance_id, value): From 74ad2e2c7ed451807934f9e640e1f2be2aab350c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Jul 2025 17:39:54 +0200 Subject: [PATCH 215/312] add settings category to CollectAnatomyInstanceData --- .../ayon_core/plugins/publish/collect_anatomy_instance_data.py | 2 ++ 1 file changed, 2 insertions(+) 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 2fcf562dd0..2cb2297bf7 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py @@ -46,6 +46,8 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.49 label = "Collect Anatomy Instance data" + settings_category = "core" + follow_workfile_version = False def process(self, context): From 06dbaf2d635d42ad9ba82701593b37a453a5f6e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 29 Jul 2025 18:01:34 +0200 Subject: [PATCH 216/312] :recycle: add link types --- .../workfile/workfile_template_builder.py | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index bfa192d834..6b82e3b04d 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -204,7 +204,9 @@ class AbstractTemplateBuilder(ABC): @property def linked_folder_entities(self): if self._linked_folder_entities is _NOT_SET: - self._linked_folder_entities = self._get_linked_folder_entities() + self._linked_folder_entities = self._get_linked_folder_entities( + link_type="template" + ) return self._linked_folder_entities @property @@ -307,14 +309,14 @@ class AbstractTemplateBuilder(ABC): self._loaders_by_name = get_loaders_by_name() return self._loaders_by_name - def _get_linked_folder_entities(self): + def _get_linked_folder_entities(self, link_type: str = "template"): project_name = self.project_name folder_entity = self.current_folder_entity if not folder_entity: return [] links = get_folder_links( project_name, - folder_entity["id"], link_types=["template"], link_direction="in" + folder_entity["id"], link_types=[link_type], link_direction="in" ) linked_folder_ids = { link["entityId"] @@ -1433,6 +1435,14 @@ class PlaceholderLoadMixin(object): {"label": "Linked folders", "value": "linked_folders"}, {"label": "All folders", "value": "all_folders"}, ] + + link_types = ayon_api.get_link_types(self.builder.project_name) + + link_types_enum_item = [ + {"label": link_type["name"], "value": link_type["linkType"]} + for link_type in link_types + + ] build_type_label = "Folder Builder Type" build_type_help = ( "Folder Builder Type\n" @@ -1461,6 +1471,17 @@ class PlaceholderLoadMixin(object): items=builder_type_enum_items, tooltip=build_type_help ), + attribute_definitions.EnumDef( + "link_type", + label="Link Type", + default="template", + items=link_types_enum_item, + tooltip=( + "Link Type\n" + "\nDefines what type of link will be used to" + " link the asset to the current folder." + ) + ), attribute_definitions.EnumDef( "product_type", label="Product type", From d8392a2133d4196c3985357feb6fd4344084f954 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Jul 2025 18:07:17 +0200 Subject: [PATCH 217/312] fix possible issue with missing instance data --- .../publisher/widgets/list_view_widgets.py | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index f0fb5dcf82..9e3113001b 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -1022,6 +1022,7 @@ class InstanceListView(AbstractInstanceView): instance_ids ) instance_ids = set(instance_items_by_id) + available_ids = set(instance_ids) group_items = list(self._group_items.values()) if self._missing_parent_item is not None: @@ -1050,17 +1051,22 @@ class InstanceListView(AbstractInstanceView): # Add children ids to 'instance_ids' to traverse them too add_children = False if instance_id in instance_ids: - instance_ids.discard(instance_id) - discarted_ids.add(instance_id) # Parent active state changed -> traverse children too add_children = ( parent_active is not widget.is_parent_active() ) - widget.update_instance( - instance_items_by_id[instance_id], - context_info_by_id[instance_id], - parent_active, - ) + if instance_id in available_ids: + available_ids.discard(instance_id) + widget.update_instance( + instance_items_by_id[instance_id], + context_info_by_id[instance_id], + parent_active, + ) + else: + widget.set_active(parent_active) + + instance_ids.discard(instance_id) + discarted_ids.add(instance_id) if not child.hasChildren(): continue From 6bc3e5130e0ded5fbc0986770ce9f8e72a5afdf2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Jul 2025 18:19:41 +0200 Subject: [PATCH 218/312] reworked card view for easier maintanance of widget updates --- .../publisher/widgets/card_view_widgets.py | 644 +++++++++--------- 1 file changed, 326 insertions(+), 318 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 1a2855888a..238f270f1f 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -23,7 +23,7 @@ from __future__ import annotations import re import collections -from typing import Dict +from typing import Optional from qtpy import QtWidgets, QtCore @@ -87,7 +87,6 @@ class BaseGroupWidget(QtWidgets.QWidget): self._group = group_name self._widgets_by_id = {} - self._ordered_item_ids = [] self._label_widget = label_widget self._content_layout = layout @@ -102,48 +101,25 @@ class BaseGroupWidget(QtWidgets.QWidget): return self._group - def get_widget_by_item_id(self, item_id): - """Get instance widget by its id.""" + def set_widgets( + self, + widgets_by_id: dict[str, QtWidgets.QWidget], + ordered_ids: list[str], + ) -> None: + self._remove_all_except(set(self._widgets_by_id)) + idx = 1 + for item_id in ordered_ids: + widget = widgets_by_id[item_id] + self._content_layout.insertWidget(idx, widget) + self._widgets_by_id[item_id] = widget + idx += 1 - return self._widgets_by_id.get(item_id) - - def get_selected_item_ids(self): - """Selected instance ids. - - Returns: - Set[str]: Instance ids that are selected. - """ - - return { - instance_id - for instance_id, widget in self._widgets_by_id.items() - if widget.is_selected - } - - def get_selected_widgets(self): - """Access to widgets marked as selected. - - Returns: - List[InstanceCardWidget]: Instance widgets that are selected. - """ - - return [ - widget - for instance_id, widget in self._widgets_by_id.items() - if widget.is_selected - ] - - def get_ordered_widgets(self): - """Get instance ids in order as are shown in ui. - - Returns: - List[str]: Instance ids. - """ - - return [ - self._widgets_by_id[instance_id] - for instance_id in self._ordered_item_ids - ] + def take_widgets(self, widget_ids: set[str]): + for widget_id in widget_ids: + widget = self._widgets_by_id.pop(widget_id) + index = self._content_layout.indexOf(widget) + if index >= 0: + self._content_layout.takeAt(index) def _remove_all_except(self, item_ids): item_ids = set(item_ids) @@ -160,149 +136,6 @@ class BaseGroupWidget(QtWidgets.QWidget): self._content_layout.removeWidget(widget) widget.deleteLater() - def _update_ordered_item_ids(self): - ordered_item_ids = [] - for idx in range(self._content_layout.count()): - if idx > 0: - item = self._content_layout.itemAt(idx) - widget = item.widget() - if widget is not None: - ordered_item_ids.append(widget.id) - - self._ordered_item_ids = ordered_item_ids - - def _on_widget_selection(self, instance_id, group_id, selection_type): - self.selected.emit(instance_id, group_id, selection_type) - - def set_active_toggle_enabled(self, enabled): - for widget in self._widgets_by_id.values(): - if isinstance(widget, InstanceCardWidget): - widget.set_active_toggle_enabled(enabled) - - -class ConvertorItemsGroupWidget(BaseGroupWidget): - def update_items(self, items_by_id): - items_by_label = collections.defaultdict(list) - for item in items_by_id.values(): - items_by_label[item.label].append(item) - - # Remove instance widgets that are not in passed instances - self._remove_all_except(items_by_id.keys()) - - # Sort instances by product name - sorted_labels = list(sorted(items_by_label.keys())) - - # Add new instances to widget - widget_idx = 1 - for label in sorted_labels: - for item in items_by_label[label]: - if item.id in self._widgets_by_id: - widget = self._widgets_by_id[item.id] - widget.update_item(item) - else: - widget = ConvertorItemCardWidget(item, self) - widget.selected.connect(self._on_widget_selection) - widget.double_clicked.connect(self.double_clicked) - self._widgets_by_id[item.id] = widget - self._content_layout.insertWidget(widget_idx, widget) - widget_idx += 1 - - self._update_ordered_item_ids() - - -class InstanceGroupWidget(BaseGroupWidget): - """Widget wrapping instances under group.""" - - active_changed = QtCore.Signal(str, str, bool) - - def __init__(self, group_icons, *args, **kwargs): - super().__init__(*args, **kwargs) - - self._group_icons = group_icons - - def update_icons(self, group_icons): - self._group_icons = group_icons - - def update_instance_values( - self, - context_info_by_id, - instance_items_by_id, - instance_ids, - parent_is_active_by_id, - ): - """Trigger update on instance widgets.""" - - for instance_id, widget in self._widgets_by_id.items(): - if instance_ids is not None and instance_id not in instance_ids: - continue - widget.update_instance( - instance_items_by_id[instance_id], - context_info_by_id[instance_id], - parent_is_active_by_id[instance_id], - ) - - def update_instances( - self, - instances: list[InstanceItem], - context_info_by_id: dict[str, InstanceContextInfo], - parent_active_by_id: dict[str, bool] - ): - """Update instances for the group. - - Args: - instances (list[InstanceItem]): List of instances in - CreateContext. - context_info_by_id (dict[str, InstanceContextInfo]): Instance - context info by instance id. - parent_active_by_id (dict[str, bool]): Instance has active parent. - - """ - # Store instances by id and by product name - instances_by_id = {} - instances_by_product_name = collections.defaultdict(list) - for instance in instances: - instances_by_id[instance.id] = instance - product_name = instance.product_name - instances_by_product_name[product_name].append(instance) - - # Remove instance widgets that are not in passed instances - self._remove_all_except(instances_by_id.keys()) - - # Sort instances by product name - sorted_product_names = list(sorted(instances_by_product_name.keys())) - - # Add new instances to widget - widget_idx = 1 - for product_names in sorted_product_names: - for instance in instances_by_product_name[product_names]: - context_info = context_info_by_id[instance.id] - parent_is_active = parent_active_by_id[instance.id] - if instance.id in self._widgets_by_id: - widget = self._widgets_by_id[instance.id] - widget.update_instance( - instance, context_info, parent_is_active - ) - else: - group_icon = self._group_icons[instance.creator_identifier] - widget = InstanceCardWidget( - instance, - context_info, - parent_is_active, - group_icon, - self - ) - widget.selected.connect(self._on_widget_selection) - widget.active_changed.connect(self._on_active_changed) - widget.double_clicked.connect(self.double_clicked) - self._widgets_by_id[instance.id] = widget - self._content_layout.insertWidget(widget_idx, widget) - widget_idx += 1 - - self._update_ordered_item_ids() - - def _on_active_changed(self, instance_id, value): - self.active_changed.emit(self.group_name, instance_id, value) - class CardWidget(BaseClickableFrame): """Clickable card used as bigger button.""" @@ -423,6 +256,10 @@ class ConvertorItemCardWidget(CardWidget): self._icon_widget = icon_widget self._label_widget = label_widget + def update_item(self, item): + self._id = item.id + self.identifier = item.identifier + class InstanceCardWidget(CardWidget): """Card widget representing instance.""" @@ -433,7 +270,7 @@ class InstanceCardWidget(CardWidget): self, instance, context_info, - parent_is_active: bool, + is_parent_active: bool, group_icon, parent: BaseGroupWidget, ): @@ -444,7 +281,7 @@ class InstanceCardWidget(CardWidget): self._id = instance.id self._group_identifier = instance.group_label self._group_icon = group_icon - self._parent_is_active = parent_is_active + self._is_parent_active = is_parent_active self._toggle_is_enabled = True self._last_product_name = None @@ -499,18 +336,26 @@ class InstanceCardWidget(CardWidget): self._active_checkbox = active_checkbox self._expand_btn = expand_btn - self._update_instance_values(context_info, parent_is_active) + self._update_instance_values(context_info, is_parent_active) - def set_active_toggle_enabled(self, enabled): + def set_active_toggle_enabled(self, enabled: bool) -> None: if self._toggle_is_enabled is enabled: return self._toggle_is_enabled = enabled self._update_checkbox_state() - @property - def is_active(self): + def is_active(self) -> bool: return self._active_checkbox.isChecked() + def is_parent_active(self) -> bool: + return self._is_parent_active + + def set_parent_active(self, is_active: bool) -> None: + if self._is_parent_active is is_active: + return + self._is_parent_active = is_active + self._update_checkbox_state() + def is_checkbox_enabled(self) -> bool: """Checkbox can be changed by user.""" return ( @@ -518,10 +363,10 @@ class InstanceCardWidget(CardWidget): and not self.instance.is_mandatory ) - def update_instance(self, instance, context_info, parent_is_active): + def update_instance(self, instance, context_info, is_parent_active): """Update instance object and update UI.""" self.instance = instance - self._update_instance_values(context_info, parent_is_active) + self._update_instance_values(context_info, is_parent_active) def _validate_context(self, context_info): valid = context_info.is_valid @@ -560,9 +405,9 @@ class InstanceCardWidget(CardWidget): QtCore.Qt.NoTextInteraction ) - def _update_instance_values(self, context_info, parent_is_active): + def _update_instance_values(self, context_info, is_parent_active): """Update instance data""" - self._parent_is_active = parent_is_active + self._is_parent_active = is_parent_active self._update_product_name() self._update_checkbox_state() self._validate_context(context_info) @@ -587,7 +432,7 @@ class InstanceCardWidget(CardWidget): def _used_parent_active(self) -> bool: parent_enabled = True if self.instance.parent_flags & ParentFlags.share_active: - parent_enabled = self._parent_is_active + parent_enabled = self._is_parent_active return parent_enabled def _set_expanded(self, expanded=None): @@ -654,11 +499,20 @@ class InstanceCardView(AbstractInstanceView): self._content_layout = content_layout self._content_widget = content_widget - self._context_widget = None - self._convertor_items_group = None - self._active_toggle_enabled = True - self._widgets_by_group: Dict[str, InstanceGroupWidget] = {} + self._active_toggle_enabled: bool = True + self._convertors_group: Optional[BaseGroupWidget] = None + self._convertor_widgets_by_id: dict[str, ConvertorItemCardWidget] = {} + self._convertor_ids: list[str] = [] + + self._group_name_by_instance_id: dict[str, str] = {} + self._instance_ids_by_group_name: dict[str, list[str]] = ( + collections.defaultdict(list) + ) self._ordered_groups = [] + self._group_icons = {} + self._context_widget: Optional[ContextCardWidget] = None + self._widgets_by_id: dict[str, InstanceCardWidget] = {} + self._widgets_by_group: dict[str, BaseGroupWidget] = {} self._instance_ids_by_parent_id = collections.defaultdict(set) @@ -694,7 +548,7 @@ class InstanceCardView(AbstractInstanceView): continue instance_id = widget.id - is_active = widget.is_active + is_active = widget.is_active() if value == -1: active_state_by_id[instance_id] = not is_active continue @@ -731,12 +585,17 @@ class InstanceCardView(AbstractInstanceView): ): output.append(self._context_widget) - if self._convertor_items_group is not None: - output.extend(self._convertor_items_group.get_selected_widgets()) + output.extend( + widget + for widget in self._convertor_widgets_by_id.values() + if widget.is_selected + ) - for group_widget in self._widgets_by_group.values(): - for widget in group_widget.get_selected_widgets(): - output.append(widget) + output.extend( + widget + for widget in self._widgets_by_id.values() + if widget.is_selected + ) return output def _get_selected_instance_ids(self): @@ -747,11 +606,17 @@ class InstanceCardView(AbstractInstanceView): ): output.append(CONTEXT_ID) - if self._convertor_items_group is not None: - output.extend(self._convertor_items_group.get_selected_item_ids()) + output.extend( + conv_id + for conv_id, widget in self._widgets_by_id.items() + if widget.is_selected + ) - for group_widget in self._widgets_by_group.values(): - output.extend(group_widget.get_selected_item_ids()) + output.extend( + widget.id + for instance_id, widget in self._widgets_by_id.items() + if widget.is_selected + ) return output def refresh(self): @@ -759,13 +624,14 @@ class InstanceCardView(AbstractInstanceView): self._make_sure_context_widget_exists() - self._update_convertor_items_group() + self._update_convertors_group() context_info_by_id = self._controller.get_instances_context_info() # Prepare instances by group and identifiers by group instances_by_group = collections.defaultdict(list) identifiers_by_group = collections.defaultdict(set) + identifiers: set[str] = set() instances_by_id = {} instance_ids_by_parent_id = collections.defaultdict(set) instance_items = self._controller.get_instance_items() @@ -775,6 +641,7 @@ class InstanceCardView(AbstractInstanceView): identifiers_by_group[group_name].add( instance.creator_identifier ) + identifiers.add(instance.creator_identifier) instances_by_id[instance.id] = instance instance_ids_by_parent_id[instance.parent_instance_id].add( instance.id @@ -787,28 +654,67 @@ class InstanceCardView(AbstractInstanceView): _queue = collections.deque() _queue.append((None, True)) while _queue: - parent_id, parent_is_active = _queue.popleft() + parent_id, is_parent_active = _queue.popleft() for instance_id in instance_ids_by_parent_id[parent_id]: instance_item = instances_by_id[instance_id] is_active = instance_item.is_active if ( - not parent_is_active + not is_parent_active and instance_item.parent_flags & ParentFlags.share_active ): is_active = False - parent_active_by_id[instance_id] = parent_is_active + parent_active_by_id[instance_id] = is_parent_active _queue.append( (instance_id, is_active) ) - self._instance_ids_by_parent_id = instance_ids_by_parent_id + # Remove groups that were not found in passed instances + groups_to_remove = ( + set(self._widgets_by_group) - set(instances_by_group) + ) - # Remove groups that were not found in apassed instances - for group_name in tuple(self._widgets_by_group.keys()): - if group_name in instances_by_group: - continue + # Sort groups + sorted_group_names = list(sorted(instances_by_group.keys())) + # Keep track of widget indexes + # - we start with 1 because Context item as at the top + widget_idx = 1 + if self._convertors_group is not None: + widget_idx += 1 + + group_by_instance_id = {} + instance_ids_by_group_name = collections.defaultdict(list) + group_icons = { + identifier: self._controller.get_creator_icon(identifier) + for identifier in identifiers + } + for group_name in sorted_group_names: + if group_name not in self._widgets_by_group: + group_widget = BaseGroupWidget( + group_name, self._content_widget + ) + group_widget.double_clicked.connect(self.double_clicked) + self._content_layout.insertWidget(widget_idx, group_widget) + self._widgets_by_group[group_name] = group_widget + + widget_idx += 1 + + instances = instances_by_group[group_name] + for instance in instances: + group_by_instance_id[instance.id] = group_name + instance_ids_by_group_name[group_name].append(instance.id) + + self._update_instances( + group_name, + instances, + context_info_by_id, + parent_active_by_id, + group_icons, + ) + + # Remove empty groups + for group_name in groups_to_remove: widget = self._widgets_by_group.pop(group_name) widget.setVisible(False) self._content_layout.removeWidget(widget) @@ -817,63 +723,85 @@ class InstanceCardView(AbstractInstanceView): if group_name in self._explicitly_selected_groups: self._explicitly_selected_groups.remove(group_name) - # Sort groups - sorted_group_names = list(sorted(instances_by_group.keys())) - - # Keep track of widget indexes - # - we start with 1 because Context item as at the top - widget_idx = 1 - if self._convertor_items_group is not None: - widget_idx += 1 - - for group_name in sorted_group_names: - group_icons = { - identifier: self._controller.get_creator_icon(identifier) - for identifier in identifiers_by_group[group_name] - } - if group_name in self._widgets_by_group: - group_widget = self._widgets_by_group[group_name] - group_widget.update_icons(group_icons) - - else: - group_widget = InstanceGroupWidget( - group_icons, group_name, self._content_widget - ) - group_widget.active_changed.connect(self._on_active_changed) - group_widget.selected.connect(self._on_widget_selection) - group_widget.double_clicked.connect(self.double_clicked) - self._content_layout.insertWidget(widget_idx, group_widget) - self._widgets_by_group[group_name] = group_widget - - widget_idx += 1 - group_widget.update_instances( - instances_by_group[group_name], - context_info_by_id, - parent_active_by_id - ) - group_widget.set_active_toggle_enabled( - self._active_toggle_enabled - ) - - self._update_ordered_group_names() + self._instance_ids_by_parent_id = instance_ids_by_parent_id + self._group_name_by_instance_id = group_by_instance_id + self._instance_ids_by_group_name = instance_ids_by_group_name + self._ordered_groups = sorted_group_names def has_items(self) -> bool: - if self._convertor_items_group is not None: + if self._convertors_group is not None: return True - if self._widgets_by_group: + if self._widgets_by_id: return True return False - def _update_ordered_group_names(self): - ordered_group_names = [CONTEXT_GROUP] - for idx in range(self._content_layout.count()): - if idx > 0: - item = self._content_layout.itemAt(idx) - group_widget = item.widget() - if group_widget is not None: - ordered_group_names.append(group_widget.group_name) + def _update_instances( + self, + group_name: str, + instances: list[InstanceItem], + context_info_by_id: dict[str, InstanceContextInfo], + parent_active_by_id: dict[str, bool], + group_icons: dict[str, str], + ): + """Update instances for the group. - self._ordered_groups = ordered_group_names + Args: + instances (list[InstanceItem]): List of instances in + CreateContext. + context_info_by_id (dict[str, InstanceContextInfo]): Instance + context info by instance id. + parent_active_by_id (dict[str, bool]): Instance has active parent. + + """ + # Store instances by id and by product name + group_widget: BaseGroupWidget = self._widgets_by_group[group_name] + instances_by_id = {} + instances_by_product_name = collections.defaultdict(list) + for instance in instances: + instances_by_id[instance.id] = instance + product_name = instance.product_name + instances_by_product_name[product_name].append(instance) + + to_remove_ids = set( + self._instance_ids_by_group_name[group_name] + ) - set(instances_by_id) + group_widget.take_widgets(to_remove_ids) + + # Sort instances by product name + sorted_product_names = list(sorted(instances_by_product_name.keys())) + + # Add new instances to widget + ordered_ids = [] + widgets_by_id = {} + for product_names in sorted_product_names: + for instance in instances_by_product_name[product_names]: + context_info = context_info_by_id[instance.id] + is_parent_active = parent_active_by_id[instance.id] + if instance.id in self._widgets_by_id: + widget = self._widgets_by_id[instance.id] + widget.update_instance( + instance, context_info, is_parent_active + ) + else: + group_icon = group_icons[instance.creator_identifier] + widget = InstanceCardWidget( + instance, + context_info, + is_parent_active, + group_icon, + group_widget + ) + widget.selected.connect(self._on_widget_selection) + widget.active_changed.connect(self._on_active_changed) + widget.double_clicked.connect(self.double_clicked) + self._widgets_by_id[instance.id] = widget + + ordered_ids.append(instance.id) + widgets_by_id[instance.id] = widget + + group_widget.set_widgets(widgets_by_id, ordered_ids) + + return ordered_ids def _make_sure_context_widget_exists(self): # Create context item if is not already existing @@ -891,28 +819,65 @@ class InstanceCardView(AbstractInstanceView): self.selection_changed.emit() self._content_layout.insertWidget(0, widget) - def _update_convertor_items_group(self): + def _update_convertors_group(self): convertor_items = self._controller.get_convertor_items() - if not convertor_items and self._convertor_items_group is None: + if not convertor_items and self._convertors_group is None: return + ids_to_remove = set(self._convertor_widgets_by_id) - set( + convertor_items + ) + if ids_to_remove: + self._convertors_group.take_widgets(ids_to_remove) + + for conv_id in ids_to_remove: + widget = self._convertor_widgets_by_id.pop(conv_id) + widget.setVisible(False) + widget.deleteLater() + if not convertor_items: - self._convertor_items_group.setVisible(False) - self._content_layout.removeWidget(self._convertor_items_group) - self._convertor_items_group.deleteLater() - self._convertor_items_group = None + self._convertors_group.setVisible(False) + self._content_layout.removeWidget(self._convertors_group) + self._convertors_group.deleteLater() + self._convertors_group = None + self._convertor_ids = [] + self._convertor_widgets_by_id = {} return - if self._convertor_items_group is None: - group_widget = ConvertorItemsGroupWidget( + if self._convertors_group is None: + group_widget = BaseGroupWidget( CONVERTOR_ITEM_GROUP, self._content_widget ) - group_widget.selected.connect(self._on_widget_selection) - group_widget.double_clicked.connect(self.double_clicked) self._content_layout.insertWidget(1, group_widget) - self._convertor_items_group = group_widget + self._convertors_group = group_widget - self._convertor_items_group.update_items(convertor_items) + # TODO create convertor widgets + items_by_label = collections.defaultdict(list) + for item in convertor_items.values(): + items_by_label[item.label].append(item) + + # Sort instances by product name + sorted_labels = list(sorted(items_by_label.keys())) + + # Add new instances to widget + convertor_ids: list[str] = [] + widgets_by_id: dict[str, ConvertorItemCardWidget] = {} + for label in sorted_labels: + for item in items_by_label[label]: + convertor_ids.append(item.id) + if item.id in self._convertor_widgets_by_id: + widget = self._convertor_widgets_by_id[item.id] + widget.update_item(item) + else: + widget = ConvertorItemCardWidget(item, self) + widget.selected.connect(self._on_widget_selection) + widget.double_clicked.connect(self.double_clicked) + self._convertor_widgets_by_id[item.id] = widget + widgets_by_id[item.id] = widget + + self._convertors_group.set_widgets(widgets_by_id, convertor_ids) + self._convertor_ids = convertor_ids + self._convertor_widgets_by_id = widgets_by_id def refresh_instance_states(self, instance_ids=None): """Trigger update of instances on group widgets.""" @@ -922,36 +887,57 @@ class InstanceCardView(AbstractInstanceView): instance_items_by_id = self._controller.get_instance_items_by_id( instance_ids ) - instance_ids = set(instance_items_by_id) + instance_ids: set[str] = set(instance_items_by_id) + available_ids: set[str] = set(instance_items_by_id) + discarted_ids: set[str] = set() - parent_is_active_by_id = { - instance_id: False - for instance_id in instance_ids - } - - discarted_ids = set() _queue = collections.deque() - _queue.append((None, True)) + _queue.append((set(self._instance_ids_by_parent_id[None]), True)) while _queue: - parent_id, parent_is_active = _queue.pop() - for instance_id in self._instance_ids_by_parent_id[parent_id]: + if not instance_ids: + break + + chilren_ids, is_parent_active = _queue.pop() + for instance_id in chilren_ids: + widget = self._widgets_by_id[instance_id] + add_children = False if instance_id in instance_ids: + add_children = ( + is_parent_active is not widget.is_parent_active() + ) + + if instance_id in available_ids: + available_ids.discard(instance_id) + widget.update_instance( + instance_items_by_id[instance_id], + context_info_by_id[instance_id], + is_parent_active, + ) + else: + # TODO implement 'set_parent_active' + widget.set_parent_active(is_parent_active) + instance_ids.discard(instance_id) discarted_ids.add(instance_id) - # TODO there is no way how to get current state - parent_is_active_by_id[instance_id] = parent_is_active - for widget in self._widgets_by_group.values(): - widget.update_instance_values( - context_info_by_id, - instance_items_by_id, - instance_ids, - parent_is_active_by_id, - ) + if not instance_ids: + break - def _on_active_changed(self, group_name, instance_id, value): - group_widget = self._widgets_by_group[group_name] - instance_widget = group_widget.get_widget_by_item_id(instance_id) + if not add_children: + continue + + children_ids = self._instance_ids_by_parent_id[instance_id] + children = { + child_id + for child_id in children_ids + if child_id not in discarted_ids + } + + if children: + _queue.append((children, widget.is_active())) + + def _on_active_changed(self, instance_id, value): + instance_widget = self._widgets_by_id[instance_id] active_state_by_id = {} if not instance_widget.is_selected: active_state_by_id[instance_id] = value @@ -973,10 +959,9 @@ class InstanceCardView(AbstractInstanceView): else: if group_name == CONVERTOR_ITEM_GROUP: - group_widget = self._convertor_items_group + new_widget = self._convertor_widgets_by_id[instance_id] else: - group_widget = self._widgets_by_group[group_name] - new_widget = group_widget.get_widget_by_item_id(instance_id) + new_widget = self._widgets_by_id[instance_id] if selection_type == SelectionTypes.clear: self._select_item_clear(instance_id, group_name, new_widget) @@ -1021,11 +1006,21 @@ class InstanceCardView(AbstractInstanceView): if instance_id == CONTEXT_ID: remove_group = True else: + has_selected_items = False if group_name == CONVERTOR_ITEM_GROUP: - group_widget = self._convertor_items_group + for widget in self._convertor_widgets_by_id.values(): + if widget.is_selected: + has_selected_items = True + break else: - group_widget = self._widgets_by_group[group_name] - if not group_widget.get_selected_widgets(): + group_ids = self._instance_ids_by_group_name[group_name] + for instance_id in group_ids: + widget = self._widgets_by_id[instance_id] + if widget.is_selected: + has_selected_items = True + break + + if not has_selected_items: remove_group = True if remove_group: @@ -1137,10 +1132,16 @@ class InstanceCardView(AbstractInstanceView): sorted_widgets = [self._context_widget] else: if name == CONVERTOR_ITEM_GROUP: - group_widget = self._convertor_items_group + sorted_widgets = [ + self._convertor_widgets_by_id[conv_id] + for conv_id in self._convertor_ids + ] else: - group_widget = self._widgets_by_group[name] - sorted_widgets = group_widget.get_ordered_widgets() + instance_ids = self._instance_ids_by_group_name[name] + sorted_widgets = [ + self._widgets_by_id[instance_id] + for instance_id in instance_ids + ] # Change selection based on explicit selection if start group # was not passed yet @@ -1298,12 +1299,19 @@ class InstanceCardView(AbstractInstanceView): is_convertor_group = group_name == CONVERTOR_ITEM_GROUP if is_convertor_group: - group_widget = self._convertor_items_group + sorted_widgets = [ + self._convertor_widgets_by_id[conv_id] + for conv_id in self._convertor_ids + ] else: - group_widget = self._widgets_by_group[group_name] + instance_ids = self._instance_ids_by_group_name[group_name] + sorted_widgets = [ + self._widgets_by_id[instance_id] + for instance_id in instance_ids + ] group_selected = False - for widget in group_widget.get_ordered_widgets(): + for widget in sorted_widgets: select = False if is_convertor_group: is_in = widget.identifier in s_convertor_identifiers @@ -1325,5 +1333,5 @@ class InstanceCardView(AbstractInstanceView): if self._active_toggle_enabled is enabled: return self._active_toggle_enabled = enabled - for group_widget in self._widgets_by_group.values(): - group_widget.set_active_toggle_enabled(enabled) + for widget in self._widgets_by_id.values(): + widget.set_active_toggle_enabled(enabled) From 067f218752aa605d1433a75dd54266fb07e8171a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Jul 2025 18:58:09 +0200 Subject: [PATCH 219/312] few enhancements --- .../tools/publisher/widgets/card_view_widgets.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 238f270f1f..e3e8a98ad5 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -705,7 +705,7 @@ class InstanceCardView(AbstractInstanceView): group_by_instance_id[instance.id] = group_name instance_ids_by_group_name[group_name].append(instance.id) - self._update_instances( + self._update_instance_widgets( group_name, instances, context_info_by_id, @@ -735,7 +735,7 @@ class InstanceCardView(AbstractInstanceView): return True return False - def _update_instances( + def _update_instance_widgets( self, group_name: str, instances: list[InstanceItem], @@ -905,7 +905,6 @@ class InstanceCardView(AbstractInstanceView): add_children = ( is_parent_active is not widget.is_parent_active() ) - if instance_id in available_ids: available_ids.discard(instance_id) widget.update_instance( @@ -914,15 +913,11 @@ class InstanceCardView(AbstractInstanceView): is_parent_active, ) else: - # TODO implement 'set_parent_active' widget.set_parent_active(is_parent_active) instance_ids.discard(instance_id) discarted_ids.add(instance_id) - if not instance_ids: - break - if not add_children: continue @@ -934,8 +929,12 @@ class InstanceCardView(AbstractInstanceView): } if children: + instance_ids |= children _queue.append((children, widget.is_active())) + if not instance_ids: + break + def _on_active_changed(self, instance_id, value): instance_widget = self._widgets_by_id[instance_id] active_state_by_id = {} From 744d36042c5d5ef570c98a474309d894b5f28f7b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Jul 2025 18:59:45 +0200 Subject: [PATCH 220/312] remove parent active validation --- client/ayon_core/tools/publisher/widgets/list_view_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 9e3113001b..3440a91b6f 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -1144,7 +1144,7 @@ class InstanceListView(AbstractInstanceView): instance_id = child.data(INSTANCE_ID_ROLE) widget = self._widgets_by_id[instance_id] widget.set_parent_is_active(parent_active) - if parent_active and instance_id in instance_ids: + if instance_id in instance_ids: value = new_value if value is None: value = not widget.is_active() From d74435525bfcb17aebcba0dd5ce1834bdf327b91 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Jul 2025 19:19:05 +0200 Subject: [PATCH 221/312] fix signal handling on update --- client/ayon_core/tools/publisher/widgets/list_view_widgets.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 3440a91b6f..7d11746254 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -295,8 +295,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): # Visually disable instance if parent is disabled checked = parent_enabled and self._instance_is_active - if checked is not self._active_checkbox.isChecked(): - self._active_checkbox.setChecked(checked) + self._set_checked(checked) def _on_active_change(self): self.active_changed.emit( From e6ae3fb84736b549b7a0e34097574385f513170e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 29 Jul 2025 19:22:04 +0200 Subject: [PATCH 222/312] few minor fixes --- .../publisher/widgets/card_view_widgets.py | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index e3e8a98ad5..4f1327baaf 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -277,6 +277,7 @@ class InstanceCardWidget(CardWidget): super().__init__(parent) self.instance = instance + self._is_active = instance.is_active self._id = instance.id self._group_identifier = instance.group_label @@ -366,6 +367,7 @@ class InstanceCardWidget(CardWidget): def update_instance(self, instance, context_info, is_parent_active): """Update instance object and update UI.""" self.instance = instance + self._is_active = instance.is_active self._update_instance_values(context_info, is_parent_active) def _validate_context(self, context_info): @@ -378,8 +380,6 @@ class InstanceCardWidget(CardWidget): product_name = self.instance.product_name label = self.instance.label - parent_is_enabled = self._used_parent_active() - self._label_widget.setEnabled(parent_is_enabled) if ( variant == self._last_variant and product_name == self._last_product_name @@ -414,6 +414,7 @@ class InstanceCardWidget(CardWidget): def _update_checkbox_state(self): parent_is_enabled = self._used_parent_active() + self._label_widget.setEnabled(parent_is_enabled) self._active_checkbox.setEnabled( self._toggle_is_enabled and not self.instance.is_mandatory @@ -423,7 +424,7 @@ class InstanceCardWidget(CardWidget): self._active_checkbox.setVisible(not self.instance.is_mandatory) # Visually disable instance if parent is disabled - checked = parent_is_enabled and self.instance.is_active + checked = parent_is_enabled and self._is_active if checked is not self._active_checkbox.isChecked(): self._active_checkbox.blockSignals(True) self._active_checkbox.setChecked(checked) @@ -442,10 +443,10 @@ class InstanceCardWidget(CardWidget): def _on_active_change(self): new_value = self._active_checkbox.isChecked() - old_value = self.instance.is_active - if new_value == old_value: + old_value = self._is_active + if new_value is old_value: return - + self._is_active = new_value self.active_changed.emit(self._id, new_value) def _on_expend_clicked(self): @@ -742,7 +743,7 @@ class InstanceCardView(AbstractInstanceView): context_info_by_id: dict[str, InstanceContextInfo], parent_active_by_id: dict[str, bool], group_icons: dict[str, str], - ): + ) -> None: """Update instances for the group. Args: @@ -801,8 +802,6 @@ class InstanceCardView(AbstractInstanceView): group_widget.set_widgets(widgets_by_id, ordered_ids) - return ordered_ids - def _make_sure_context_widget_exists(self): # Create context item if is not already existing # - this must be as first thing to do as context item should be at the @@ -945,6 +944,32 @@ class InstanceCardView(AbstractInstanceView): if isinstance(widget, InstanceCardWidget): active_state_by_id[widget.id] = value + if not active_state_by_id: + return + + _queue = collections.deque() + _queue.append((set(self._instance_ids_by_parent_id[None]), True)) + instance_ids = set(active_state_by_id) + discarted_ids = set() + while _queue: + children, parent_active = _queue.popleft() + for instance_id in children: + widget = self._widgets_by_id[instance_id] + old_active = widget.is_active() + widget.set_parent_active(parent_active) + is_active = widget.is_active() + if old_active is not is_active: + active_state_by_id[instance_id] = is_active + + if instance_id in instance_ids: + instance_ids.discard(instance_id) + discarted_ids.add(instance_id) + + _queue.append(( + set(self._instance_ids_by_parent_id[instance_id]), + is_active + )) + self._controller.set_instances_active_state(active_state_by_id) def _on_widget_selection(self, instance_id, group_name, selection_type): From 1758576955a8af9b03a13e78baa0f15f2c29946a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:31:04 +0200 Subject: [PATCH 223/312] fix state change in cards view --- .../publisher/widgets/card_view_widgets.py | 149 ++++++++++-------- 1 file changed, 85 insertions(+), 64 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 4f1327baaf..1d2ef9b0d2 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -348,6 +348,13 @@ class InstanceCardWidget(CardWidget): def is_active(self) -> bool: return self._active_checkbox.isChecked() + def set_active(self, active: Optional[bool]) -> None: + if not self.is_checkbox_enabled(): + return + if active is None: + active = not self.is_active() + self._set_checked(active) + def is_parent_active(self) -> bool: return self._is_parent_active @@ -425,6 +432,9 @@ class InstanceCardWidget(CardWidget): # Visually disable instance if parent is disabled checked = parent_is_enabled and self._is_active + self._set_checked(checked) + + def _set_checked(self, checked: bool) -> None: if checked is not self._active_checkbox.isChecked(): self._active_checkbox.blockSignals(True) self._active_checkbox.setChecked(checked) @@ -538,42 +548,85 @@ class InstanceCardView(AbstractInstanceView): result.setWidth(width) return result - def _toggle_instances(self, value): - if not self._active_toggle_enabled: - return + def _toggle_instances( + self, + new_value: Optional[bool], + active_id: Optional[str] = None, + ) -> None: + instance_ids = { + widget.id + for widget in self._get_selected_instance_widgets() + if widget.is_selected + } + active_by_id = {} + if active_id and active_id not in instance_ids: + instance_ids = {active_id} - widgets = self._get_selected_widgets() - active_state_by_id = {} - for widget in widgets: - if not isinstance(widget, InstanceCardWidget): - continue + affected_ids = set(instance_ids) + _queue = collections.deque() + _queue.append((set(self._instance_ids_by_parent_id[None]), True)) + discarted_ids = set() + while _queue: + if not instance_ids: + break - instance_id = widget.id - is_active = widget.is_active() - if value == -1: - active_state_by_id[instance_id] = not is_active - continue + chilren_ids, is_parent_active = _queue.pop() + for instance_id in chilren_ids: + widget = self._widgets_by_id[instance_id] + add_children = False + if instance_id in affected_ids: + affected_ids.discard(instance_id) + instance_ids.discard(instance_id) + discarted_ids.add(instance_id) + add_children = True + value = new_value + if value is None: + value = not widget.is_active() + old_value = widget.is_active() + widget.set_active(value) + if old_value is not widget.is_active(): + active_by_id[instance_id] = value - _value = bool(value) - if is_active is not _value: - active_state_by_id[instance_id] = _value + if ( + instance_id in instance_ids + and is_parent_active is not widget.is_parent_active() + ): + add_children = True + widget.set_parent_active(is_parent_active) - if not active_state_by_id: - return + instance_ids.discard(instance_id) + discarted_ids.add(instance_id) - self._controller.set_instances_active_state(active_state_by_id) + if not add_children: + continue + + children_ids = self._instance_ids_by_parent_id[instance_id] + children = { + child_id + for child_id in children_ids + if child_id not in discarted_ids + } + + if children: + instance_ids |= children + _queue.append((children, widget.is_active())) + + if not instance_ids: + break + + self._controller.set_instances_active_state(active_by_id) def keyPressEvent(self, event): if event.key() == QtCore.Qt.Key_Space: - self._toggle_instances(-1) + self._toggle_instances(None) return True elif event.key() == QtCore.Qt.Key_Backspace: - self._toggle_instances(0) + self._toggle_instances(False) return True elif event.key() == QtCore.Qt.Key_Return: - self._toggle_instances(1) + self._toggle_instances(True) return True return super().keyPressEvent(event) @@ -592,14 +645,17 @@ class InstanceCardView(AbstractInstanceView): if widget.is_selected ) - output.extend( + output.extend(self._get_selected_instance_widgets()) + return output + + def _get_selected_instance_widgets(self) -> list[InstanceCardWidget]: + return [ widget for widget in self._widgets_by_id.values() if widget.is_selected - ) - return output + ] - def _get_selected_instance_ids(self): + def _get_selected_item_ids(self): output = [] if ( self._context_widget is not None @@ -934,43 +990,8 @@ class InstanceCardView(AbstractInstanceView): if not instance_ids: break - def _on_active_changed(self, instance_id, value): - instance_widget = self._widgets_by_id[instance_id] - active_state_by_id = {} - if not instance_widget.is_selected: - active_state_by_id[instance_id] = value - else: - for widget in self._get_selected_widgets(): - if isinstance(widget, InstanceCardWidget): - active_state_by_id[widget.id] = value - - if not active_state_by_id: - return - - _queue = collections.deque() - _queue.append((set(self._instance_ids_by_parent_id[None]), True)) - instance_ids = set(active_state_by_id) - discarted_ids = set() - while _queue: - children, parent_active = _queue.popleft() - for instance_id in children: - widget = self._widgets_by_id[instance_id] - old_active = widget.is_active() - widget.set_parent_active(parent_active) - is_active = widget.is_active() - if old_active is not is_active: - active_state_by_id[instance_id] = is_active - - if instance_id in instance_ids: - instance_ids.discard(instance_id) - discarted_ids.add(instance_id) - - _queue.append(( - set(self._instance_ids_by_parent_id[instance_id]), - is_active - )) - - self._controller.set_instances_active_state(active_state_by_id) + def _on_active_changed(self, instance_id: str, value: bool) -> None: + self._toggle_instances(value, instance_id) def _on_widget_selection(self, instance_id, group_name, selection_type): """Select specific item by instance id. @@ -1021,7 +1042,7 @@ class InstanceCardView(AbstractInstanceView): """ self._explicitly_selected_instance_ids = ( - self._get_selected_instance_ids() + self._get_selected_item_ids() ) if new_widget.is_selected: self._explicitly_selected_instance_ids.remove(instance_id) From 5324c6122dacbd94f9a230fa3358384ca56484d9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Jul 2025 14:31:20 +0200 Subject: [PATCH 224/312] fix state changes in list view --- .../publisher/widgets/list_view_widgets.py | 95 +++++++------------ 1 file changed, 34 insertions(+), 61 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 7d11746254..a2aadd9cfa 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -506,14 +506,13 @@ class InstanceListView(AbstractInstanceView): if not self._active_toggle_enabled: return - selected_instance_ids = self._instance_view.get_selected_instance_ids() if toggle == -1: active = None elif toggle == 1: active = True else: active = False - self._toggle_active_state(selected_instance_ids, active) + self._toggle_active_state(active) def _update_group_checkstate(self, group_name): """Update checkstate of one group.""" @@ -1086,75 +1085,49 @@ class InstanceListView(AbstractInstanceView): _queue.append((children, widget.is_active())) def _on_active_changed(self, changed_instance_id, new_value): - selected_instance_ids, _, _ = self.get_selected_items() - if changed_instance_id not in selected_instance_ids: - selected_instance_ids = {changed_instance_id} - - self._toggle_active_state( - set(selected_instance_ids), - new_value, - changed_instance_id - ) + self._toggle_active_state(new_value, changed_instance_id) def _toggle_active_state( self, - instance_ids: set[str], new_value: Optional[bool], active_id: Optional[str] = None, ) -> None: - active_widget = None - if active_id: - active_widget = self._widgets_by_id[active_id] - active_by_id = {} + instance_ids, _, _ = self.get_selected_items() if active_id and active_id not in instance_ids: - if not active_widget.is_checkbox_enabled(): - return - if new_value is None: - new_value = not active_widget.is_active() - active_by_id[active_id] = new_value - active_widget.set_active(new_value) - else: - # First make sure that the item under mouse is changed if possible - if active_widget and active_widget.is_checkbox_enabled(): - value = new_value - if value is None: - value = not active_widget.is_active() + instance_ids = {active_id} - active_by_id[active_id] = value - active_widget.set_active(new_value) - instance_ids.discard(active_id) + active_by_id = {} + # Change the states from top to bottom + group_items = list(self._group_items.values()) + if self._missing_parent_item is not None: + group_items.append(self._missing_parent_item) - # Change the states from top to bottom - group_items = list(self._group_items.values()) - if self._missing_parent_item is not None: - group_items.append(self._missing_parent_item) + _queue = collections.deque() + for group_item in group_items: + children = [ + group_item.child(row) + for row in range(group_item.rowCount()) + ] + _queue.append((children, True)) + + while _queue: + children, parent_active = _queue.popleft() + for child in children: + instance_id = child.data(INSTANCE_ID_ROLE) + widget = self._widgets_by_id[instance_id] + widget.set_parent_is_active(parent_active) + if instance_id in instance_ids: + value = new_value + if value is None: + value = not widget.is_active() + widget.set_active(value) + active_by_id[instance_id] = value - _queue = collections.deque() - for group_item in group_items: children = [ - group_item.child(row) - for row in range(group_item.rowCount()) + child.child(row) + for row in range(child.rowCount()) ] - _queue.append((children, True)) - - while _queue: - children, parent_active = _queue.popleft() - for child in children: - instance_id = child.data(INSTANCE_ID_ROLE) - widget = self._widgets_by_id[instance_id] - widget.set_parent_is_active(parent_active) - if instance_id in instance_ids: - value = new_value - if value is None: - value = not widget.is_active() - widget.set_active(value) - active_by_id[instance_id] = value - - children = [ - child.child(row) - for row in range(child.rowCount()) - ] - _queue.append((children, widget.is_active())) + _queue.append((children, widget.is_active())) self._controller.set_instances_active_state(active_by_id) @@ -1195,7 +1168,7 @@ class InstanceListView(AbstractInstanceView): instance_id = child.data(INSTANCE_ID_ROLE) instance_ids.add(instance_id) - self._toggle_active_state(instance_ids, active) + self._toggle_active_state(active) proxy_index = self._proxy_model.mapFromSource(group_item.index()) if not self._instance_view.isExpanded(proxy_index): @@ -1339,7 +1312,7 @@ class InstanceListView(AbstractInstanceView): | QtCore.QItemSelectionModel.Rows ) - def set_active_toggle_enabled(self, enabled: bool) -> bool: + def set_active_toggle_enabled(self, enabled: bool) -> None: if self._active_toggle_enabled is enabled: return From 19bafc10d31d35de691c47d8a83b65fbbd5544c9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Jul 2025 15:00:43 +0200 Subject: [PATCH 225/312] fix cleanup of removed instances --- .../publisher/widgets/card_view_widgets.py | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 1d2ef9b0d2..b8185fbb3f 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -639,12 +639,7 @@ class InstanceCardView(AbstractInstanceView): ): output.append(self._context_widget) - output.extend( - widget - for widget in self._convertor_widgets_by_id.values() - if widget.is_selected - ) - + output.extend(self._get_selected_convertor_widgets()) output.extend(self._get_selected_instance_widgets()) return output @@ -655,6 +650,13 @@ class InstanceCardView(AbstractInstanceView): if widget.is_selected ] + def _get_selected_convertor_widgets(self) -> list[ConvertorItemCardWidget]: + return [ + widget + for widget in self._convertor_widgets_by_id.values() + if widget.is_selected + ] + def _get_selected_item_ids(self): output = [] if ( @@ -730,6 +732,9 @@ class InstanceCardView(AbstractInstanceView): groups_to_remove = ( set(self._widgets_by_group) - set(instances_by_group) ) + ids_to_remove = ( + set(self._widgets_by_id) - set(instances_by_id) + ) # Sort groups sorted_group_names = list(sorted(instances_by_group.keys())) @@ -780,6 +785,11 @@ class InstanceCardView(AbstractInstanceView): if group_name in self._explicitly_selected_groups: self._explicitly_selected_groups.remove(group_name) + for instance_id in ids_to_remove: + widget = self._widgets_by_id.pop(instance_id) + widget.setVisible(False) + widget.deleteLater() + self._instance_ids_by_parent_id = instance_ids_by_parent_id self._group_name_by_instance_id = group_by_instance_id self._instance_ids_by_group_name = instance_ids_by_group_name @@ -1298,21 +1308,18 @@ class InstanceCardView(AbstractInstanceView): def get_selected_items(self): """Get selected instance ids and context.""" - convertor_identifiers = [] - instances = [] - selected_widgets = self._get_selected_widgets() - - context_selected = False - for widget in selected_widgets: - if widget is self._context_widget: - context_selected = True - - elif isinstance(widget, InstanceCardWidget): - instances.append(widget.id) - - elif isinstance(widget, ConvertorItemCardWidget): - convertor_identifiers.append(widget.identifier) - + context_selected = ( + self._context_widget is not None + and self._context_widget.is_selected + ) + instances = [ + widget.id + for widget in self._get_selected_instance_widgets() + ] + convertor_identifiers = [ + widget.identifier + for widget in self._get_selected_convertor_widgets() + ] return instances, context_selected, convertor_identifiers def set_selected_items( From c4d6723c51e64f14947b095757e4e15750cf0e48 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:04:00 +0200 Subject: [PATCH 226/312] formatting fixes --- client/ayon_core/pipeline/create/context.py | 2 +- .../ayon_core/plugins/publish/collect_from_create_context.py | 4 ++-- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 5e069cd62e..b006924750 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -2629,4 +2629,4 @@ class CreateContext: INSTANCE_PARENT_CHANGED_TOPIC, {"instances": instances}, sender, - ) \ No newline at end of file + ) diff --git a/client/ayon_core/plugins/publish/collect_from_create_context.py b/client/ayon_core/plugins/publish/collect_from_create_context.py index 7b8aeee457..5e0ecbdff4 100644 --- a/client/ayon_core/plugins/publish/collect_from_create_context.py +++ b/client/ayon_core/plugins/publish/collect_from_create_context.py @@ -60,8 +60,8 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): is_active = created_instance["active"] # Use a parent's active state if parent flags defines that if ( - is_active - and created_instance.parent_flags & ParentFlags.share_active + created_instance.parent_flags & ParentFlags.share_active + and is_active ): is_active = parent_is_active diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index b8185fbb3f..6d95906364 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -792,7 +792,7 @@ class InstanceCardView(AbstractInstanceView): self._instance_ids_by_parent_id = instance_ids_by_parent_id self._group_name_by_instance_id = group_by_instance_id - self._instance_ids_by_group_name = instance_ids_by_group_name + self._instance_ids_by_group_name = instance_ids_by_group_name self._ordered_groups = sorted_group_names def has_items(self) -> bool: From eaf47d8731a9dab98ff38d637984ea2d0837dc8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 30 Jul 2025 18:32:09 +0200 Subject: [PATCH 227/312] :recycle: don't allow duplicate loaders --- client/ayon_core/pipeline/load/plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index dc5bb0f66f..48e860e834 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -373,7 +373,7 @@ def discover_loader_plugins(project_name=None): if not project_name: project_name = get_current_project_name() project_settings = get_project_settings(project_name) - plugins = discover(LoaderPlugin) + plugins = discover(LoaderPlugin, allow_duplicates=False) hooks = discover(LoaderHookPlugin) sorted_hooks = sorted(hooks, key=lambda hook: hook.order) for plugin in plugins: From ea3b4524d405f63a698d34bac7d15fafff42831b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:31:46 +0200 Subject: [PATCH 228/312] capture 'ItemNotFoundException' error if possible --- client/ayon_core/lib/local_settings.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 91b881cf57..a582a6c1b9 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -124,6 +124,10 @@ def get_addons_resources_dir(addon_name: str, *args) -> str: return os.path.join(addons_resources_dir, addon_name, *args) +class _FakeException(Exception): + """Placeholder exception used if real exception is not available.""" + + class AYONSecureRegistry: """Store information using keyring. @@ -195,7 +199,17 @@ class AYONSecureRegistry: """ import keyring - value = keyring.get_password(self._name, name) + # Capture 'ItemNotFoundException' exception (on linux) + try: + from secretstorage.exceptions import ItemNotFoundException + except ImportError: + ItemNotFoundException = _FakeException + + try: + value = keyring.get_password(self._name, name) + except ItemNotFoundException: + value = None + if value is not None: return value From 97a3ab142c291ced73aef586bad8e6c3b62d5ab4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:46:37 +0200 Subject: [PATCH 229/312] raise dedicated exception if item is not available --- client/ayon_core/lib/local_settings.py | 41 +++++++++++++++----------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 91b881cf57..7c6459fad6 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -15,6 +15,11 @@ import ayon_api _PLACEHOLDER = object() +# TODO should use 'KeyError' or 'Exception' as base +class RegistryItemNotFound(ValueError): + """Raised when the item is not found in keyring.""" + + class _Cache: username = None @@ -187,7 +192,7 @@ class AYONSecureRegistry: value (str): Value of the item. Raises: - ValueError: If item doesn't exist and default is not defined. + RegistryItemNotFound: If item doesn't exist and default is not defined. .. _Keyring module: https://github.com/jaraco/keyring @@ -202,9 +207,8 @@ class AYONSecureRegistry: if default is not _PLACEHOLDER: return default - # NOTE Should raise `KeyError` - raise ValueError( - "Item {}:{} does not exist in keyring.".format(self._name, name) + raise RegistryItemNotFound( + f"Item {self._name}:{name} not found in keyring." ) def delete_item(self, name): @@ -277,7 +281,7 @@ class ASettingRegistry(ABC): value (str): Value of the item. Raises: - ValueError: If item doesn't exist. + RegistryItemNotFound: If the item doesn't exist. """ return self._get_item(name) @@ -388,7 +392,7 @@ class IniSettingRegistry(ASettingRegistry): str: Value of item. Raises: - ValueError: If value doesn't exist. + RegistryItemNotFound: If value doesn't exist. """ return super(IniSettingRegistry, self).get_item(name) @@ -399,8 +403,8 @@ class IniSettingRegistry(ASettingRegistry): """Get item from section of ini file. This will read ini file and try to get item value from specified - section. If that section or item doesn't exist, :exc:`ValueError` - is risen. + section. If that section or item doesn't exist, + :exc:`RegistryItemNotFound` is risen. Args: section (str): Name of ini section. @@ -410,7 +414,7 @@ class IniSettingRegistry(ASettingRegistry): str: Item value. Raises: - ValueError: If value doesn't exist. + RegistryItemNotFound: If value doesn't exist. """ config = configparser.ConfigParser() @@ -418,8 +422,9 @@ class IniSettingRegistry(ASettingRegistry): try: value = config[section][name] except KeyError: - raise ValueError( - "Registry doesn't contain value {}:{}".format(section, name)) + raise RegistryItemNotFound( + f"Registry doesn't contain value {section}:{name}" + ) return value def _get_item(self, name): @@ -435,7 +440,7 @@ class IniSettingRegistry(ASettingRegistry): name (str): Name of the item. Raises: - ValueError: If item doesn't exist. + RegistryItemNotFound: If the item doesn't exist. """ self.get_item_from_section.cache_clear() @@ -444,8 +449,9 @@ class IniSettingRegistry(ASettingRegistry): try: _ = config[section][name] except KeyError: - raise ValueError( - "Registry doesn't contain value {}:{}".format(section, name)) + raise RegistryItemNotFound( + f"Registry doesn't contain value {section}:{name}" + ) config.remove_option(section, name) # if section is empty, delete it @@ -494,8 +500,9 @@ class JSONSettingRegistry(ASettingRegistry): try: value = data["registry"][name] except KeyError: - raise ValueError( - "Registry doesn't contain value {}".format(name)) + raise RegistryItemNotFound( + f"Registry doesn't contain value {name}" + ) return value def get_item(self, name): @@ -509,7 +516,7 @@ class JSONSettingRegistry(ASettingRegistry): value of the item Raises: - ValueError: If item is not found in registry file. + RegistryItemNotFound: If the item is not found in registry file. """ return self._get_item(name) From 88b01a2797c39e9b19d5808f475c3ecb49ce885f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:54:26 +0200 Subject: [PATCH 230/312] added type-hints --- client/ayon_core/lib/local_settings.py | 101 ++++++++++--------------- 1 file changed, 38 insertions(+), 63 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 7c6459fad6..19ffffd63f 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -8,6 +8,7 @@ import warnings from datetime import datetime from abc import ABC, abstractmethod from functools import lru_cache +from typing import Optional, Any import platformdirs import ayon_api @@ -24,14 +25,14 @@ class _Cache: username = None -def _get_ayon_appdirs(*args): +def _get_ayon_appdirs(*args: str) -> str: return os.path.join( platformdirs.user_data_dir("AYON", "Ynput"), *args ) -def get_ayon_appdirs(*args): +def get_ayon_appdirs(*args: str) -> str: """Local app data directory of AYON client. Deprecated: @@ -141,7 +142,7 @@ class AYONSecureRegistry: Args: name(str): Name of registry used as identifier for data. """ - def __init__(self, name): + def __init__(self, name: str) -> None: try: import keyring @@ -159,8 +160,7 @@ class AYONSecureRegistry: # Force "AYON" prefix self._name = "/".join(("AYON", name)) - def set_item(self, name, value): - # type: (str, str) -> None + def set_item(self, name: str, value: str) -> None: """Set sensitive item into system's keyring. This uses `Keyring module`_ to save sensitive stuff into system's @@ -179,7 +179,9 @@ class AYONSecureRegistry: keyring.set_password(self._name, name, value) @lru_cache(maxsize=32) - def get_item(self, name, default=_PLACEHOLDER): + def get_item( + self, name: str, default: Any = _PLACEHOLDER + ) -> Optional[str]: """Get value of sensitive item from system's keyring. See also `Keyring module`_ @@ -211,8 +213,7 @@ class AYONSecureRegistry: f"Item {self._name}:{name} not found in keyring." ) - def delete_item(self, name): - # type: (str) -> None + def delete_item(self, name: str) -> None: """Delete value stored in system's keyring. See also `Keyring module`_ @@ -241,16 +242,13 @@ class ASettingRegistry(ABC): _name (str): Registry names. """ - - def __init__(self, name): - # type: (str) -> ASettingRegistry + def __init__(self, name: str) -> None: super(ASettingRegistry, self).__init__() self._name = name self._items = {} - def set_item(self, name, value): - # type: (str, str) -> None + def set_item(self, name: str, value: str) -> None: """Set item to settings registry. Args: @@ -261,17 +259,14 @@ class ASettingRegistry(ABC): self._set_item(name, value) @abstractmethod - def _set_item(self, name, value): - # type: (str, str) -> None - # Implement it - pass + def _set_item(self, name: str, value: str) -> None: + """Set item value to registry.""" - def __setitem__(self, name, value): + def __setitem__(self, name: str, value: str) -> None: self._items[name] = value self._set_item(name, value) - def get_item(self, name): - # type: (str) -> str + def get_item(self, name: str) -> str: """Get item from settings registry. Args: @@ -287,16 +282,13 @@ class ASettingRegistry(ABC): return self._get_item(name) @abstractmethod - def _get_item(self, name): - # type: (str) -> str - # Implement it - pass + def _get_item(self, name: str) -> str: + """Get item value from registry.""" - def __getitem__(self, name): + def __getitem__(self, name: str) -> Any: return self._get_item(name) - def delete_item(self, name): - # type: (str) -> None + def delete_item(self, name: str) -> None: """Delete item from settings registry. Args: @@ -306,12 +298,10 @@ class ASettingRegistry(ABC): self._delete_item(name) @abstractmethod - def _delete_item(self, name): - # type: (str) -> None - """Delete item from settings.""" - pass + def _delete_item(self, name: str) -> None: + """Delete item from registry.""" - def __delitem__(self, name): + def __delitem__(self, name: str) -> None: del self._items[name] self._delete_item(name) @@ -322,9 +312,7 @@ class IniSettingRegistry(ASettingRegistry): This class is using :mod:`configparser` (ini) files to store items. """ - - def __init__(self, name, path): - # type: (str, str) -> IniSettingRegistry + def __init__(self, name: str, path: str) -> None: super(IniSettingRegistry, self).__init__(name) # get registry file self._registry_file = os.path.join(path, "{}.ini".format(name)) @@ -334,8 +322,7 @@ class IniSettingRegistry(ASettingRegistry): now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") print("# {}".format(now), cfg) - def set_item_section(self, section, name, value): - # type: (str, str, str) -> None + def set_item_section(self, section: str, name: str, value: str) -> None: """Set item to specific section of ini registry. If section doesn't exists, it is created. @@ -358,12 +345,10 @@ class IniSettingRegistry(ASettingRegistry): with open(self._registry_file, mode="w") as cfg: config.write(cfg) - def _set_item(self, name, value): - # type: (str, str) -> None + def _set_item(self, name: str, value: str) -> None: self.set_item_section("MAIN", name, value) - def set_item(self, name, value): - # type: (str, str) -> None + def set_item(self, name: str, value: str) -> None: """Set item to settings ini file. This saves item to ``DEFAULT`` section of ini as each item there @@ -378,8 +363,7 @@ class IniSettingRegistry(ASettingRegistry): # we cast value to str as ini options values must be strings. super(IniSettingRegistry, self).set_item(name, str(value)) - def get_item(self, name): - # type: (str) -> str + def get_item(self, name: str) -> str: """Gets item from settings ini file. This gets settings from ``DEFAULT`` section of ini file as each item @@ -398,8 +382,7 @@ class IniSettingRegistry(ASettingRegistry): return super(IniSettingRegistry, self).get_item(name) @lru_cache(maxsize=32) - def get_item_from_section(self, section, name): - # type: (str, str) -> str + def get_item_from_section(self, section: str, name: str) -> str: """Get item from section of ini file. This will read ini file and try to get item value from specified @@ -427,12 +410,10 @@ class IniSettingRegistry(ASettingRegistry): ) return value - def _get_item(self, name): - # type: (str) -> str + def _get_item(self, name: str) -> str: return self.get_item_from_section("MAIN", name) - def delete_item_from_section(self, section, name): - # type: (str, str) -> None + def delete_item_from_section(self, section: str, name: str) -> None: """Delete item from section in ini file. Args: @@ -469,8 +450,7 @@ class IniSettingRegistry(ASettingRegistry): class JSONSettingRegistry(ASettingRegistry): """Class using json file as storage.""" - def __init__(self, name, path): - # type: (str, str) -> JSONSettingRegistry + def __init__(self, name: str, path: str) -> None: super(JSONSettingRegistry, self).__init__(name) #: str: name of registry file self._registry_file = os.path.join(path, "{}.json".format(name)) @@ -487,8 +467,7 @@ class JSONSettingRegistry(ASettingRegistry): json.dump(header, cfg, indent=4) @lru_cache(maxsize=32) - def _get_item(self, name): - # type: (str) -> object + def _get_item(self, name: str) -> Any: """Get item value from registry json. Note: @@ -505,8 +484,7 @@ class JSONSettingRegistry(ASettingRegistry): ) return value - def get_item(self, name): - # type: (str) -> object + def get_item(self, name: str) -> Any: """Get item value from registry json. Args: @@ -521,8 +499,7 @@ class JSONSettingRegistry(ASettingRegistry): """ return self._get_item(name) - def _set_item(self, name, value): - # type: (str, object) -> None + def _set_item(self, name: str, value: Any) -> None: """Set item value to registry json. Note: @@ -536,8 +513,7 @@ class JSONSettingRegistry(ASettingRegistry): cfg.seek(0) json.dump(data, cfg, indent=4) - def set_item(self, name, value): - # type: (str, object) -> None + def set_item(self, name: str, value: Any) -> None: """Set item and its value into json registry file. Args: @@ -547,8 +523,7 @@ class JSONSettingRegistry(ASettingRegistry): """ self._set_item(name, value) - def _delete_item(self, name): - # type: (str) -> None + def _delete_item(self, name: str) -> None: self._get_item.cache_clear() with open(self._registry_file, "r+") as cfg: data = json.load(cfg) @@ -563,9 +538,9 @@ class AYONSettingsRegistry(JSONSettingRegistry): Args: name (Optional[str]): Name of the registry. - """ - def __init__(self, name=None): + """ + def __init__(self, name: Optional[str] = None) -> None: if not name: name = "AYON_settings" path = get_launcher_storage_dir() From d431956963c55bb60405142649efd636011b89ca Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:55:34 +0200 Subject: [PATCH 231/312] simplified super calls --- client/ayon_core/lib/local_settings.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 19ffffd63f..26db587835 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -243,7 +243,7 @@ class ASettingRegistry(ABC): """ def __init__(self, name: str) -> None: - super(ASettingRegistry, self).__init__() + super().__init__() self._name = name self._items = {} @@ -313,7 +313,7 @@ class IniSettingRegistry(ASettingRegistry): """ def __init__(self, name: str, path: str) -> None: - super(IniSettingRegistry, self).__init__(name) + super().__init__(name) # get registry file self._registry_file = os.path.join(path, "{}.ini".format(name)) if not os.path.exists(self._registry_file): @@ -361,7 +361,7 @@ class IniSettingRegistry(ASettingRegistry): """ # this does the some, overridden just for different docstring. # we cast value to str as ini options values must be strings. - super(IniSettingRegistry, self).set_item(name, str(value)) + super().set_item(name, str(value)) def get_item(self, name: str) -> str: """Gets item from settings ini file. @@ -379,7 +379,7 @@ class IniSettingRegistry(ASettingRegistry): RegistryItemNotFound: If value doesn't exist. """ - return super(IniSettingRegistry, self).get_item(name) + return super().get_item(name) @lru_cache(maxsize=32) def get_item_from_section(self, section: str, name: str) -> str: @@ -451,7 +451,7 @@ class JSONSettingRegistry(ASettingRegistry): """Class using json file as storage.""" def __init__(self, name: str, path: str) -> None: - super(JSONSettingRegistry, self).__init__(name) + super().__init__(name) #: str: name of registry file self._registry_file = os.path.join(path, "{}.json".format(name)) now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") @@ -544,7 +544,7 @@ class AYONSettingsRegistry(JSONSettingRegistry): if not name: name = "AYON_settings" path = get_launcher_storage_dir() - super(AYONSettingsRegistry, self).__init__(name, path) + super().__init__(name, path) def get_local_site_id(): From 08c242edefe4b11046a589fca10a7e8e7f969177 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:55:59 +0200 Subject: [PATCH 232/312] use f-strings --- client/ayon_core/lib/local_settings.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 26db587835..36abeb4283 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -158,7 +158,7 @@ class AYONSecureRegistry: keyring.set_keyring(Windows.WinVaultKeyring()) # Force "AYON" prefix - self._name = "/".join(("AYON", name)) + self._name = f"AYON/{name}" def set_item(self, name: str, value: str) -> None: """Set sensitive item into system's keyring. @@ -315,12 +315,12 @@ class IniSettingRegistry(ASettingRegistry): def __init__(self, name: str, path: str) -> None: super().__init__(name) # get registry file - self._registry_file = os.path.join(path, "{}.ini".format(name)) + self._registry_file = os.path.join(path, f"{name}.ini") if not os.path.exists(self._registry_file): with open(self._registry_file, mode="w") as cfg: print("# Settings registry", cfg) now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") - print("# {}".format(now), cfg) + print(f"# {now}", cfg) def set_item_section(self, section: str, name: str, value: str) -> None: """Set item to specific section of ini registry. @@ -452,8 +452,7 @@ class JSONSettingRegistry(ASettingRegistry): def __init__(self, name: str, path: str) -> None: super().__init__(name) - #: str: name of registry file - self._registry_file = os.path.join(path, "{}.json".format(name)) + self._registry_file = os.path.join(path, f"{name}.json") now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") header = { "__metadata__": {"generated": now}, From d1fce584fa577d209feff5c91867eda12399acec Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:56:25 +0200 Subject: [PATCH 233/312] remove unncessary variable --- client/ayon_core/lib/local_settings.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 36abeb4283..a52539a4dd 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -238,15 +238,11 @@ class ASettingRegistry(ABC): mechanism for storing common items must be implemented in abstract methods. - Attributes: - _name (str): Registry names. - """ def __init__(self, name: str) -> None: super().__init__() self._name = name - self._items = {} def set_item(self, name: str, value: str) -> None: """Set item to settings registry. @@ -263,7 +259,6 @@ class ASettingRegistry(ABC): """Set item value to registry.""" def __setitem__(self, name: str, value: str) -> None: - self._items[name] = value self._set_item(name, value) def get_item(self, name: str) -> str: @@ -302,7 +297,6 @@ class ASettingRegistry(ABC): """Delete item from registry.""" def __delitem__(self, name: str) -> None: - del self._items[name] self._delete_item(name) From d88a8678729fc2e51c9e49e7f798499e5e74cdcd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:56:39 +0200 Subject: [PATCH 234/312] reset cache on set item --- client/ayon_core/lib/local_settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index a52539a4dd..162e17fd94 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -177,6 +177,7 @@ class AYONSecureRegistry: import keyring keyring.set_password(self._name, name, value) + self.get_item.cache_clear() @lru_cache(maxsize=32) def get_item( From 41228915eca2c0b29fdc7d7c5eb208ecda0fd568 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:58:08 +0200 Subject: [PATCH 235/312] more explicit dir creation --- client/ayon_core/lib/local_settings.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 162e17fd94..98eec3af4f 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -454,8 +454,10 @@ class JSONSettingRegistry(ASettingRegistry): "registry": {} } - if not os.path.exists(os.path.dirname(self._registry_file)): - os.makedirs(os.path.dirname(self._registry_file), exist_ok=True) + # Use 'os.path.dirname' in case someone uses slashes in 'name' + dirpath = os.path.dirname(self._registry_file) + if not os.path.exists(dirpath): + os.makedirs(dirpath, exist_ok=True) if not os.path.exists(self._registry_file): with open(self._registry_file, mode="w") as cfg: json.dump(header, cfg, indent=4) From 2013eea5c4cc52942f79c371032f7c9b6126870a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 10:58:21 +0200 Subject: [PATCH 236/312] formatting change --- client/ayon_core/lib/local_settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 98eec3af4f..b06b890992 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -141,6 +141,7 @@ class AYONSecureRegistry: Args: name(str): Name of registry used as identifier for data. + """ def __init__(self, name: str) -> None: try: From 1a46f2c027b60e03ac35a25d231bf034042bcb1c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:00:51 +0200 Subject: [PATCH 237/312] remove unnecessary super call --- client/ayon_core/lib/local_settings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index b06b890992..79e0e24307 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -242,8 +242,6 @@ class ASettingRegistry(ABC): """ def __init__(self, name: str) -> None: - super().__init__() - self._name = name def set_item(self, name: str, value: str) -> None: From b403db76e6626b450dad91d489e20bc997284746 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:01:52 +0200 Subject: [PATCH 238/312] better order of methods --- client/ayon_core/lib/local_settings.py | 46 ++++++++++++++------------ 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 79e0e24307..4b85a76b2d 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -244,23 +244,31 @@ class ASettingRegistry(ABC): def __init__(self, name: str) -> None: self._name = name - def set_item(self, name: str, value: str) -> None: - """Set item to settings registry. - - Args: - name (str): Name of the item. - value (str): Value of the item. - - """ - self._set_item(name, value) + @abstractmethod + def _get_item(self, name: str) -> Any: + """Get item value from registry.""" @abstractmethod def _set_item(self, name: str, value: str) -> None: """Set item value to registry.""" + @abstractmethod + def _delete_item(self, name: str) -> None: + """Delete item from registry.""" + + def __getitem__(self, name: str) -> Any: + return self._get_item(name) + def __setitem__(self, name: str, value: str) -> None: self._set_item(name, value) + def __delitem__(self, name: str) -> None: + self._delete_item(name) + + @property + def name(self) -> str: + return self._name + def get_item(self, name: str) -> str: """Get item from settings registry. @@ -276,12 +284,15 @@ class ASettingRegistry(ABC): """ return self._get_item(name) - @abstractmethod - def _get_item(self, name: str) -> str: - """Get item value from registry.""" + def set_item(self, name: str, value: str) -> None: + """Set item to settings registry. - def __getitem__(self, name: str) -> Any: - return self._get_item(name) + Args: + name (str): Name of the item. + value (str): Value of the item. + + """ + self._set_item(name, value) def delete_item(self, name: str) -> None: """Delete item from settings registry. @@ -292,13 +303,6 @@ class ASettingRegistry(ABC): """ self._delete_item(name) - @abstractmethod - def _delete_item(self, name: str) -> None: - """Delete item from registry.""" - - def __delitem__(self, name: str) -> None: - self._delete_item(name) - class IniSettingRegistry(ASettingRegistry): """Class using :mod:`configparser`. From d4092c8e314eb5e02cf004e07ac8a5f69f39a36c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:02:12 +0200 Subject: [PATCH 239/312] deprecated not passed name in 'AYONSettingsRegistry' --- client/ayon_core/lib/local_settings.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 4b85a76b2d..8511c8d15e 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -536,12 +536,21 @@ class AYONSettingsRegistry(JSONSettingRegistry): """Class handling AYON general settings registry. Args: - name (Optional[str]): Name of the registry. + name (Optional[str]): Name of the registry. Using 'None' or not + passing name is deprecated. """ def __init__(self, name: Optional[str] = None) -> None: if not name: name = "AYON_settings" + warnings.warn( + ( + "Used 'AYONSettingsRegistry' without 'name' argument." + " The argument will be required in future versions." + ), + DeprecationWarning, + stacklevel=2, + ) path = get_launcher_storage_dir() super().__init__(name, path) From 473cf8b0c13f19d4763d2d9bea4f0b376ca85f77 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:19:39 +0200 Subject: [PATCH 240/312] grammar fixes --- client/ayon_core/lib/local_settings.py | 37 +++++++++++++------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 8511c8d15e..19381b18e0 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -18,7 +18,7 @@ _PLACEHOLDER = object() # TODO should use 'KeyError' or 'Exception' as base class RegistryItemNotFound(ValueError): - """Raised when the item is not found in keyring.""" + """Raised when the item is not found in the keyring.""" class _Cache: @@ -37,10 +37,10 @@ def get_ayon_appdirs(*args: str) -> str: Deprecated: Use 'get_launcher_local_dir' or 'get_launcher_storage_dir' based on - use-case. Deprecation added 24/08/09 (0.4.4-dev.1). + a use-case. Deprecation added 24/08/09 (0.4.4-dev.1). Args: - *args (Iterable[str]): Subdirectories/files in local app data dir. + *args (Iterable[str]): Subdirectories/files in the local app data dir. Returns: str: Path to directory/file in local app data dir. @@ -58,7 +58,7 @@ def get_ayon_appdirs(*args: str) -> str: def get_launcher_storage_dir(*subdirs: str) -> str: - """Get storage directory for launcher. + """Get a storage directory for launcher. Storage directory is used for storing shims, addons, dependencies, etc. @@ -83,14 +83,14 @@ def get_launcher_storage_dir(*subdirs: str) -> str: def get_launcher_local_dir(*subdirs: str) -> str: - """Get local directory for launcher. + """Get a local directory for launcher. - Local directory is used for storing machine or user specific data. + Local directory is used for storing machine or user-specific data. - The location is user specific. + The location is user-specific. Note: - This function should be called at least once on bootstrap. + This function should be called at least once on the bootstrap. Args: *subdirs (str): Subdirectories relative to local dir. @@ -107,7 +107,7 @@ def get_launcher_local_dir(*subdirs: str) -> str: def get_addons_resources_dir(addon_name: str, *args) -> str: - """Get directory for storing resources for addons. + """Get a directory for storing resources for addons. Some addons might need to store ad-hoc resources that are not part of addon client package (e.g. because of size). Studio might define @@ -117,7 +117,7 @@ def get_addons_resources_dir(addon_name: str, *args) -> str: Args: addon_name (str): Addon name. - *args (str): Subfolders in resources directory. + *args (str): Subfolders in the resources directory. Returns: str: Path to resources directory. @@ -140,7 +140,7 @@ class AYONSecureRegistry: identify which data were created by AYON. Args: - name(str): Name of registry used as identifier for data. + name(str): Name of registry used as the identifier for data. """ def __init__(self, name: str) -> None: @@ -162,9 +162,9 @@ class AYONSecureRegistry: self._name = f"AYON/{name}" def set_item(self, name: str, value: str) -> None: - """Set sensitive item into system's keyring. + """Set sensitive item into the system's keyring. - This uses `Keyring module`_ to save sensitive stuff into system's + This uses `Keyring module`_ to save sensitive stuff into the system's keyring. Args: @@ -184,19 +184,20 @@ class AYONSecureRegistry: def get_item( self, name: str, default: Any = _PLACEHOLDER ) -> Optional[str]: - """Get value of sensitive item from system's keyring. + """Get value of sensitive item from the system's keyring. See also `Keyring module`_ Args: name (str): Name of the item. - default (Any): Default value if item is not available. + default (Any): Default value if the item is not available. Returns: value (str): Value of the item. Raises: - RegistryItemNotFound: If item doesn't exist and default is not defined. + RegistryItemNotFound: If the item doesn't exist and default + is not defined. .. _Keyring module: https://github.com/jaraco/keyring @@ -216,7 +217,7 @@ class AYONSecureRegistry: ) def delete_item(self, name: str) -> None: - """Delete value stored in system's keyring. + """Delete value stored in the system's keyring. See also `Keyring module`_ @@ -446,7 +447,7 @@ class IniSettingRegistry(ASettingRegistry): class JSONSettingRegistry(ASettingRegistry): - """Class using json file as storage.""" + """Class using a json file as storage.""" def __init__(self, name: str, path: str) -> None: super().__init__(name) From 3c867c517c4773b75aae84d049a1edf5e8323512 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:20:08 +0200 Subject: [PATCH 241/312] change value of json registry to 'str' --- client/ayon_core/lib/local_settings.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 19381b18e0..09855c6075 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -467,8 +467,8 @@ class JSONSettingRegistry(ASettingRegistry): json.dump(header, cfg, indent=4) @lru_cache(maxsize=32) - def _get_item(self, name: str) -> Any: - """Get item value from registry json. + def _get_item(self, name: str) -> str: + """Get item value from the registry. Note: See :meth:`ayon_core.lib.JSONSettingRegistry.get_item` @@ -499,8 +499,8 @@ class JSONSettingRegistry(ASettingRegistry): """ return self._get_item(name) - def _set_item(self, name: str, value: Any) -> None: - """Set item value to registry json. + def _set_item(self, name: str, value: str) -> None: + """Set item value to the registry. Note: See :meth:`ayon_core.lib.JSONSettingRegistry.set_item` From 6433ada42c78a586899c9329b91838a5ade936e7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:20:50 +0200 Subject: [PATCH 242/312] remove unnecessary overriden methods --- client/ayon_core/lib/local_settings.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 09855c6075..7982a2797e 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -484,21 +484,6 @@ class JSONSettingRegistry(ASettingRegistry): ) return value - def get_item(self, name: str) -> Any: - """Get item value from registry json. - - Args: - name (str): Name of the item. - - Returns: - value of the item - - Raises: - RegistryItemNotFound: If the item is not found in registry file. - - """ - return self._get_item(name) - def _set_item(self, name: str, value: str) -> None: """Set item value to the registry. @@ -513,18 +498,7 @@ class JSONSettingRegistry(ASettingRegistry): cfg.seek(0) json.dump(data, cfg, indent=4) - def set_item(self, name: str, value: Any) -> None: - """Set item and its value into json registry file. - - Args: - name (str): name of the item. - value (Any): value of the item. - - """ - self._set_item(name, value) - def _delete_item(self, name: str) -> None: - self._get_item.cache_clear() with open(self._registry_file, "r+") as cfg: data = json.load(cfg) del data["registry"][name] From 83b109be28602958e1fa0b666fc3e10188ad7cae Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:21:11 +0200 Subject: [PATCH 243/312] fix cache again --- client/ayon_core/lib/local_settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 7982a2797e..4cfe059e2a 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -497,6 +497,7 @@ class JSONSettingRegistry(ASettingRegistry): cfg.truncate(0) cfg.seek(0) json.dump(data, cfg, indent=4) + self._get_item.cache_clear() def _delete_item(self, name: str) -> None: with open(self._registry_file, "r+") as cfg: @@ -505,6 +506,7 @@ class JSONSettingRegistry(ASettingRegistry): cfg.truncate(0) cfg.seek(0) json.dump(data, cfg, indent=4) + self._get_item.cache_clear() class AYONSettingsRegistry(JSONSettingRegistry): From 1017becebd118f5cdd3bd021ed7fbe5891bf954e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:21:26 +0200 Subject: [PATCH 244/312] changed abstract class docstring --- client/ayon_core/lib/local_settings.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 4cfe059e2a..85ece54d6f 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -235,11 +235,7 @@ class AYONSecureRegistry: class ASettingRegistry(ABC): - """Abstract class defining structure of **SettingRegistry** class. - - It is implementing methods to store secure items into keyring, otherwise - mechanism for storing common items must be implemented in abstract - methods. + """Abstract class to defining structure of registry class. """ def __init__(self, name: str) -> None: From 47af183d04f82ccc5d27af20143ee03ddf8eeb49 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 12:12:00 +0200 Subject: [PATCH 245/312] check for availability that don't live in workdir --- client/ayon_core/host/interfaces/workfiles.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index b6c33337e9..693aac5fe5 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -1155,12 +1155,15 @@ class IWorkfileHost: comment = parsed_data.comment filepath = list_workfiles_context.anatomy.fill_root(rootless_path) + available = False + if filepath != rootless_path: + available = os.path.exists(filepath) items.append(WorkfileInfo.new( filepath, rootless_path, version=version, comment=comment, - available=False, + available=available, workfile_entity=workfile_entity, )) From 4f296e0ed78138cef63791605c40e1a6b82e7ebf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 12:41:33 +0200 Subject: [PATCH 246/312] simplified --- client/ayon_core/host/interfaces/workfiles.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 693aac5fe5..14e60bda20 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -1155,9 +1155,7 @@ class IWorkfileHost: comment = parsed_data.comment filepath = list_workfiles_context.anatomy.fill_root(rootless_path) - available = False - if filepath != rootless_path: - available = os.path.exists(filepath) + available = os.path.exists(filepath) items.append(WorkfileInfo.new( filepath, rootless_path, From a85cf5d2e907c13dc19d1818231fc28ba0829000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 31 Jul 2025 15:41:47 +0200 Subject: [PATCH 247/312] :recycle: handle more link types --- .../workfile/workfile_template_builder.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 6b82e3b04d..7920abb23f 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -201,14 +201,6 @@ class AbstractTemplateBuilder(ABC): ) return self._current_folder_entity - @property - def linked_folder_entities(self): - if self._linked_folder_entities is _NOT_SET: - self._linked_folder_entities = self._get_linked_folder_entities( - link_type="template" - ) - return self._linked_folder_entities - @property def current_task_entity(self): if self._current_task_entity is _NOT_SET: @@ -309,7 +301,7 @@ class AbstractTemplateBuilder(ABC): self._loaders_by_name = get_loaders_by_name() return self._loaders_by_name - def _get_linked_folder_entities(self, link_type: str = "template"): + def get_linked_folder_entities(self, link_type: str = "template"): project_name = self.project_name folder_entity = self.current_folder_entity if not folder_entity: @@ -1642,6 +1634,8 @@ class PlaceholderLoadMixin(object): folder_ids = [current_folder_entity["id"]] elif builder_type == "linked_folders": + # link type from placeholder data or default to "template" + link_type = placeholder.data.get("link_type", "template") # Get all linked folders for the current folder if hasattr(self, "builder") and isinstance( self.builder, AbstractTemplateBuilder): @@ -1649,7 +1643,8 @@ class PlaceholderLoadMixin(object): folder_ids = [ linked_folder_entity["id"] for linked_folder_entity in ( - self.builder.linked_folder_entities) + self.builder.get_linked_folder_entities( + link_type=link_type)) ] if not folder_ids: From b247762806f3e9f4dfa38afdb812d3da461d34d8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:44:02 +0200 Subject: [PATCH 248/312] make 'get_plugin_paths' optional --- client/ayon_core/addon/interfaces.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index 232c056fb4..b0f2d25c08 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -48,14 +48,23 @@ class IPluginPaths(AYONInterface): } """ - @abstractmethod def get_plugin_paths(self) -> dict[str, list[str]]: """Return plugin paths for addon. + This method was abstract (required) in the past, so raise the required + 'core' addon version when 'get_plugin_paths' is removed from + addon. + + Deprecated: + Please implement specific methods 'get_create_plugin_paths', + 'get_load_plugin_paths', 'get_inventory_action_paths' and + 'get_publish_plugin_paths' to return plugin paths. + Returns: dict[str, list[str]]: Plugin paths for addon. """ + return {} def _get_plugin_paths_by_type( self, plugin_type: str) -> list[str]: From 67f039bf5dc102b3da3c97e1010efe0639745a84 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:44:36 +0200 Subject: [PATCH 249/312] warn about using deprecated method --- client/ayon_core/addon/interfaces.py | 29 ++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index b0f2d25c08..010a5aaca1 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -1,6 +1,7 @@ """Addon interfaces for AYON.""" from __future__ import annotations +import warnings from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING, Callable, Optional, Type @@ -39,14 +40,7 @@ class AYONInterface(metaclass=_AYONInterfaceMeta): class IPluginPaths(AYONInterface): - """Addon has plugin paths to return. - - Expected result is dictionary with keys "publish", "create", "load", - "actions" or "inventory" and values as list or string. - { - "publish": ["path/to/publish_plugins"] - } - """ + """Addon wants to register plugin paths.""" def get_plugin_paths(self) -> dict[str, list[str]]: """Return plugin paths for addon. @@ -87,6 +81,25 @@ class IPluginPaths(AYONInterface): if not isinstance(paths, (list, tuple, set)): paths = [paths] + + new_function_name = "get_launcher_action_paths" + if plugin_type == "create": + new_function_name = "get_create_plugin_paths" + elif plugin_type == "load": + new_function_name = "get_load_plugin_paths" + elif plugin_type == "publish": + new_function_name = "get_publish_plugin_paths" + elif plugin_type == "inventory": + new_function_name = "get_inventory_action_paths" + + warnings.warn( + f"Addon '{self.name}' returns '{plugin_type}' paths using" + " 'get_plugin_paths' method. Please implement" + f" '{new_function_name}' instead.", + DeprecationWarning, + stacklevel=2 + + ) return paths def get_launcher_action_paths(self) -> list[str]: From 487b5dda98a3ff19084d0c6143de7da0708209b5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:44:45 +0200 Subject: [PATCH 250/312] small formatting change --- client/ayon_core/addon/interfaces.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index 010a5aaca1..9f2a14a264 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -61,7 +61,8 @@ class IPluginPaths(AYONInterface): return {} def _get_plugin_paths_by_type( - self, plugin_type: str) -> list[str]: + self, plugin_type: str + ) -> list[str]: """Get plugin paths by type. Args: From ba4412577bafaf5e759e39d942133d03a7a0392b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:45:12 +0200 Subject: [PATCH 251/312] mark 'collect_plugin_paths' as deprecated --- client/ayon_core/addon/base.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 72270fa585..80e1ceaa1e 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -8,6 +8,7 @@ import inspect import logging import threading import collections +import warnings from uuid import uuid4 from abc import ABC, abstractmethod from typing import Optional @@ -815,10 +816,26 @@ class AddonsManager: Unknown keys are logged out. + Deprecated: + Use targeted methods 'collect_launcher_action_paths', + 'collect_create_plugin_paths', 'collect_load_plugin_paths', + 'collect_publish_plugin_paths' and + 'collect_inventory_action_paths' to collect plugin paths. + Returns: dict: Output is dictionary with keys "publish", "create", "load", "actions" and "inventory" each containing list of paths. + """ + warnings.warn( + "Used deprecated method 'collect_plugin_paths'. Please use" + " targeted methods 'collect_launcher_action_paths'," + " 'collect_create_plugin_paths', 'collect_load_plugin_paths'" + " 'collect_publish_plugin_paths' and" + " 'collect_inventory_action_paths'", + DeprecationWarning, + stacklevel=2 + ) # Output structure output = { "publish": [], From 5e8dece22e2fbae722f83f45d74d30cad04708cf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:46:03 +0200 Subject: [PATCH 252/312] warn about having string as output from plugin getter method --- client/ayon_core/addon/base.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 80e1ceaa1e..57968b0e09 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -891,24 +891,28 @@ class AddonsManager: if not isinstance(addon, IPluginPaths): continue + paths = None method = getattr(addon, method_name) try: paths = method(*args, **kwargs) except Exception: self.log.warning( - ( - "Failed to get plugin paths from addon" - " '{}' using '{}'." - ).format(addon.__class__.__name__, method_name), + "Failed to get plugin paths from addon" + f" '{addon.name}' using '{method_name}'.", exc_info=True ) + + if not paths: continue - if paths: - # Convert to list if value is not list - if not isinstance(paths, (list, tuple, set)): - paths = [paths] - output.extend(paths) + if isinstance(paths, str): + paths = [paths] + self.log.warning( + f"Addon '{addon.name}' returned invalid output type" + f" from '{method_name}'." + f" Got 'str' expected 'list[str]'." + ) + output.extend(paths) return output def collect_launcher_action_paths(self): From 2ee875b90b98cc501f9887867ab090b053b03038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 31 Jul 2025 17:17:44 +0200 Subject: [PATCH 253/312] :recycle: remove defaults --- .../ayon_core/pipeline/workfile/workfile_template_builder.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 7920abb23f..7b20747768 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -301,7 +301,7 @@ class AbstractTemplateBuilder(ABC): self._loaders_by_name = get_loaders_by_name() return self._loaders_by_name - def get_linked_folder_entities(self, link_type: str = "template"): + def get_linked_folder_entities(self, link_type: str): project_name = self.project_name folder_entity = self.current_folder_entity if not folder_entity: @@ -1466,7 +1466,6 @@ class PlaceholderLoadMixin(object): attribute_definitions.EnumDef( "link_type", label="Link Type", - default="template", items=link_types_enum_item, tooltip=( "Link Type\n" From 5fa11b24e4bb0b165b67cd779d0e8c25ed8a2984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 31 Jul 2025 17:21:21 +0200 Subject: [PATCH 254/312] :recycle: limit link types to folder <-> folder --- .../workfile/workfile_template_builder.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 7b20747768..6a36fd12e4 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -1430,11 +1430,21 @@ class PlaceholderLoadMixin(object): link_types = ayon_api.get_link_types(self.builder.project_name) - link_types_enum_item = [ + # Filter link types for folder to folder links + link_types_enum_items = [ {"label": link_type["name"], "value": link_type["linkType"]} for link_type in link_types - + if ( + link_type["inputType"] == "folder" + and link_type["outputType"] == "folder" + ) ] + + if not link_types_enum_items: + link_types_enum_items.append( + {"label": "", "value": None} + ) + build_type_label = "Folder Builder Type" build_type_help = ( "Folder Builder Type\n" @@ -1466,7 +1476,7 @@ class PlaceholderLoadMixin(object): attribute_definitions.EnumDef( "link_type", label="Link Type", - items=link_types_enum_item, + items=link_types_enum_items, tooltip=( "Link Type\n" "\nDefines what type of link will be used to" From 8e2f33d483791c606cdbc4230e04be2ad88f6889 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 1 Aug 2025 11:23:44 +0200 Subject: [PATCH 255/312] use filepath instead of rootless path for workfile entity mapping --- client/ayon_core/host/interfaces/workfiles.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 14e60bda20..b519751ba2 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -1072,10 +1072,13 @@ class IWorkfileHost: prepared_data=prepared_data, ) - workfile_entities_by_path = { - workfile_entity["path"]: workfile_entity - for workfile_entity in list_workfiles_context.workfile_entities - } + workfile_entities_by_path = {} + for workfile_entity in list_workfiles_context.workfile_entities: + rootless_path = workfile_entity["path"] + path = os.path.normpath( + list_workfiles_context.anatomy.fill_root(rootless_path) + ) + workfile_entities_by_path[path] = workfile_entity workdir_data = get_template_data( list_workfiles_context.project_entity, @@ -1114,10 +1117,10 @@ class IWorkfileHost: rootless_path = f"{rootless_workdir}/{filename}" workfile_entity = workfile_entities_by_path.pop( - rootless_path, None + filepath, None ) version = comment = None - if workfile_entity: + if workfile_entity is not None: _data = workfile_entity["data"] version = _data.get("version") comment = _data.get("comment") @@ -1137,7 +1140,7 @@ class IWorkfileHost: ) items.append(item) - for workfile_entity in workfile_entities_by_path.values(): + for filepath, workfile_entity in workfile_entities_by_path.items(): # Workfile entity is not in the filesystem # but it is in the database rootless_path = workfile_entity["path"] @@ -1154,7 +1157,6 @@ class IWorkfileHost: version = parsed_data.version comment = parsed_data.comment - filepath = list_workfiles_context.anatomy.fill_root(rootless_path) available = os.path.exists(filepath) items.append(WorkfileInfo.new( filepath, From e7ea930d557d5e8264beb09a94b39044178fee33 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Fri, 1 Aug 2025 10:23:24 +0000 Subject: [PATCH 256/312] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 7f55a17a01..c16b31f2fc 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.5.0+dev" +__version__ = "1.5.1" diff --git a/package.py b/package.py index 807e4e4b35..9c131794d7 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.5.0+dev" +version = "1.5.1" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index e7977a5579..686cc1e3f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.5.0+dev" +version = "1.5.1" description = "" authors = ["Ynput Team "] readme = "README.md" From 302619176bbc651642dbe847d98eda76f543bd29 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Fri, 1 Aug 2025 10:24:02 +0000 Subject: [PATCH 257/312] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index c16b31f2fc..784105572b 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.5.1" +__version__ = "1.5.1+dev" diff --git a/package.py b/package.py index 9c131794d7..a0d7b26703 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.5.1" +version = "1.5.1+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 686cc1e3f8..b544afa346 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.5.1" +version = "1.5.1+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 745a394cdddd688a054870c2ae08559683850d82 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 Aug 2025 10:24:54 +0000 Subject: [PATCH 258/312] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9202190f8b..364d1709e0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.5.1 - 1.5.0 - 1.4.1 - 1.4.0 From 9f456f7cb8b3afa3c32ba605ca7ed22276b47c6d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 1 Aug 2025 14:37:21 +0200 Subject: [PATCH 259/312] added safe importing of otio --- .../publish/collect_otio_frame_ranges.py | 17 +++++++++++------ .../publish/extract_otio_audio_tracks.py | 2 +- .../plugins/publish/extract_otio_review.py | 4 ++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index 0a4efc2172..d68970d428 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -8,13 +8,7 @@ This module contains a unified plugin that handles: from pprint import pformat -import opentimelineio as otio import pyblish.api -from ayon_core.pipeline.editorial import ( - get_media_range_with_retimes, - otio_range_to_frame_range, - otio_range_with_handles, -) def validate_otio_clip(instance, logger): @@ -74,6 +68,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): if not validate_otio_clip(instance, self.log): return + import opentimelineio as otio + otio_clip = instance.data["otioClip"] # Collect timeline ranges if workfile start frame is available @@ -100,6 +96,11 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): def _collect_timeline_ranges(self, instance, otio_clip): """Collect basic timeline frame ranges.""" + from ayon_core.pipeline.editorial import ( + otio_range_to_frame_range, + otio_range_with_handles, + ) + workfile_start = instance.data["workfileFrameStart"] # Get timeline ranges @@ -129,6 +130,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): def _collect_source_ranges(self, instance, otio_clip): """Collect source media frame ranges.""" + import opentimelineio as otio + # Get source ranges otio_src_range = otio_clip.source_range otio_available_range = otio_clip.available_range() @@ -178,6 +181,8 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): def _collect_retimed_ranges(self, instance, otio_clip): """Handle retimed clip frame ranges.""" + from ayon_core.pipeline.editorial import get_media_range_with_retimes + retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0) self.log.debug(f"Retimed attributes: {retimed_attributes}") diff --git a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py index 2aec4a5415..86d18ed147 100644 --- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py +++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py @@ -7,7 +7,6 @@ from ayon_core.lib import ( get_ffmpeg_tool_args, run_subprocess ) -from ayon_core.pipeline import editorial class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): @@ -159,6 +158,7 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): """ # Not all hosts can import this module. import opentimelineio as otio + from ayon_core.pipeline.editorial import OTIO_EPSILON output = [] # go trough all audio tracks diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 74cf45e474..28452bc0e9 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -25,7 +25,6 @@ from ayon_core.lib import ( ) from ayon_core.pipeline import ( KnownPublishError, - editorial, publish, ) @@ -359,6 +358,7 @@ class ExtractOTIOReview( import opentimelineio as otio from ayon_core.pipeline.editorial import ( trim_media_range, + OTIO_EPSILON, ) def _round_to_frame(rational_time): @@ -380,7 +380,7 @@ class ExtractOTIOReview( # Avoid rounding issue on media available range. if start.almost_equal( avl_start, - editorial.OTIO_EPSILON + OTIO_EPSILON ): avl_start = start From 798b281e6731947cd4700591632f4e4c4134b73b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 1 Aug 2025 14:42:16 +0200 Subject: [PATCH 260/312] fix OTIO_EPSILON usage --- client/ayon_core/plugins/publish/extract_otio_audio_tracks.py | 2 +- client/ayon_core/plugins/publish/extract_otio_review.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py index 86d18ed147..3a450a4f33 100644 --- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py +++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py @@ -177,7 +177,7 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): # Avoid rounding issue on media available range. if clip_start.almost_equal( conformed_av_start, - editorial.OTIO_EPSILON + OTIO_EPSILON ): conformed_av_start = clip_start diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 28452bc0e9..90215bd2c9 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -406,7 +406,7 @@ class ExtractOTIOReview( # Avoid rounding issue on media available range. if end_point.almost_equal( avl_end_point, - editorial.OTIO_EPSILON + OTIO_EPSILON ): avl_end_point = end_point From 8bcc4a3939d4e52cce731a0e712b87b283fee37d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 1 Aug 2025 18:23:15 +0200 Subject: [PATCH 261/312] Make sure workdir exists when workfile is being saved --- client/ayon_core/host/interfaces/workfiles.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index b519751ba2..82d71d152a 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -944,6 +944,8 @@ class IWorkfileHost: self._emit_workfile_save_event(event_data, after_save=False) workdir = os.path.dirname(filepath) + if not os.path.exists(workdir): + os.makedirs(workdir, exist_ok=True) # Set 'AYON_WORKDIR' environment variable os.environ["AYON_WORKDIR"] = workdir From 955d8166a5fc4d216368db6ee0a7bf80710f7b32 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Fri, 1 Aug 2025 16:36:08 +0000 Subject: [PATCH 262/312] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 784105572b..b6958f1be5 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.5.1+dev" +__version__ = "1.5.2" diff --git a/package.py b/package.py index a0d7b26703..79fe4f83b1 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.5.1+dev" +version = "1.5.2" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index b544afa346..73fa4336f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.5.1+dev" +version = "1.5.2" description = "" authors = ["Ynput Team "] readme = "README.md" From 8cd7037c6f0910ff4ae789dd2b1213b5b5307e85 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Fri, 1 Aug 2025 16:36:43 +0000 Subject: [PATCH 263/312] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index b6958f1be5..9f1bac6805 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.5.2" +__version__ = "1.5.2+dev" diff --git a/package.py b/package.py index 79fe4f83b1..7bd806159f 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.5.2" +version = "1.5.2+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 73fa4336f1..e67fcc2138 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.5.2" +version = "1.5.2+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 55f5551b31b1ad2aef3ca801207ad458a19d153e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 Aug 2025 16:37:35 +0000 Subject: [PATCH 264/312] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 364d1709e0..933448a6a9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.5.2 - 1.5.1 - 1.5.0 - 1.4.1 From 4f0e18b42ed081370bc7d5279a2a91159a7b139f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 4 Aug 2025 10:17:50 +0200 Subject: [PATCH 265/312] Remove unnecessary line --- client/ayon_core/addon/interfaces.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index 9f2a14a264..bf08ccd48c 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -99,7 +99,6 @@ class IPluginPaths(AYONInterface): f" '{new_function_name}' instead.", DeprecationWarning, stacklevel=2 - ) return paths From c219403b13c1f9436b17758e095b2f7fdd6788d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Mon, 4 Aug 2025 18:18:10 +0200 Subject: [PATCH 266/312] Update client/ayon_core/pipeline/workfile/workfile_template_builder.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../ayon_core/pipeline/workfile/workfile_template_builder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 6a36fd12e4..9994bcfd4e 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -301,7 +301,9 @@ class AbstractTemplateBuilder(ABC): self._loaders_by_name = get_loaders_by_name() return self._loaders_by_name - def get_linked_folder_entities(self, link_type: str): + def get_linked_folder_entities(self, link_type: Optional[str]): + if not link_type: + return [] project_name = self.project_name folder_entity = self.current_folder_entity if not folder_entity: From 9929c80425cdf1caa5986ce8bacbfda442d009ae Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 7 Aug 2025 14:58:33 +0200 Subject: [PATCH 267/312] better detail widget varaible --- .../tools/publisher/widgets/card_view_widgets.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 6d95906364..1cce09e97a 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -309,10 +309,6 @@ class InstanceCardWidget(CardWidget): expand_btn.setMaximumWidth(14) expand_btn.setEnabled(False) - detail_widget = QtWidgets.QWidget(self) - detail_widget.setVisible(False) - self.detail_widget = detail_widget - top_layout = QtWidgets.QHBoxLayout() top_layout.addLayout(icon_layout, 0) top_layout.addWidget(label_widget, 1) @@ -320,6 +316,9 @@ class InstanceCardWidget(CardWidget): top_layout.addWidget(active_checkbox, 0) top_layout.addWidget(expand_btn, 0) + detail_widget = QtWidgets.QWidget(self) + detail_widget.setVisible(False) + layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 2, 10, 2) layout.addLayout(top_layout) @@ -337,6 +336,8 @@ class InstanceCardWidget(CardWidget): self._active_checkbox = active_checkbox self._expand_btn = expand_btn + self._detail_widget = detail_widget + self._update_instance_values(context_info, is_parent_active) def set_active_toggle_enabled(self, enabled: bool) -> None: @@ -448,8 +449,8 @@ class InstanceCardWidget(CardWidget): def _set_expanded(self, expanded=None): if expanded is None: - expanded = not self.detail_widget.isVisible() - self.detail_widget.setVisible(expanded) + expanded = not self._detail_widget.isVisible() + self._detail_widget.setVisible(expanded) def _on_active_change(self): new_value = self._active_checkbox.isChecked() From 5ab31a0bd9ee03e66c5908068bdbabce29de7098 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 7 Aug 2025 14:58:42 +0200 Subject: [PATCH 268/312] remove unused variable --- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 1cce09e97a..5b9b104c16 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -521,7 +521,6 @@ class InstanceCardView(AbstractInstanceView): collections.defaultdict(list) ) self._ordered_groups = [] - self._group_icons = {} self._context_widget: Optional[ContextCardWidget] = None self._widgets_by_id: dict[str, InstanceCardWidget] = {} self._widgets_by_group: dict[str, BaseGroupWidget] = {} From dba9ea95a2cc9aace75b3f84e88ffe6aa42ba323 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 7 Aug 2025 14:59:11 +0200 Subject: [PATCH 269/312] add missing abstract method --- .../ayon_core/tools/publisher/widgets/widgets.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index a9d34c4c66..9de1f753b2 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -370,6 +370,21 @@ class AbstractInstanceView(QtWidgets.QWidget): "{} Method 'set_active_toggle_enabled' is not implemented." ).format(self.__class__.__name__)) + def refresh_instance_states(self, instance_ids=None): + """Refresh instance states. + + Args: + instance_ids: Optional[Iterable[str]]: Instance ids to refresh. + If not passed then all instances are refreshed. + + """ + + raise NotImplementedError( + f"{self.__class__.__name__} Method 'refresh_instance_states'" + " is not implemented." + ) + + class ClickableLineEdit(QtWidgets.QLineEdit): """QLineEdit capturing left mouse click. From a4bd8523f2203b9e588f22d75bef78e0f152dc01 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 7 Aug 2025 15:00:43 +0200 Subject: [PATCH 270/312] better view handling --- .../publisher/widgets/overview_widget.py | 75 ++++++++++++------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index d78b143ce6..10bd2bb354 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import Generator + from qtpy import QtWidgets, QtCore from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend @@ -250,7 +254,7 @@ class OverviewWidget(QtWidgets.QFrame): ) def has_items(self): - view = self._product_views_layout.currentWidget() + view = self._get_current_view() return view.has_items() def _on_create_clicked(self): @@ -369,16 +373,14 @@ class OverviewWidget(QtWidgets.QFrame): self._refresh_instance_states(event["instance_ids"]) def _refresh_instance_states(self, instance_ids): - current_idx = self._product_views_layout.currentIndex() - for idx in range(self._product_views_layout.count()): - if idx == current_idx: - continue - widget = self._product_views_layout.widget(idx) - if widget.refreshed: - widget.set_refreshed(False) + current_view = self._get_current_view() + for view in self._iter_views(): + if view is current_view: + current_view = view + elif view.refreshed: + view.set_refreshed(False) - current_widget = self._product_views_layout.widget(current_idx) - current_widget.refresh_instance_states(instance_ids) + current_view.refresh_instance_states(instance_ids) def _on_convert_requested(self): self.convert_requested.emit() @@ -392,7 +394,7 @@ class OverviewWidget(QtWidgets.QFrame): convertor plugins. """ - view = self._product_views_layout.currentWidget() + view = self._get_current_view() return view.get_selected_items() def get_selected_legacy_convertors(self): @@ -410,8 +412,8 @@ class OverviewWidget(QtWidgets.QFrame): idx = self._product_views_layout.currentIndex() new_idx = (idx + 1) % self._product_views_layout.count() - old_view = self._product_views_layout.currentWidget() - new_view = self._product_views_layout.widget(new_idx) + old_view = self._get_current_view() + new_view = self._get_view_by_idx(new_idx) if not new_view.refreshed: new_view.refresh() @@ -430,17 +432,41 @@ class OverviewWidget(QtWidgets.QFrame): self._on_product_change() + def _iter_views(self) -> Generator[AbstractInstanceView, None, None]: + for idx in range(self._product_views_layout.count()): + widget = self._product_views_layout.widget(idx) + if not isinstance(widget, AbstractInstanceView): + raise TypeError( + "Current widget is not instance of 'AbstractInstanceView'" + ) + yield widget + + def _get_current_view(self) -> AbstractInstanceView: + widget = self._product_views_layout.currentWidget() + if isinstance(widget, AbstractInstanceView): + return widget + raise TypeError( + "Current widget is not instance of 'AbstractInstanceView'" + ) + + def _get_view_by_idx(self, idx: int) -> AbstractInstanceView: + widget = self._product_views_layout.widget(idx) + if isinstance(widget, AbstractInstanceView): + return widget + raise TypeError( + "Current widget is not instance of 'AbstractInstanceView'" + ) + def _refresh_instances(self): if self._refreshing_instances: return self._refreshing_instances = True - for idx in range(self._product_views_layout.count()): - widget = self._product_views_layout.widget(idx) - widget.set_refreshed(False) + for view in self._iter_views(): + view.set_refreshed(False) - view = self._product_views_layout.currentWidget() + view = self._get_current_view() view.refresh() view.set_refreshed(True) @@ -451,25 +477,22 @@ class OverviewWidget(QtWidgets.QFrame): # Give a change to process Resize Request QtWidgets.QApplication.processEvents() - # Trigger update geometry of - widget = self._product_views_layout.currentWidget() - widget.updateGeometry() + # Trigger update geometry + view.updateGeometry() def _on_publish_start(self): """Publish started.""" self._create_btn.setEnabled(False) self._product_attributes_wrap.setEnabled(False) - for idx in range(self._product_views_layout.count()): - widget = self._product_views_layout.widget(idx) - widget.set_active_toggle_enabled(False) + for view in self._iter_views(): + view.set_active_toggle_enabled(False) def _on_controller_reset_start(self): """Controller reset started.""" - for idx in range(self._product_views_layout.count()): - widget = self._product_views_layout.widget(idx) - widget.set_active_toggle_enabled(True) + for view in self._iter_views(): + view.set_active_toggle_enabled(True) def _on_publish_reset(self): """Context in controller has been reseted.""" From bc6bd4be29614630f875cc6ecfc809c6fa7d3859 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 7 Aug 2025 15:00:57 +0200 Subject: [PATCH 271/312] added missing import --- client/ayon_core/tools/publisher/widgets/overview_widget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index 10bd2bb354..44581feac8 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -10,6 +10,7 @@ from .border_label_widget import BorderedLabelWidget from .card_view_widgets import InstanceCardView from .list_view_widgets import InstanceListView from .widgets import ( + AbstractInstanceView, CreateInstanceBtn, RemoveInstanceBtn, ChangeViewBtn, From c800e35f3fc4ad44df28a39f34040f46334cce89 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 7 Aug 2025 15:01:34 +0200 Subject: [PATCH 272/312] change change view button --- .../publisher/widgets/overview_widget.py | 5 ++++ .../tools/publisher/widgets/widgets.py | 24 +++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index 44581feac8..4ff38c26cd 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -178,6 +178,7 @@ class OverviewWidget(QtWidgets.QFrame): self._create_btn = create_btn self._delete_btn = delete_btn + self._change_view_btn = change_view_btn self._product_attributes_widget = product_attributes_widget self._create_widget = create_widget @@ -415,6 +416,7 @@ class OverviewWidget(QtWidgets.QFrame): old_view = self._get_current_view() new_view = self._get_view_by_idx(new_idx) + is_list_view = isinstance(new_view, InstanceListView) if not new_view.refreshed: new_view.refresh() @@ -429,6 +431,9 @@ class OverviewWidget(QtWidgets.QFrame): instance_ids, context_selected, convertor_identifiers ) + self._change_view_btn.set_view_type( + "card" if is_list_view else "list" + ) self._product_views_layout.setCurrentIndex(new_idx) self._on_product_change() diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index 9de1f753b2..b1c4a3afcc 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -10,6 +10,7 @@ from ayon_core.tools.flickcharm import FlickCharm from ayon_core.tools.utils import ( IconButton, PixmapLabel, + get_qt_icon, ) from ayon_core.tools.publisher.constants import ResetKeySequence @@ -287,12 +288,27 @@ class RemoveInstanceBtn(PublishIconBtn): self.setToolTip("Remove selected instances") -class ChangeViewBtn(PublishIconBtn): +class ChangeViewBtn(IconButton): """Create toggle view button.""" def __init__(self, parent=None): - icon_path = get_icon_path("change_view") - super().__init__(icon_path, parent) - self.setToolTip("Swap between views") + super().__init__(parent) + self.set_view_type("list") + + def set_view_type(self, view_type): + if view_type == "list": + # icon_name = "data_table" + icon_name = "view_agenda" + tooltip = "Change to list view" + else: + icon_name = "dehaze" + tooltip = "Change to card view" + + icon = get_qt_icon({ + "type": "material-symbols", + "name": icon_name, + }) + self.setIcon(icon) + self.setToolTip(tooltip) class AbstractInstanceView(QtWidgets.QWidget): From a7a3834fdcc168188d8c944144f8514e24f8bd56 Mon Sep 17 00:00:00 2001 From: Sasbom Date: Fri, 8 Aug 2025 08:31:22 +0200 Subject: [PATCH 273/312] force in UI element from laucher to workfiles --- .../tools/workfiles/widgets/window.py | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index 1649a059cb..81f1d76c71 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -1,21 +1,20 @@ -from qtpy import QtCore, QtWidgets, QtGui +from qtpy import QtCore, QtGui, QtWidgets -from ayon_core import style, resources +from ayon_core import resources, style from ayon_core.tools.utils import ( - PlaceholderLineEdit, - MessageOverlayObject, -) - -from ayon_core.tools.workfiles.control import BaseWorkfileController -from ayon_core.tools.utils import ( - GoToCurrentButton, - RefreshButton, FoldersWidget, + GoToCurrentButton, + MessageOverlayObject, + NiceCheckbox, + PlaceholderLineEdit, + RefreshButton, TasksWidget, ) +from ayon_core.tools.utils.lib import checkstate_int_to_enum +from ayon_core.tools.workfiles.control import BaseWorkfileController -from .side_panel import SidePanelWidget from .files_widget import FilesWidget +from .side_panel import SidePanelWidget from .utils import BaseOverlayFrame @@ -186,11 +185,24 @@ class WorkfilesToolWindow(QtWidgets.QWidget): controller, col_widget, handle_expected_selection=True ) + my_tasks_tooltip = ( + "Filter folders and task to only those you are assigned to." + ) + + my_tasks_label = QtWidgets.QLabel("My tasks") + my_tasks_label.setToolTip(my_tasks_tooltip) + + my_tasks_checkbox = NiceCheckbox(folder_widget) + my_tasks_checkbox.setChecked(False) + my_tasks_checkbox.setToolTip(my_tasks_tooltip) + header_layout = QtWidgets.QHBoxLayout(header_widget) header_layout.setContentsMargins(0, 0, 0, 0) header_layout.addWidget(folder_filter_input, 1) header_layout.addWidget(go_to_current_btn, 0) header_layout.addWidget(refresh_btn, 0) + header_layout.addWidget(my_tasks_label, 0) + header_layout.addWidget(my_tasks_checkbox, 0) col_layout = QtWidgets.QVBoxLayout(col_widget) col_layout.setContentsMargins(0, 0, 0, 0) @@ -200,6 +212,9 @@ class WorkfilesToolWindow(QtWidgets.QWidget): folder_filter_input.textChanged.connect(self._on_folder_filter_change) go_to_current_btn.clicked.connect(self._on_go_to_current_clicked) refresh_btn.clicked.connect(self._on_refresh_clicked) + my_tasks_checkbox.stateChanged.connect( + self._on_my_tasks_checkbox_state_changed + ) self._folder_filter_input = folder_filter_input self._folders_widget = folder_widget @@ -385,3 +400,16 @@ class WorkfilesToolWindow(QtWidgets.QWidget): ) else: self.close() + + 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) From 20614562cd5a8818191d8daa1822cf354ffa6a86 Mon Sep 17 00:00:00 2001 From: Sasbom Date: Fri, 8 Aug 2025 09:13:31 +0200 Subject: [PATCH 274/312] implement interface for "my task" functionality in workfiles control / window --- client/ayon_core/tools/workfiles/control.py | 26 +++++++++++++++---- .../tools/workfiles/widgets/window.py | 4 +++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index 4391e6b5fd..f0e0f0e416 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -3,25 +3,26 @@ import os import ayon_api from ayon_core.host import IWorkfileHost -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.pipeline import Anatomy, registered_host from ayon_core.pipeline.context_tools import get_global_context - +from ayon_core.settings import get_project_settings from ayon_core.tools.common_models import ( - HierarchyModel, HierarchyExpectedSelection, + HierarchyModel, ProjectsModel, UsersModel, ) from .abstract import ( - AbstractWorkfilesFrontend, AbstractWorkfilesBackend, + AbstractWorkfilesFrontend, ) from .models import SelectionModel, WorkfilesModel +NOT_SET = object() + class WorkfilesToolExpectedSelection(HierarchyExpectedSelection): def __init__(self, controller): @@ -143,6 +144,7 @@ class BaseWorkfileController( self._project_settings = None self._event_system = None self._log = None + self._username = NOT_SET self._current_project_name = None self._current_folder_path = None @@ -588,6 +590,20 @@ class BaseWorkfileController( description, ) + 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/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index 81f1d76c71..1e78b89851 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -1,3 +1,4 @@ + from qtpy import QtCore, QtGui, QtWidgets from ayon_core import resources, style @@ -156,6 +157,9 @@ class WorkfilesToolWindow(QtWidgets.QWidget): self._home_body_widget = home_body_widget self._split_widget = split_widget + host = self._controller._host + self._project_name = host.get_current_project_name() + self._tasks_widget = tasks_widget self._side_panel = side_panel From f4578e93a9f5a8a4f86cad0a4e9510d375b0d707 Mon Sep 17 00:00:00 2001 From: Sasbom Date: Fri, 8 Aug 2025 09:16:32 +0200 Subject: [PATCH 275/312] embiggen first panel to accommodate added ui element --- client/ayon_core/tools/workfiles/widgets/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index 1e78b89851..7c00499b2d 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -107,7 +107,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget): split_widget.addWidget(tasks_widget) split_widget.addWidget(col_3_widget) split_widget.addWidget(side_panel) - split_widget.setSizes([255, 175, 550, 190]) + split_widget.setSizes([350, 175, 550, 190]) body_layout.addWidget(split_widget) From 0d945c90ecf37c794f84ff7048c0e0474de30bf1 Mon Sep 17 00:00:00 2001 From: Sasbom Date: Fri, 8 Aug 2025 10:17:13 +0200 Subject: [PATCH 276/312] neaten up project name retrieval through canonical means --- client/ayon_core/tools/workfiles/widgets/window.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index 7c00499b2d..3f96f0bb15 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -157,8 +157,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget): self._home_body_widget = home_body_widget self._split_widget = split_widget - host = self._controller._host - self._project_name = host.get_current_project_name() + self._project_name = self._controller.get_current_project_name() self._tasks_widget = tasks_widget self._side_panel = side_panel From 80cd3a3ea811dc70dd2e0c44b22eb42ebb8d4d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 8 Aug 2025 12:23:43 +0200 Subject: [PATCH 277/312] :bug: fix import --- client/ayon_core/pipeline/workfile/workfile_template_builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 9994bcfd4e..e2add99752 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -16,6 +16,7 @@ import re import collections import copy from abc import ABC, abstractmethod +from typing import Optional import ayon_api from ayon_api import ( From e07b11b7fabd89e1d9bd95697acff8a4a843d6f7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 8 Aug 2025 13:56:30 +0200 Subject: [PATCH 278/312] change view on too much instances --- .../tools/publisher/widgets/card_view_widgets.py | 4 ++++ .../tools/publisher/widgets/overview_widget.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 5b9b104c16..3c8a99b2c9 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -548,6 +548,10 @@ class InstanceCardView(AbstractInstanceView): result.setWidth(width) return result + def get_current_instance_count(self) -> int: + """How many instances are currently in the view.""" + return len(self._widgets_by_id) + def _toggle_instances( self, new_value: Optional[bool], diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index 4ff38c26cd..27b1a2e185 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -513,7 +513,19 @@ class OverviewWidget(QtWidgets.QFrame): self._refresh_instances() def _on_instances_added(self): + view = self._get_current_view() + is_card_view = False + count = 0 + if isinstance(view, InstanceCardView): + is_card_view = True + count = view.get_current_instance_count() + self._refresh_instances() + if is_card_view and count < 10: + new_count = view.get_current_instance_count() + if new_count > count and new_count >= 10: + self._change_view_type() + def _on_instances_removed(self): self._refresh_instances() From de68250995e727f2804f31ad1184585576fc3ef0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:00:11 +0200 Subject: [PATCH 279/312] use parent ids structure instead of UI model to traverse hierarchy --- .../publisher/widgets/list_view_widgets.py | 80 +++++++------------ 1 file changed, 31 insertions(+), 49 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index a2aadd9cfa..62c5b6aa4c 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -485,6 +485,7 @@ class InstanceListView(AbstractInstanceView): self._widgets_by_id: dict[str, InstanceListItemWidget] = {} self._items_by_id = {} self._parent_id_by_id = {} + self._instance_ids_by_parent_id = collections.defaultdict(set) # Group by instance id for handling of active state self._group_by_instance_id = {} self._context_item = None @@ -565,10 +566,14 @@ class InstanceListView(AbstractInstanceView): # Prepare instances by their groups instances_by_group_name = collections.defaultdict(list) instances_by_parent_id = collections.defaultdict(list) + instance_ids_by_parent_id = collections.defaultdict(set) group_names = set() instance_ids = set() for instance in instance_items: instance_ids.add(instance.id) + instance_ids_by_parent_id[instance.parent_instance_id].add( + instance.id + ) if instance.parent_instance_id: instances_by_parent_id[instance.parent_instance_id].append( instance @@ -663,20 +668,19 @@ class InstanceListView(AbstractInstanceView): self._parent_id_by_id[instance_id] = parent_id - children = instances_by_parent_id.pop(instance_id, []) items_with_instance.append( ( item, instance, parent_id, is_orpaned_item, - bool(children) ) ) item.setData(instance.product_name, SORT_VALUE_ROLE) item.setData(instance.product_name, GROUP_ROLE) + children = instances_by_parent_id.pop(instance_id, []) for child in children: _queue.append((child, item, instance_id)) @@ -705,7 +709,7 @@ class InstanceListView(AbstractInstanceView): parent_item.appendRows(items) for ( - item, instance, parent_id, is_orpaned_item, has_children + item, instance, parent_id, is_orpaned_item ) in items_with_instance: context_info = context_info_by_id[instance.id] # TODO expand all parents @@ -752,6 +756,7 @@ class InstanceListView(AbstractInstanceView): widget.deleteLater() self._widgets_by_id = widgets_by_id + self._instance_ids_by_parent_id = instance_ids_by_parent_id # Expand items marked for expanding items_to_expand = [] @@ -1022,29 +1027,16 @@ class InstanceListView(AbstractInstanceView): instance_ids = set(instance_items_by_id) available_ids = set(instance_ids) - group_items = list(self._group_items.values()) - if self._missing_parent_item is not None: - group_items.append(self._missing_parent_item) - _queue = collections.deque() - for group_item in group_items: - if not group_item.hasChildren(): - continue - - children = [ - group_item.child(row) - for row in range(group_item.rowCount()) - ] - _queue.append((children, True)) + _queue.append((set(self._instance_ids_by_parent_id[None]), True)) discarted_ids = set() while _queue: if not instance_ids: break - children, parent_active = _queue.popleft() - for child in children: - instance_id = child.data(INSTANCE_ID_ROLE) + children_ids, parent_active = _queue.popleft() + for instance_id in children_ids: widget = self._widgets_by_id[instance_id] # Add children ids to 'instance_ids' to traverse them too add_children = False @@ -1066,23 +1058,24 @@ class InstanceListView(AbstractInstanceView): instance_ids.discard(instance_id) discarted_ids.add(instance_id) - if not child.hasChildren(): + if not add_children: continue - children = [ - child.child(row) - for row in range(child.rowCount()) - ] - if add_children: - for new_child in children: - instance_id = new_child.data(INSTANCE_ID_ROLE) - if instance_id not in discarted_ids: - instance_ids.add(instance_id) + _children = { + child_id + for child_id in ( + self._instance_ids_by_parent_id[instance_id] + ) + if child_id not in discarted_ids + } + + if _children: + instance_ids |= _children + _queue.append((_children, widget.is_active())) if not instance_ids: break - _queue.append((children, widget.is_active())) def _on_active_changed(self, changed_instance_id, new_value): self._toggle_active_state(new_value, changed_instance_id) @@ -1097,23 +1090,12 @@ class InstanceListView(AbstractInstanceView): instance_ids = {active_id} active_by_id = {} - # Change the states from top to bottom - group_items = list(self._group_items.values()) - if self._missing_parent_item is not None: - group_items.append(self._missing_parent_item) - _queue = collections.deque() - for group_item in group_items: - children = [ - group_item.child(row) - for row in range(group_item.rowCount()) - ] - _queue.append((children, True)) + _queue.append((set(self._instance_ids_by_parent_id[None]), True)) while _queue: - children, parent_active = _queue.popleft() - for child in children: - instance_id = child.data(INSTANCE_ID_ROLE) + children_ids, parent_active = _queue.popleft() + for instance_id in children_ids: widget = self._widgets_by_id[instance_id] widget.set_parent_is_active(parent_active) if instance_id in instance_ids: @@ -1123,11 +1105,11 @@ class InstanceListView(AbstractInstanceView): widget.set_active(value) active_by_id[instance_id] = value - children = [ - child.child(row) - for row in range(child.rowCount()) - ] - _queue.append((children, widget.is_active())) + children = set( + self._instance_ids_by_parent_id[instance_id] + ) + if children: + _queue.append((children, widget.is_active())) self._controller.set_instances_active_state(active_by_id) From 4fda90d135430087fde7631b2acd36d1123fd634 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:00:25 +0200 Subject: [PATCH 280/312] added 3rd view --- .../publisher/widgets/list_view_widgets.py | 17 ++++++++++++-- .../publisher/widgets/overview_widget.py | 23 +++++++++++++++---- .../tools/publisher/widgets/widgets.py | 13 +++++++---- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 62c5b6aa4c..89ed60a076 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -491,6 +491,7 @@ class InstanceListView(AbstractInstanceView): self._context_item = None self._context_widget = None self._missing_parent_item = None + self._parent_grouping = True self._convertor_group_item = None self._convertor_group_widget = None @@ -578,7 +579,8 @@ class InstanceListView(AbstractInstanceView): instances_by_parent_id[instance.parent_instance_id].append( instance ) - continue + if self._parent_grouping: + continue group_label = instance.group_label group_names.add(group_label) @@ -664,6 +666,9 @@ class InstanceListView(AbstractInstanceView): new_items[parent_id].append(item) elif item.parent() is not parent_item: + current_parent = item.parent() + if current_parent is not None: + current_parent.takeRow(item.row()) new_items[parent_id].append(item) self._parent_id_by_id[instance_id] = parent_id @@ -680,6 +685,9 @@ class InstanceListView(AbstractInstanceView): item.setData(instance.product_name, SORT_VALUE_ROLE) item.setData(instance.product_name, GROUP_ROLE) + if not self._parent_grouping: + continue + children = instances_by_parent_id.pop(instance_id, []) for child in children: _queue.append((child, item, instance_id)) @@ -701,7 +709,7 @@ class InstanceListView(AbstractInstanceView): # Add items under group item for parent_id, items in new_items.items(): - if parent_id is None: + if parent_id is None or not self._parent_grouping: parent_item = group_item else: parent_item = self._items_by_id[parent_id] @@ -1076,6 +1084,11 @@ class InstanceListView(AbstractInstanceView): if not instance_ids: break + def parent_grouping_enabled(self) -> bool: + return self._parent_grouping + + def set_parent_grouping(self, parent_grouping: bool) -> None: + self._parent_grouping = parent_grouping def _on_active_changed(self, changed_instance_id, new_value): self._toggle_active_state(new_value, changed_instance_id) diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index 27b1a2e185..cb7e2b39cf 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -411,14 +411,27 @@ class OverviewWidget(QtWidgets.QFrame): return convertor_identifiers def _change_view_type(self): + old_view = self._get_current_view() + if ( + isinstance(old_view, InstanceListView) + and not old_view.parent_grouping_enabled() + ): + self._change_view_btn.set_view_type("card") + old_view.set_parent_grouping(True) + old_view.refresh() + old_view.set_refreshed(True) + return + idx = self._product_views_layout.currentIndex() new_idx = (idx + 1) % self._product_views_layout.count() - old_view = self._get_current_view() new_view = self._get_view_by_idx(new_idx) - is_list_view = isinstance(new_view, InstanceListView) + if isinstance(new_view, InstanceListView): + new_view.set_parent_grouping(False) + new_view.refresh() + new_view.set_refreshed(True) - if not new_view.refreshed: + elif not new_view.refreshed: new_view.refresh() new_view.set_refreshed(True) else: @@ -432,7 +445,9 @@ class OverviewWidget(QtWidgets.QFrame): ) self._change_view_btn.set_view_type( - "card" if is_list_view else "list" + "list" + if isinstance(new_view, InstanceCardView) + else "list-parent-grouping" ) self._product_views_layout.setCurrentIndex(new_idx) diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index b1c4a3afcc..921a13ba77 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -289,7 +289,7 @@ class RemoveInstanceBtn(PublishIconBtn): class ChangeViewBtn(IconButton): - """Create toggle view button.""" + """Toggle views button.""" def __init__(self, parent=None): super().__init__(parent) self.set_view_type("list") @@ -297,12 +297,17 @@ class ChangeViewBtn(IconButton): def set_view_type(self, view_type): if view_type == "list": # icon_name = "data_table" - icon_name = "view_agenda" - tooltip = "Change to list view" - else: icon_name = "dehaze" + tooltip = "Change to list view" + elif view_type == "card": + icon_name = "view_agenda" tooltip = "Change to card view" + else: + icon_name = "segment" + tooltip = "Change to parent grouping view" + # "format_align_right" + # "segment" icon = get_qt_icon({ "type": "material-symbols", "name": icon_name, From 78faa1c36f95ba30fe25cf1e2abf02289f234216 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:06:03 +0200 Subject: [PATCH 281/312] formatting fix --- client/ayon_core/tools/publisher/widgets/widgets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index 921a13ba77..793b0f501b 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -406,7 +406,6 @@ class AbstractInstanceView(QtWidgets.QWidget): ) - class ClickableLineEdit(QtWidgets.QLineEdit): """QLineEdit capturing left mouse click. From f11fe9c089b775d431d458c15d1022b24d9a7c2a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:52:10 +0200 Subject: [PATCH 282/312] allow copy of published workfile without task --- client/ayon_core/tools/workfiles/widgets/files_widget.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/workfiles/widgets/files_widget.py b/client/ayon_core/tools/workfiles/widgets/files_widget.py index 0c8ad392e2..9c12fa575c 100644 --- a/client/ayon_core/tools/workfiles/widgets/files_widget.py +++ b/client/ayon_core/tools/workfiles/widgets/files_widget.py @@ -287,10 +287,11 @@ class FilesWidget(QtWidgets.QWidget): def _update_published_btns_state(self): enabled = ( self._valid_representation_id - and self._valid_selected_context and self._is_save_enabled ) - self._published_btn_copy_n_open.setEnabled(enabled) + self._published_btn_copy_n_open.setEnabled( + enabled and self._valid_selected_context + ) self._published_btn_change_context.setEnabled(enabled) def _update_workarea_btns_state(self): From 8faac875a491a113471464e0957941245a883134 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 13 Aug 2025 10:15:20 +0200 Subject: [PATCH 283/312] fix group checkbox functionality --- .../ayon_core/tools/publisher/widgets/list_view_widgets.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 89ed60a076..c54f9b94b0 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -1097,8 +1097,10 @@ class InstanceListView(AbstractInstanceView): self, new_value: Optional[bool], active_id: Optional[str] = None, + instance_ids: Optional[set[str]] = None, ) -> None: - instance_ids, _, _ = self.get_selected_items() + if instance_ids is None: + instance_ids, _, _ = self.get_selected_items() if active_id and active_id not in instance_ids: instance_ids = {active_id} @@ -1163,7 +1165,7 @@ class InstanceListView(AbstractInstanceView): instance_id = child.data(INSTANCE_ID_ROLE) instance_ids.add(instance_id) - self._toggle_active_state(active) + self._toggle_active_state(active, instance_ids=instance_ids) proxy_index = self._proxy_model.mapFromSource(group_item.index()) if not self._instance_view.isExpanded(proxy_index): From b66f4fe325f5d1fee9df4ce75fab176cab7fa4a8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 13 Aug 2025 11:47:12 +0200 Subject: [PATCH 284/312] emit event only if active actually changed --- client/ayon_core/tools/publisher/models/create.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 0b0d287448..5098826b8b 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -583,15 +583,21 @@ class CreateModel: def set_instances_active_state( self, active_state_by_id: Dict[str, bool] ): + changed_ids = set() with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): for instance_id, active in active_state_by_id.items(): instance = self._create_context.get_instance_by_id(instance_id) - instance["active"] = active + if instance["active"] is not active: + instance["active"] = active + changed_ids.add(instance_id) + + if not changed_ids: + return self._emit_event( "create.model.instances.context.changed", { - "instance_ids": set(active_state_by_id.keys()) + "instance_ids": changed_ids } ) From 822182f21a434465302961d8a20d41078d1668db Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 13 Aug 2025 11:47:32 +0200 Subject: [PATCH 285/312] fix parent active issue --- .../tools/publisher/widgets/card_view_widgets.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 3c8a99b2c9..1491cdf7ec 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -583,13 +583,16 @@ class InstanceCardView(AbstractInstanceView): instance_ids.discard(instance_id) discarted_ids.add(instance_id) add_children = True + if is_parent_active is not widget.is_parent_active(): + add_children = True + widget.set_parent_active(is_parent_active) + + old_value = widget.is_active() value = new_value if value is None: - value = not widget.is_active() - old_value = widget.is_active() + value = not old_value widget.set_active(value) - if old_value is not widget.is_active(): - active_by_id[instance_id] = value + active_by_id[instance_id] = widget.is_active() if ( instance_id in instance_ids From 2d97cc9a29b5d28fdf6efadc0108690192693c63 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 13 Aug 2025 11:48:28 +0200 Subject: [PATCH 286/312] don't re-using the same view --- .../publisher/widgets/overview_widget.py | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index cb7e2b39cf..01799ac908 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -48,10 +48,16 @@ class OverviewWidget(QtWidgets.QFrame): product_view_cards = InstanceCardView(controller, product_views_widget) product_list_view = InstanceListView(controller, product_views_widget) + product_list_view.set_parent_grouping(False) + product_list_view_grouped = InstanceListView( + controller, product_views_widget + ) + product_list_view_grouped.set_parent_grouping(True) product_views_layout = QtWidgets.QStackedLayout() product_views_layout.addWidget(product_view_cards) product_views_layout.addWidget(product_list_view) + product_views_layout.addWidget(product_list_view_grouped) product_views_layout.setCurrentWidget(product_view_cards) # Buttons at the bottom of product view @@ -123,6 +129,12 @@ class OverviewWidget(QtWidgets.QFrame): product_list_view.double_clicked.connect( self.publish_tab_requested ) + product_list_view_grouped.selection_changed.connect( + self._on_product_change + ) + product_list_view_grouped.double_clicked.connect( + self.publish_tab_requested + ) product_view_cards.selection_changed.connect( self._on_product_change ) @@ -174,6 +186,7 @@ class OverviewWidget(QtWidgets.QFrame): self._product_view_cards = product_view_cards self._product_list_view = product_list_view + self._product_list_view_grouped = product_list_view_grouped self._product_views_layout = product_views_layout self._create_btn = create_btn @@ -412,26 +425,12 @@ class OverviewWidget(QtWidgets.QFrame): def _change_view_type(self): old_view = self._get_current_view() - if ( - isinstance(old_view, InstanceListView) - and not old_view.parent_grouping_enabled() - ): - self._change_view_btn.set_view_type("card") - old_view.set_parent_grouping(True) - old_view.refresh() - old_view.set_refreshed(True) - return idx = self._product_views_layout.currentIndex() new_idx = (idx + 1) % self._product_views_layout.count() new_view = self._get_view_by_idx(new_idx) - if isinstance(new_view, InstanceListView): - new_view.set_parent_grouping(False) - new_view.refresh() - new_view.set_refreshed(True) - - elif not new_view.refreshed: + if not new_view.refreshed: new_view.refresh() new_view.set_refreshed(True) else: @@ -443,12 +442,13 @@ class OverviewWidget(QtWidgets.QFrame): new_view.set_selected_items( instance_ids, context_selected, convertor_identifiers ) + view_type = "list" + if new_view is self._product_list_view_grouped: + view_type = "card" + elif new_view is self._product_list_view: + view_type = "list-parent-grouping" - self._change_view_btn.set_view_type( - "list" - if isinstance(new_view, InstanceCardView) - else "list-parent-grouping" - ) + self._change_view_btn.set_view_type(view_type) self._product_views_layout.setCurrentIndex(new_idx) self._on_product_change() From e6522e4d4e80c7d00fca0994451dbf4414d45b2f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 13 Aug 2025 11:49:17 +0200 Subject: [PATCH 287/312] make sure parent is active is always checked --- .../ayon_core/tools/publisher/widgets/list_view_widgets.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index c54f9b94b0..9ea0f85bcb 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -1060,12 +1060,14 @@ class InstanceListView(AbstractInstanceView): context_info_by_id[instance_id], parent_active, ) - else: - widget.set_active(parent_active) instance_ids.discard(instance_id) discarted_ids.add(instance_id) + if parent_active is not widget.is_parent_active(): + widget.set_parent_is_active(parent_active) + add_children = True + if not add_children: continue From 10ebfa6d8e3865b6ca0a4b3ece5c3674a9317a9d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 13 Aug 2025 11:49:28 +0200 Subject: [PATCH 288/312] few enhancements --- .../tools/publisher/widgets/list_view_widgets.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 9ea0f85bcb..86df4223a4 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -1046,10 +1046,9 @@ class InstanceListView(AbstractInstanceView): children_ids, parent_active = _queue.popleft() for instance_id in children_ids: widget = self._widgets_by_id[instance_id] - # Add children ids to 'instance_ids' to traverse them too - add_children = False + # Parent active state changed -> traverse children too + add_children = False if instance_id in instance_ids: - # Parent active state changed -> traverse children too add_children = ( parent_active is not widget.is_parent_active() ) @@ -1069,16 +1068,11 @@ class InstanceListView(AbstractInstanceView): add_children = True if not add_children: + if not instance_ids: + break continue - _children = { - child_id - for child_id in ( - self._instance_ids_by_parent_id[instance_id] - ) - if child_id not in discarted_ids - } - + _children = set(self._instance_ids_by_parent_id[instance_id]) if _children: instance_ids |= _children _queue.append((_children, widget.is_active())) From 277489b4252464230ff2574f8287f334149fa03b Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 13 Aug 2025 12:19:27 +0000 Subject: [PATCH 289/312] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 9f1bac6805..11cbfa61b5 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.5.2+dev" +__version__ = "1.5.3" diff --git a/package.py b/package.py index 7bd806159f..012bbd081c 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.5.2+dev" +version = "1.5.3" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index e67fcc2138..91748f801b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.5.2+dev" +version = "1.5.3" description = "" authors = ["Ynput Team "] readme = "README.md" From c8f802b210026bb0b47c85efea4fe1d23c516835 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 13 Aug 2025 12:20:11 +0000 Subject: [PATCH 290/312] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 11cbfa61b5..f2aa94020f 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.5.3" +__version__ = "1.5.3+dev" diff --git a/package.py b/package.py index 012bbd081c..07a1246c9f 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.5.3" +version = "1.5.3+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 91748f801b..ee6c35b50b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.5.3" +version = "1.5.3+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 3ef40e203bafbd229cd21417b604a939fc1cd9fb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 Aug 2025 12:21:07 +0000 Subject: [PATCH 291/312] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 933448a6a9..ce5982969c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.5.3 - 1.5.2 - 1.5.1 - 1.5.0 From 56ebe87bcb298be9507ca027248c882a6bb0ffe8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 13 Aug 2025 14:46:09 +0200 Subject: [PATCH 292/312] fix card view changes --- .../publisher/widgets/card_view_widgets.py | 102 ++++++++++-------- 1 file changed, 55 insertions(+), 47 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 1491cdf7ec..24daae151a 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -353,7 +353,7 @@ class InstanceCardWidget(CardWidget): if not self.is_checkbox_enabled(): return if active is None: - active = not self.is_active() + active = not self._is_active self._set_checked(active) def is_parent_active(self) -> bool: @@ -453,6 +453,8 @@ class InstanceCardWidget(CardWidget): self._detail_widget.setVisible(expanded) def _on_active_change(self): + if not self.is_checkbox_enabled(): + return new_value = self._active_checkbox.isChecked() old_value = self._is_active if new_value is old_value: @@ -525,6 +527,7 @@ class InstanceCardView(AbstractInstanceView): self._widgets_by_id: dict[str, InstanceCardWidget] = {} self._widgets_by_group: dict[str, BaseGroupWidget] = {} + self._parent_id_by_id = {} self._instance_ids_by_parent_id = collections.defaultdict(set) self._explicitly_selected_instance_ids = [] @@ -552,6 +555,26 @@ class InstanceCardView(AbstractInstanceView): """How many instances are currently in the view.""" return len(self._widgets_by_id) + def _get_affected_ids(self, instance_ids: set[str]) -> set[str]: + affected_ids = set() + affected_queue = collections.deque() + affected_queue.extend(instance_ids) + while affected_queue: + instance_id = affected_queue.popleft() + if instance_id in affected_ids: + continue + affected_ids.add(instance_id) + parent_id = instance_id + while True: + parent_id = self._parent_id_by_id[parent_id] + if parent_id is None: + break + affected_ids.add(parent_id) + + child_ids = set(self._instance_ids_by_parent_id[instance_id]) + affected_queue.extend(child_ids - affected_ids) + return affected_ids + def _toggle_instances( self, new_value: Optional[bool], @@ -566,7 +589,10 @@ class InstanceCardView(AbstractInstanceView): if active_id and active_id not in instance_ids: instance_ids = {active_id} - affected_ids = set(instance_ids) + ids_to_toggle = set(instance_ids) + + affected_ids = self._get_affected_ids(instance_ids) + _queue = collections.deque() _queue.append((set(self._instance_ids_by_parent_id[None]), True)) discarted_ids = set() @@ -576,36 +602,24 @@ class InstanceCardView(AbstractInstanceView): chilren_ids, is_parent_active = _queue.pop() for instance_id in chilren_ids: - widget = self._widgets_by_id[instance_id] - add_children = False - if instance_id in affected_ids: - affected_ids.discard(instance_id) - instance_ids.discard(instance_id) - discarted_ids.add(instance_id) - add_children = True - if is_parent_active is not widget.is_parent_active(): - add_children = True - widget.set_parent_active(is_parent_active) + if instance_id not in affected_ids: + continue + widget = self._widgets_by_id[instance_id] + if is_parent_active is not widget.is_parent_active(): + widget.set_parent_active(is_parent_active) + + instance_ids.discard(instance_id) + if instance_id in ids_to_toggle: + discarted_ids.add(instance_id) old_value = widget.is_active() value = new_value if value is None: value = not old_value + widget.set_active(value) - active_by_id[instance_id] = widget.is_active() - - if ( - instance_id in instance_ids - and is_parent_active is not widget.is_parent_active() - ): - add_children = True - widget.set_parent_active(is_parent_active) - - instance_ids.discard(instance_id) - discarted_ids.add(instance_id) - - if not add_children: - continue + if widget.is_parent_active(): + active_by_id[instance_id] = widget.is_active() children_ids = self._instance_ids_by_parent_id[instance_id] children = { @@ -621,7 +635,8 @@ class InstanceCardView(AbstractInstanceView): if not instance_ids: break - self._controller.set_instances_active_state(active_by_id) + if active_by_id: + self._controller.set_instances_active_state(active_by_id) def keyPressEvent(self, event): if event.key() == QtCore.Qt.Key_Space: @@ -699,6 +714,7 @@ class InstanceCardView(AbstractInstanceView): identifiers_by_group = collections.defaultdict(set) identifiers: set[str] = set() instances_by_id = {} + parent_id_by_id = {} instance_ids_by_parent_id = collections.defaultdict(set) instance_items = self._controller.get_instance_items() for instance in instance_items: @@ -712,6 +728,7 @@ class InstanceCardView(AbstractInstanceView): instance_ids_by_parent_id[instance.parent_instance_id].add( instance.id ) + parent_id_by_id[instance.id] = instance.parent_instance_id parent_active_by_id = { instance_id: False @@ -797,6 +814,7 @@ class InstanceCardView(AbstractInstanceView): widget.setVisible(False) widget.deleteLater() + self._parent_id_by_id = parent_id_by_id self._instance_ids_by_parent_id = instance_ids_by_parent_id self._group_name_by_instance_id = group_by_instance_id self._instance_ids_by_group_name = instance_ids_by_group_name @@ -961,22 +979,23 @@ class InstanceCardView(AbstractInstanceView): ) instance_ids: set[str] = set(instance_items_by_id) available_ids: set[str] = set(instance_items_by_id) - discarted_ids: set[str] = set() + + affected_ids = self._get_affected_ids(instance_ids) _queue = collections.deque() _queue.append((set(self._instance_ids_by_parent_id[None]), True)) while _queue: - if not instance_ids: + if not affected_ids: break chilren_ids, is_parent_active = _queue.pop() for instance_id in chilren_ids: + if instance_id not in affected_ids: + continue + affected_ids.discard(instance_id) widget = self._widgets_by_id[instance_id] - add_children = False if instance_id in instance_ids: - add_children = ( - is_parent_active is not widget.is_parent_active() - ) + instance_ids.discard(instance_id) if instance_id in available_ids: available_ids.discard(instance_id) widget.update_instance( @@ -987,25 +1006,14 @@ class InstanceCardView(AbstractInstanceView): else: widget.set_parent_active(is_parent_active) - instance_ids.discard(instance_id) - discarted_ids.add(instance_id) - - if not add_children: - continue - - children_ids = self._instance_ids_by_parent_id[instance_id] - children = { - child_id - for child_id in children_ids - if child_id not in discarted_ids - } + if not affected_ids: + break + children = set(self._instance_ids_by_parent_id[instance_id]) if children: instance_ids |= children _queue.append((children, widget.is_active())) - if not instance_ids: - break def _on_active_changed(self, instance_id: str, value: bool) -> None: self._toggle_instances(value, instance_id) From ef3cf62a41779ecaac5d132c02d491e1fad2dab6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 13 Aug 2025 15:15:18 +0200 Subject: [PATCH 293/312] fix list view refresh --- .../publisher/widgets/list_view_widgets.py | 124 ++++++++---------- 1 file changed, 56 insertions(+), 68 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 86df4223a4..cd1a1dbb9a 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -621,6 +621,7 @@ class InstanceListView(AbstractInstanceView): orphans_item, )) + items_with_instance = {} # Process changes in each group item # - create new instance, update existing and remove not existing for group_widget, group_instances, group_item in group_items: @@ -633,26 +634,12 @@ class InstanceListView(AbstractInstanceView): # - 'None' is used if parent is group item new_items = collections.defaultdict(list) # Tuples of model item and instance itself - items_with_instance = [] - # Group activity (should be {-1;0;1} at the end) - # - 0 when all instances are disabled - # - 1 when all instances are enabled - # - -1 when it's mixed - activity = None for instance in group_instances: _queue = collections.deque() _queue.append((instance, group_item, None)) while _queue: instance, parent_item, parent_id = _queue.popleft() instance_id = instance.id - # Handle group activity - if activity is None: - activity = int(instance.is_active) - elif activity == -1: - pass - elif activity != instance.is_active: - activity = -1 - # Remove group name from groups mapping if parent_id is not None: self._group_by_instance_id.pop(instance_id, None) @@ -673,13 +660,10 @@ class InstanceListView(AbstractInstanceView): self._parent_id_by_id[instance_id] = parent_id - items_with_instance.append( - ( - item, - instance, - parent_id, - is_orpaned_item, - ) + items_with_instance[instance.id] = ( + item, + instance, + is_orpaned_item, ) item.setData(instance.product_name, SORT_VALUE_ROLE) @@ -692,15 +676,6 @@ class InstanceListView(AbstractInstanceView): for child in children: _queue.append((child, item, instance_id)) - # Set checkstate of group checkbox - if group_widget is not None: - state = QtCore.Qt.PartiallyChecked - if activity == 0: - state = QtCore.Qt.Unchecked - elif activity == 1: - state = QtCore.Qt.Checked - group_widget.set_checkstate(state) - # Process new instance items and add them to model and create # their widgets if new_items: @@ -716,48 +691,57 @@ class InstanceListView(AbstractInstanceView): parent_item.appendRows(items) - for ( - item, instance, parent_id, is_orpaned_item - ) in items_with_instance: - context_info = context_info_by_id[instance.id] - # TODO expand all parents - if not context_info.is_valid: - expand_to_items.append(item) + ids_order = [] + ids_queue = collections.deque() + ids_queue.extend(instance_ids_by_parent_id[None]) + while ids_queue: + parent_id = ids_queue.popleft() + ids_order.append(parent_id) + ids_queue.extend(instance_ids_by_parent_id[parent_id]) + ids_order.extend(set(items_with_instance) - set(ids_order)) - parent_active = True - if is_orpaned_item: - parent_active = False + for instance_id in ids_order: + item, instance, is_orpaned_item = items_with_instance[instance_id] + context_info = context_info_by_id[instance.id] + # TODO expand all parents + if not context_info.is_valid: + expand_to_items.append(item) - if parent_id: - parent_widget = widgets_by_id.get(parent_id) - parent_active = False - if parent_widget is not None: - parent_active = parent_widget.is_active() - item_index = self._instance_model.indexFromItem(item) - proxy_index = self._proxy_model.mapFromSource(item_index) - widget = self._instance_view.indexWidget(proxy_index) - if isinstance(widget, InstanceListItemWidget): - widget.update_instance( - instance, - context_info, - parent_active, - ) - else: - widget = InstanceListItemWidget( - instance, - context_info, - parent_active, - self._instance_view - ) - widget.active_changed.connect(self._on_active_changed) - widget.double_clicked.connect(self.double_clicked) - self._instance_view.setIndexWidget(proxy_index, widget) - widget.set_active_toggle_enabled( - self._active_toggle_enabled + parent_active = True + if is_orpaned_item: + parent_active = False + + parent_id = instance.parent_instance_id + if parent_id: + parent_widget = widgets_by_id.get(parent_id) + parent_active = False + if parent_widget is not None: + parent_active = parent_widget.is_active() + item_index = self._instance_model.indexFromItem(item) + proxy_index = self._proxy_model.mapFromSource(item_index) + widget = self._instance_view.indexWidget(proxy_index) + if isinstance(widget, InstanceListItemWidget): + widget.update_instance( + instance, + context_info, + parent_active, ) + else: + widget = InstanceListItemWidget( + instance, + context_info, + parent_active, + self._instance_view + ) + widget.active_changed.connect(self._on_active_changed) + widget.double_clicked.connect(self.double_clicked) + self._instance_view.setIndexWidget(proxy_index, widget) + widget.set_active_toggle_enabled( + self._active_toggle_enabled + ) - widgets_by_id[instance.id] = widget - self._widgets_by_id.pop(instance.id, None) + widgets_by_id[instance.id] = widget + self._widgets_by_id.pop(instance.id, None) for widget in self._widgets_by_id.values(): widget.setVisible(False) @@ -766,6 +750,10 @@ class InstanceListView(AbstractInstanceView): self._widgets_by_id = widgets_by_id self._instance_ids_by_parent_id = instance_ids_by_parent_id + # Set checkstate of group checkbox + for group_name in self._group_items: + self._update_group_checkstate(group_name) + # Expand items marked for expanding items_to_expand = [] _marked_ids = set() From 152e32ac323f376a5fe04b8eaed2a3f1b132506f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 13 Aug 2025 15:20:32 +0200 Subject: [PATCH 294/312] formatting fixes --- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 1 - client/ayon_core/tools/publisher/widgets/list_view_widgets.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 24daae151a..84786a671e 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -1014,7 +1014,6 @@ class InstanceCardView(AbstractInstanceView): instance_ids |= children _queue.append((children, widget.is_active())) - def _on_active_changed(self, instance_id: str, value: bool) -> None: self._toggle_instances(value, instance_id) diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index cd1a1dbb9a..c524b96d5f 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -1035,7 +1035,7 @@ class InstanceListView(AbstractInstanceView): for instance_id in children_ids: widget = self._widgets_by_id[instance_id] # Parent active state changed -> traverse children too - add_children = False + add_children = False if instance_id in instance_ids: add_children = ( parent_active is not widget.is_parent_active() From 4951c9442a86bc23bd8ffa40618fc9c9676a8c49 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Aug 2025 16:45:22 +0200 Subject: [PATCH 295/312] small enhancements in nice checkbox --- client/ayon_core/tools/utils/nice_checkbox.py | 200 +++++++++--------- 1 file changed, 100 insertions(+), 100 deletions(-) diff --git a/client/ayon_core/tools/utils/nice_checkbox.py b/client/ayon_core/tools/utils/nice_checkbox.py index 3d9d63b6bc..d1cc8d16f5 100644 --- a/client/ayon_core/tools/utils/nice_checkbox.py +++ b/client/ayon_core/tools/utils/nice_checkbox.py @@ -1,7 +1,8 @@ -from math import floor, sqrt, ceil +from math import floor, ceil + from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.style import get_objected_colors +from ayon_core.style import load_stylesheet, get_objected_colors class NiceCheckbox(QtWidgets.QFrame): @@ -9,12 +10,15 @@ class NiceCheckbox(QtWidgets.QFrame): clicked = QtCore.Signal() _checked_bg_color = None + _checked_bg_color_disabled = None _unchecked_bg_color = None + _unchecked_bg_color_disabled = None _checker_color = None + _checker_color_disabled = None _checker_hover_color = None def __init__(self, checked=False, draw_icons=False, parent=None): - super(NiceCheckbox, self).__init__(parent) + super().__init__(parent) self.setObjectName("NiceCheckbox") self.setAttribute(QtCore.Qt.WA_TranslucentBackground) @@ -48,8 +52,6 @@ class NiceCheckbox(QtWidgets.QFrame): self._pressed = False self._under_mouse = False - self.icon_scale_factor = sqrt(2) / 2 - icon_path_stroker = QtGui.QPainterPathStroker() icon_path_stroker.setCapStyle(QtCore.Qt.RoundCap) icon_path_stroker.setJoinStyle(QtCore.Qt.RoundJoin) @@ -61,35 +63,6 @@ class NiceCheckbox(QtWidgets.QFrame): self._base_size = QtCore.QSize(90, 50) self._load_colors() - @classmethod - def _load_colors(cls): - if cls._checked_bg_color is not None: - return - - colors_info = get_objected_colors("nice-checkbox") - - cls._checked_bg_color = colors_info["bg-checked"].get_qcolor() - cls._unchecked_bg_color = colors_info["bg-unchecked"].get_qcolor() - - cls._checker_color = colors_info["bg-checker"].get_qcolor() - cls._checker_hover_color = colors_info["bg-checker-hover"].get_qcolor() - - @property - def checked_bg_color(self): - return self._checked_bg_color - - @property - def unchecked_bg_color(self): - return self._unchecked_bg_color - - @property - def checker_color(self): - return self._checker_color - - @property - def checker_hover_color(self): - return self._checker_hover_color - def setTristate(self, tristate=True): if self._is_tristate != tristate: self._is_tristate = tristate @@ -121,14 +94,14 @@ class NiceCheckbox(QtWidgets.QFrame): def setFixedHeight(self, *args, **kwargs): self._fixed_height_set = True - super(NiceCheckbox, self).setFixedHeight(*args, **kwargs) + super().setFixedHeight(*args, **kwargs) if not self._fixed_width_set: width = self.get_width_hint_by_height(self.height()) self.setFixedWidth(width) def setFixedWidth(self, *args, **kwargs): self._fixed_width_set = True - super(NiceCheckbox, self).setFixedWidth(*args, **kwargs) + super().setFixedWidth(*args, **kwargs) if not self._fixed_height_set: height = self.get_height_hint_by_width(self.width()) self.setFixedHeight(height) @@ -136,7 +109,7 @@ class NiceCheckbox(QtWidgets.QFrame): def setFixedSize(self, *args, **kwargs): self._fixed_height_set = True self._fixed_width_set = True - super(NiceCheckbox, self).setFixedSize(*args, **kwargs) + super().setFixedSize(*args, **kwargs) def steps(self): return self._steps @@ -242,7 +215,7 @@ class NiceCheckbox(QtWidgets.QFrame): if event.buttons() & QtCore.Qt.LeftButton: self._pressed = True self.repaint() - super(NiceCheckbox, self).mousePressEvent(event) + super().mousePressEvent(event) def mouseReleaseEvent(self, event): if self._pressed and not event.buttons() & QtCore.Qt.LeftButton: @@ -252,7 +225,7 @@ class NiceCheckbox(QtWidgets.QFrame): self.clicked.emit() event.accept() return - super(NiceCheckbox, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) def mouseMoveEvent(self, event): if self._pressed: @@ -261,19 +234,19 @@ class NiceCheckbox(QtWidgets.QFrame): self._under_mouse = under_mouse self.repaint() - super(NiceCheckbox, self).mouseMoveEvent(event) + super().mouseMoveEvent(event) def enterEvent(self, event): self._under_mouse = True if self.isEnabled(): self.repaint() - super(NiceCheckbox, self).enterEvent(event) + super().enterEvent(event) def leaveEvent(self, event): self._under_mouse = False if self.isEnabled(): self.repaint() - super(NiceCheckbox, self).leaveEvent(event) + super().leaveEvent(event) def _on_animation_timeout(self): if self._checkstate == QtCore.Qt.Checked: @@ -302,24 +275,13 @@ class NiceCheckbox(QtWidgets.QFrame): @staticmethod def steped_color(color1, color2, offset_ratio): - red_dif = ( - color1.red() - color2.red() - ) - green_dif = ( - color1.green() - color2.green() - ) - blue_dif = ( - color1.blue() - color2.blue() - ) - red = int(color2.red() + ( - red_dif * offset_ratio - )) - green = int(color2.green() + ( - green_dif * offset_ratio - )) - blue = int(color2.blue() + ( - blue_dif * offset_ratio - )) + red_dif = color1.red() - color2.red() + green_dif = color1.green() - color2.green() + blue_dif = color1.blue() - color2.blue() + + red = int(color2.red() + (red_dif * offset_ratio)) + green = int(color2.green() + (green_dif * offset_ratio)) + blue = int(color2.blue() + (blue_dif * offset_ratio)) return QtGui.QColor(red, green, blue) @@ -334,20 +296,28 @@ class NiceCheckbox(QtWidgets.QFrame): painter = QtGui.QPainter(self) painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.setPen(QtCore.Qt.NoPen) # Draw inner background - if self._current_step == self._steps: - bg_color = self.checked_bg_color + if not self.isEnabled(): + bg_color = ( + self._checked_bg_color_disabled + if self._current_step == self._steps + else self._unchecked_bg_color_disabled + ) + + elif self._current_step == self._steps: + bg_color = self._checked_bg_color elif self._current_step == 0: - bg_color = self.unchecked_bg_color + bg_color = self._unchecked_bg_color else: offset_ratio = float(self._current_step) / self._steps # Animation bg bg_color = self.steped_color( - self.checked_bg_color, - self.unchecked_bg_color, + self._checked_bg_color, + self._unchecked_bg_color, offset_ratio ) @@ -378,14 +348,20 @@ class NiceCheckbox(QtWidgets.QFrame): -margin_size_c, -margin_size_c ) - if checkbox_rect.width() > checkbox_rect.height(): - radius = floor(checkbox_rect.height() * 0.5) - else: - radius = floor(checkbox_rect.width() * 0.5) + slider_rect = QtCore.QRect(checkbox_rect) + slider_offset = int( + ceil(min(slider_rect.width(), slider_rect.height())) * 0.08 + ) + if slider_offset < 1: + slider_offset = 1 + slider_rect.adjust( + slider_offset, slider_offset, + -slider_offset, -slider_offset + ) + radius = floor(min(slider_rect.width(), slider_rect.height()) * 0.5) - painter.setPen(QtCore.Qt.NoPen) painter.setBrush(bg_color) - painter.drawRoundedRect(checkbox_rect, radius, radius) + painter.drawRoundedRect(slider_rect, radius, radius) # Draw checker checker_size = size_without_margins - (margin_size_c * 2) @@ -394,9 +370,8 @@ class NiceCheckbox(QtWidgets.QFrame): - (margin_size_c * 2) - checker_size ) - if self._current_step == 0: - x_offset = 0 - else: + x_offset = 0 + if self._current_step != 0: x_offset = (float(area_width) / self._steps) * self._current_step pos_x = checkbox_rect.x() + x_offset + margin_size_c @@ -404,55 +379,80 @@ class NiceCheckbox(QtWidgets.QFrame): checker_rect = QtCore.QRect(pos_x, pos_y, checker_size, checker_size) - under_mouse = self.isEnabled() and self._under_mouse - if under_mouse: - checker_color = self.checker_hover_color - else: - checker_color = self.checker_color + checker_color = self._checker_color + if not self.isEnabled(): + checker_color = self._checker_color_disabled + elif self._under_mouse: + checker_color = self._checker_hover_color painter.setBrush(checker_color) painter.drawEllipse(checker_rect) if self._draw_icons: painter.setBrush(bg_color) - icon_path = self._get_icon_path(painter, checker_rect) + icon_path = self._get_icon_path(checker_rect) painter.drawPath(icon_path) - # Draw shadow overlay - if not self.isEnabled(): - level = 33 - alpha = 127 - painter.setPen(QtCore.Qt.transparent) - painter.setBrush(QtGui.QColor(level, level, level, alpha)) - painter.drawRoundedRect(checkbox_rect, radius, radius) - painter.end() - def _get_icon_path(self, painter, checker_rect): + @classmethod + def _load_colors(cls): + if cls._checked_bg_color is not None: + return + + colors_info = get_objected_colors("nice-checkbox") + + disabled_color = QtGui.QColor(33, 33, 33, 127) + + cls._checked_bg_color = colors_info["bg-checked"].get_qcolor() + cls._checked_bg_color_disabled = cls._merge_colors( + cls._checked_bg_color, disabled_color + ) + cls._unchecked_bg_color = colors_info["bg-unchecked"].get_qcolor() + cls._unchecked_bg_color_disabled = cls._merge_colors( + cls._unchecked_bg_color, disabled_color + ) + + cls._checker_color = colors_info["bg-checker"].get_qcolor() + cls._checker_color_disabled = cls._merge_colors( + cls._checker_color, disabled_color + ) + cls._checker_hover_color = colors_info["bg-checker-hover"].get_qcolor() + + @staticmethod + def _merge_colors(color_1, color_2): + a = color_2.alphaF() + return QtGui.QColor( + floor((color_1.red() + (color_2.red() * a)) * 0.5), + floor((color_1.green() + (color_2.green() * a)) * 0.5), + floor((color_1.blue() + (color_2.blue() * a)) * 0.5), + color_1.alpha() + ) + + def _get_icon_path(self, checker_rect): self.icon_path_stroker.setWidth(checker_rect.height() / 5) if self._current_step == self._steps: - return self._get_enabled_icon_path(painter, checker_rect) + return self._get_enabled_icon_path(checker_rect) if self._current_step == 0: - return self._get_disabled_icon_path(painter, checker_rect) + return self._get_disabled_icon_path(checker_rect) if self._current_step == self._middle_step: - return self._get_middle_circle_path(painter, checker_rect) + return self._get_middle_circle_path(checker_rect) disabled_step = self._steps - self._current_step enabled_step = self._steps - disabled_step half_steps = self._steps + 1 - ((self._steps + 1) % 2) if enabled_step > disabled_step: return self._get_enabled_icon_path( - painter, checker_rect, enabled_step, half_steps - ) - else: - return self._get_disabled_icon_path( - painter, checker_rect, disabled_step, half_steps + checker_rect, enabled_step, half_steps ) + return self._get_disabled_icon_path( + checker_rect, disabled_step, half_steps + ) - def _get_middle_circle_path(self, painter, checker_rect): + def _get_middle_circle_path(self, checker_rect): width = self.icon_path_stroker.width() path = QtGui.QPainterPath() path.addEllipse(checker_rect.center(), width, width) @@ -460,7 +460,7 @@ class NiceCheckbox(QtWidgets.QFrame): return path def _get_enabled_icon_path( - self, painter, checker_rect, step=None, half_steps=None + self, checker_rect, step=None, half_steps=None ): fifteenth = float(checker_rect.height()) / 15 # Left point @@ -509,7 +509,7 @@ class NiceCheckbox(QtWidgets.QFrame): return self.icon_path_stroker.createStroke(path) def _get_disabled_icon_path( - self, painter, checker_rect, step=None, half_steps=None + self, checker_rect, step=None, half_steps=None ): center_point = QtCore.QPointF( float(checker_rect.width()) / 2, From f4855402cf82e44554df9d471d82366c2637a2eb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Aug 2025 16:50:38 +0200 Subject: [PATCH 296/312] remove unused import --- client/ayon_core/tools/utils/nice_checkbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/utils/nice_checkbox.py b/client/ayon_core/tools/utils/nice_checkbox.py index d1cc8d16f5..f16b62eb9c 100644 --- a/client/ayon_core/tools/utils/nice_checkbox.py +++ b/client/ayon_core/tools/utils/nice_checkbox.py @@ -2,7 +2,7 @@ from math import floor, ceil from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.style import load_stylesheet, get_objected_colors +from ayon_core.style import get_objected_colors class NiceCheckbox(QtWidgets.QFrame): From 40e8384b1cb33c0771e62eba7b0dedaac9e28e94 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Aug 2025 16:50:44 +0200 Subject: [PATCH 297/312] formatting fix --- client/ayon_core/tools/utils/nice_checkbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/utils/nice_checkbox.py b/client/ayon_core/tools/utils/nice_checkbox.py index f16b62eb9c..c33533b0e4 100644 --- a/client/ayon_core/tools/utils/nice_checkbox.py +++ b/client/ayon_core/tools/utils/nice_checkbox.py @@ -358,7 +358,7 @@ class NiceCheckbox(QtWidgets.QFrame): slider_offset, slider_offset, -slider_offset, -slider_offset ) - radius = floor(min(slider_rect.width(), slider_rect.height()) * 0.5) + radius = floor(min(slider_rect.width(), slider_rect.height()) * 0.5) painter.setBrush(bg_color) painter.drawRoundedRect(slider_rect, radius, radius) From 65672ccafdd362be171bd48c01c01a8349ac2089 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Aug 2025 17:15:07 +0200 Subject: [PATCH 298/312] removed legacy create tool --- client/ayon_core/tools/creator/model.py | 2 - client/ayon_core/tools/creator/window.py | 1 - client/ayon_core/tools/utils/host_tools.py | 60 +++++----------------- 3 files changed, 14 insertions(+), 49 deletions(-) diff --git a/client/ayon_core/tools/creator/model.py b/client/ayon_core/tools/creator/model.py index bf6c7380a1..16d24cc8bc 100644 --- a/client/ayon_core/tools/creator/model.py +++ b/client/ayon_core/tools/creator/model.py @@ -1,8 +1,6 @@ import uuid from qtpy import QtGui, QtCore -from ayon_core.pipeline import discover_legacy_creator_plugins - from . constants import ( PRODUCT_TYPE_ROLE, ITEM_ID_ROLE diff --git a/client/ayon_core/tools/creator/window.py b/client/ayon_core/tools/creator/window.py index 5d1c0a272a..fe8ee86dcf 100644 --- a/client/ayon_core/tools/creator/window.py +++ b/client/ayon_core/tools/creator/window.py @@ -15,7 +15,6 @@ from ayon_core.pipeline import ( ) from ayon_core.pipeline.create import ( PRODUCT_NAME_ALLOWED_SYMBOLS, - legacy_create, CreatorError, ) diff --git a/client/ayon_core/tools/utils/host_tools.py b/client/ayon_core/tools/utils/host_tools.py index 3d356555f3..bfd008925b 100644 --- a/client/ayon_core/tools/utils/host_tools.py +++ b/client/ayon_core/tools/utils/host_tools.py @@ -31,7 +31,6 @@ class HostToolsHelper: # Prepare attributes for all tools self._workfiles_tool = None self._loader_tool = None - self._creator_tool = None self._publisher_tool = None self._subset_manager_tool = None self._scene_inventory_tool = None @@ -96,27 +95,6 @@ class HostToolsHelper: loader_tool.refresh() - def get_creator_tool(self, parent): - """Create, cache and return creator tool window.""" - if self._creator_tool is None: - from ayon_core.tools.creator import CreatorWindow - - creator_window = CreatorWindow(parent=parent or self._parent) - self._creator_tool = creator_window - - return self._creator_tool - - def show_creator(self, parent=None): - """Show tool to create new instantes for publishing.""" - with qt_app_context(): - creator_tool = self.get_creator_tool(parent) - creator_tool.refresh() - creator_tool.show() - - # Pull window to the front. - creator_tool.raise_() - creator_tool.activateWindow() - def get_subset_manager_tool(self, parent): """Create, cache and return subset manager tool window.""" if self._subset_manager_tool is None: @@ -261,35 +239,32 @@ class HostToolsHelper: if tool_name == "workfiles": return self.get_workfiles_tool(parent, *args, **kwargs) - elif tool_name == "loader": + if tool_name == "loader": return self.get_loader_tool(parent, *args, **kwargs) - elif tool_name == "libraryloader": + if tool_name == "libraryloader": return self.get_library_loader_tool(parent, *args, **kwargs) - elif tool_name == "creator": - return self.get_creator_tool(parent, *args, **kwargs) - - elif tool_name == "subsetmanager": + if tool_name == "subsetmanager": return self.get_subset_manager_tool(parent, *args, **kwargs) - elif tool_name == "sceneinventory": + if tool_name == "sceneinventory": return self.get_scene_inventory_tool(parent, *args, **kwargs) - elif tool_name == "publish": - self.log.info("Can't return publish tool window.") - - # "new" publisher - elif tool_name == "publisher": + if tool_name == "publisher": return self.get_publisher_tool(parent, *args, **kwargs) - elif tool_name == "experimental_tools": + if tool_name == "experimental_tools": return self.get_experimental_tools_dialog(parent, *args, **kwargs) - else: - self.log.warning( - "Can't show unknown tool name: \"{}\"".format(tool_name) - ) + if tool_name == "publish": + self.log.info("Can't return publish tool window.") + return None + + self.log.warning( + "Can't show unknown tool name: \"{}\"".format(tool_name) + ) + return None def show_tool_by_name(self, tool_name, parent=None, *args, **kwargs): """Show tool by it's name. @@ -305,9 +280,6 @@ class HostToolsHelper: elif tool_name == "libraryloader": self.show_library_loader(parent, *args, **kwargs) - elif tool_name == "creator": - self.show_creator(parent, *args, **kwargs) - elif tool_name == "subsetmanager": self.show_subset_manager(parent, *args, **kwargs) @@ -379,10 +351,6 @@ def show_library_loader(parent=None): _SingletonPoint.show_tool_by_name("libraryloader", parent) -def show_creator(parent=None): - _SingletonPoint.show_tool_by_name("creator", parent) - - def show_subset_manager(parent=None): _SingletonPoint.show_tool_by_name("subsetmanager", parent) From 81d30462e26d4ae05f853c06fe23c00f7ba5946f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Aug 2025 17:15:31 +0200 Subject: [PATCH 299/312] removed legacy create and related functions --- client/ayon_core/pipeline/__init__.py | 8 - client/ayon_core/pipeline/create/__init__.py | 14 -- .../pipeline/create/creator_plugins.py | 58 ----- .../pipeline/create/legacy_create.py | 216 ------------------ 4 files changed, 296 deletions(-) delete mode 100644 client/ayon_core/pipeline/create/legacy_create.py diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 137736c302..65ad55d06e 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -19,9 +19,6 @@ from .create import ( CreatedInstance, CreatorError, - LegacyCreator, - legacy_create, - discover_creator_plugins, discover_legacy_creator_plugins, register_creator_plugin, @@ -141,12 +138,7 @@ __all__ = ( "CreatorError", - # - legacy creation - "LegacyCreator", - "legacy_create", - "discover_creator_plugins", - "discover_legacy_creator_plugins", "register_creator_plugin", "deregister_creator_plugin", "register_creator_plugin_path", diff --git a/client/ayon_core/pipeline/create/__init__.py b/client/ayon_core/pipeline/create/__init__.py index ced43528eb..2f076b63f6 100644 --- a/client/ayon_core/pipeline/create/__init__.py +++ b/client/ayon_core/pipeline/create/__init__.py @@ -44,9 +44,6 @@ from .creator_plugins import ( AutoCreator, HiddenCreator, - discover_legacy_creator_plugins, - get_legacy_creator_by_name, - discover_creator_plugins, register_creator_plugin, deregister_creator_plugin, @@ -58,11 +55,6 @@ from .creator_plugins import ( from .context import CreateContext -from .legacy_create import ( - LegacyCreator, - legacy_create, -) - __all__ = ( "PRODUCT_NAME_ALLOWED_SYMBOLS", @@ -105,9 +97,6 @@ __all__ = ( "AutoCreator", "HiddenCreator", - "discover_legacy_creator_plugins", - "get_legacy_creator_by_name", - "discover_creator_plugins", "register_creator_plugin", "deregister_creator_plugin", @@ -117,7 +106,4 @@ __all__ = ( "cache_and_get_instances", "CreateContext", - - "LegacyCreator", - "legacy_create", ) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index cbc06145fb..b890704649 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -20,7 +20,6 @@ from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir from .constants import DEFAULT_VARIANT_VALUE from .product_name import get_product_name from .utils import get_next_versions_for_instances -from .legacy_create import LegacyCreator from .structures import CreatedInstance if TYPE_CHECKING: @@ -975,62 +974,10 @@ def discover_convertor_plugins(*args, **kwargs): return discover(ProductConvertorPlugin, *args, **kwargs) -def discover_legacy_creator_plugins(): - from ayon_core.pipeline import get_current_project_name - - log = Logger.get_logger("CreatorDiscover") - - plugins = discover(LegacyCreator) - project_name = get_current_project_name() - project_settings = get_project_settings(project_name) - for plugin in plugins: - try: - plugin.apply_settings(project_settings) - except Exception: - log.warning( - "Failed to apply settings to creator {}".format( - plugin.__name__ - ), - exc_info=True - ) - return plugins - - -def get_legacy_creator_by_name(creator_name, case_sensitive=False): - """Find creator plugin by name. - - Args: - creator_name (str): Name of creator class that should be returned. - case_sensitive (bool): Match of creator plugin name is case sensitive. - Set to `False` by default. - - Returns: - Creator: Return first matching plugin or `None`. - """ - - # Lower input creator name if is not case sensitive - if not case_sensitive: - creator_name = creator_name.lower() - - for creator_plugin in discover_legacy_creator_plugins(): - _creator_name = creator_plugin.__name__ - - # Lower creator plugin name if is not case sensitive - if not case_sensitive: - _creator_name = _creator_name.lower() - - if _creator_name == creator_name: - return creator_plugin - return None - - def register_creator_plugin(plugin): if issubclass(plugin, BaseCreator): register_plugin(BaseCreator, plugin) - elif issubclass(plugin, LegacyCreator): - register_plugin(LegacyCreator, plugin) - elif issubclass(plugin, ProductConvertorPlugin): register_plugin(ProductConvertorPlugin, plugin) @@ -1039,22 +986,17 @@ def deregister_creator_plugin(plugin): if issubclass(plugin, BaseCreator): deregister_plugin(BaseCreator, plugin) - elif issubclass(plugin, LegacyCreator): - deregister_plugin(LegacyCreator, plugin) - elif issubclass(plugin, ProductConvertorPlugin): deregister_plugin(ProductConvertorPlugin, plugin) def register_creator_plugin_path(path): register_plugin_path(BaseCreator, path) - register_plugin_path(LegacyCreator, path) register_plugin_path(ProductConvertorPlugin, path) def deregister_creator_plugin_path(path): deregister_plugin_path(BaseCreator, path) - deregister_plugin_path(LegacyCreator, path) deregister_plugin_path(ProductConvertorPlugin, path) diff --git a/client/ayon_core/pipeline/create/legacy_create.py b/client/ayon_core/pipeline/create/legacy_create.py deleted file mode 100644 index f6427d9bd1..0000000000 --- a/client/ayon_core/pipeline/create/legacy_create.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Create workflow moved from avalon-core repository. - -Renamed classes and functions -- 'Creator' -> 'LegacyCreator' -- 'create' -> 'legacy_create' -""" - -import os -import logging -import collections - -from ayon_core.pipeline.constants import AYON_INSTANCE_ID - -from .product_name import get_product_name - - -class LegacyCreator: - """Determine how assets are created""" - label = None - product_type = None - defaults = None - maintain_selection = True - enabled = True - - dynamic_product_name_keys = [] - - log = logging.getLogger("LegacyCreator") - log.propagate = True - - def __init__(self, name, folder_path, options=None, data=None): - self.name = name # For backwards compatibility - self.options = options - - # Default data - self.data = collections.OrderedDict() - # TODO use 'AYON_INSTANCE_ID' when all hosts support it - self.data["id"] = AYON_INSTANCE_ID - self.data["productType"] = self.product_type - self.data["folderPath"] = folder_path - self.data["productName"] = name - self.data["active"] = True - - self.data.update(data or {}) - - @classmethod - def apply_settings(cls, project_settings): - """Apply AYON settings to a plugin class.""" - - host_name = os.environ.get("AYON_HOST_NAME") - plugin_type = "create" - plugin_type_settings = ( - project_settings - .get(host_name, {}) - .get(plugin_type, {}) - ) - global_type_settings = ( - project_settings - .get("core", {}) - .get(plugin_type, {}) - ) - if not global_type_settings and not plugin_type_settings: - return - - plugin_name = cls.__name__ - - plugin_settings = None - # Look for plugin settings in host specific settings - if plugin_name in plugin_type_settings: - plugin_settings = plugin_type_settings[plugin_name] - - # Look for plugin settings in global settings - elif plugin_name in global_type_settings: - plugin_settings = global_type_settings[plugin_name] - - if not plugin_settings: - return - - cls.log.debug(">>> We have preset for {}".format(plugin_name)) - for option, value in plugin_settings.items(): - if option == "enabled" and value is False: - cls.log.debug(" - is disabled by preset") - else: - cls.log.debug(" - setting `{}`: `{}`".format(option, value)) - setattr(cls, option, value) - - def process(self): - pass - - @classmethod - def get_dynamic_data( - cls, project_name, folder_entity, task_entity, variant, host_name - ): - """Return dynamic data for current Creator plugin. - - By default return keys from `dynamic_product_name_keys` attribute - as mapping to keep formatted template unchanged. - - ``` - dynamic_product_name_keys = ["my_key"] - --- - output = { - "my_key": "{my_key}" - } - ``` - - Dynamic keys may override default Creator keys (productType, task, - folderPath, ...) but do it wisely if you need. - - All of keys will be converted into 3 variants unchanged, capitalized - and all upper letters. Because of that are all keys lowered. - - This method can be modified to prefill some values just keep in mind it - is class method. - - Args: - project_name (str): Context's project name. - folder_entity (dict[str, Any]): Folder entity. - task_entity (dict[str, Any]): Task entity. - variant (str): What is entered by user in creator tool. - host_name (str): Name of host. - - Returns: - dict: Fill data for product name template. - """ - dynamic_data = {} - for key in cls.dynamic_product_name_keys: - key = key.lower() - dynamic_data[key] = "{" + key + "}" - return dynamic_data - - @classmethod - def get_product_name( - cls, project_name, folder_entity, task_entity, variant, host_name=None - ): - """Return product name created with entered arguments. - - Logic extracted from Creator tool. This method should give ability - to get product name without the tool. - - TODO: Maybe change `variant` variable. - - By default is output concatenated product type with variant. - - Args: - project_name (str): Context's project name. - folder_entity (dict[str, Any]): Folder entity. - task_entity (dict[str, Any]): Task entity. - variant (str): What is entered by user in creator tool. - host_name (str): Name of host. - - Returns: - str: Formatted product name with entered arguments. Should match - config's logic. - """ - - dynamic_data = cls.get_dynamic_data( - project_name, folder_entity, task_entity, variant, host_name - ) - task_name = task_type = None - if task_entity: - task_name = task_entity["name"] - task_type = task_entity["taskType"] - return get_product_name( - project_name, - task_name, - task_type, - host_name, - cls.product_type, - variant, - dynamic_data=dynamic_data - ) - - -def legacy_create( - Creator, product_name, folder_path, options=None, data=None -): - """Create a new instance - - Associate nodes with a product name and type. These nodes are later - validated, according to their `product type`, and integrated into the - shared environment, relative their `productName`. - - Data relative each product type, along with default data, are imprinted - into the resulting objectSet. This data is later used by extractors - and finally asset browsers to help identify the origin of the asset. - - Arguments: - Creator (Creator): Class of creator. - product_name (str): Name of product. - folder_path (str): Folder path. - options (dict, optional): Additional options from GUI. - data (dict, optional): Additional data from GUI. - - Raises: - NameError on `productName` already exists - KeyError on invalid dynamic property - RuntimeError on host error - - Returns: - Name of instance - - """ - from ayon_core.pipeline import registered_host - - host = registered_host() - plugin = Creator(product_name, folder_path, options, data) - - if plugin.maintain_selection is True: - with host.maintained_selection(): - print("Running %s with maintained selection" % plugin) - instance = plugin.process() - return instance - - print("Running %s" % plugin) - instance = plugin.process() - return instance From 027f148b102d464e8097173f4ff365cb0fa25125 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Aug 2025 17:16:11 +0200 Subject: [PATCH 300/312] remove legacy creators logic from template builder --- .../workfile/workfile_template_builder.py | 44 ++++--------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index e2add99752..37f76a2268 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -54,7 +54,6 @@ from ayon_core.pipeline.plugin_discover import ( ) from ayon_core.pipeline.create import ( - discover_legacy_creator_plugins, CreateContext, HiddenCreator, ) @@ -131,7 +130,6 @@ class AbstractTemplateBuilder(ABC): """ _log = None - use_legacy_creators = False def __init__(self, host): # Get host name @@ -321,19 +319,6 @@ class AbstractTemplateBuilder(ABC): return list(get_folders(project_name, folder_ids=linked_folder_ids)) - def _collect_legacy_creators(self): - creators_by_name = {} - for creator in discover_legacy_creator_plugins(): - if not creator.enabled: - continue - creator_name = creator.__name__ - if creator_name in creators_by_name: - raise KeyError( - "Duplicated creator name {} !".format(creator_name) - ) - creators_by_name[creator_name] = creator - self._creators_by_name = creators_by_name - def _collect_creators(self): self._creators_by_name = { identifier: creator @@ -345,10 +330,7 @@ class AbstractTemplateBuilder(ABC): def get_creators_by_name(self): if self._creators_by_name is None: - if self.use_legacy_creators: - self._collect_legacy_creators() - else: - self._collect_creators() + self._collect_creators() return self._creators_by_name @@ -1938,8 +1920,6 @@ class PlaceholderCreateMixin(object): pre_create_data (dict): dictionary of configuration from Creator configuration in UI """ - - legacy_create = self.builder.use_legacy_creators creator_name = placeholder.data["creator"] create_variant = placeholder.data["create_variant"] active = placeholder.data.get("active") @@ -1979,20 +1959,14 @@ class PlaceholderCreateMixin(object): # compile product name from variant try: - if legacy_create: - creator_instance = creator_plugin( - product_name, - folder_path - ).process() - else: - creator_instance = self.builder.create_context.create( - creator_plugin.identifier, - create_variant, - folder_entity, - task_entity, - pre_create_data=pre_create_data, - active=active - ) + creator_instance = self.builder.create_context.create( + creator_plugin.identifier, + create_variant, + folder_entity, + task_entity, + pre_create_data=pre_create_data, + active=active + ) except: # noqa: E722 failed = True From 94a55d588bc94d9f6f17564bdd5d1e798d29f5be Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Aug 2025 17:21:53 +0200 Subject: [PATCH 301/312] removed subset manager tool --- .../ayon_core/tools/subsetmanager/README.md | 19 -- .../ayon_core/tools/subsetmanager/__init__.py | 9 - client/ayon_core/tools/subsetmanager/model.py | 56 ----- .../ayon_core/tools/subsetmanager/widgets.py | 110 --------- .../ayon_core/tools/subsetmanager/window.py | 218 ------------------ client/ayon_core/tools/utils/host_tools.py | 33 --- 6 files changed, 445 deletions(-) delete mode 100644 client/ayon_core/tools/subsetmanager/README.md delete mode 100644 client/ayon_core/tools/subsetmanager/__init__.py delete mode 100644 client/ayon_core/tools/subsetmanager/model.py delete mode 100644 client/ayon_core/tools/subsetmanager/widgets.py delete mode 100644 client/ayon_core/tools/subsetmanager/window.py diff --git a/client/ayon_core/tools/subsetmanager/README.md b/client/ayon_core/tools/subsetmanager/README.md deleted file mode 100644 index 35b80ea114..0000000000 --- a/client/ayon_core/tools/subsetmanager/README.md +++ /dev/null @@ -1,19 +0,0 @@ -Subset manager --------------- - -Simple UI showing list of created subset that will be published via Pyblish. -Useful for applications (Photoshop, AfterEffects, TVPaint, Harmony) which are -storing metadata about instance hidden from user. - -This UI allows listing all created subset and removal of them if needed ( -in case use doesn't want to publish anymore, its using workfile as a starting -file for different task and instances should be completely different etc. -) - -Host is expected to implemented: -- `list_instances` - returning list of dictionaries (instances), must contain - unique uuid field - example: - ```[{"uuid":"15","active":true,"subset":"imageBG","family":"image","id":"ayon.create.instance","asset":"Town"}]``` -- `remove_instance(instance)` - removes instance from file's metadata - instance is a dictionary, with uuid field \ No newline at end of file diff --git a/client/ayon_core/tools/subsetmanager/__init__.py b/client/ayon_core/tools/subsetmanager/__init__.py deleted file mode 100644 index 6cfca7db66..0000000000 --- a/client/ayon_core/tools/subsetmanager/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .window import ( - show, - SubsetManagerWindow -) - -__all__ = ( - "show", - "SubsetManagerWindow" -) diff --git a/client/ayon_core/tools/subsetmanager/model.py b/client/ayon_core/tools/subsetmanager/model.py deleted file mode 100644 index 4964abd86d..0000000000 --- a/client/ayon_core/tools/subsetmanager/model.py +++ /dev/null @@ -1,56 +0,0 @@ -import uuid - -from qtpy import QtCore, QtGui - -from ayon_core.pipeline import registered_host - -ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 - - -class InstanceModel(QtGui.QStandardItemModel): - def __init__(self, *args, **kwargs): - super(InstanceModel, self).__init__(*args, **kwargs) - self._instances_by_item_id = {} - - def get_instance_by_id(self, item_id): - return self._instances_by_item_id.get(item_id) - - def refresh(self): - self.clear() - - self._instances_by_item_id = {} - - instances = None - host = registered_host() - list_instances = getattr(host, "list_instances", None) - if list_instances: - instances = list_instances() - - if not instances: - return - - items = [] - for instance_data in instances: - item_id = str(uuid.uuid4()) - product_name = ( - instance_data.get("productName") - or instance_data.get("subset") - ) - label = instance_data.get("label") or product_name - item = QtGui.QStandardItem(label) - item.setEnabled(True) - item.setEditable(False) - item.setData(item_id, ITEM_ID_ROLE) - items.append(item) - self._instances_by_item_id[item_id] = instance_data - - if items: - self.invisibleRootItem().appendRows(items) - - def headerData(self, section, orientation, role): - if role == QtCore.Qt.DisplayRole and section == 0: - return "Instance" - - return super(InstanceModel, self).headerData( - section, orientation, role - ) diff --git a/client/ayon_core/tools/subsetmanager/widgets.py b/client/ayon_core/tools/subsetmanager/widgets.py deleted file mode 100644 index 1067474c44..0000000000 --- a/client/ayon_core/tools/subsetmanager/widgets.py +++ /dev/null @@ -1,110 +0,0 @@ -import json -from qtpy import QtWidgets, QtCore - - -class InstanceDetail(QtWidgets.QWidget): - save_triggered = QtCore.Signal() - - def __init__(self, parent=None): - super(InstanceDetail, self).__init__(parent) - - details_widget = QtWidgets.QPlainTextEdit(self) - details_widget.setObjectName("SubsetManagerDetailsText") - - save_btn = QtWidgets.QPushButton("Save", self) - - self._block_changes = False - self._editable = False - self._item_id = None - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(details_widget, 1) - layout.addWidget(save_btn, 0, QtCore.Qt.AlignRight) - - save_btn.clicked.connect(self._on_save_clicked) - details_widget.textChanged.connect(self._on_text_change) - - self._details_widget = details_widget - self._save_btn = save_btn - - self.set_editable(False) - - def _on_save_clicked(self): - if self.is_valid(): - self.save_triggered.emit() - - def set_editable(self, enabled=True): - self._editable = enabled - self.update_state() - - def update_state(self, valid=None): - editable = self._editable - if not self._item_id: - editable = False - - self._save_btn.setVisible(editable) - self._details_widget.setReadOnly(not editable) - if valid is None: - valid = self.is_valid() - - self._save_btn.setEnabled(valid) - self._set_invalid_detail(valid) - - def _set_invalid_detail(self, valid): - state = "" - if not valid: - state = "invalid" - - current_state = self._details_widget.property("state") - if current_state != state: - self._details_widget.setProperty("state", state) - self._details_widget.style().polish(self._details_widget) - - def set_details(self, container, item_id): - self._item_id = item_id - - text = "Nothing selected" - if item_id: - try: - text = json.dumps(container, indent=4) - except Exception: - text = str(container) - - self._block_changes = True - self._details_widget.setPlainText(text) - self._block_changes = False - - self.update_state() - - def instance_data_from_text(self): - try: - jsoned = json.loads(self._details_widget.toPlainText()) - except Exception: - jsoned = None - return jsoned - - def item_id(self): - return self._item_id - - def is_valid(self): - if not self._item_id: - return True - - value = self._details_widget.toPlainText() - valid = False - try: - jsoned = json.loads(value) - if jsoned and isinstance(jsoned, dict): - valid = True - - except Exception: - pass - return valid - - def _on_text_change(self): - if self._block_changes or not self._item_id: - return - - valid = self.is_valid() - self.update_state(valid) diff --git a/client/ayon_core/tools/subsetmanager/window.py b/client/ayon_core/tools/subsetmanager/window.py deleted file mode 100644 index 164ffa95a7..0000000000 --- a/client/ayon_core/tools/subsetmanager/window.py +++ /dev/null @@ -1,218 +0,0 @@ -import os -import sys - -from qtpy import QtWidgets, QtCore -import qtawesome - -from ayon_core import style -from ayon_core.pipeline import registered_host -from ayon_core.tools.utils import PlaceholderLineEdit -from ayon_core.tools.utils.lib import ( - iter_model_rows, - qt_app_context -) -from ayon_core.tools.utils.models import RecursiveSortFilterProxyModel -from .model import ( - InstanceModel, - ITEM_ID_ROLE -) -from .widgets import InstanceDetail - - -module = sys.modules[__name__] -module.window = None - - -class SubsetManagerWindow(QtWidgets.QDialog): - def __init__(self, parent=None): - super(SubsetManagerWindow, self).__init__(parent=parent) - self.setWindowTitle("Subset Manager 0.1") - self.setObjectName("SubsetManager") - if not parent: - self.setWindowFlags( - self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint - ) - - self.resize(780, 430) - - # Trigger refresh on first called show - self._first_show = True - - left_side_widget = QtWidgets.QWidget(self) - - # Header part - header_widget = QtWidgets.QWidget(left_side_widget) - - # Filter input - filter_input = PlaceholderLineEdit(header_widget) - filter_input.setPlaceholderText("Filter products..") - - # Refresh button - icon = qtawesome.icon("fa.refresh", color="white") - refresh_btn = QtWidgets.QPushButton(header_widget) - refresh_btn.setIcon(icon) - - header_layout = QtWidgets.QHBoxLayout(header_widget) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.addWidget(filter_input) - header_layout.addWidget(refresh_btn) - - # Instances view - view = QtWidgets.QTreeView(left_side_widget) - view.setIndentation(0) - view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - - model = InstanceModel(view) - proxy = RecursiveSortFilterProxyModel() - proxy.setSourceModel(model) - proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - - view.setModel(proxy) - - left_side_layout = QtWidgets.QVBoxLayout(left_side_widget) - left_side_layout.setContentsMargins(0, 0, 0, 0) - left_side_layout.addWidget(header_widget) - left_side_layout.addWidget(view) - - details_widget = InstanceDetail(self) - - layout = QtWidgets.QHBoxLayout(self) - layout.addWidget(left_side_widget, 0) - layout.addWidget(details_widget, 1) - - filter_input.textChanged.connect(proxy.setFilterFixedString) - refresh_btn.clicked.connect(self._on_refresh_clicked) - view.clicked.connect(self._on_activated) - view.customContextMenuRequested.connect(self.on_context_menu) - details_widget.save_triggered.connect(self._on_save) - - self._model = model - self._proxy = proxy - self._view = view - self._details_widget = details_widget - self._refresh_btn = refresh_btn - - def _on_refresh_clicked(self): - self.refresh() - - def _on_activated(self, index): - container = None - item_id = None - if index.isValid(): - item_id = index.data(ITEM_ID_ROLE) - container = self._model.get_instance_by_id(item_id) - - self._details_widget.set_details(container, item_id) - - def _on_save(self): - host = registered_host() - if not hasattr(host, "save_instances"): - print("BUG: Host does not have \"save_instances\" method") - return - - current_index = self._view.selectionModel().currentIndex() - if not current_index.isValid(): - return - - item_id = current_index.data(ITEM_ID_ROLE) - if item_id != self._details_widget.item_id(): - return - - item_data = self._details_widget.instance_data_from_text() - new_instances = [] - for index in iter_model_rows(self._model, 0): - _item_id = index.data(ITEM_ID_ROLE) - if _item_id == item_id: - instance_data = item_data - else: - instance_data = self._model.get_instance_by_id(item_id) - new_instances.append(instance_data) - - host.save_instances(new_instances) - - def on_context_menu(self, point): - point_index = self._view.indexAt(point) - item_id = point_index.data(ITEM_ID_ROLE) - instance_data = self._model.get_instance_by_id(item_id) - if instance_data is None: - return - - # Prepare menu - menu = QtWidgets.QMenu(self) - actions = [] - host = registered_host() - if hasattr(host, "remove_instance"): - action = QtWidgets.QAction("Remove instance", menu) - action.setData(host.remove_instance) - actions.append(action) - - if hasattr(host, "select_instance"): - action = QtWidgets.QAction("Select instance", menu) - action.setData(host.select_instance) - actions.append(action) - - if not actions: - actions.append(QtWidgets.QAction("* Nothing to do", menu)) - - for action in actions: - menu.addAction(action) - - # Show menu under mouse - global_point = self._view.mapToGlobal(point) - action = menu.exec_(global_point) - if not action or not action.data(): - return - - # Process action - # TODO catch exceptions - function = action.data() - function(instance_data) - - # Reset modified data - self.refresh() - - def refresh(self): - self._details_widget.set_details(None, None) - self._model.refresh() - - host = registered_host() - dev_mode = os.environ.get("AVALON_DEVELOP_MODE") or "" - editable = False - if dev_mode.lower() in ("1", "yes", "true", "on"): - editable = hasattr(host, "save_instances") - self._details_widget.set_editable(editable) - - def showEvent(self, *args, **kwargs): - super(SubsetManagerWindow, self).showEvent(*args, **kwargs) - if self._first_show: - self._first_show = False - self.setStyleSheet(style.load_stylesheet()) - self.refresh() - - -def show(root=None, debug=False, parent=None): - """Display Scene Inventory GUI - - Arguments: - debug (bool, optional): Run in debug-mode, - defaults to False - parent (QtCore.QObject, optional): When provided parent the interface - to this QObject. - - """ - - try: - module.window.close() - del module.window - except (RuntimeError, AttributeError): - pass - - with qt_app_context(): - window = SubsetManagerWindow(parent) - window.show() - - module.window = window - - # Pull window to the front. - module.window.raise_() - module.window.activateWindow() diff --git a/client/ayon_core/tools/utils/host_tools.py b/client/ayon_core/tools/utils/host_tools.py index 3d356555f3..94e3c946c5 100644 --- a/client/ayon_core/tools/utils/host_tools.py +++ b/client/ayon_core/tools/utils/host_tools.py @@ -33,7 +33,6 @@ class HostToolsHelper: self._loader_tool = None self._creator_tool = None self._publisher_tool = None - self._subset_manager_tool = None self._scene_inventory_tool = None self._experimental_tools_dialog = None @@ -117,28 +116,6 @@ class HostToolsHelper: creator_tool.raise_() creator_tool.activateWindow() - def get_subset_manager_tool(self, parent): - """Create, cache and return subset manager tool window.""" - if self._subset_manager_tool is None: - from ayon_core.tools.subsetmanager import SubsetManagerWindow - - subset_manager_window = SubsetManagerWindow( - parent=parent or self._parent - ) - self._subset_manager_tool = subset_manager_window - - return self._subset_manager_tool - - def show_subset_manager(self, parent=None): - """Show tool display/remove existing created instances.""" - with qt_app_context(): - subset_manager_tool = self.get_subset_manager_tool(parent) - subset_manager_tool.show() - - # Pull window to the front. - subset_manager_tool.raise_() - subset_manager_tool.activateWindow() - def get_scene_inventory_tool(self, parent): """Create, cache and return scene inventory tool window.""" if self._scene_inventory_tool is None: @@ -270,9 +247,6 @@ class HostToolsHelper: elif tool_name == "creator": return self.get_creator_tool(parent, *args, **kwargs) - elif tool_name == "subsetmanager": - return self.get_subset_manager_tool(parent, *args, **kwargs) - elif tool_name == "sceneinventory": return self.get_scene_inventory_tool(parent, *args, **kwargs) @@ -308,9 +282,6 @@ class HostToolsHelper: elif tool_name == "creator": self.show_creator(parent, *args, **kwargs) - elif tool_name == "subsetmanager": - self.show_subset_manager(parent, *args, **kwargs) - elif tool_name == "sceneinventory": self.show_scene_inventory(parent, *args, **kwargs) @@ -383,10 +354,6 @@ def show_creator(parent=None): _SingletonPoint.show_tool_by_name("creator", parent) -def show_subset_manager(parent=None): - _SingletonPoint.show_tool_by_name("subsetmanager", parent) - - def show_scene_inventory(parent=None): _SingletonPoint.show_tool_by_name("sceneinventory", parent) From 7b0d54e7a8a9842fd905e77fe7d8026a78a35b4c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Aug 2025 17:25:15 +0200 Subject: [PATCH 302/312] remove creator tool Now really remove it... --- client/ayon_core/tools/creator/__init__.py | 9 - client/ayon_core/tools/creator/constants.py | 8 - client/ayon_core/tools/creator/model.py | 59 --- client/ayon_core/tools/creator/widgets.py | 275 ----------- client/ayon_core/tools/creator/window.py | 507 -------------------- 5 files changed, 858 deletions(-) delete mode 100644 client/ayon_core/tools/creator/__init__.py delete mode 100644 client/ayon_core/tools/creator/constants.py delete mode 100644 client/ayon_core/tools/creator/model.py delete mode 100644 client/ayon_core/tools/creator/widgets.py delete mode 100644 client/ayon_core/tools/creator/window.py diff --git a/client/ayon_core/tools/creator/__init__.py b/client/ayon_core/tools/creator/__init__.py deleted file mode 100644 index 585b8bdf80..0000000000 --- a/client/ayon_core/tools/creator/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .window import ( - show, - CreatorWindow -) - -__all__ = ( - "show", - "CreatorWindow" -) diff --git a/client/ayon_core/tools/creator/constants.py b/client/ayon_core/tools/creator/constants.py deleted file mode 100644 index ec555fbe9c..0000000000 --- a/client/ayon_core/tools/creator/constants.py +++ /dev/null @@ -1,8 +0,0 @@ -from qtpy import QtCore - - -PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1 -ITEM_ID_ROLE = QtCore.Qt.UserRole + 2 - -SEPARATOR = "---" -SEPARATORS = {"---", "---separator---"} diff --git a/client/ayon_core/tools/creator/model.py b/client/ayon_core/tools/creator/model.py deleted file mode 100644 index 16d24cc8bc..0000000000 --- a/client/ayon_core/tools/creator/model.py +++ /dev/null @@ -1,59 +0,0 @@ -import uuid -from qtpy import QtGui, QtCore - -from . constants import ( - PRODUCT_TYPE_ROLE, - ITEM_ID_ROLE -) - - -class CreatorsModel(QtGui.QStandardItemModel): - def __init__(self, *args, **kwargs): - super(CreatorsModel, self).__init__(*args, **kwargs) - - self._creators_by_id = {} - - def reset(self): - # TODO change to refresh when clearing is not needed - self.clear() - self._creators_by_id = {} - - items = [] - creators = discover_legacy_creator_plugins() - for creator in creators: - if not creator.enabled: - continue - item_id = str(uuid.uuid4()) - self._creators_by_id[item_id] = creator - - label = creator.label or creator.product_type - item = QtGui.QStandardItem(label) - item.setEditable(False) - item.setData(item_id, ITEM_ID_ROLE) - item.setData(creator.product_type, PRODUCT_TYPE_ROLE) - items.append(item) - - if not items: - item = QtGui.QStandardItem("No registered create plugins") - item.setEnabled(False) - item.setData(False, QtCore.Qt.ItemIsEnabled) - items.append(item) - - items.sort(key=lambda item: item.text()) - self.invisibleRootItem().appendRows(items) - - def get_creator_by_id(self, item_id): - return self._creators_by_id.get(item_id) - - def get_indexes_by_product_type(self, product_type): - indexes = [] - for row in range(self.rowCount()): - index = self.index(row, 0) - item_id = index.data(ITEM_ID_ROLE) - creator_plugin = self._creators_by_id.get(item_id) - if creator_plugin and ( - creator_plugin.label.lower() == product_type.lower() - or creator_plugin.product_type.lower() == product_type.lower() - ): - indexes.append(index) - return indexes diff --git a/client/ayon_core/tools/creator/widgets.py b/client/ayon_core/tools/creator/widgets.py deleted file mode 100644 index bbc6848e6c..0000000000 --- a/client/ayon_core/tools/creator/widgets.py +++ /dev/null @@ -1,275 +0,0 @@ -import re -import inspect - -from qtpy import QtWidgets, QtCore, QtGui - -import qtawesome - -from ayon_core.pipeline.create import PRODUCT_NAME_ALLOWED_SYMBOLS -from ayon_core.tools.utils import ErrorMessageBox - -if hasattr(QtGui, "QRegularExpressionValidator"): - RegularExpressionValidatorClass = QtGui.QRegularExpressionValidator - RegularExpressionClass = QtCore.QRegularExpression -else: - RegularExpressionValidatorClass = QtGui.QRegExpValidator - RegularExpressionClass = QtCore.QRegExp - - -class CreateErrorMessageBox(ErrorMessageBox): - def __init__( - self, - product_type, - product_name, - folder_path, - exc_msg, - formatted_traceback, - parent - ): - self._product_type = product_type - self._product_name = product_name - self._folder_path = folder_path - self._exc_msg = exc_msg - self._formatted_traceback = formatted_traceback - super(CreateErrorMessageBox, self).__init__("Creation failed", parent) - - def _create_top_widget(self, parent_widget): - label_widget = QtWidgets.QLabel(parent_widget) - label_widget.setText( - "Failed to create" - ) - return label_widget - - def _get_report_data(self): - report_message = ( - "Failed to create Product: \"{product_name}\"" - " Type: \"{product_type}\"" - " in Folder: \"{folder_path}\"" - "\n\nError: {message}" - ).format( - product_name=self._product_name, - product_type=self._product_type, - folder_path=self._folder_path, - message=self._exc_msg - ) - if self._formatted_traceback: - report_message += "\n\n{}".format(self._formatted_traceback) - return [report_message] - - def _create_content(self, content_layout): - item_name_template = ( - "{}: {{}}
" - "{}: {{}}
" - "{}: {{}}
" - ).format( - "Product type", - "Product name", - "Folder" - ) - exc_msg_template = "{}" - - line = self._create_line() - content_layout.addWidget(line) - - item_name_widget = QtWidgets.QLabel(self) - item_name_widget.setText( - item_name_template.format( - self._product_type, self._product_name, self._folder_path - ) - ) - content_layout.addWidget(item_name_widget) - - message_label_widget = QtWidgets.QLabel(self) - message_label_widget.setText( - exc_msg_template.format(self.convert_text_for_html(self._exc_msg)) - ) - content_layout.addWidget(message_label_widget) - - if self._formatted_traceback: - line_widget = self._create_line() - tb_widget = self._create_traceback_widget( - self._formatted_traceback - ) - content_layout.addWidget(line_widget) - content_layout.addWidget(tb_widget) - - -class ProductNameValidator(RegularExpressionValidatorClass): - invalid = QtCore.Signal(set) - pattern = "^[{}]*$".format(PRODUCT_NAME_ALLOWED_SYMBOLS) - - def __init__(self): - reg = RegularExpressionClass(self.pattern) - super(ProductNameValidator, self).__init__(reg) - - def validate(self, text, pos): - results = super(ProductNameValidator, self).validate(text, pos) - if results[0] == RegularExpressionValidatorClass.Invalid: - self.invalid.emit(self.invalid_chars(text)) - return results - - def invalid_chars(self, text): - invalid = set() - re_valid = re.compile(self.pattern) - for char in text: - if char == " ": - invalid.add("' '") - continue - if not re_valid.match(char): - invalid.add(char) - return invalid - - -class VariantLineEdit(QtWidgets.QLineEdit): - report = QtCore.Signal(str) - colors = { - "empty": (QtGui.QColor("#78879b"), ""), - "exists": (QtGui.QColor("#4E76BB"), "border-color: #4E76BB;"), - "new": (QtGui.QColor("#7AAB8F"), "border-color: #7AAB8F;"), - } - - def __init__(self, *args, **kwargs): - super(VariantLineEdit, self).__init__(*args, **kwargs) - - validator = ProductNameValidator() - self.setValidator(validator) - self.setToolTip("Only alphanumeric characters (A-Z a-z 0-9), " - "'_' and '.' are allowed.") - - self._status_color = self.colors["empty"][0] - - anim = QtCore.QPropertyAnimation() - anim.setTargetObject(self) - anim.setPropertyName(b"status_color") - anim.setEasingCurve(QtCore.QEasingCurve.InCubic) - anim.setDuration(300) - anim.setStartValue(QtGui.QColor("#C84747")) # `Invalid` status color - self.animation = anim - - validator.invalid.connect(self.on_invalid) - - def on_invalid(self, invalid): - message = "Invalid character: %s" % ", ".join(invalid) - self.report.emit(message) - self.animation.stop() - self.animation.start() - - def as_empty(self): - self._set_border("empty") - self.report.emit("Empty product name ..") - - def as_exists(self): - self._set_border("exists") - self.report.emit("Existing product, appending next version.") - - def as_new(self): - self._set_border("new") - self.report.emit("New product, creating first version.") - - def _set_border(self, status): - qcolor, style = self.colors[status] - self.animation.setEndValue(qcolor) - self.setStyleSheet(style) - - def _get_status_color(self): - return self._status_color - - def _set_status_color(self, color): - self._status_color = color - self.setStyleSheet("border-color: %s;" % color.name()) - - status_color = QtCore.Property( - QtGui.QColor, _get_status_color, _set_status_color - ) - - -class ProductTypeDescriptionWidget(QtWidgets.QWidget): - """A product type description widget. - - Shows a product type icon, name and a help description. - Used in creator header. - - _______________________ - | ____ | - | |icon| PRODUCT TYPE | - | |____| help | - |_______________________| - - """ - - SIZE = 35 - - def __init__(self, parent=None): - super(ProductTypeDescriptionWidget, self).__init__(parent=parent) - - icon_label = QtWidgets.QLabel(self) - icon_label.setSizePolicy( - QtWidgets.QSizePolicy.Maximum, - QtWidgets.QSizePolicy.Maximum - ) - - # Add 4 pixel padding to avoid icon being cut off - icon_label.setFixedWidth(self.SIZE + 4) - icon_label.setFixedHeight(self.SIZE + 4) - - label_layout = QtWidgets.QVBoxLayout() - label_layout.setSpacing(0) - - product_type_label = QtWidgets.QLabel(self) - product_type_label.setObjectName("CreatorProductTypeLabel") - product_type_label.setAlignment( - QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft - ) - - help_label = QtWidgets.QLabel(self) - help_label.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) - - label_layout.addWidget(product_type_label) - label_layout.addWidget(help_label) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(5) - layout.addWidget(icon_label) - layout.addLayout(label_layout) - - self._help_label = help_label - self._product_type_label = product_type_label - self._icon_label = icon_label - - def set_item(self, creator_plugin): - """Update elements to display information of a product type item. - - Args: - creator_plugin (dict): A product type item as registered with - name, help and icon. - - Returns: - None - - """ - if not creator_plugin: - self._icon_label.setPixmap(None) - self._product_type_label.setText("") - self._help_label.setText("") - return - - # Support a font-awesome icon - icon_name = getattr(creator_plugin, "icon", None) or "info-circle" - try: - icon = qtawesome.icon("fa.{}".format(icon_name), color="white") - pixmap = icon.pixmap(self.SIZE, self.SIZE) - except Exception: - print("BUG: Couldn't load icon \"fa.{}\"".format(str(icon_name))) - # Create transparent pixmap - pixmap = QtGui.QPixmap() - pixmap.fill(QtCore.Qt.transparent) - pixmap = pixmap.scaled(self.SIZE, self.SIZE) - - # Parse a clean line from the Creator's docstring - docstring = inspect.getdoc(creator_plugin) - creator_help = docstring.splitlines()[0] if docstring else "" - - self._icon_label.setPixmap(pixmap) - self._product_type_label.setText(creator_plugin.product_type) - self._help_label.setText(creator_help) diff --git a/client/ayon_core/tools/creator/window.py b/client/ayon_core/tools/creator/window.py deleted file mode 100644 index fe8ee86dcf..0000000000 --- a/client/ayon_core/tools/creator/window.py +++ /dev/null @@ -1,507 +0,0 @@ -import sys -import traceback -import re - -import ayon_api -from qtpy import QtWidgets, QtCore - -from ayon_core import style -from ayon_core.settings import get_current_project_settings -from ayon_core.tools.utils.lib import qt_app_context -from ayon_core.pipeline import ( - get_current_project_name, - get_current_folder_path, - get_current_task_name, -) -from ayon_core.pipeline.create import ( - PRODUCT_NAME_ALLOWED_SYMBOLS, - CreatorError, -) - -from .model import CreatorsModel -from .widgets import ( - CreateErrorMessageBox, - VariantLineEdit, - ProductTypeDescriptionWidget -) -from .constants import ( - ITEM_ID_ROLE, - SEPARATOR, - SEPARATORS -) - -module = sys.modules[__name__] -module.window = None - - -class CreatorWindow(QtWidgets.QDialog): - def __init__(self, parent=None): - super(CreatorWindow, self).__init__(parent) - self.setWindowTitle("Instance Creator") - self.setFocusPolicy(QtCore.Qt.StrongFocus) - if not parent: - self.setWindowFlags( - self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint - ) - - creator_info = ProductTypeDescriptionWidget(self) - - creators_model = CreatorsModel() - - creators_proxy = QtCore.QSortFilterProxyModel() - creators_proxy.setSourceModel(creators_model) - - creators_view = QtWidgets.QListView(self) - creators_view.setObjectName("CreatorsView") - creators_view.setModel(creators_proxy) - - folder_path_input = QtWidgets.QLineEdit(self) - variant_input = VariantLineEdit(self) - product_name_input = QtWidgets.QLineEdit(self) - product_name_input.setEnabled(False) - - variants_btn = QtWidgets.QPushButton() - variants_btn.setFixedWidth(18) - variants_menu = QtWidgets.QMenu(variants_btn) - variants_btn.setMenu(variants_menu) - - name_layout = QtWidgets.QHBoxLayout() - name_layout.addWidget(variant_input) - name_layout.addWidget(variants_btn) - name_layout.setSpacing(3) - name_layout.setContentsMargins(0, 0, 0, 0) - - body_layout = QtWidgets.QVBoxLayout() - body_layout.setContentsMargins(0, 0, 0, 0) - - body_layout.addWidget(creator_info, 0) - body_layout.addWidget(QtWidgets.QLabel("Product type", self), 0) - body_layout.addWidget(creators_view, 1) - body_layout.addWidget(QtWidgets.QLabel("Folder path", self), 0) - body_layout.addWidget(folder_path_input, 0) - body_layout.addWidget(QtWidgets.QLabel("Product name", self), 0) - body_layout.addLayout(name_layout, 0) - body_layout.addWidget(product_name_input, 0) - - useselection_chk = QtWidgets.QCheckBox("Use selection", self) - useselection_chk.setCheckState(QtCore.Qt.Checked) - - create_btn = QtWidgets.QPushButton("Create", self) - # Need to store error_msg to prevent garbage collection - msg_label = QtWidgets.QLabel(self) - - footer_layout = QtWidgets.QVBoxLayout() - footer_layout.addWidget(create_btn, 0) - footer_layout.addWidget(msg_label, 0) - footer_layout.setContentsMargins(0, 0, 0, 0) - - layout = QtWidgets.QVBoxLayout(self) - layout.addLayout(body_layout, 1) - layout.addWidget(useselection_chk, 0, QtCore.Qt.AlignLeft) - layout.addLayout(footer_layout, 0) - - msg_timer = QtCore.QTimer() - msg_timer.setSingleShot(True) - msg_timer.setInterval(5000) - - validation_timer = QtCore.QTimer() - validation_timer.setSingleShot(True) - validation_timer.setInterval(300) - - msg_timer.timeout.connect(self._on_msg_timer) - validation_timer.timeout.connect(self._on_validation_timer) - - create_btn.clicked.connect(self._on_create) - variant_input.returnPressed.connect(self._on_create) - variant_input.textChanged.connect(self._on_data_changed) - variant_input.report.connect(self.echo) - folder_path_input.textChanged.connect(self._on_data_changed) - creators_view.selectionModel().currentChanged.connect( - self._on_selection_changed - ) - - # Store valid states and - self._is_valid = False - create_btn.setEnabled(self._is_valid) - - self._first_show = True - - # Message dialog when something goes wrong during creation - self._message_dialog = None - - self._creator_info = creator_info - self._create_btn = create_btn - self._useselection_chk = useselection_chk - self._variant_input = variant_input - self._product_name_input = product_name_input - self._folder_path_input = folder_path_input - - self._creators_model = creators_model - self._creators_proxy = creators_proxy - self._creators_view = creators_view - - self._variants_btn = variants_btn - self._variants_menu = variants_menu - - self._msg_label = msg_label - - self._validation_timer = validation_timer - self._msg_timer = msg_timer - - # Defaults - self.resize(300, 500) - variant_input.setFocus() - - def _set_valid_state(self, valid): - if self._is_valid == valid: - return - self._is_valid = valid - self._create_btn.setEnabled(valid) - - def _build_menu(self, default_names=None): - """Create optional predefined variants. - - Args: - default_names(list): all predefined names - - Returns: - None - """ - if not default_names: - default_names = [] - - menu = self._variants_menu - button = self._variants_btn - - # Get and destroy the action group - group = button.findChild(QtWidgets.QActionGroup) - if group: - group.deleteLater() - - state = any(default_names) - button.setEnabled(state) - if state is False: - return - - # Build new action group - group = QtWidgets.QActionGroup(button) - for name in default_names: - if name in SEPARATORS: - menu.addSeparator() - continue - action = group.addAction(name) - menu.addAction(action) - - group.triggered.connect(self._on_action_clicked) - - def _on_action_clicked(self, action): - self._variant_input.setText(action.text()) - - def _on_data_changed(self, *args): - # Set invalid state until it's reconfirmed to be valid by the - # scheduled callback so any form of creation is held back until - # valid again - self._set_valid_state(False) - - self._validation_timer.start() - - def _on_validation_timer(self): - index = self._creators_view.currentIndex() - item_id = index.data(ITEM_ID_ROLE) - creator_plugin = self._creators_model.get_creator_by_id(item_id) - user_input_text = self._variant_input.text() - folder_path = self._folder_path_input.text() - - # Early exit if no folder path - if not folder_path: - self._build_menu() - self.echo("Folder is required ..") - self._set_valid_state(False) - return - - project_name = get_current_project_name() - folder_entity = None - if creator_plugin: - # Get the folder from the database which match with the name - folder_entity = ayon_api.get_folder_by_path( - project_name, folder_path, fields={"id"} - ) - - # Get plugin - if not folder_entity or not creator_plugin: - self._build_menu() - - if not creator_plugin: - self.echo("No registered product types ..") - else: - self.echo("Folder '{}' not found ..".format(folder_path)) - self._set_valid_state(False) - return - - folder_id = folder_entity["id"] - - task_name = get_current_task_name() - task_entity = ayon_api.get_task_by_name( - project_name, folder_id, task_name - ) - - # Calculate product name with Creator plugin - product_name = creator_plugin.get_product_name( - project_name, folder_entity, task_entity, user_input_text - ) - # Force replacement of prohibited symbols - # QUESTION should Creator care about this and here should be only - # validated with schema regex? - - # Allow curly brackets in product name for dynamic keys - curly_left = "__cbl__" - curly_right = "__cbr__" - tmp_product_name = ( - product_name - .replace("{", curly_left) - .replace("}", curly_right) - ) - # Replace prohibited symbols - tmp_product_name = re.sub( - "[^{}]+".format(PRODUCT_NAME_ALLOWED_SYMBOLS), - "", - tmp_product_name - ) - product_name = ( - tmp_product_name - .replace(curly_left, "{") - .replace(curly_right, "}") - ) - self._product_name_input.setText(product_name) - - # Get all products of the current folder - product_entities = ayon_api.get_products( - project_name, folder_ids={folder_id}, fields={"name"} - ) - existing_product_names = { - product_entity["name"] - for product_entity in product_entities - } - existing_product_names_low = set( - _name.lower() - for _name in existing_product_names - ) - - # Defaults to dropdown - defaults = [] - # Check if Creator plugin has set defaults - if ( - creator_plugin.defaults - and isinstance(creator_plugin.defaults, (list, tuple, set)) - ): - defaults = list(creator_plugin.defaults) - - # Replace - compare_regex = re.compile(re.sub( - user_input_text, "(.+)", product_name, flags=re.IGNORECASE - )) - variant_hints = set() - if user_input_text: - for _name in existing_product_names: - _result = compare_regex.search(_name) - if _result: - variant_hints |= set(_result.groups()) - - if variant_hints: - if defaults: - defaults.append(SEPARATOR) - defaults.extend(variant_hints) - self._build_menu(defaults) - - # Indicate product existence - if not user_input_text: - self._variant_input.as_empty() - elif product_name.lower() in existing_product_names_low: - # validate existence of product name with lowered text - # - "renderMain" vs. "rensermain" mean same path item for - # windows - self._variant_input.as_exists() - else: - self._variant_input.as_new() - - # Update the valid state - valid = product_name.strip() != "" - - self._set_valid_state(valid) - - def _on_selection_changed(self, old_idx, new_idx): - index = self._creators_view.currentIndex() - item_id = index.data(ITEM_ID_ROLE) - - creator_plugin = self._creators_model.get_creator_by_id(item_id) - - self._creator_info.set_item(creator_plugin) - - if creator_plugin is None: - return - - default = None - if hasattr(creator_plugin, "get_default_variant"): - default = creator_plugin.get_default_variant() - - if not default: - if ( - creator_plugin.defaults - and isinstance(creator_plugin.defaults, list) - ): - default = creator_plugin.defaults[0] - else: - default = "Default" - - self._variant_input.setText(default) - - self._on_data_changed() - - def keyPressEvent(self, event): - """Custom keyPressEvent. - - Override keyPressEvent to do nothing so that Maya's panels won't - take focus when pressing "SHIFT" whilst mouse is over viewport or - outliner. This way users don't accidentally perform Maya commands - whilst trying to name an instance. - - """ - pass - - def showEvent(self, event): - super(CreatorWindow, self).showEvent(event) - if self._first_show: - self._first_show = False - self.setStyleSheet(style.load_stylesheet()) - - def refresh(self): - self._folder_path_input.setText(get_current_folder_path()) - - self._creators_model.reset() - - product_types_smart_select = ( - get_current_project_settings() - ["core"] - ["tools"] - ["creator"] - ["product_types_smart_select"] - ) - current_index = None - product_type = None - task_name = get_current_task_name() or None - lowered_task_name = task_name.lower() - if task_name: - for smart_item in product_types_smart_select: - _low_task_names = { - name.lower() for name in smart_item["task_names"] - } - for _task_name in _low_task_names: - if _task_name in lowered_task_name: - product_type = smart_item["name"] - break - if product_type: - break - - if product_type: - indexes = self._creators_model.get_indexes_by_product_type( - product_type - ) - if indexes: - index = indexes[0] - current_index = self._creators_proxy.mapFromSource(index) - - if current_index is None or not current_index.isValid(): - current_index = self._creators_proxy.index(0, 0) - - self._creators_view.setCurrentIndex(current_index) - - def _on_create(self): - # Do not allow creation in an invalid state - if not self._is_valid: - return - - index = self._creators_view.currentIndex() - item_id = index.data(ITEM_ID_ROLE) - creator_plugin = self._creators_model.get_creator_by_id(item_id) - if creator_plugin is None: - return - - product_name = self._product_name_input.text() - folder_path = self._folder_path_input.text() - use_selection = self._useselection_chk.isChecked() - - variant = self._variant_input.text() - - error_info = None - try: - legacy_create( - creator_plugin, - product_name, - folder_path, - options={"useSelection": use_selection}, - data={"variant": variant} - ) - - except CreatorError as exc: - self.echo("Creator error: {}".format(str(exc))) - error_info = (str(exc), None) - - except Exception as exc: - self.echo("Program error: %s" % str(exc)) - - exc_type, exc_value, exc_traceback = sys.exc_info() - formatted_traceback = "".join(traceback.format_exception( - exc_type, exc_value, exc_traceback - )) - error_info = (str(exc), formatted_traceback) - - if error_info: - box = CreateErrorMessageBox( - creator_plugin.product_type, - product_name, - folder_path, - *error_info, - parent=self - ) - box.show() - # Store dialog so is not garbage collected before is shown - self._message_dialog = box - - else: - self.echo("Created %s .." % product_name) - - def _on_msg_timer(self): - self._msg_label.setText("") - - def echo(self, message): - self._msg_label.setText(str(message)) - self._msg_timer.start() - - -def show(parent=None): - """Display product creator GUI - - Arguments: - debug (bool, optional): Run loader in debug-mode, - defaults to False - parent (QtCore.QObject, optional): When provided parent the interface - to this QObject. - - """ - - try: - module.window.close() - del module.window - except (AttributeError, RuntimeError): - pass - - with qt_app_context(): - window = CreatorWindow(parent) - window.refresh() - window.show() - - module.window = window - - # Pull window to the front. - module.window.raise_() - module.window.activateWindow() From 7856ee98fef309a4404d37275631f7903864140c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Aug 2025 17:25:22 +0200 Subject: [PATCH 303/312] remove import --- client/ayon_core/pipeline/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 65ad55d06e..f2ec952cd6 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -20,7 +20,6 @@ from .create import ( CreatorError, discover_creator_plugins, - discover_legacy_creator_plugins, register_creator_plugin, deregister_creator_plugin, register_creator_plugin_path, From 8314d83a0d52adbef475e63dffdc85cc51f47e45 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 14 Aug 2025 17:26:00 +0200 Subject: [PATCH 304/312] remove unused import --- client/ayon_core/pipeline/create/creator_plugins.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index b890704649..7573589b82 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Optional, Dict, Any from abc import ABC, abstractmethod -from ayon_core.settings import get_project_settings from ayon_core.lib import Logger, get_version_from_path from ayon_core.pipeline.plugin_discover import ( discover, From 6b6c93376e18e16ebe3ef589345c121e0e2c2b06 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 15 Aug 2025 11:08:27 +0200 Subject: [PATCH 305/312] implemented abstract host class --- client/ayon_core/host/abstract.py | 96 +++++++++++++++++++++++++++++++ client/ayon_core/host/typing.py | 7 +++ 2 files changed, 103 insertions(+) create mode 100644 client/ayon_core/host/abstract.py create mode 100644 client/ayon_core/host/typing.py diff --git a/client/ayon_core/host/abstract.py b/client/ayon_core/host/abstract.py new file mode 100644 index 0000000000..26771aaffa --- /dev/null +++ b/client/ayon_core/host/abstract.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +import typing +from typing import Optional, Any + +from .constants import ContextChangeReason + +if typing.TYPE_CHECKING: + from ayon_core.pipeline import Anatomy + + from .typing import HostContextData + + +class AbstractHost(ABC): + """Abstract definition of host implementation.""" + @property + @abstractmethod + def log(self) -> logging.Logger: + pass + + @property + @abstractmethod + def name(self) -> str: + """Host name.""" + pass + + @abstractmethod + def get_current_context(self) -> HostContextData: + """Get the current context of the host. + + Current context is defined by project name, folder path and task name. + + Returns: + HostContextData: The current context of the host. + + """ + pass + + @abstractmethod + def set_current_context( + self, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + *, + reason: ContextChangeReason = ContextChangeReason.undefined, + project_entity: Optional[dict[str, Any]] = None, + anatomy: Optional[Anatomy] = None, + ) -> HostContextData: + """Change context of the host. + + Args: + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + reason (ContextChangeReason): Reason for change. + project_entity (dict[str, Any]): Project entity. + anatomy (Anatomy): Anatomy entity. + + """ + pass + + @abstractmethod + def get_current_project_name(self) -> str: + """Get the current project name. + + Returns: + Optional[str]: The current project name. + + """ + pass + + @abstractmethod + def get_current_folder_path(self) -> Optional[str]: + """Get the current folder path. + + Returns: + Optional[str]: The current folder path. + + """ + pass + + @abstractmethod + def get_current_task_name(self) -> Optional[str]: + """Get the current task name. + + Returns: + Optional[str]: The current task name. + + """ + pass + + @abstractmethod + def get_context_title(self) -> str: + """Get the context title used in UIs.""" + pass diff --git a/client/ayon_core/host/typing.py b/client/ayon_core/host/typing.py new file mode 100644 index 0000000000..a51460713b --- /dev/null +++ b/client/ayon_core/host/typing.py @@ -0,0 +1,7 @@ +from typing import Optional, TypedDict + + +class HostContextData(TypedDict): + project_name: str + folder_path: Optional[str] + task_name: Optional[str] From 044e41471810b06e28ab4051775169896916a455 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 15 Aug 2025 11:09:01 +0200 Subject: [PATCH 306/312] use AbstractHost for interfaces and HotBase --- client/ayon_core/host/host.py | 13 ++++--------- client/ayon_core/host/interfaces/interfaces.py | 6 ++++-- client/ayon_core/host/interfaces/workfiles.py | 3 ++- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 7fc4b19bdd..7fd63a5864 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -3,26 +3,21 @@ from __future__ import annotations import os import logging import contextlib -from abc import ABC, abstractmethod -from dataclasses import dataclass import typing from typing import Optional, Any +from dataclasses import dataclass import ayon_api from ayon_core.lib import emit_event from .constants import ContextChangeReason +from .abstract import AbstractHost if typing.TYPE_CHECKING: from ayon_core.pipeline import Anatomy - from typing import TypedDict - - class HostContextData(TypedDict): - project_name: str - folder_path: Optional[str] - task_name: Optional[str] + from .typing import HostContextData @dataclass @@ -34,7 +29,7 @@ class ContextChangeData: anatomy: Anatomy -class HostBase(ABC): +class HostBase(AbstractHost): """Base of host implementation class. Host is pipeline implementation of DCC application. This class should help diff --git a/client/ayon_core/host/interfaces/interfaces.py b/client/ayon_core/host/interfaces/interfaces.py index a41dffe92a..8b7005085e 100644 --- a/client/ayon_core/host/interfaces/interfaces.py +++ b/client/ayon_core/host/interfaces/interfaces.py @@ -1,9 +1,11 @@ from abc import abstractmethod +from ayon_core.host.abstract import AbstractHost + from .exceptions import MissingMethodsError -class ILoadHost: +class ILoadHost(AbstractHost): """Implementation requirements to be able use reference of representations. The load plugins can do referencing even without implementation of methods @@ -83,7 +85,7 @@ class ILoadHost: return self.get_containers() -class IPublishHost: +class IPublishHost(AbstractHost): """Functions related to new creation system in new publisher. New publisher is not storing information only about each created instance diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 82d71d152a..93aad4c117 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -15,6 +15,7 @@ import arrow from ayon_core.lib import emit_event from ayon_core.settings import get_project_settings +from ayon_core.host.abstract import AbstractHost from ayon_core.host.constants import ContextChangeReason if typing.TYPE_CHECKING: @@ -821,7 +822,7 @@ class PublishedWorkfileInfo: return PublishedWorkfileInfo(**data) -class IWorkfileHost: +class IWorkfileHost(AbstractHost): """Implementation requirements to be able to use workfiles utils and tool. Some of the methods are pre-implemented as they generally do the same in From 89e92f555684382bce822165213e0b8cadee2f40 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 15 Aug 2025 11:09:33 +0200 Subject: [PATCH 307/312] remove name abstraction --- client/ayon_core/host/host.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 7fd63a5864..9b7d43be94 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -114,13 +114,6 @@ class HostBase(AbstractHost): self._log = logging.getLogger(self.__class__.__name__) return self._log - @property - @abstractmethod - def name(self) -> str: - """Host name.""" - - pass - def get_current_project_name(self): """ Returns: From 77383fea1e3e23354e182e8637e94f51e4d11765 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 15 Aug 2025 11:09:54 +0200 Subject: [PATCH 308/312] updated docstrings and type hints --- client/ayon_core/host/host.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 9b7d43be94..28cb6b0a09 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -104,41 +104,41 @@ class HostBase(AbstractHost): It is called automatically when 'ayon_core.pipeline.install_host' is triggered. - """ + """ pass @property - def log(self): + def log(self) -> logging.Logger: if self._log is None: self._log = logging.getLogger(self.__class__.__name__) return self._log - def get_current_project_name(self): + def get_current_project_name(self) -> str: """ Returns: - Union[str, None]: Current project name. - """ + str: Current project name. - return os.environ.get("AYON_PROJECT_NAME") + """ + return os.environ["AYON_PROJECT_NAME"] def get_current_folder_path(self) -> Optional[str]: """ Returns: - Union[str, None]: Current asset name. - """ + Optional[str]: Current asset name. + """ return os.environ.get("AYON_FOLDER_PATH") def get_current_task_name(self) -> Optional[str]: """ Returns: - Union[str, None]: Current task name. - """ + Optional[str]: Current task name. + """ return os.environ.get("AYON_TASK_NAME") - def get_current_context(self) -> "HostContextData": + def get_current_context(self) -> HostContextData: """Get current context information. This method should be used to get current context of host. Usage of @@ -147,10 +147,10 @@ class HostBase(AbstractHost): can't be caught properly. Returns: - Dict[str, Union[str, None]]: Context with 3 keys 'project_name', - 'folder_path' and 'task_name'. All of them can be 'None'. - """ + HostContextData: Current context with 'project_name', + 'folder_path' and 'task_name'. + """ return { "project_name": self.get_current_project_name(), "folder_path": self.get_current_folder_path(), @@ -165,7 +165,7 @@ class HostBase(AbstractHost): reason: ContextChangeReason = ContextChangeReason.undefined, project_entity: Optional[dict[str, Any]] = None, anatomy: Optional[Anatomy] = None, - ) -> "HostContextData": + ) -> HostContextData: """Set current context information. This method should be used to set current context of host. Usage of @@ -278,7 +278,7 @@ class HostBase(AbstractHost): project_name: str, folder_path: Optional[str], task_name: Optional[str], - ) -> "HostContextData": + ) -> HostContextData: """Emit context change event. Args: @@ -290,7 +290,7 @@ class HostBase(AbstractHost): HostContextData: Data send to context change event. """ - data = { + data: HostContextData = { "project_name": project_name, "folder_path": folder_path, "task_name": task_name, From 2bd18c4d9614e502d2a774093a2b4e8aa1b42397 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 15 Aug 2025 12:39:32 +0200 Subject: [PATCH 309/312] added some of the classes to host init --- client/ayon_core/host/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ayon_core/host/__init__.py b/client/ayon_core/host/__init__.py index ef5c324028..a20165bce2 100644 --- a/client/ayon_core/host/__init__.py +++ b/client/ayon_core/host/__init__.py @@ -1,6 +1,8 @@ from .constants import ContextChangeReason +from .abstract import AbstractHost from .host import ( HostBase, + HostContextData, ) from .interfaces import ( @@ -18,7 +20,10 @@ from .dirmap import HostDirmap __all__ = ( "ContextChangeReason", + "AbstractHost", + "HostBase", + "HostContextData", "IWorkfileHost", "WorkfileInfo", From 53d0d4985a1d1d1dfbcc8d9ec63006afca5bf10e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 15 Aug 2025 12:47:45 +0200 Subject: [PATCH 310/312] use 'AbstractHost' for type checking --- .../ayon_core/host/interfaces/interfaces.py | 8 ++++---- client/ayon_core/pipeline/context_tools.py | 20 +++++++++---------- .../workfile/workfile_template_builder.py | 16 +++++++-------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/client/ayon_core/host/interfaces/interfaces.py b/client/ayon_core/host/interfaces/interfaces.py index 8b7005085e..6f9a3d8c87 100644 --- a/client/ayon_core/host/interfaces/interfaces.py +++ b/client/ayon_core/host/interfaces/interfaces.py @@ -26,7 +26,7 @@ class ILoadHost(AbstractHost): loading. Checks only existence of methods. Args: - Union[ModuleType, HostBase]: Object of host where to look for + Union[ModuleType, AbstractHost]: Object of host where to look for required methods. Returns: @@ -48,7 +48,7 @@ class ILoadHost(AbstractHost): """Validate implemented methods of "old type" host for load workflow. Args: - Union[ModuleType, HostBase]: Object of host to validate. + Union[ModuleType, AbstractHost]: Object of host to validate. Raises: MissingMethodsError: If there are missing methods on host @@ -101,7 +101,7 @@ class IPublishHost(AbstractHost): new publish creation. Checks only existence of methods. Args: - Union[ModuleType, HostBase]: Host module where to look for + Union[ModuleType, AbstractHost]: Host module where to look for required methods. Returns: @@ -129,7 +129,7 @@ class IPublishHost(AbstractHost): """Validate implemented methods of "old type" host. Args: - Union[ModuleType, HostBase]: Host module to validate. + Union[ModuleType, AbstractHost]: Host module to validate. Raises: MissingMethodsError: If there are missing methods on host diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 423e8f7216..0589eeb49f 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -13,7 +13,7 @@ import pyblish.api from pyblish.lib import MessageHandler from ayon_core import AYON_CORE_ROOT -from ayon_core.host import HostBase +from ayon_core.host import AbstractHost from ayon_core.lib import ( is_in_tests, initialize_ayon_connection, @@ -100,16 +100,16 @@ def registered_root(): return _registered_root["_"] -def install_host(host: HostBase) -> None: +def install_host(host: AbstractHost) -> None: """Install `host` into the running Python session. Args: - host (HostBase): A host interface object. + host (AbstractHost): A host interface object. """ - if not isinstance(host, HostBase): + if not isinstance(host, AbstractHost): log.error( - f"Host must be a subclass of 'HostBase', got '{type(host)}'." + f"Host must be a subclass of 'AbstractHost', got '{type(host)}'." ) global _is_installed @@ -310,7 +310,7 @@ def get_current_host_name(): """ host = registered_host() - if isinstance(host, HostBase): + if isinstance(host, AbstractHost): return host.name return os.environ.get("AYON_HOST_NAME") @@ -346,28 +346,28 @@ def get_global_context(): def get_current_context(): host = registered_host() - if isinstance(host, HostBase): + if isinstance(host, AbstractHost): return host.get_current_context() return get_global_context() def get_current_project_name(): host = registered_host() - if isinstance(host, HostBase): + if isinstance(host, AbstractHost): return host.get_current_project_name() return get_global_context()["project_name"] def get_current_folder_path(): host = registered_host() - if isinstance(host, HostBase): + if isinstance(host, AbstractHost): return host.get_current_folder_path() return get_global_context()["folder_path"] def get_current_task_name(): host = registered_host() - if isinstance(host, HostBase): + if isinstance(host, AbstractHost): return host.get_current_task_name() return get_global_context()["task_name"] diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index e2add99752..4349585b82 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -30,7 +30,7 @@ from ayon_api import ( ) from ayon_core.settings import get_project_settings -from ayon_core.host import IWorkfileHost, HostBase +from ayon_core.host import IWorkfileHost, AbstractHost from ayon_core.lib import ( Logger, StringTemplate, @@ -127,7 +127,7 @@ class AbstractTemplateBuilder(ABC): placeholder population. Args: - host (Union[HostBase, ModuleType]): Implementation of host. + host (Union[AbstractHost, ModuleType]): Implementation of host. """ _log = None @@ -135,7 +135,7 @@ class AbstractTemplateBuilder(ABC): def __init__(self, host): # Get host name - if isinstance(host, HostBase): + if isinstance(host, AbstractHost): host_name = host.name else: host_name = os.environ.get("AYON_HOST_NAME") @@ -163,24 +163,24 @@ class AbstractTemplateBuilder(ABC): @property def project_name(self): - if isinstance(self._host, HostBase): + if isinstance(self._host, AbstractHost): return self._host.get_current_project_name() return os.getenv("AYON_PROJECT_NAME") @property def current_folder_path(self): - if isinstance(self._host, HostBase): + if isinstance(self._host, AbstractHost): return self._host.get_current_folder_path() return os.getenv("AYON_FOLDER_PATH") @property def current_task_name(self): - if isinstance(self._host, HostBase): + if isinstance(self._host, AbstractHost): return self._host.get_current_task_name() return os.getenv("AYON_TASK_NAME") def get_current_context(self): - if isinstance(self._host, HostBase): + if isinstance(self._host, AbstractHost): return self._host.get_current_context() return { "project_name": self.project_name, @@ -256,7 +256,7 @@ class AbstractTemplateBuilder(ABC): """Access to host implementation. Returns: - Union[HostBase, ModuleType]: Implementation of host. + Union[AbstractHost, ModuleType]: Implementation of host. """ return self._host From ec92be4cae509a5baa31b71725da4bd5c1d68c54 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 15 Aug 2025 12:48:10 +0200 Subject: [PATCH 311/312] simplified few type hints --- client/ayon_core/pipeline/create/context.py | 7 ++----- client/ayon_core/tools/publisher/abstract.py | 4 ++-- client/ayon_core/tools/sceneinventory/control.py | 4 ++-- client/ayon_core/tools/workfiles/models/workfiles.py | 9 ++------- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 929cc59d2a..bd7dd4414f 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -49,15 +49,12 @@ from .creator_plugins import ( discover_convertor_plugins, ) if typing.TYPE_CHECKING: - from ayon_core.host import HostBase from ayon_core.lib import AbstractAttrDef from ayon_core.lib.events import EventCallback, Event from .structures import CreatedInstance from .creator_plugins import BaseCreator - class PublishHost(HostBase, IPublishHost): - pass # Import of functions and classes that were moved to different file # TODO Should be removed in future release - Added 24/08/28, 0.4.3-dev.1 @@ -163,7 +160,7 @@ class CreateContext: context which should be handled by host. Args: - host (PublishHost): Host implementation which handles implementation + host (IPublishHost): Host implementation which handles implementation and global metadata. headless (bool): Context is created out of UI (Current not used). reset (bool): Reset context on initialization. @@ -173,7 +170,7 @@ class CreateContext: def __init__( self, - host: "PublishHost", + host: IPublishHost, headless: bool = False, reset: bool = True, discover_publish_plugins: bool = True, diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 6d0027d35d..14da15793d 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -13,7 +13,7 @@ from typing import ( ) from ayon_core.lib import AbstractAttrDef -from ayon_core.host import HostBase +from ayon_core.host import AbstractHost from ayon_core.pipeline.create import ( CreateContext, ConvertorItem, @@ -176,7 +176,7 @@ class AbstractPublisherBackend(AbstractPublisherCommon): pass @abstractmethod - def get_host(self) -> HostBase: + def get_host(self) -> AbstractHost: pass @abstractmethod diff --git a/client/ayon_core/tools/sceneinventory/control.py b/client/ayon_core/tools/sceneinventory/control.py index 60d9bc77a9..45f76a54ac 100644 --- a/client/ayon_core/tools/sceneinventory/control.py +++ b/client/ayon_core/tools/sceneinventory/control.py @@ -1,7 +1,7 @@ import ayon_api from ayon_core.lib.events import QueuedEventSystem -from ayon_core.host import HostBase +from ayon_core.host import ILoadHost from ayon_core.pipeline import ( registered_host, get_current_context, @@ -35,7 +35,7 @@ class SceneInventoryController: self._projects_model = ProjectsModel(self) self._event_system = self._create_event_system() - def get_host(self) -> HostBase: + def get_host(self) -> ILoadHost: return self._host def emit_event(self, topic, data=None, source=None): diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index d33a532222..5b5591fe43 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -14,7 +14,6 @@ from ayon_core.lib import ( Logger, ) from ayon_core.host import ( - HostBase, IWorkfileHost, WorkfileInfo, PublishedWorkfileInfo, @@ -49,19 +48,15 @@ if typing.TYPE_CHECKING: _NOT_SET = object() -class HostType(HostBase, IWorkfileHost): - pass - - class WorkfilesModel: """Workfiles model.""" def __init__( self, - host: HostType, + host: IWorkfileHost, controller: AbstractWorkfilesBackend ): - self._host: HostType = host + self._host: IWorkfileHost = host self._controller: AbstractWorkfilesBackend = controller self._log = Logger.get_logger("WorkfilesModel") From 644130ad7a4a09e9b51652773ec9cb83c12424a9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 15 Aug 2025 12:51:33 +0200 Subject: [PATCH 312/312] fix imported class --- client/ayon_core/host/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/host/__init__.py b/client/ayon_core/host/__init__.py index a20165bce2..950c14564e 100644 --- a/client/ayon_core/host/__init__.py +++ b/client/ayon_core/host/__init__.py @@ -2,7 +2,7 @@ from .constants import ContextChangeReason from .abstract import AbstractHost from .host import ( HostBase, - HostContextData, + ContextChangeData, ) from .interfaces import ( @@ -23,7 +23,7 @@ __all__ = ( "AbstractHost", "HostBase", - "HostContextData", + "ContextChangeData", "IWorkfileHost", "WorkfileInfo",