From 0a207ad032273365f41b9032631d9eb26dfc0d5e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 20 Jun 2024 20:19:08 +0200 Subject: [PATCH 001/781] Add simple Create Project Structure launcher action --- .../create_project_folder_structure.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 client/ayon_core/plugins/actions/create_project_folder_structure.py diff --git a/client/ayon_core/plugins/actions/create_project_folder_structure.py b/client/ayon_core/plugins/actions/create_project_folder_structure.py new file mode 100644 index 0000000000..6df52abbcb --- /dev/null +++ b/client/ayon_core/plugins/actions/create_project_folder_structure.py @@ -0,0 +1,20 @@ +from ayon_core.pipeline import LauncherAction +from ayon_core.pipeline import project_folders + + +class CreateProjectStructureAction(LauncherAction): + """Create project structure as defined in settings.""" + name = "create_project_structure" + label = "Create Project Structure" + icon = "sitemap" + color = "#e0e1e1" + order = 1000 + + def is_compatible(self, selection) -> bool: + return ( + selection.is_project_selected and + not selection.is_folder_selected + ) + + def process(self, selection, **kwargs): + project_folders.create_project_folders(selection.project_name) \ No newline at end of file From 905241f6f4c0674c173b8070579431802b8f1b88 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 20 Jun 2024 20:25:24 +0200 Subject: [PATCH 002/781] Cosmetics --- .../plugins/actions/create_project_folder_structure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/actions/create_project_folder_structure.py b/client/ayon_core/plugins/actions/create_project_folder_structure.py index 6df52abbcb..e555f08301 100644 --- a/client/ayon_core/plugins/actions/create_project_folder_structure.py +++ b/client/ayon_core/plugins/actions/create_project_folder_structure.py @@ -17,4 +17,4 @@ class CreateProjectStructureAction(LauncherAction): ) def process(self, selection, **kwargs): - project_folders.create_project_folders(selection.project_name) \ No newline at end of file + project_folders.create_project_folders(selection.project_name) From fbd27357729146efed795db1302cc97897807621 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 20 Jun 2024 20:26:47 +0200 Subject: [PATCH 003/781] Cosmetics --- .../plugins/actions/create_project_folder_structure.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/actions/create_project_folder_structure.py b/client/ayon_core/plugins/actions/create_project_folder_structure.py index e555f08301..95c46099d7 100644 --- a/client/ayon_core/plugins/actions/create_project_folder_structure.py +++ b/client/ayon_core/plugins/actions/create_project_folder_structure.py @@ -1,5 +1,4 @@ -from ayon_core.pipeline import LauncherAction -from ayon_core.pipeline import project_folders +from ayon_core.pipeline import LauncherAction, project_folders class CreateProjectStructureAction(LauncherAction): From 18f750f39a893d93fe66c5447c7c033c531e8baa Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 21 Jun 2024 09:38:57 +0200 Subject: [PATCH 004/781] Update client/ayon_core/plugins/actions/create_project_folder_structure.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../plugins/actions/create_project_folder_structure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/actions/create_project_folder_structure.py b/client/ayon_core/plugins/actions/create_project_folder_structure.py index 95c46099d7..c12db9b353 100644 --- a/client/ayon_core/plugins/actions/create_project_folder_structure.py +++ b/client/ayon_core/plugins/actions/create_project_folder_structure.py @@ -11,8 +11,8 @@ class CreateProjectStructureAction(LauncherAction): def is_compatible(self, selection) -> bool: return ( - selection.is_project_selected and - not selection.is_folder_selected + selection.is_project_selected + and not selection.is_folder_selected ) def process(self, selection, **kwargs): From 56fb5c4c021777664b64922b153499ee7cf8ee26 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 4 Oct 2024 11:08:56 +0200 Subject: [PATCH 005/781] Rename action to Create Project Folders --- ...ject_folder_structure.py => create_project_folders.py} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename client/ayon_core/plugins/actions/{create_project_folder_structure.py => create_project_folders.py} (68%) diff --git a/client/ayon_core/plugins/actions/create_project_folder_structure.py b/client/ayon_core/plugins/actions/create_project_folders.py similarity index 68% rename from client/ayon_core/plugins/actions/create_project_folder_structure.py rename to client/ayon_core/plugins/actions/create_project_folders.py index c12db9b353..2dd26b578a 100644 --- a/client/ayon_core/plugins/actions/create_project_folder_structure.py +++ b/client/ayon_core/plugins/actions/create_project_folders.py @@ -1,10 +1,10 @@ from ayon_core.pipeline import LauncherAction, project_folders -class CreateProjectStructureAction(LauncherAction): - """Create project structure as defined in settings.""" - name = "create_project_structure" - label = "Create Project Structure" +class CreateProjectFoldersAction(LauncherAction): + """Create project folders as defined in settings.""" + name = "create_project_folders" + label = "Create Project Folders" icon = "sitemap" color = "#e0e1e1" order = 1000 From 8383fdefa40814aa40fe3ca1ef311f7261f8cebb Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 4 Oct 2024 11:12:03 +0200 Subject: [PATCH 006/781] Hide action if setting is empty --- .../plugins/actions/create_project_folders.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/client/ayon_core/plugins/actions/create_project_folders.py b/client/ayon_core/plugins/actions/create_project_folders.py index 2dd26b578a..934a9169d7 100644 --- a/client/ayon_core/plugins/actions/create_project_folders.py +++ b/client/ayon_core/plugins/actions/create_project_folders.py @@ -10,6 +10,16 @@ class CreateProjectFoldersAction(LauncherAction): order = 1000 def is_compatible(self, selection) -> bool: + + # Disable when the project folder structure setting is empty + # in settings + project_settings = selection.get_project_settings() + folder_structure = ( + project_settings["core"]["project_folder_structure"] + ).strip() + if not folder_structure or folder_structure == "{}": + return False + return ( selection.is_project_selected and not selection.is_folder_selected From 5aa7e9c8caa7a400f0b01db3a12a8e9e49fbed64 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 4 Oct 2024 11:17:33 +0200 Subject: [PATCH 007/781] Add description to setting --- server/settings/main.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/settings/main.py b/server/settings/main.py index 249bab85fd..5eaacc7072 100644 --- a/server/settings/main.py +++ b/server/settings/main.py @@ -301,6 +301,11 @@ class CoreSettings(BaseSettingsModel): "{}", widget="textarea", title="Project folder structure", + description=( + "Defines project folders to create on 'Create project folders'." + " When empty, the action will be hidden from users in the" + " launcher because there is nothing to create." + ), section="---" ) project_environments: str = SettingsField( From 5065e0e8dbb4da9e4a05918abced984b2f077e5d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 4 Oct 2024 12:04:14 +0200 Subject: [PATCH 008/781] Explicit description to describe diff between ftrack and launcher --- server/settings/main.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/settings/main.py b/server/settings/main.py index 5eaacc7072..a5129e69af 100644 --- a/server/settings/main.py +++ b/server/settings/main.py @@ -303,8 +303,12 @@ class CoreSettings(BaseSettingsModel): title="Project folder structure", description=( "Defines project folders to create on 'Create project folders'." - " When empty, the action will be hidden from users in the" - " launcher because there is nothing to create." + "\n\n" + "- In the launcher, this only creates the folders on disk and " + " when the setting is empty it will be hidden from users in the" + " launcher.\n" + "- In `ayon-ftrack` this will create the folders on disk **and**" + " will also create ftrack entities. It is never hidden there." ), section="---" ) From b9774e491298fcf500f982c581c9a53da86a1588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 8 Oct 2024 15:39:22 +0200 Subject: [PATCH 009/781] :wrench: WIP on feature/909-define-basic-trait-type-using-dataclasses --- client/ayon_core/pipeline/traits/__init__.py | 22 + .../pipeline/traits/three_dimensional.py | 21 + client/ayon_core/pipeline/traits/trait.py | 35 ++ .../pipeline/traits/two_dimensional.py | 130 +++++ client/pyproject.toml | 1 + poetry.lock | 468 +++++++++++------- pyproject.toml | 10 +- 7 files changed, 515 insertions(+), 172 deletions(-) create mode 100644 client/ayon_core/pipeline/traits/__init__.py create mode 100644 client/ayon_core/pipeline/traits/three_dimensional.py create mode 100644 client/ayon_core/pipeline/traits/trait.py create mode 100644 client/ayon_core/pipeline/traits/two_dimensional.py diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py new file mode 100644 index 0000000000..e85e88269b --- /dev/null +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -0,0 +1,22 @@ +"""Trait classes for the pipeline.""" +from .trait import TraitBase +from .two_dimensional import ( + Compressed, + Deep, + Image, + Overscan, + PixelBased, + Planar, +) + +__all__ = [ + # base + "TraitBase", + # two-dimensional + "Image", + "PixelBased", + "Planar", + "Deep", + "Compressed", + "Overscan", +] diff --git a/client/ayon_core/pipeline/traits/three_dimensional.py b/client/ayon_core/pipeline/traits/three_dimensional.py new file mode 100644 index 0000000000..c27b7ee56b --- /dev/null +++ b/client/ayon_core/pipeline/traits/three_dimensional.py @@ -0,0 +1,21 @@ +"""Two-dimensional image traits.""" +from pydantic import Field + +from .trait import TraitBase + + +class Spatial(TraitBase): + """Spatial trait model. + + Attributes: + up_axis (str): Up axis. + handedness (str): Handedness. + meters_per_unit (float): Meters per unit. + + """ + id: str = "ayon.content.Spatial.v1" + name: str = "Spatial" + description = "Spatial trait model." + up_axis: str = Field(..., title="Up axis") + handedness: str = Field(..., title="Handedness") + meters_per_unit: float = Field(..., title="Meters per unit") diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py new file mode 100644 index 0000000000..0ac2d63cdd --- /dev/null +++ b/client/ayon_core/pipeline/traits/trait.py @@ -0,0 +1,35 @@ +"""Defines the base trait model.""" +from pydantic import BaseModel, Field + + +def camelize(src: str) -> str: + """Convert snake_case to camelCase.""" + components = src.split("_") + return components[0] + "".join(x.title() for x in components[1:]) + + +class TraitBase(BaseModel): + """Base trait model. + + This model must be used as a base for all trait models. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + as ``ayon.content.LocatableBundle.v1`` + + """ + + class Config: + """API model config.""" + + orm_mode = True + allow_population_by_field_name = True + alias_generator = camelize + + name: str = Field(..., title="Trait name") + description: str = Field(..., title="Trait description") + # id should be: ayon.content.LocatableBundle.v1 + id: str = Field(..., title="Trait ID", + description="Unique identifier for the trait.") diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py new file mode 100644 index 0000000000..cd180ffc6b --- /dev/null +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -0,0 +1,130 @@ +"""Two-dimensional image traits.""" +from pydantic import Field + +from .trait import TraitBase + + +class Image(TraitBase): + """Image trait model. + + This model represents an image trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + + """ + + name: str = "Image" + description = "Image Trait" + id: str = "ayon.content.Image.v1" + + +class PixelBased(TraitBase): + """PixelBased trait model. + + This model represents a pixel based trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + display_window_width (int): Width of the image display window. + display_window_height (int): Height of the image display window. + pixel_aspect_ratio (float): Pixel aspect ratio. + + """ + + name: str = "PixelBased" + description = "PixelBased Trait Model" + id: str = "ayon.content.PixelBased.v1" + display_window_width: int = Field(..., title="Display Window Width") + display_window_height: int = Field(..., title="Display Window Height") + pixel_aspect_ratio: float = Field(..., title="Pixel Aspect Ratio") + + +class Planar(TraitBase): + """Planar trait model. + + This model represents an Image with planar configuration. + + Todo (antirotor): Is this really a planar configuration? As with + bitplanes and everything? If it serves as differentiator for + Deep images, should it be named differently? Like Raster? + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + planar_configuration (str): Planar configuration. + + """ + + name: str = "Planar" + description = "Planar Trait Model" + id: str = "ayon.content.Planar.v1" + planar_configuration: str = Field(..., title="Planar-based Image") + + +class Deep(TraitBase): + """Deep trait model. + + This model represents a deep image trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + deep_data_type (str): Deep data type. + + """ + + name: str = "Deep" + description = "Deep Trait Model" + id: str = "ayon.content.Deep.v1" + deep_data_type: str = Field(..., title="Deep Data Type") + + +class Compressed(TraitBase): + """Compressed trait model. + + This model represents a compressed image trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + compression_type (str): Compression type. + + """ + + name: str = "Compressed" + description = "Compressed Trait" + id: str = "ayon.content.Compressed.v1" + compression_type: str = Field(..., title="Compression Type") + + +class Overscan(TraitBase): + """Overscan trait model. + + This model represents an overscan (or underscan) trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + left (int): Left overscan/underscan. + right (int): Right overscan/underscan. + top (int): Top overscan/underscan. + bottom (int): Bottom overscan/underscan. + + """ + + name: str = "Overscan" + description = "Overscan Trait" + id: str = "ayon.content.Overscan.v1" + left: int = Field(..., title="Left Overscan") + right: int = Field(..., title="Right Overscan") + top: int = Field(..., title="Top Overscan") + bottom: int = Field(..., title="Bottom Overscan") diff --git a/client/pyproject.toml b/client/pyproject.toml index a0be9605b6..54685c5215 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -10,6 +10,7 @@ pyblish-base = "^1.8.11" speedcopy = "^2.1" six = "^1.15" qtawesome = "0.7.3" +pydantic = "^2.9.2" [ayon.runtimeDependencies] aiohttp-middlewares = "^2.0.0" diff --git a/poetry.lock b/poetry.lock index be5a3b2c2c..ba3956df39 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,15 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] [[package]] name = "appdirs" @@ -13,30 +24,29 @@ files = [ [[package]] name = "ayon-python-api" -version = "1.0.1" +version = "1.0.9" description = "AYON Python API" optional = false python-versions = "*" files = [ - {file = "ayon-python-api-1.0.1.tar.gz", hash = "sha256:6a53af84903317e2097f3c6bba0094e90d905d6670fb9c7d3ad3aa9de6552bc1"}, - {file = "ayon_python_api-1.0.1-py3-none-any.whl", hash = "sha256:d4b649ac39c9003cdbd60f172c0d35f05d310fba3a0649b6d16300fe67f967d6"}, + {file = "ayon-python-api-1.0.9.tar.gz", hash = "sha256:d1a3d467bdcb5a27120fed59d8996e97b6ef4d56a570b5957df922fc5ef58074"}, + {file = "ayon_python_api-1.0.9-py3-none-any.whl", hash = "sha256:0e4d623befe24bfb4c0c58746f49cbfe182d48ab13cd743177d1af3702e0de43"}, ] [package.dependencies] appdirs = ">=1,<2" requests = ">=2.27.1" -six = ">=1.15" -Unidecode = ">=1.2.0" +Unidecode = ">=1.3.0" [[package]] name = "certifi" -version = "2024.2.2" +version = "2024.8.30" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, - {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, ] [[package]] @@ -151,13 +161,13 @@ files = [ [[package]] name = "codespell" -version = "2.2.6" +version = "2.3.0" description = "Codespell" optional = false python-versions = ">=3.8" files = [ - {file = "codespell-2.2.6-py3-none-any.whl", hash = "sha256:9ee9a3e5df0990604013ac2a9f22fa8e57669c827124a2e961fe8a1da4cacc07"}, - {file = "codespell-2.2.6.tar.gz", hash = "sha256:a8c65d8eb3faa03deabab6b3bbe798bea72e1799c7e9e955d57eca4096abcff9"}, + {file = "codespell-2.3.0-py3-none-any.whl", hash = "sha256:a9c7cef2501c9cfede2110fd6d4e5e62296920efe9abfb84648df866e47f58d1"}, + {file = "codespell-2.3.0.tar.gz", hash = "sha256:360c7d10f75e65f67bad720af7007e1060a5d395670ec11a7ed1fed9dd17471f"}, ] [package.extras] @@ -190,13 +200,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.2.0" +version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [package.extras] @@ -204,29 +214,29 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.13.1" +version = "3.16.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, - {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, + {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, + {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, ] [package.extras] -docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] -typing = ["typing-extensions (>=4.8)"] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] +typing = ["typing-extensions (>=4.12.2)"] [[package]] name = "identify" -version = "2.5.35" +version = "2.6.1" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, - {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, + {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"}, + {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"}, ] [package.extras] @@ -234,15 +244,18 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.6" +version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" files = [ - {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, - {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "iniconfig" version = "2.0.0" @@ -256,53 +269,51 @@ files = [ [[package]] name = "nodeenv" -version = "1.8.0" +version = "1.9.1" description = "Node.js virtual environment builder" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[package.dependencies] -setuptools = "*" - [[package]] name = "packaging" -version = "24.0" +version = "24.1" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] name = "platformdirs" -version = "4.2.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, - {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, ] [package.extras] -docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] [[package]] name = "pluggy" -version = "1.4.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, - {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] @@ -311,13 +322,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.6.2" +version = "4.0.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-3.6.2-py2.py3-none-any.whl", hash = "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c"}, - {file = "pre_commit-3.6.2.tar.gz", hash = "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e"}, + {file = "pre_commit-4.0.0-py2.py3-none-any.whl", hash = "sha256:0ca2341cf94ac1865350970951e54b1a50521e57b7b500403307aed4315a1234"}, + {file = "pre_commit-4.0.0.tar.gz", hash = "sha256:5d9807162cc5537940f94f266cbe2d716a75cfad0d78a317a92cac16287cfed6"}, ] [package.dependencies] @@ -327,15 +338,136 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "pydantic" +version = "2.9.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.23.4" +typing-extensions = {version = ">=4.6.1", markers = "python_version < \"3.13\""} + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata"] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, + {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, + {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, + {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, + {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, + {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, + {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, + {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, + {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, + {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, + {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pytest" -version = "8.1.1" +version = "8.3.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, - {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, ] [package.dependencies] @@ -343,97 +475,100 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.4,<2.0" +pluggy = ">=1.5,<2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-print" -version = "1.0.0" +version = "1.0.2" description = "pytest-print adds the printer fixture you can use to print messages to the user (directly to the pytest runner, not stdout)" optional = false python-versions = ">=3.8" files = [ - {file = "pytest_print-1.0.0-py3-none-any.whl", hash = "sha256:23484f42b906b87e31abd564761efffeb0348a6f83109fb857ee6e8e5df42b69"}, - {file = "pytest_print-1.0.0.tar.gz", hash = "sha256:1fcde9945fba462227a8959271369b10bb7a193be8452162707e63cd60875ca0"}, + {file = "pytest_print-1.0.2-py3-none-any.whl", hash = "sha256:3ae7891085dddc3cd697bd6956787240107fe76d6b5cdcfcd782e33ca6543de9"}, + {file = "pytest_print-1.0.2.tar.gz", hash = "sha256:2780350a7bbe7117f99c5d708dc7b0431beceda021b1fd3f11200670d7f33679"}, ] [package.dependencies] -pytest = ">=7.4" +pytest = ">=8.3.2" [package.extras] -test = ["covdefaults (>=2.3)", "coverage (>=7.3)", "pytest-mock (>=3.11.1)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "pytest-mock (>=3.14)"] [[package]] name = "pyyaml" -version = "6.0.1" +version = "6.0.2" description = "YAML parser and emitter for Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] [[package]] name = "requests" -version = "2.31.0" +version = "2.32.3" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -448,66 +583,61 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "ruff" -version = "0.3.3" +version = "0.3.7" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:973a0e388b7bc2e9148c7f9be8b8c6ae7471b9be37e1cc732f8f44a6f6d7720d"}, - {file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfa60d23269d6e2031129b053fdb4e5a7b0637fc6c9c0586737b962b2f834493"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eca7ff7a47043cf6ce5c7f45f603b09121a7cc047447744b029d1b719278eb5"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7d3f6762217c1da954de24b4a1a70515630d29f71e268ec5000afe81377642d"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b24c19e8598916d9c6f5a5437671f55ee93c212a2c4c569605dc3842b6820386"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5a6cbf216b69c7090f0fe4669501a27326c34e119068c1494f35aaf4cc683778"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:352e95ead6964974b234e16ba8a66dad102ec7bf8ac064a23f95371d8b198aab"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d6ab88c81c4040a817aa432484e838aaddf8bfd7ca70e4e615482757acb64f8"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79bca3a03a759cc773fca69e0bdeac8abd1c13c31b798d5bb3c9da4a03144a9f"}, - {file = "ruff-0.3.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2700a804d5336bcffe063fd789ca2c7b02b552d2e323a336700abb8ae9e6a3f8"}, - {file = "ruff-0.3.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd66469f1a18fdb9d32e22b79f486223052ddf057dc56dea0caaf1a47bdfaf4e"}, - {file = "ruff-0.3.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45817af234605525cdf6317005923bf532514e1ea3d9270acf61ca2440691376"}, - {file = "ruff-0.3.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0da458989ce0159555ef224d5b7c24d3d2e4bf4c300b85467b08c3261c6bc6a8"}, - {file = "ruff-0.3.3-py3-none-win32.whl", hash = "sha256:f2831ec6a580a97f1ea82ea1eda0401c3cdf512cf2045fa3c85e8ef109e87de0"}, - {file = "ruff-0.3.3-py3-none-win_amd64.whl", hash = "sha256:be90bcae57c24d9f9d023b12d627e958eb55f595428bafcb7fec0791ad25ddfc"}, - {file = "ruff-0.3.3-py3-none-win_arm64.whl", hash = "sha256:0171aab5fecdc54383993389710a3d1227f2da124d76a2784a7098e818f92d61"}, - {file = "ruff-0.3.3.tar.gz", hash = "sha256:38671be06f57a2f8aba957d9f701ea889aa5736be806f18c0cd03d6ff0cbca8d"}, + {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0e8377cccb2f07abd25e84fc5b2cbe48eeb0fea9f1719cad7caedb061d70e5ce"}, + {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:15a4d1cc1e64e556fa0d67bfd388fed416b7f3b26d5d1c3e7d192c897e39ba4b"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d28bdf3d7dc71dd46929fafeec98ba89b7c3550c3f0978e36389b5631b793663"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:379b67d4f49774ba679593b232dcd90d9e10f04d96e3c8ce4a28037ae473f7bb"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c060aea8ad5ef21cdfbbe05475ab5104ce7827b639a78dd55383a6e9895b7c51"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ebf8f615dde968272d70502c083ebf963b6781aacd3079081e03b32adfe4d58a"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d48098bd8f5c38897b03604f5428901b65e3c97d40b3952e38637b5404b739a2"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da8a4fda219bf9024692b1bc68c9cff4b80507879ada8769dc7e985755d662ea"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c44e0149f1d8b48c4d5c33d88c677a4aa22fd09b1683d6a7ff55b816b5d074f"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3050ec0af72b709a62ecc2aca941b9cd479a7bf2b36cc4562f0033d688e44fa1"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a29cc38e4c1ab00da18a3f6777f8b50099d73326981bb7d182e54a9a21bb4ff7"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b15cc59c19edca917f51b1956637db47e200b0fc5e6e1878233d3a938384b0b"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e491045781b1e38b72c91247cf4634f040f8d0cb3e6d3d64d38dcf43616650b4"}, + {file = "ruff-0.3.7-py3-none-win32.whl", hash = "sha256:bc931de87593d64fad3a22e201e55ad76271f1d5bfc44e1a1887edd0903c7d9f"}, + {file = "ruff-0.3.7-py3-none-win_amd64.whl", hash = "sha256:5ef0e501e1e39f35e03c2acb1d1238c595b8bb36cf7a170e7c1df1b73da00e74"}, + {file = "ruff-0.3.7-py3-none-win_arm64.whl", hash = "sha256:789e144f6dc7019d1f92a812891c645274ed08af6037d11fc65fcbc183b7d59f"}, + {file = "ruff-0.3.7.tar.gz", hash = "sha256:d5c1aebee5162c2226784800ae031f660c350e7a3402c4d1f8ea4e97e232e3ba"}, ] [[package]] -name = "setuptools" -version = "69.2.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" +name = "semver" +version = "3.0.2" +description = "Python helper for Semantic Versioning (https://semver.org)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "setuptools-69.2.0-py3-none-any.whl", hash = "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"}, - {file = "setuptools-69.2.0.tar.gz", hash = "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, + {file = "semver-3.0.2-py3-none-any.whl", hash = "sha256:b1ea4686fe70b981f85359eda33199d60c53964284e0cfb4977d243e37cf4bf4"}, + {file = "semver-3.0.2.tar.gz", hash = "sha256:6253adb39c70f6e51afed2fa7152bcd414c411286088fb4b9effb133885ab4cc"}, ] [[package]] name = "tomli" -version = "2.0.1" +version = "2.0.2" description = "A lil' TOML parser" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, + {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, + {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] @@ -523,13 +653,13 @@ files = [ [[package]] name = "urllib3" -version = "2.2.1" +version = "2.2.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, - {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, ] [package.extras] @@ -540,13 +670,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.25.1" +version = "20.26.6" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, - {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, + {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, + {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, ] [package.dependencies] @@ -555,10 +685,10 @@ filelock = ">=3.12.2,<4" platformdirs = ">=3.9.1,<5" [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [metadata] lock-version = "2.0" python-versions = ">=3.9.1,<3.10" -content-hash = "1bb724694792fbc2b3c05e3355e6c25305d9f4034eb7b1b4b1791ee95427f8d2" +content-hash = "9a36c702dd5e991e030c3ecdd8367ffe88aa8515b4ad5cb498df98ba7afc41a7" diff --git a/pyproject.toml b/pyproject.toml index 0a7d0d76c9..db5912567f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,8 @@ readme = "README.md" [tool.poetry.dependencies] python = ">=3.9.1,<3.10" +pydantic = "^2.9.2" +pre-commit = "^4.0.0" [tool.poetry.dev-dependencies] @@ -21,7 +23,7 @@ pytest-print = "^1.0" ayon-python-api = "^1.0" # linting dependencies ruff = "^0.3.3" -pre-commit = "^3.6.2" +pre-commit = "^4" codespell = "^2.2.6" semver = "^3.0.2" @@ -63,13 +65,15 @@ exclude = [ line-length = 79 indent-width = 4 + # Assume Python 3.9 target-version = "py39" [tool.ruff.lint] +pydocstyle.convention = "google" # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -select = ["E4", "E7", "E9", "F", "W"] -ignore = [] +select = ["ALL"] +ignore = ["PTH", "ANN101", "ANN102", "ANN204", "COM812", "S603", "ERA001", "TRY003", "UP007", "ARG002"] # Allow fix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] From 88a4aa15ee3981647a065e2958d3a47dec6230de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 9 Oct 2024 17:36:20 +0200 Subject: [PATCH 010/781] :recycle: tweak ruff linting --- pyproject.toml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index db5912567f..71df7d7d8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ readme = "README.md" [tool.poetry.dependencies] python = ">=3.9.1,<3.10" pydantic = "^2.9.2" +typing-extensions=">=3.9.1,<3.10.0" pre-commit = "^4.0.0" @@ -73,7 +74,20 @@ target-version = "py39" pydocstyle.convention = "google" # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. select = ["ALL"] -ignore = ["PTH", "ANN101", "ANN102", "ANN204", "COM812", "S603", "ERA001", "TRY003", "UP007", "ARG002"] +ignore = [ + "PTH", + "ANN101", + "ANN102", + "ANN204", + "COM812", + "S603", + "ERA001", + "TRY003", + "UP006", # support for older python version (type vs. Type) + "UP007", # ..^ + "UP035", # .. + "ARG002" +] # Allow fix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] @@ -89,6 +103,7 @@ exclude = [ [tool.ruff.lint.per-file-ignores] "client/ayon_core/lib/__init__.py" = ["E402"] +"tests/*.py" = ["S101"] [tool.ruff.format] # Like Black, use double quotes for strings. From 092325e64e09dd4cd60c97654ecf14224bca2e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 9 Oct 2024 17:40:22 +0200 Subject: [PATCH 011/781] :art: implement `TraitsData` --- client/ayon_core/pipeline/traits/__init__.py | 11 +- client/ayon_core/pipeline/traits/content.py | 89 ++++++++ .../pipeline/traits/three_dimensional.py | 8 +- client/ayon_core/pipeline/traits/trait.py | 210 ++++++++++++++++-- .../pipeline/traits/two_dimensional.py | 50 ++--- 5 files changed, 307 insertions(+), 61 deletions(-) create mode 100644 client/ayon_core/pipeline/traits/content.py diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index e85e88269b..1dbac8764d 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -1,7 +1,8 @@ """Trait classes for the pipeline.""" -from .trait import TraitBase +from .content import Compressed, FileLocation, RootlessLocation +from .three_dimensional import Spatial +from .trait import TraitBase, TraitsData from .two_dimensional import ( - Compressed, Deep, Image, Overscan, @@ -12,6 +13,10 @@ from .two_dimensional import ( __all__ = [ # base "TraitBase", + "TraitsData", + # content + "FileLocation", + "RootlessLocation", # two-dimensional "Image", "PixelBased", @@ -19,4 +24,6 @@ __all__ = [ "Deep", "Compressed", "Overscan", + # three-dimensional + "Spatial", ] diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py new file mode 100644 index 0000000000..1db36856d9 --- /dev/null +++ b/client/ayon_core/pipeline/traits/content.py @@ -0,0 +1,89 @@ +"""Content traits for the pipeline.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, Optional + +from pydantic import Field + +from .trait import TraitBase + +if TYPE_CHECKING: + from pathlib import Path + + +class MimeType(TraitBase): + """MimeType trait model. + + This model represents a mime type trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + mime_type (str): Mime type. + + """ + + name: ClassVar[str] = "MimeType" + description: ClassVar[str] = "MimeType Trait Model" + id: ClassVar[str] = "ayon.content.MimeType.v1" + mime_type: str = Field(..., title="Mime Type") + +class FileLocation(TraitBase): + """FileLocation trait model. + + This model represents a file location trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + file_path (str): File path. + file_size (int): File size in bytes. + file_hash (str): File hash. + + """ + + name: ClassVar[str] = "FileLocation" + description: ClassVar[str] = "FileLocation Trait Model" + id: ClassVar[str] = "ayon.content.FileLocation.v1" + file_path: Path = Field(..., title="File Path") + file_size: int = Field(..., title="File Size") + file_hash: Optional[str] = Field(..., title="File Hash") + +class RootlessLocation(TraitBase): + """RootlessLocation trait model. + + This model represents a rootless location trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + rootless_path (str): Rootless path. + + """ + + name: ClassVar[str] = "RootlessLocation" + description: ClassVar[str] = "RootlessLocation Trait Model" + id: ClassVar[str] = "ayon.content.RootlessLocation.v1" + rootless_path: str = Field(..., title="File Path") + + +class Compressed(TraitBase): + """Compressed trait model. + + This model represents a compressed trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + compression_type (str): Compression type. + + """ + + name: ClassVar[str] = "Compressed" + description: ClassVar[str] = "Compressed Trait" + id: ClassVar[str] = "ayon.content.Compressed.v1" + compression_type: str = Field(..., title="Compression Type") diff --git a/client/ayon_core/pipeline/traits/three_dimensional.py b/client/ayon_core/pipeline/traits/three_dimensional.py index c27b7ee56b..0638700ab7 100644 --- a/client/ayon_core/pipeline/traits/three_dimensional.py +++ b/client/ayon_core/pipeline/traits/three_dimensional.py @@ -1,4 +1,6 @@ """Two-dimensional image traits.""" +from typing import ClassVar + from pydantic import Field from .trait import TraitBase @@ -13,9 +15,9 @@ class Spatial(TraitBase): meters_per_unit (float): Meters per unit. """ - id: str = "ayon.content.Spatial.v1" - name: str = "Spatial" - description = "Spatial trait model." + id: ClassVar[str] = "ayon.3d.Spatial.v1" + name: ClassVar[str] = "Spatial" + description: ClassVar[str] = "Spatial trait model." up_axis: str = Field(..., title="Up axis") handedness: str = Field(..., title="Handedness") meters_per_unit: float = Field(..., title="Meters per unit") diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 0ac2d63cdd..c6b7258535 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -1,35 +1,197 @@ """Defines the base trait model.""" -from pydantic import BaseModel, Field +from __future__ import annotations + +import inspect +import sys +from abc import ABC, abstractmethod +from functools import lru_cache +from typing import ClassVar, Optional, Type, Union + +import pydantic.alias_generators +from pydantic import AliasGenerator, BaseModel, ConfigDict -def camelize(src: str) -> str: - """Convert snake_case to camelCase.""" - components = src.split("_") - return components[0] + "".join(x.title() for x in components[1:]) - - -class TraitBase(BaseModel): +class TraitBase(ABC, BaseModel): """Base trait model. This model must be used as a base for all trait models. - Attributes: - name (str): Trait name. - description (str): Trait description. - id (str): id should be namespaced trait name with version - as ``ayon.content.LocatableBundle.v1`` - """ - class Config: - """API model config.""" + model_config = ConfigDict( + alias_generator=AliasGenerator( + serialization_alias=pydantic.alias_generators.to_camel, + ) + ) - orm_mode = True - allow_population_by_field_name = True - alias_generator = camelize + @property + @abstractmethod + def id(self) -> str: + """Abstract attribute for ID.""" + ... - name: str = Field(..., title="Trait name") - description: str = Field(..., title="Trait description") - # id should be: ayon.content.LocatableBundle.v1 - id: str = Field(..., title="Trait ID", - description="Unique identifier for the trait.") + @property + @abstractmethod + def name(self) -> str: + """Abstract attribute for name.""" + ... + + @property + @abstractmethod + def description(self) -> str: + """Abstract attribute for description.""" + ... + + + +class TraitsData: + """Traits data container. + + This model represents the data of a trait. + + """ + _data: dict + _module_blacklist: ClassVar[list[str]] = [ + "_", "builtins", "pydantic"] + + @lru_cache(maxsize=64) # noqa: B019 + def _get_trait_class(self, trait_id: str) -> Union[Type[TraitBase], None]: + """Get the trait class with corresponding to given ID. + + This method will search for the trait class in all the modules except + the blacklisted modules. There is some issue in Pydantic where + ``issubclass`` is not working properly so we are excluding explicitly + modules with offending classes. This list can be updated as needed to + speed up the search. + + Args: + trait_id (str): Trait ID. + + Returns: + Type[TraitBase]: Trait class. + + """ + modules = sys.modules.copy() + filtered_modules = modules.copy() + for module_name in modules: + for bl_module in self._module_blacklist: + if module_name.startswith(bl_module): + filtered_modules.pop(module_name) + + for module in filtered_modules.values(): + if not module: + continue + for _, klass in inspect.getmembers(module, inspect.isclass): + if inspect.isclass(klass) and \ + issubclass(klass, TraitBase) and \ + klass.id == trait_id: + return klass + return None + + + def add(self, trait: TraitBase, *, exists_ok: bool=False) -> None: + """Add a trait to the data. + + Args: + trait (TraitBase): Trait to add. + exists_ok (bool, optional): If True, do not raise an error if the + trait already exists. Defaults to False. + + Raises: + ValueError: If the trait ID is not provided or the trait already + exists. + + """ + if not trait.id: + error_msg = f"Invalid trait {trait} - ID is required." + raise ValueError(error_msg) + if trait.id in self._data and not exists_ok: + error_msg = f"Trait with ID {trait.id} already exists." + raise ValueError(error_msg) + self._data[trait.id] = trait + + def remove(self, + trait_id: Optional[str], + trait: Optional[Type[TraitBase]]) -> None: + """Remove a trait from the data. + + Args: + trait_id (str, optional): Trait ID. + trait (TraitBase, optional): Trait class. + + """ + if trait_id: + self._data.pop(trait_id) + elif trait: + self._data.pop(trait.id) + + def has_trait(self, + trait_id: Optional[str]=None, + trait: Optional[Type[TraitBase]]=None) -> bool: + """Check if the trait exists. + + Args: + trait_id (str, optional): Trait ID. + trait (TraitBase, optional): Trait class. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + if not trait_id: + trait_id = trait.id + return hasattr(self, trait_id) + + def get(self, + trait_id: Optional[str]=None, + trait: Optional[Type[TraitBase]]=None) -> Union[TraitBase, None]: + """Get a trait from the data. + + Args: + trait_id (str, optional): Trait ID. + trait (TraitBase, optional): Trait class. + + Returns: + TraitBase: Trait instance. + + """ + trait_class = None + if trait_id: + trait_class = self._get_trait_class(trait_id) + if not trait_class: + error_msg = f"Trait model with ID {trait_id} not found." + raise ValueError(error_msg) + + if trait: + trait_class = trait + trait_id = trait.id + + if not trait_class and not trait_id: + error_msg = "Trait ID or Trait class is required" + raise ValueError(error_msg) + + return self._data[trait_id] if self._data.get(trait_id) else None + + def as_dict(self) -> dict: + """Return the data as a dictionary. + + Returns: + dict: Data dictionary. + + """ + result = { + trait_id: dict(sorted(trait.dict())) + for trait_id, trait in self._data.items() + } + return dict(sorted(result)) + + def __len__(self): + """Return the length of the data.""" + return len(self._data) + + def __init__(self, traits: Optional[list[TraitBase]]): + """Initialize the data.""" + self._data = {} + if traits: + for trait in traits: + self.add(trait) diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py index cd180ffc6b..0b6fea315b 100644 --- a/client/ayon_core/pipeline/traits/two_dimensional.py +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -1,4 +1,6 @@ """Two-dimensional image traits.""" +from typing import ClassVar + from pydantic import Field from .trait import TraitBase @@ -16,9 +18,9 @@ class Image(TraitBase): """ - name: str = "Image" - description = "Image Trait" - id: str = "ayon.content.Image.v1" + name: ClassVar[str] = "Image" + description: ClassVar[str] = "Image Trait" + id: ClassVar[str] = "ayon.2d.Image.v1" class PixelBased(TraitBase): @@ -36,9 +38,9 @@ class PixelBased(TraitBase): """ - name: str = "PixelBased" - description = "PixelBased Trait Model" - id: str = "ayon.content.PixelBased.v1" + name: ClassVar[str] = "PixelBased" + description: ClassVar[str] = "PixelBased Trait Model" + id: ClassVar[str] = "ayon.2d.PixelBased.v1" display_window_width: int = Field(..., title="Display Window Width") display_window_height: int = Field(..., title="Display Window Height") pixel_aspect_ratio: float = Field(..., title="Pixel Aspect Ratio") @@ -49,7 +51,7 @@ class Planar(TraitBase): This model represents an Image with planar configuration. - Todo (antirotor): Is this really a planar configuration? As with + TODO (antirotor): Is this really a planar configuration? As with bitplanes and everything? If it serves as differentiator for Deep images, should it be named differently? Like Raster? @@ -61,9 +63,9 @@ class Planar(TraitBase): """ - name: str = "Planar" - description = "Planar Trait Model" - id: str = "ayon.content.Planar.v1" + name: ClassVar[str] = "Planar" + description: ClassVar[str] = "Planar Trait Model" + id: ClassVar[str] = "ayon.2d.Planar.v1" planar_configuration: str = Field(..., title="Planar-based Image") @@ -80,29 +82,13 @@ class Deep(TraitBase): """ - name: str = "Deep" - description = "Deep Trait Model" - id: str = "ayon.content.Deep.v1" + name: ClassVar[str] = "Deep" + description: ClassVar[str] = "Deep Trait Model" + id: ClassVar[str] = "ayon.2d.Deep.v1" deep_data_type: str = Field(..., title="Deep Data Type") -class Compressed(TraitBase): - """Compressed trait model. - This model represents a compressed image trait. - - Attributes: - name (str): Trait name. - description (str): Trait description. - id (str): id should be namespaced trait name with version - compression_type (str): Compression type. - - """ - - name: str = "Compressed" - description = "Compressed Trait" - id: str = "ayon.content.Compressed.v1" - compression_type: str = Field(..., title="Compression Type") class Overscan(TraitBase): @@ -121,9 +107,9 @@ class Overscan(TraitBase): """ - name: str = "Overscan" - description = "Overscan Trait" - id: str = "ayon.content.Overscan.v1" + name: ClassVar[str] = "Overscan" + description: ClassVar[str] = "Overscan Trait" + id: ClassVar[str] = "ayon.2d.Overscan.v1" left: int = Field(..., title="Left Overscan") right: int = Field(..., title="Right Overscan") top: int = Field(..., title="Top Overscan") From 3981a2e4da8f24e4b037d6fecf8547e6702863e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 10 Oct 2024 11:28:00 +0200 Subject: [PATCH 012/781] :alembic: add tests --- client/ayon_core/pipeline/traits/content.py | 7 +- client/ayon_core/pipeline/traits/trait.py | 13 +-- pyproject.toml | 3 +- .../ayon_core/pipeline/traits/__init__.py | 1 + .../ayon_core/pipeline/traits/test_traits.py | 83 +++++++++++++++++++ 5 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 tests/client/ayon_core/pipeline/traits/__init__.py create mode 100644 tests/client/ayon_core/pipeline/traits/test_traits.py diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 1db36856d9..3eec848f69 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -1,15 +1,14 @@ """Content traits for the pipeline.""" from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar, Optional +# TCH003 is there because Path in TYPECHECKING will fail in tests +from pathlib import Path # noqa: TCH003 +from typing import ClassVar, Optional from pydantic import Field from .trait import TraitBase -if TYPE_CHECKING: - from pathlib import Path - class MimeType(TraitBase): """MimeType trait model. diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index c6b7258535..8db3f091ae 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -4,6 +4,7 @@ from __future__ import annotations import inspect import sys from abc import ABC, abstractmethod +from collections import OrderedDict from functools import lru_cache from typing import ClassVar, Optional, Type, Union @@ -179,11 +180,13 @@ class TraitsData: dict: Data dictionary. """ - result = { - trait_id: dict(sorted(trait.dict())) - for trait_id, trait in self._data.items() - } - return dict(sorted(result)) + result = OrderedDict() + for trait_id, trait in self._data.items(): + if not trait or not trait_id: + continue + result[trait_id] = OrderedDict(trait.dict()) + + return result def __len__(self): """Return the length of the data.""" diff --git a/pyproject.toml b/pyproject.toml index 71df7d7d8b..8c9ff7cf5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,8 @@ ignore = [ "UP006", # support for older python version (type vs. Type) "UP007", # ..^ "UP035", # .. - "ARG002" + "ARG002", + "INP001", # add `__init__.py` to namespaced package ] # Allow fix for all enabled rules (when `--fix`) is provided. diff --git a/tests/client/ayon_core/pipeline/traits/__init__.py b/tests/client/ayon_core/pipeline/traits/__init__.py new file mode 100644 index 0000000000..ead0593ced --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/__init__.py @@ -0,0 +1 @@ +"""Tests for the representation traits.""" diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py new file mode 100644 index 0000000000..be8800f929 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -0,0 +1,83 @@ +"""Tests for the representation traits.""" +from __future__ import annotations + +from pathlib import Path + +import pytest +from ayon_core.pipeline.traits import ( + FileLocation, + Image, + PixelBased, + Planar, + TraitBase, + TraitsData, +) + +TRAITS_DATA = { + FileLocation.id: { + "file_path": Path("/path/to/file"), + "file_size": 1024, + "file_hash": None, + }, + Image.id: {}, + PixelBased.id: { + "display_window_width": 1920, + "display_window_height": 1080, + "pixel_aspect_ratio": 1.0, + }, + Planar.id: { + "planar_configuration": "RGB", + }, + } + + +@pytest.fixture() +def traits_data() -> TraitsData: + """Return a traits data instance.""" + return TraitsData(traits=[ + FileLocation(**TRAITS_DATA[FileLocation.id]), + Image(), + PixelBased(**TRAITS_DATA[PixelBased.id]), + Planar(**TRAITS_DATA[Planar.id]), + ]) + +def test_traits_data(traits_data: TraitsData) -> None: + """Test setting and getting traits.""" + assert len(traits_data) == len(TRAITS_DATA) + assert traits_data.get(trait_id=FileLocation.id) + assert traits_data.get(trait_id=Image.id) + assert traits_data.get(trait_id=PixelBased.id) + assert traits_data.get(trait_id=Planar.id) + + assert traits_data.get(trait=FileLocation) + assert traits_data.get(trait=Image) + assert traits_data.get(trait=PixelBased) + assert traits_data.get(trait=Planar) + + assert issubclass(type(traits_data.get(trait=FileLocation)), TraitBase) + + assert traits_data.get( + trait=FileLocation) == traits_data.get(trait_id=FileLocation.id) + assert traits_data.get( + trait=Image) == traits_data.get(trait_id=Image.id) + assert traits_data.get( + trait=PixelBased) == traits_data.get(trait_id=PixelBased.id) + assert traits_data.get( + trait=Planar) == traits_data.get(trait_id=Planar.id) + + assert traits_data.get(trait_id="ayon.2d.Image.v1") + assert traits_data.get(trait_id="ayon.2d.PixelBased.v1") + assert traits_data.get(trait_id="ayon.2d.Planar.v1") + + assert traits_data.get( + trait_id="ayon.2d.PixelBased.v1").display_window_width == \ + TRAITS_DATA[PixelBased.id]["display_window_width"] + assert traits_data.get( + trait=PixelBased).display_window_height == \ + TRAITS_DATA[PixelBased.id]["display_window_height"] + + +def test_traits_data_to_dict(traits_data: TraitsData) -> None: + """Test converting traits data to dictionary.""" + result = traits_data.as_dict() + assert result == TRAITS_DATA From 6d0730789884246a00366349f945d9ab956fb334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 10 Oct 2024 14:28:53 +0200 Subject: [PATCH 013/781] :recycle: refactor `TraitsData` to `Representation` added few helper methods to query/set/remove bunch of traits at once --- client/ayon_core/pipeline/traits/__init__.py | 4 +- client/ayon_core/pipeline/traits/trait.py | 118 +++++++++++++++--- .../ayon_core/pipeline/traits/test_traits.py | 94 ++++++++------ 3 files changed, 161 insertions(+), 55 deletions(-) diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index 1dbac8764d..429b307e3d 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -1,7 +1,7 @@ """Trait classes for the pipeline.""" from .content import Compressed, FileLocation, RootlessLocation from .three_dimensional import Spatial -from .trait import TraitBase, TraitsData +from .trait import Representation, TraitBase from .two_dimensional import ( Deep, Image, @@ -13,7 +13,7 @@ from .two_dimensional import ( __all__ = [ # base "TraitBase", - "TraitsData", + "Representation", # content "FileLocation", "RootlessLocation", diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 8db3f091ae..fc71b3d5d0 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -1,4 +1,4 @@ -"""Defines the base trait model.""" +"""Defines the base trait model and representation.""" from __future__ import annotations import inspect @@ -16,6 +16,9 @@ class TraitBase(ABC, BaseModel): """Base trait model. This model must be used as a base for all trait models. + It is using Pydantic BaseModel for serialization and validation. + ``id``, ``name``, and ``description`` are abstract attributes that must be + implemented in the derived classes. """ @@ -45,10 +48,15 @@ class TraitBase(ABC, BaseModel): -class TraitsData: - """Traits data container. +class Representation: + """Representation of products. - This model represents the data of a trait. + Representation defines collection of individual properties that describe + the specific "form" of the product. Each property is represented by a + trait therefore the Representation is a collection of traits. + + It holds methods to add, remove, get, and check for the existence of a + trait in the representation. It also provides a method to get all the """ _data: dict @@ -90,8 +98,8 @@ class TraitsData: return None - def add(self, trait: TraitBase, *, exists_ok: bool=False) -> None: - """Add a trait to the data. + def add_trait(self, trait: TraitBase, *, exists_ok: bool=False) -> None: + """Add a trait to the Representation. Args: trait (TraitBase): Trait to add. @@ -111,9 +119,22 @@ class TraitsData: raise ValueError(error_msg) self._data[trait.id] = trait - def remove(self, - trait_id: Optional[str], - trait: Optional[Type[TraitBase]]) -> None: + def add_traits( + self, traits: list[TraitBase], *, exists_ok: bool=False) -> None: + """Add a list of traits to the Representation. + + Args: + traits (list[TraitBase]): List of traits to add. + exists_ok (bool, optional): If True, do not raise an error if the + trait already exists. Defaults to False. + + """ + for trait in traits: + self.add_trait(trait, exists_ok=exists_ok) + + def remove_trait(self, + trait_id: Optional[str]=None, + trait: Optional[Type[TraitBase]]=None) -> None: """Remove a trait from the data. Args: @@ -126,6 +147,23 @@ class TraitsData: elif trait: self._data.pop(trait.id) + def remove_traits(self, + trait_ids: Optional[list[str]]=None, + traits: Optional[list[Type[TraitBase]]]=None) -> None: + """Remove a list of traits from the Representation. + + Args: + trait_ids (list[str], optional): List of trait IDs. + traits (list[TraitBase], optional): List of trait classes. + + """ + if trait_ids: + for trait_id in trait_ids: + self.remove_trait(trait_id=trait_id) + elif traits: + for trait in traits: + self.remove_trait(trait=trait) + def has_trait(self, trait_id: Optional[str]=None, trait: Optional[Type[TraitBase]]=None) -> bool: @@ -143,10 +181,34 @@ class TraitsData: trait_id = trait.id return hasattr(self, trait_id) - def get(self, - trait_id: Optional[str]=None, - trait: Optional[Type[TraitBase]]=None) -> Union[TraitBase, None]: - """Get a trait from the data. + def has_traits(self, + trait_ids: Optional[list[str]]=None, + traits: Optional[list[Type[TraitBase]]]=None) -> bool: + """Check if the traits exist. + + Args: + trait_ids (list[str], optional): List of trait IDs. + traits (list[TraitBase], optional): List of trait classes. + + Returns: + bool: True if all traits exist, False otherwise. + + """ + if trait_ids: + for trait_id in trait_ids: + if not self.has_trait(trait_id=trait_id): + return False + elif traits: + for trait in traits: + if not self.has_trait(trait=trait): + return False + return True + + def get_trait(self, + trait_id: Optional[str]=None, + trait: Optional[Type[TraitBase]]=None + ) -> Union[TraitBase, None]: + """Get a trait from the representation. Args: trait_id (str, optional): Trait ID. @@ -173,11 +235,33 @@ class TraitsData: return self._data[trait_id] if self._data.get(trait_id) else None - def as_dict(self) -> dict: - """Return the data as a dictionary. + def get_traits(self, + trait_ids: Optional[list[str]]=None, + traits: Optional[list[Type[TraitBase]]]=None) -> dict: + """Get a list of traits from the representation. + + Args: + trait_ids (list[str], optional): List of trait IDs. + traits (list[TraitBase], optional): List of trait classes. + + Returns: + dict: Dictionary of traits. + + """ + result = {} + if trait_ids: + for trait_id in trait_ids: + result[trait_id] = self.get_trait(trait_id=trait_id) + elif traits: + for trait in traits: + result[trait.id] = self.get_trait(trait=trait) + return result + + def traits_as_dict(self) -> dict: + """Return the traits from Representation data as a dictionary. Returns: - dict: Data dictionary. + dict: Traits data dictionary. """ result = OrderedDict() @@ -197,4 +281,4 @@ class TraitsData: self._data = {} if traits: for trait in traits: - self.add(trait) + self.add_trait(trait) diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index be8800f929..0bf08219ed 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -9,11 +9,11 @@ from ayon_core.pipeline.traits import ( Image, PixelBased, Planar, + Representation, TraitBase, - TraitsData, ) -TRAITS_DATA = { +REPRESENTATION_DATA = { FileLocation.id: { "file_path": Path("/path/to/file"), "file_size": 1024, @@ -32,52 +32,74 @@ TRAITS_DATA = { @pytest.fixture() -def traits_data() -> TraitsData: +def representation() -> Representation: """Return a traits data instance.""" - return TraitsData(traits=[ - FileLocation(**TRAITS_DATA[FileLocation.id]), + return Representation(traits=[ + FileLocation(**REPRESENTATION_DATA[FileLocation.id]), Image(), - PixelBased(**TRAITS_DATA[PixelBased.id]), - Planar(**TRAITS_DATA[Planar.id]), + PixelBased(**REPRESENTATION_DATA[PixelBased.id]), + Planar(**REPRESENTATION_DATA[Planar.id]), ]) -def test_traits_data(traits_data: TraitsData) -> None: +def test_representation_traits(representation: Representation) -> None: """Test setting and getting traits.""" - assert len(traits_data) == len(TRAITS_DATA) - assert traits_data.get(trait_id=FileLocation.id) - assert traits_data.get(trait_id=Image.id) - assert traits_data.get(trait_id=PixelBased.id) - assert traits_data.get(trait_id=Planar.id) + assert len(representation) == len(REPRESENTATION_DATA) + assert representation.get_trait(trait_id=FileLocation.id) + assert representation.get_trait(trait_id=Image.id) + assert representation.get_trait(trait_id=PixelBased.id) + assert representation.get_trait(trait_id=Planar.id) - assert traits_data.get(trait=FileLocation) - assert traits_data.get(trait=Image) - assert traits_data.get(trait=PixelBased) - assert traits_data.get(trait=Planar) + assert representation.get_trait(trait=FileLocation) + assert representation.get_trait(trait=Image) + assert representation.get_trait(trait=PixelBased) + assert representation.get_trait(trait=Planar) - assert issubclass(type(traits_data.get(trait=FileLocation)), TraitBase) + assert issubclass( + type(representation.get_trait(trait=FileLocation)), TraitBase) - assert traits_data.get( - trait=FileLocation) == traits_data.get(trait_id=FileLocation.id) - assert traits_data.get( - trait=Image) == traits_data.get(trait_id=Image.id) - assert traits_data.get( - trait=PixelBased) == traits_data.get(trait_id=PixelBased.id) - assert traits_data.get( - trait=Planar) == traits_data.get(trait_id=Planar.id) + assert representation.get_trait( + trait=FileLocation) == representation.get_trait( + trait_id=FileLocation.id) + assert representation.get_trait( + trait=Image) == representation.get_trait( + trait_id=Image.id) + assert representation.get_trait( + trait=PixelBased) == representation.get_trait( + trait_id=PixelBased.id) + assert representation.get_trait( + trait=Planar) == representation.get_trait( + trait_id=Planar.id) - assert traits_data.get(trait_id="ayon.2d.Image.v1") - assert traits_data.get(trait_id="ayon.2d.PixelBased.v1") - assert traits_data.get(trait_id="ayon.2d.Planar.v1") + assert representation.get_trait(trait_id="ayon.2d.Image.v1") + assert representation.get_trait(trait_id="ayon.2d.PixelBased.v1") + assert representation.get_trait(trait_id="ayon.2d.Planar.v1") - assert traits_data.get( + assert representation.get_trait( trait_id="ayon.2d.PixelBased.v1").display_window_width == \ - TRAITS_DATA[PixelBased.id]["display_window_width"] - assert traits_data.get( + REPRESENTATION_DATA[PixelBased.id]["display_window_width"] + assert representation.get_trait( trait=PixelBased).display_window_height == \ - TRAITS_DATA[PixelBased.id]["display_window_height"] + REPRESENTATION_DATA[PixelBased.id]["display_window_height"] + +def test_getting_traits_data(representation: Representation) -> None: + """Test getting a batch of traits.""" + result = representation.get_traits( + trait_ids=[FileLocation.id, Image.id, PixelBased.id, Planar.id]) + assert result == { + "ayon.2d.Image.v1": Image(), + "ayon.2d.PixelBased.v1": PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + "ayon.2d.Planar.v1": Planar(planar_configuration="RGB"), + "ayon.content.FileLocation.v1": FileLocation( + file_path=Path("/path/to/file"), + file_size=1024, + file_hash=None) + } -def test_traits_data_to_dict(traits_data: TraitsData) -> None: +def test_traits_data_to_dict(representation: Representation) -> None: """Test converting traits data to dictionary.""" - result = traits_data.as_dict() - assert result == TRAITS_DATA + result = representation.traits_as_dict() + assert result == REPRESENTATION_DATA From 43edcb82b7dbca5f09f0a17186c605525a57c429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 10 Oct 2024 14:29:16 +0200 Subject: [PATCH 014/781] :alembic: add dependencies necessary to run tests --- poetry.lock | 257 ++++++++++++++++++++++++++++++------------------- pyproject.toml | 4 +- 2 files changed, 162 insertions(+), 99 deletions(-) diff --git a/poetry.lock b/poetry.lock index ba3956df39..3a188dcb4b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -22,6 +22,25 @@ files = [ {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + [[package]] name = "ayon-python-api" version = "1.0.9" @@ -62,103 +81,134 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.3.2" +version = "3.4.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, ] +[[package]] +name = "clique" +version = "2.0.0" +description = "Manage collections with common numerical component" +optional = false +python-versions = ">=3.0, <4.0" +files = [ + {file = "clique-2.0.0-py2.py3-none-any.whl", hash = "sha256:45e2a4c6078382e0b217e5e369494279cf03846d95ee601f93290bed5214c22e"}, + {file = "clique-2.0.0.tar.gz", hash = "sha256:6e1115dbf21b1726f4b3db9e9567a662d6bdf72487c4a0a1f8cb7f10cf4f4754"}, +] + +[package.extras] +dev = ["lowdown (>=0.2.0,<1)", "pytest (>=2.3.5,<5)", "pytest-cov (>=2,<3)", "pytest-runner (>=2.7,<3)", "sphinx (>=2,<4)", "sphinx-rtd-theme (>=0.1.6,<1)"] +doc = ["lowdown (>=0.2.0,<1)", "sphinx (>=2,<4)", "sphinx-rtd-theme (>=0.1.6,<1)"] +test = ["pytest (>=2.3.5,<5)", "pytest-cov (>=2,<3)", "pytest-runner (>=2.7,<3)"] + [[package]] name = "codespell" version = "2.3.0" @@ -189,13 +239,13 @@ files = [ [[package]] name = "distlib" -version = "0.3.8" +version = "0.3.9" description = "Distribution utilities" optional = false python-versions = "*" files = [ - {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, - {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, ] [[package]] @@ -322,13 +372,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "4.0.0" +version = "4.0.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-4.0.0-py2.py3-none-any.whl", hash = "sha256:0ca2341cf94ac1865350970951e54b1a50521e57b7b500403307aed4315a1234"}, - {file = "pre_commit-4.0.0.tar.gz", hash = "sha256:5d9807162cc5537940f94f266cbe2d716a75cfad0d78a317a92cac16287cfed6"}, + {file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"}, + {file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"}, ] [package.dependencies] @@ -338,6 +388,17 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "pyblish-base" +version = "1.8.12" +description = "Plug-in driven automation framework for content" +optional = false +python-versions = "*" +files = [ + {file = "pyblish-base-1.8.12.tar.gz", hash = "sha256:ebc184eb038864380555227a8b58055dd24ece7e6ef7f16d33416c718512871b"}, + {file = "pyblish_base-1.8.12-py2.py3-none-any.whl", hash = "sha256:2cbe956bfbd4175a2d7d22b344cd345800f4d4437153434ab658fc12646a11e8"}, +] + [[package]] name = "pydantic" version = "2.9.2" @@ -691,4 +752,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.9.1,<3.10" -content-hash = "9a36c702dd5e991e030c3ecdd8367ffe88aa8515b4ad5cb498df98ba7afc41a7" +content-hash = "cf5603d09b7f2409e56e0ceb13386813204a92cfbabbd23bcd8a2fad0db3aed4" diff --git a/pyproject.toml b/pyproject.toml index 8c9ff7cf5e..0abec2d5b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +13,10 @@ readme = "README.md" [tool.poetry.dependencies] python = ">=3.9.1,<3.10" pydantic = "^2.9.2" -typing-extensions=">=3.9.1,<3.10.0" pre-commit = "^4.0.0" +clique = "^2" +pyblish-base = "^1.8" +attrs = "^24.2.0" [tool.poetry.dev-dependencies] From e0a6e1767c6cd827d425a97ad612a7ae7b968af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 14 Oct 2024 22:36:16 +0200 Subject: [PATCH 015/781] :recycle: add traits and refactor api --- client/ayon_core/pipeline/traits/__init__.py | 29 ++- client/ayon_core/pipeline/traits/content.py | 29 ++- client/ayon_core/pipeline/traits/time.py | 81 +++++++ client/ayon_core/pipeline/traits/trait.py | 215 ++++++++++++------ .../ayon_core/pipeline/traits/test_traits.py | 164 ++++++++++--- 5 files changed, 410 insertions(+), 108 deletions(-) create mode 100644 client/ayon_core/pipeline/traits/time.py diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index 429b307e3d..2d17cccf7e 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -1,6 +1,13 @@ """Trait classes for the pipeline.""" -from .content import Compressed, FileLocation, RootlessLocation +from .content import ( + Bundle, + Compressed, + FileLocation, + MimeType, + RootlessLocation, +) from .three_dimensional import Spatial +from .time import Clip, GapPolicy, Sequence, SMPTETimecode from .trait import Representation, TraitBase from .two_dimensional import ( Deep, @@ -12,18 +19,30 @@ from .two_dimensional import ( __all__ = [ # base - "TraitBase", "Representation", + "TraitBase", + # content + "Bundle", + "Compressed", "FileLocation", + "MimeType", "RootlessLocation", + # two-dimensional + "Compressed", + "Deep", "Image", + "Overscan", "PixelBased", "Planar", - "Deep", - "Compressed", - "Overscan", + # three-dimensional "Spatial", + + # time + "Clip", + "GapPolicy", + "Sequence", + "SMPTETimecode", ] diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 3eec848f69..1cb7366928 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -7,7 +7,7 @@ from typing import ClassVar, Optional from pydantic import Field -from .trait import TraitBase +from .trait import Representation, TraitBase class MimeType(TraitBase): @@ -86,3 +86,30 @@ class Compressed(TraitBase): description: ClassVar[str] = "Compressed Trait" id: ClassVar[str] = "ayon.content.Compressed.v1" compression_type: str = Field(..., title="Compression Type") + + +class Bundle(TraitBase): + """Bundle trait model. + + This model list of independent Representation traits + that are bundled together. This is useful for representing + a collection of representations that are part of a single + entity. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + items (list[list[TraitBase]]): List of representations. + + """ + + name: ClassVar[str] = "Bundle" + description: ClassVar[str] = "Bundle Trait" + id: ClassVar[str] = "ayon.content.Bundle.v1" + items: list[list[TraitBase]] = Field( + ..., title="Bundles of traits") + + def to_representation(self) -> Representation: + """Convert to a representation.""" + return Representation(traits=self.items) diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py new file mode 100644 index 0000000000..de4ff53790 --- /dev/null +++ b/client/ayon_core/pipeline/traits/time.py @@ -0,0 +1,81 @@ +"""Temporal (time related) traits.""" +from enum import Enum, auto +from typing import ClassVar + +from pydantic import Field + +from .trait import TraitBase + + +class GapPolicy(Enum): + """Gap policy enumeration. + + Attributes: + forbidden (int): Gaps are forbidden. + missing (int): Gaps are interpreted as missing frames. + hold (int): Gaps are interpreted as hold frames (last existing frames). + black (int): Gaps are interpreted as black frames. + """ + forbidden = auto() + missing = auto() + hold = auto() + black = auto() + +class Clip(TraitBase): + """Clip trait model. + + Model representing a clip trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + frame_start (int): Frame start. + frame_end (int): Frame end. + frame_start_handle (int): Frame start handle. + frame_end_handle (int): Frame end handle. + + """ + name: ClassVar[str] = "Clip" + description: ClassVar[str] = "Clip Trait" + id: ClassVar[str] = "ayon.time.Clip.v1" + frame_start: int = Field(..., title="Frame Start") + frame_end: int = Field(..., title="Frame End") + frame_start_handle: int = Field(..., title="Frame Start Handle") + frame_end_handle: int = Field(..., title="Frame End Handle") + +class Sequence(Clip): + """Sequence trait model. + + This model represents a sequence trait. Based on the Clip trait, + adding handling for steps, gaps policy and frame padding. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + step (int): Frame step. + gaps_policy (GapPolicy): Gaps policy - how to handle gaps in + sequence. + frame_padding (int): Frame padding. + frame_regex (str): Frame regex - regular expression to match + frame numbers. + + """ + name: ClassVar[str] = "Sequence" + description: ClassVar[str] = "Sequence Trait Model" + id: ClassVar[str] = "ayon.time.Sequence.v1" + step: int = Field(..., title="Step") + gaps_policy: GapPolicy = Field( + GapPolicy.forbidden, title="Gaps Policy") + frame_padding: int = Field(..., title="Frame Padding") + frame_regex: str = Field(..., title="Frame Regex") + + +# Do we need one for drop and non-drop frame? +class SMPTETimecode(TraitBase): + """Timecode trait model.""" + name: ClassVar[str] = "Timecode" + description: ClassVar[str] = "SMPTE Timecode Trait" + id: ClassVar[str] = "ayon.time.SMPTETimecode.v1" + timecode: str = Field(..., title="SMPTE Timecode HH:MM:SS:FF") diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index fc71b3d5d0..6fa36aeb51 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -132,130 +132,197 @@ class Representation: for trait in traits: self.add_trait(trait, exists_ok=exists_ok) - def remove_trait(self, - trait_id: Optional[str]=None, - trait: Optional[Type[TraitBase]]=None) -> None: + def remove_trait(self, trait: Type[TraitBase]) -> None: """Remove a trait from the data. Args: - trait_id (str, optional): Trait ID. trait (TraitBase, optional): Trait class. - """ - if trait_id: - self._data.pop(trait_id) - elif trait: - self._data.pop(trait.id) + Raises: + ValueError: If the trait is not found. - def remove_traits(self, - trait_ids: Optional[list[str]]=None, - traits: Optional[list[Type[TraitBase]]]=None) -> None: + """ + try: + self._data.pop(trait.id) + except KeyError as e: + error_msg = f"Trait with ID {trait.id} not found." + raise ValueError(error_msg) from e + + def remove_trait_by_id(self, trait_id: str) -> None: + """Remove a trait from the data by its ID. + + Args: + trait_id (str): Trait ID. + + Raises: + ValueError: If the trait is not found. + + """ + try: + self._data.pop(trait_id) + except KeyError as e: + error_msg = f"Trait with ID {trait_id} not found." + raise ValueError(error_msg) from e + + def remove_traits(self, traits: list[Type[TraitBase]]) -> None: """Remove a list of traits from the Representation. + If no trait IDs or traits are provided, all traits will be removed. + + Args: + traits (list[TraitBase]): List of trait classes. + + """ + if not traits: + self._data = {} + return + + for trait in traits: + self.remove_trait(trait) + + def remove_traits_by_id(self, trait_ids: list[str]) -> None: + """Remove a list of traits from the Representation by their ID. + + If no trait IDs or traits are provided, all traits will be removed. + Args: trait_ids (list[str], optional): List of trait IDs. - traits (list[TraitBase], optional): List of trait classes. """ - if trait_ids: - for trait_id in trait_ids: - self.remove_trait(trait_id=trait_id) - elif traits: - for trait in traits: - self.remove_trait(trait=trait) + for trait_id in trait_ids: + self.remove_trait_by_id(trait_id) - def has_trait(self, - trait_id: Optional[str]=None, - trait: Optional[Type[TraitBase]]=None) -> bool: - """Check if the trait exists. + + def has_traits(self) -> bool: + """Check if the Representation has any traits. + + Returns: + bool: True if the Representation has any traits, False otherwise. + + """ + return bool(self._data) + + def contains_trait(self, trait: Type[TraitBase]) -> bool: + """Check if the trait exists in the Representation. Args: - trait_id (str, optional): Trait ID. - trait (TraitBase, optional): Trait class. + trait (TraitBase): Trait class. Returns: bool: True if the trait exists, False otherwise. """ - if not trait_id: - trait_id = trait.id - return hasattr(self, trait_id) + return bool(self._data.get(trait.id)) - def has_traits(self, - trait_ids: Optional[list[str]]=None, - traits: Optional[list[Type[TraitBase]]]=None) -> bool: + def contains_trait_by_id(self, trait_id: str) -> bool: + """Check if the trait exists using trait id. + + Args: + trait_id (str): Trait ID. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + return bool(self._data.get(trait_id)) + + def contains_traits(self, traits: list[Type[TraitBase]]) -> bool: """Check if the traits exist. Args: - trait_ids (list[str], optional): List of trait IDs. traits (list[TraitBase], optional): List of trait classes. Returns: bool: True if all traits exist, False otherwise. """ - if trait_ids: - for trait_id in trait_ids: - if not self.has_trait(trait_id=trait_id): - return False - elif traits: - for trait in traits: - if not self.has_trait(trait=trait): - return False - return True + return all(self.contains_trait(trait=trait) for trait in traits) - def get_trait(self, - trait_id: Optional[str]=None, - trait: Optional[Type[TraitBase]]=None - ) -> Union[TraitBase, None]: + def contains_traits_by_id(self, trait_ids: list[str]) -> bool: + """Check if the traits exist by id. + + If no trait IDs or traits are provided, it will check if the + representation has any traits. + + Args: + trait_ids (list[str]): List of trait IDs. + + Returns: + bool: True if all traits exist, False otherwise. + + """ + return all( + self.contains_trait_by_id(trait_id) for trait_id in trait_ids + ) + + def get_trait(self, trait: Type[TraitBase]) -> Union[TraitBase, None]: """Get a trait from the representation. Args: - trait_id (str, optional): Trait ID. trait (TraitBase, optional): Trait class. Returns: TraitBase: Trait instance. """ - trait_class = None - if trait_id: - trait_class = self._get_trait_class(trait_id) - if not trait_class: - error_msg = f"Trait model with ID {trait_id} not found." - raise ValueError(error_msg) + return self._data[trait.id] if self._data.get(trait.id) else None - if trait: - trait_class = trait - trait_id = trait.id + def get_trait_by_id(self, trait_id: str) -> Union[TraitBase, None]: + """Get a trait from the representation by id. - if not trait_class and not trait_id: - error_msg = "Trait ID or Trait class is required" + Args: + trait_id (str): Trait ID. + + Returns: + TraitBase: Trait instance. + + """ + trait_class = self._get_trait_class(trait_id) + if not trait_class: + error_msg = f"Trait model with ID {trait_id} not found." raise ValueError(error_msg) return self._data[trait_id] if self._data.get(trait_id) else None def get_traits(self, - trait_ids: Optional[list[str]]=None, traits: Optional[list[Type[TraitBase]]]=None) -> dict: - """Get a list of traits from the representation. + """Get a list of traits from the representation. - Args: - trait_ids (list[str], optional): List of trait IDs. - traits (list[TraitBase], optional): List of trait classes. + If no trait IDs or traits are provided, all traits will be returned. - Returns: - dict: Dictionary of traits. + Args: + traits (list[TraitBase], optional): List of trait classes. - """ - result = {} - if trait_ids: - for trait_id in trait_ids: - result[trait_id] = self.get_trait(trait_id=trait_id) - elif traits: - for trait in traits: - result[trait.id] = self.get_trait(trait=trait) - return result + Returns: + dict: Dictionary of traits. + + """ + result = {} + if not traits: + for trait_id in self._data: + result[trait_id] = self.get_trait_by_id(trait_id=trait_id) + return result + + for trait in traits: + result[trait.id] = self.get_trait(trait=trait) + return result + + def get_traits_by_ids(self, trait_ids: list[str]) -> dict: + """Get a list of traits from the representation by their id. + + If no trait IDs or traits are provided, all traits will be returned. + + Args: + trait_ids (list[str]): List of trait IDs. + + Returns: + dict: Dictionary of traits. + + """ + return { + trait_id: self.get_trait_by_id(trait_id) + for trait_id in trait_ids + } def traits_as_dict(self) -> dict: """Return the traits from Representation data as a dictionary. @@ -276,7 +343,7 @@ class Representation: """Return the length of the data.""" return len(self._data) - def __init__(self, traits: Optional[list[TraitBase]]): + def __init__(self, traits: Optional[list[TraitBase]]=None): """Initialize the data.""" self._data = {} if traits: diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index 0bf08219ed..e6ca0a1067 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -5,8 +5,10 @@ from pathlib import Path import pytest from ayon_core.pipeline.traits import ( + Bundle, FileLocation, Image, + MimeType, PixelBased, Planar, Representation, @@ -41,49 +43,111 @@ def representation() -> Representation: Planar(**REPRESENTATION_DATA[Planar.id]), ]) +def test_representation_errors(representation: Representation) -> None: + """Test errors in representation.""" + with pytest.raises(ValueError, + match="Trait ID or Trait class is required"): + representation.get_trait() + + with pytest.raises(ValueError, + match="Trait ID or Trait class is required"): + representation.contains_trait() + def test_representation_traits(representation: Representation) -> None: """Test setting and getting traits.""" assert len(representation) == len(REPRESENTATION_DATA) - assert representation.get_trait(trait_id=FileLocation.id) - assert representation.get_trait(trait_id=Image.id) - assert representation.get_trait(trait_id=PixelBased.id) - assert representation.get_trait(trait_id=Planar.id) + assert representation.get_trait_by_id(FileLocation.id) + assert representation.get_trait_by_id(Image.id) + assert representation.get_trait_by_id(trait_id="ayon.2d.Image.v1") + assert representation.get_trait_by_id(PixelBased.id) + assert representation.get_trait_by_id(trait_id="ayon.2d.PixelBased.v1") + assert representation.get_trait_by_id(Planar.id) + assert representation.get_trait_by_id(trait_id="ayon.2d.Planar.v1") - assert representation.get_trait(trait=FileLocation) - assert representation.get_trait(trait=Image) - assert representation.get_trait(trait=PixelBased) - assert representation.get_trait(trait=Planar) + assert representation.get_trait(FileLocation) + assert representation.get_trait(Image) + assert representation.get_trait(PixelBased) + assert representation.get_trait(Planar) assert issubclass( - type(representation.get_trait(trait=FileLocation)), TraitBase) + type(representation.get_trait(FileLocation)), TraitBase) - assert representation.get_trait( - trait=FileLocation) == representation.get_trait( - trait_id=FileLocation.id) - assert representation.get_trait( - trait=Image) == representation.get_trait( - trait_id=Image.id) - assert representation.get_trait( - trait=PixelBased) == representation.get_trait( - trait_id=PixelBased.id) - assert representation.get_trait( - trait=Planar) == representation.get_trait( - trait_id=Planar.id) + assert representation.get_trait(FileLocation) == \ + representation.get_trait_by_id(FileLocation.id) + assert representation.get_trait(Image) == \ + representation.get_trait_by_id(Image.id) + assert representation.get_trait(PixelBased) == \ + representation.get_trait_by_id(PixelBased.id) + assert representation.get_trait(Planar) == \ + representation.get_trait_by_id(Planar.id) - assert representation.get_trait(trait_id="ayon.2d.Image.v1") - assert representation.get_trait(trait_id="ayon.2d.PixelBased.v1") - assert representation.get_trait(trait_id="ayon.2d.Planar.v1") - - assert representation.get_trait( - trait_id="ayon.2d.PixelBased.v1").display_window_width == \ + assert representation.get_trait_by_id( + "ayon.2d.PixelBased.v1").display_window_width == \ REPRESENTATION_DATA[PixelBased.id]["display_window_width"] assert representation.get_trait( trait=PixelBased).display_window_height == \ REPRESENTATION_DATA[PixelBased.id]["display_window_height"] + repre_dict = { + FileLocation.id: FileLocation(**REPRESENTATION_DATA[FileLocation.id]), + Image.id: Image(), + PixelBased.id: PixelBased(**REPRESENTATION_DATA[PixelBased.id]), + Planar.id: Planar(**REPRESENTATION_DATA[Planar.id]), + } + assert representation.get_traits() == repre_dict + + assert representation.get_traits_by_ids( + trait_ids=[FileLocation.id, Image.id, PixelBased.id, Planar.id]) == \ + repre_dict + assert representation.get_traits( + [FileLocation, Image, PixelBased, Planar]) == \ + repre_dict + + assert representation.has_traits() is True + empty_representation = Representation(traits=[]) + assert empty_representation.has_traits() is False + + assert representation.contains_trait(trait=FileLocation) is True + assert representation.contains_traits([Image, FileLocation]) is True + assert representation.contains_trait_by_id(FileLocation.id) is True + assert representation.contains_traits_by_id( + trait_ids=[FileLocation.id, Image.id]) is True + + assert representation.contains_trait(trait=Bundle) is False + assert representation.contains_traits([Image, Bundle]) is False + assert representation.contains_trait_by_id(Bundle.id) is False + assert representation.contains_traits_by_id( + trait_ids=[FileLocation.id, Bundle.id]) is False + +def test_trait_removing(representation: Representation) -> None: + """Test removing traits.""" + assert representation.contains_trait_by_id("nonexistent") is False + with pytest.raises( + ValueError, match="Trait with ID nonexistent not found."): + representation.remove_trait_by_id("nonexistent") + + assert representation.contains_trait(trait=FileLocation) is True + representation.remove_trait(trait=FileLocation) + assert representation.contains_trait(trait=FileLocation) is False + + assert representation.contains_trait_by_id(Image.id) is True + representation.remove_trait_by_id(Image.id) + assert representation.contains_trait_by_id(Image.id) is False + + assert representation.contains_traits([PixelBased, Planar]) is True + representation.remove_traits([Planar, PixelBased]) + assert representation.contains_traits([PixelBased, Planar]) is False + + assert representation.has_traits() is False + + with pytest.raises( + ValueError, match=f"Trait with ID {Image.id} not found."): + representation.remove_trait(Image) + + def test_getting_traits_data(representation: Representation) -> None: """Test getting a batch of traits.""" - result = representation.get_traits( + result = representation.get_traits_by_ids( trait_ids=[FileLocation.id, Image.id, PixelBased.id, Planar.id]) assert result == { "ayon.2d.Image.v1": Image(), @@ -103,3 +167,47 @@ def test_traits_data_to_dict(representation: Representation) -> None: """Test converting traits data to dictionary.""" result = representation.traits_as_dict() assert result == REPRESENTATION_DATA + + +def test_bundles() -> None: + """Test bundle trait.""" + diffuse_texture = [ + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + FileLocation( + file_path=Path("/path/to/diffuse.jpg"), + file_size=1024, + file_hash=None), + MimeType(mime_type="image/jpeg"), + ] + bump_texture = [ + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + FileLocation( + file_path=Path("/path/to/bump.tif"), + file_size=1024, + file_hash=None), + MimeType(mime_type="image/tiff"), + ] + bundle = Bundle(items=[diffuse_texture, bump_texture]) + representation = Representation(traits=[bundle]) + + if representation.contains_trait(trait=Bundle): + assert representation.get_trait(trait=Bundle).items == [ + diffuse_texture, bump_texture + ] + + for item in representation.get_trait(trait=Bundle).items: + sub_representation = Representation(traits=item) + assert sub_representation.contains_trait(trait=Image) + assert sub_representation.get_trait(trait=MimeType).mime_type in [ + "image/jpeg", "image/tiff" + ] From 9b3e1cec0e39d2b19467673ee19165e2c09cb4c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 14 Oct 2024 22:47:31 +0200 Subject: [PATCH 016/781] :alembic: fix tests --- client/ayon_core/pipeline/traits/trait.py | 2 +- .../ayon_core/pipeline/traits/test_traits.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 6fa36aeb51..7d8221e370 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -111,7 +111,7 @@ class Representation: exists. """ - if not trait.id: + if not hasattr(trait, "id"): error_msg = f"Invalid trait {trait} - ID is required." raise ValueError(error_msg) if trait.id in self._data and not exists_ok: diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index e6ca0a1067..4c941f3e45 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -32,6 +32,9 @@ REPRESENTATION_DATA = { }, } +class InvalidTrait: + """Invalid trait class.""" + foo = "bar" @pytest.fixture() def representation() -> Representation: @@ -46,12 +49,16 @@ def representation() -> Representation: def test_representation_errors(representation: Representation) -> None: """Test errors in representation.""" with pytest.raises(ValueError, - match="Trait ID or Trait class is required"): - representation.get_trait() + match="Invalid trait .* - ID is required."): + representation.add_trait(InvalidTrait()) with pytest.raises(ValueError, - match="Trait ID or Trait class is required"): - representation.contains_trait() + match=f"Trait with ID {Image.id} already exists."): + representation.add_trait(Image()) + + with pytest.raises(ValueError, + match="Trait with ID .* not found."): + representation.remove_trait_by_id("foo") def test_representation_traits(representation: Representation) -> None: """Test setting and getting traits.""" From de4d5b955a473e74ce252357dabded77aad2e890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 16 Oct 2024 16:54:59 +0200 Subject: [PATCH 017/781] Auto stash before merge of "feature/909-define-basic-trait-type-using-dataclasses" and "origin/develop" --- client/ayon_core/pipeline/traits/__init__.py | 4 ++++ client/ayon_core/pipeline/traits/meta.py | 24 ++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 client/ayon_core/pipeline/traits/meta.py diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index 2d17cccf7e..ea3f90fb81 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -6,6 +6,7 @@ from .content import ( MimeType, RootlessLocation, ) +from .meta import Tagged from .three_dimensional import Spatial from .time import Clip, GapPolicy, Sequence, SMPTETimecode from .trait import Representation, TraitBase @@ -29,6 +30,9 @@ __all__ = [ "MimeType", "RootlessLocation", + # meta + "Tagged", + # two-dimensional "Compressed", "Deep", diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py new file mode 100644 index 0000000000..8c7150e6b6 --- /dev/null +++ b/client/ayon_core/pipeline/traits/meta.py @@ -0,0 +1,24 @@ +"""Metadata traits.""" +from typing import ClassVar, List + +from pydantic import Field + +from .trait import TraitBase + + +class Tagged(TraitBase): + """Tagged trait model. + + This model represents a tagged trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + tags (List[str]): Tags. + """ + + name: ClassVar[str] = "Tagged" + description: ClassVar[str] = "Tagged Trait Model" + id: ClassVar[str] = "ayon.meta.Tagged.v1" + tags: List[str] = Field(..., title="Tags") From 03dcc370e99d029af10db8bad0474b8175d0aaf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 16 Oct 2024 17:36:30 +0200 Subject: [PATCH 018/781] :art: add some more traits --- client/ayon_core/pipeline/traits/__init__.py | 10 ++++- client/ayon_core/pipeline/traits/lifecycle.py | 37 +++++++++++++++++++ client/ayon_core/pipeline/traits/meta.py | 20 ++++++++++ client/ayon_core/pipeline/traits/time.py | 3 ++ .../pipeline/traits/two_dimensional.py | 19 ++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 client/ayon_core/pipeline/traits/lifecycle.py diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index ea3f90fb81..bc9b1d69de 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -6,11 +6,13 @@ from .content import ( MimeType, RootlessLocation, ) -from .meta import Tagged +from .lifecycle import Persistent, Transient +from .meta import Tagged, TemplatePath from .three_dimensional import Spatial from .time import Clip, GapPolicy, Sequence, SMPTETimecode from .trait import Representation, TraitBase from .two_dimensional import ( + UDIM, Deep, Image, Overscan, @@ -30,8 +32,13 @@ __all__ = [ "MimeType", "RootlessLocation", + # life cycle + "Persistent", + "Transient", + # meta "Tagged", + "TemplatePath", # two-dimensional "Compressed", @@ -40,6 +47,7 @@ __all__ = [ "Overscan", "PixelBased", "Planar", + "UDIM", # three-dimensional "Spatial", diff --git a/client/ayon_core/pipeline/traits/lifecycle.py b/client/ayon_core/pipeline/traits/lifecycle.py new file mode 100644 index 0000000000..2877a4d396 --- /dev/null +++ b/client/ayon_core/pipeline/traits/lifecycle.py @@ -0,0 +1,37 @@ +"""Lifecycle traits.""" +from typing import ClassVar + +from .trait import TraitBase + + +class Transient(TraitBase): + """Transient trait model. + + This model represents a transient trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + tags (List[str]): Tags. + """ + + name: ClassVar[str] = "Transient" + description: ClassVar[str] = "Transient Trait Model" + id: ClassVar[str] = "ayon.lifecycle.Transient.v1" + + +class Persistent(TraitBase): + """Persistent trait model. + + This model represents a persistent trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + """ + + name: ClassVar[str] = "Persistent" + description: ClassVar[str] = "Persistent Trait Model" + id: ClassVar[str] = "ayon.lifecycle.Persistent.v1" diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py index 8c7150e6b6..9e3e6a43e7 100644 --- a/client/ayon_core/pipeline/traits/meta.py +++ b/client/ayon_core/pipeline/traits/meta.py @@ -22,3 +22,23 @@ class Tagged(TraitBase): description: ClassVar[str] = "Tagged Trait Model" id: ClassVar[str] = "ayon.meta.Tagged.v1" tags: List[str] = Field(..., title="Tags") + + +class TemplatePath(TraitBase): + """TemplatePath trait model. + + This model represents a template path with formatting data. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + template_path (str): Template path. + data (dict[str]): Formatting data. + """ + + name: ClassVar[str] = "TemplatePath" + description: ClassVar[str] = "Template Path Trait Model" + id: ClassVar[str] = "ayon.meta.TemplatePath.v1" + template: str = Field(..., title="Template Path") + data: dict[str] = Field(..., title="Formatting Data") diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index de4ff53790..b3741c46bc 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -60,6 +60,8 @@ class Sequence(Clip): frame_padding (int): Frame padding. frame_regex (str): Frame regex - regular expression to match frame numbers. + frame_list (str): Frame list specification of frames. This takes + string like "1-10,20-30,40-50" etc. """ name: ClassVar[str] = "Sequence" @@ -70,6 +72,7 @@ class Sequence(Clip): GapPolicy.forbidden, title="Gaps Policy") frame_padding: int = Field(..., title="Frame Padding") frame_regex: str = Field(..., title="Frame Regex") + frame_list: str = Field(..., title="Frame List") # Do we need one for drop and non-drop frame? diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py index 0b6fea315b..69e129ec41 100644 --- a/client/ayon_core/pipeline/traits/two_dimensional.py +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -114,3 +114,22 @@ class Overscan(TraitBase): right: int = Field(..., title="Right Overscan") top: int = Field(..., title="Top Overscan") bottom: int = Field(..., title="Bottom Overscan") + + +class UDIM(TraitBase): + """UDIM trait model. + + This model represents a UDIM trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + udim (int): UDIM value. + + """ + + name: ClassVar[str] = "UDIM" + description: ClassVar[str] = "UDIM Trait" + id: ClassVar[str] = "ayon.2d.UDIM.v1" + udim: int = Field(..., title="UDIM") From 2b572ae773d9776de6ddf3d2c182fcebf6fbe415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 16 Oct 2024 17:51:56 +0200 Subject: [PATCH 019/781] :art: introduce name and id for representation --- client/ayon_core/pipeline/traits/meta.py | 2 +- client/ayon_core/pipeline/traits/trait.py | 23 +++++++++++++++++-- .../ayon_core/pipeline/traits/test_traits.py | 8 +++---- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py index 9e3e6a43e7..36ad5e8b0f 100644 --- a/client/ayon_core/pipeline/traits/meta.py +++ b/client/ayon_core/pipeline/traits/meta.py @@ -41,4 +41,4 @@ class TemplatePath(TraitBase): description: ClassVar[str] = "Template Path Trait Model" id: ClassVar[str] = "ayon.meta.TemplatePath.v1" template: str = Field(..., title="Template Path") - data: dict[str] = Field(..., title="Formatting Data") + data: dict = Field(..., title="Formatting Data") diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 7d8221e370..90be38225b 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -3,6 +3,7 @@ from __future__ import annotations import inspect import sys +import uuid from abc import ABC, abstractmethod from collections import OrderedDict from functools import lru_cache @@ -58,10 +59,16 @@ class Representation: It holds methods to add, remove, get, and check for the existence of a trait in the representation. It also provides a method to get all the + Arguments: + name (str): Representation name. Must be unique within instance. + representation_id (str): Representation ID. + """ _data: dict _module_blacklist: ClassVar[list[str]] = [ "_", "builtins", "pydantic"] + name: str + representation_id: str @lru_cache(maxsize=64) # noqa: B019 def _get_trait_class(self, trait_id: str) -> Union[Type[TraitBase], None]: @@ -343,8 +350,20 @@ class Representation: """Return the length of the data.""" return len(self._data) - def __init__(self, traits: Optional[list[TraitBase]]=None): - """Initialize the data.""" + def __init__( + self, + name: str, + representation_id: Optional[str]=None, + traits: Optional[list[TraitBase]]=None): + """Initialize the data. + + Args: + name (str): Representation name. Must be unique within instance. + representation_id (str, optional): Representation ID. + traits (list[TraitBase], optional): List of traits. + """ + self.name = name + self.representation_id = representation_id or uuid.uuid4().hex self._data = {} if traits: for trait in traits: diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index 4c941f3e45..fc38e8fb56 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -39,7 +39,7 @@ class InvalidTrait: @pytest.fixture() def representation() -> Representation: """Return a traits data instance.""" - return Representation(traits=[ + return Representation(name="test", traits=[ FileLocation(**REPRESENTATION_DATA[FileLocation.id]), Image(), PixelBased(**REPRESENTATION_DATA[PixelBased.id]), @@ -111,7 +111,7 @@ def test_representation_traits(representation: Representation) -> None: repre_dict assert representation.has_traits() is True - empty_representation = Representation(traits=[]) + empty_representation = Representation(name="test", traits=[]) assert empty_representation.has_traits() is False assert representation.contains_trait(trait=FileLocation) is True @@ -205,7 +205,7 @@ def test_bundles() -> None: MimeType(mime_type="image/tiff"), ] bundle = Bundle(items=[diffuse_texture, bump_texture]) - representation = Representation(traits=[bundle]) + representation = Representation(name="test_bundle", traits=[bundle]) if representation.contains_trait(trait=Bundle): assert representation.get_trait(trait=Bundle).items == [ @@ -213,7 +213,7 @@ def test_bundles() -> None: ] for item in representation.get_trait(trait=Bundle).items: - sub_representation = Representation(traits=item) + sub_representation = Representation(name="test", traits=item) assert sub_representation.contains_trait(trait=Image) assert sub_representation.get_trait(trait=MimeType).mime_type in [ "image/jpeg", "image/tiff" From 4e0f9b42372b45e35e1eaf144371431835abd23e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 17 Oct 2024 14:41:58 +0200 Subject: [PATCH 020/781] :wrench: WIP on the integrator --- client/ayon_core/plugins/__init__.py | 1 + client/ayon_core/plugins/publish/__init__.py | 1 + .../plugins/publish/integrate_traits.py | 102 ++++++++++++++++++ .../ayon_core/plugins/publish/__init__.py | 1 + .../plugins/publish/test_integrate_traits.py | 100 +++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 client/ayon_core/plugins/__init__.py create mode 100644 client/ayon_core/plugins/publish/__init__.py create mode 100644 client/ayon_core/plugins/publish/integrate_traits.py create mode 100644 tests/client/ayon_core/plugins/publish/__init__.py create mode 100644 tests/client/ayon_core/plugins/publish/test_integrate_traits.py diff --git a/client/ayon_core/plugins/__init__.py b/client/ayon_core/plugins/__init__.py new file mode 100644 index 0000000000..376c7e5a4d --- /dev/null +++ b/client/ayon_core/plugins/__init__.py @@ -0,0 +1 @@ +"""AYON Core plugins.""" diff --git a/client/ayon_core/plugins/publish/__init__.py b/client/ayon_core/plugins/publish/__init__.py new file mode 100644 index 0000000000..86b6c901bc --- /dev/null +++ b/client/ayon_core/plugins/publish/__init__.py @@ -0,0 +1 @@ +"""AYON Core publish plugins.""" diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py new file mode 100644 index 0000000000..2a35135b84 --- /dev/null +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -0,0 +1,102 @@ +"""Integrate representations with traits.""" +import logging + +import pyblish.api + +from ayon_core.pipeline.publish import ( + get_publish_template_name, +) +from ayon_core.pipeline.traits import Persistent, Representation + + +class IntegrateTraits(pyblish.api.InstancePlugin): + """Integrate representations with traits.""" + + label = "Integrate Asset" + order = pyblish.api.IntegratorOrder + log: logging.Logger + + def process(self, instance: pyblish.api.Instance) -> None: + """Integrate representations with traits. + + Args: + instance (pyblish.api.Instance): Instance to process. + + """ + # 1) skip farm and integrate == False + + if not instance.data.get("integrate"): + self.log.debug("Instance is marked to skip integrating. Skipping") + return + + if instance.data.get("farm"): + self.log.debug( + "Instance is marked to be processed on farm. Skipping") + return + + # TODO (antirotor): Find better name for the key # noqa: FIX002, TD003 + if not instance.data.get("representations_with_traits"): + self.log.debug( + "Instance has no representations with traits. Skipping") + return + + # 2) filter representations based on LifeCycle traits + instance.data["representations_with_traits"] = self.filter_lifecycle( + instance.data["representations_with_traits"] + ) + + representations = instance.data["representations_with_traits"] + if not representations: + self.log.debug( + "Instance has no persistent representations. Skipping") + return + + # template_name = self.get_template_name(instance) + + @staticmethod + def filter_lifecycle( + representations: list[Representation]) -> list[Representation]: + """Filter representations based on LifeCycle traits. + + Args: + representations (list): List of representations. + + Returns: + list: Filtered representations. + + """ + return [ + representation + for representation in representations + if representation.contains_trait(Persistent) + ] + + def get_template_name(self, instance: pyblish.api.Instance) -> str: + """Return anatomy template name to use for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + + Returns: + str: Anatomy template name + + """ + # Anatomy data is pre-filled by Collectors + context = instance.context + project_name = context.data["projectName"] + + # Task can be optional in anatomy data + host_name = context.data["hostName"] + anatomy_data = instance.data["anatomyData"] + product_type = instance.data["productType"] + task_info = anatomy_data.get("task") or {} + + return get_publish_template_name( + project_name, + host_name, + product_type, + task_name=task_info.get("name"), + task_type=task_info.get("type"), + project_settings=context.data["project_settings"], + logger=self.log + ) diff --git a/tests/client/ayon_core/plugins/publish/__init__.py b/tests/client/ayon_core/plugins/publish/__init__.py new file mode 100644 index 0000000000..58e6112045 --- /dev/null +++ b/tests/client/ayon_core/plugins/publish/__init__.py @@ -0,0 +1 @@ +"""Test for pyblish plugins.""" diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py new file mode 100644 index 0000000000..b7d36d1252 --- /dev/null +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -0,0 +1,100 @@ +"""Tests for the representation traits.""" +from __future__ import annotations + +import base64 +from typing import TYPE_CHECKING + +import pyblish.api +import pytest +from ayon_core.pipeline.traits import ( + FileLocation, + GapPolicy, + Image, + Persistent, + PixelBased, + Representation, +) + +# Tagged, +# TemplatePath, +from ayon_core.plugins.publish.integrate_traits import IntegrateTraits +from pipeline.traits import MimeType, Sequence + +if TYPE_CHECKING: + from pathlib import Path + +PNG_FILE_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVR4AWNgAAAAAgABc3UBGAAAAABJRU5ErkJggg==" # noqa: E501 +SEQUENCE_LENGTH = 10 + +@pytest.fixture(scope="session") +def single_file(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Return a temporary image file.""" + filename = tmp_path_factory.mktemp("single") / "img.png" + with open(filename, "wb") as f: + f.write(base64.b64decode(PNG_FILE_B64)) + return filename + +def sequence_files(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: + """Return a sequence of temporary image files.""" + files = [] + for i in range(SEQUENCE_LENGTH): + filename = tmp_path_factory.mktemp("sequence") / f"img{i:04d}.png" + with open(filename, "wb") as f: + f.write(base64.b64decode(PNG_FILE_B64)) + files.append(filename) + return files + +@pytest.fixture() +def mock_context( + single_file: Path, + sequence_files: list[Path]) -> pyblish.api.Context: + """Return a mock instance.""" + context = pyblish.api.Context() + instance = context.create_instance("mock_instance") + + instance.data["integrate"] = True + instance.data["farm"] = False + + instance.data["representations_with_traits"] = [ + Representation(name="test_single", traits=[ + Persistent(), + FileLocation( + file_path=single_file, + file_size=len(base64.b64decode(PNG_FILE_B64))), + Image(), + MimeType(mime_type="image/png"), + ]), + Representation(name="test_sequence", traits=[ + Persistent(), + Sequence( + frame_start=1, + frame_end=SEQUENCE_LENGTH, + frame_padding=4, + gaps_policy=GapPolicy.forbidden, + frame_regex=r"img(\d{4}).png", + step=1, + frame_start_handle=0, + frame_end_handle=0, + frame_list=None + ), + FileLocation( + file_path=sequence_files[0], + file_size=len(base64.b64decode(PNG_FILE_B64))), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + MimeType(mime_type="image/png"), + ]), + ] + + return context + +def test_get_template_name(mock_context: pyblish.api.Context) -> None: + """Test get_template_name.""" + integrator = IntegrateTraits() + template_name = integrator.get_template_name( + mock_context["mock_instance"]) + + assert template_name == "mock_instance" From a299f8edd38f6462b4d0753fcc667a34a7de4b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 17 Oct 2024 14:44:15 +0200 Subject: [PATCH 021/781] :bug: fix test fixture --- .../ayon_core/plugins/publish/test_integrate_traits.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index b7d36d1252..3874cc43d4 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -34,11 +34,12 @@ def single_file(tmp_path_factory: pytest.TempPathFactory) -> Path: f.write(base64.b64decode(PNG_FILE_B64)) return filename +@pytest.fixture(scope="session") def sequence_files(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: """Return a sequence of temporary image files.""" files = [] for i in range(SEQUENCE_LENGTH): - filename = tmp_path_factory.mktemp("sequence") / f"img{i:04d}.png" + filename = tmp_path_factory.mktemp("sequence") / f"img.{i:04d}.png" with open(filename, "wb") as f: f.write(base64.b64decode(PNG_FILE_B64)) files.append(filename) @@ -71,7 +72,7 @@ def mock_context( frame_end=SEQUENCE_LENGTH, frame_padding=4, gaps_policy=GapPolicy.forbidden, - frame_regex=r"img(\d{4}).png", + frame_regex=r"^img\.(\d{4})\.png$", step=1, frame_start_handle=0, frame_end_handle=0, From 4bd570602d9dcbcbe75a579da5ab7c3cd116af88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 17 Oct 2024 14:50:12 +0200 Subject: [PATCH 022/781] :recycle: make some properties optional --- client/ayon_core/pipeline/traits/content.py | 2 +- client/ayon_core/pipeline/traits/time.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 1cb7366928..b2bf62805f 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -48,7 +48,7 @@ class FileLocation(TraitBase): id: ClassVar[str] = "ayon.content.FileLocation.v1" file_path: Path = Field(..., title="File Path") file_size: int = Field(..., title="File Size") - file_hash: Optional[str] = Field(..., title="File Hash") + file_hash: Optional[str] = Field(None, title="File Hash") class RootlessLocation(TraitBase): """RootlessLocation trait model. diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index b3741c46bc..9dec1ac320 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -1,6 +1,8 @@ """Temporal (time related) traits.""" +from __future__ import annotations + from enum import Enum, auto -from typing import ClassVar +from typing import ClassVar, Optional from pydantic import Field @@ -41,8 +43,8 @@ class Clip(TraitBase): id: ClassVar[str] = "ayon.time.Clip.v1" frame_start: int = Field(..., title="Frame Start") frame_end: int = Field(..., title="Frame End") - frame_start_handle: int = Field(..., title="Frame Start Handle") - frame_end_handle: int = Field(..., title="Frame End Handle") + frame_start_handle: Optional[int] = Field(0, title="Frame Start Handle") + frame_end_handle: Optional[int] = Field(0, title="Frame End Handle") class Sequence(Clip): """Sequence trait model. @@ -67,12 +69,12 @@ class Sequence(Clip): name: ClassVar[str] = "Sequence" description: ClassVar[str] = "Sequence Trait Model" id: ClassVar[str] = "ayon.time.Sequence.v1" - step: int = Field(..., title="Step") + step: Optional[int] = Field(1, title="Step") gaps_policy: GapPolicy = Field( GapPolicy.forbidden, title="Gaps Policy") frame_padding: int = Field(..., title="Frame Padding") frame_regex: str = Field(..., title="Frame Regex") - frame_list: str = Field(..., title="Frame List") + frame_list: Optional[str] = Field(None, title="Frame List") # Do we need one for drop and non-drop frame? From 4b3469c5ae30f4e8b179beb50111ca863ec3244b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 21 Oct 2024 11:38:06 +0200 Subject: [PATCH 023/781] :wrench: adding project based test note that this needs `pytest-ayon` dependency to run that will be added in subsequent commits --- .../plugins/publish/integrate_traits.py | 5 + .../plugins/publish/test_integrate_traits.py | 93 ++++++++++++++++--- 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 2a35135b84..b607e527b2 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -51,8 +51,13 @@ class IntegrateTraits(pyblish.api.InstancePlugin): "Instance has no persistent representations. Skipping") return + # 3) get anatomy template name # template_name = self.get_template_name(instance) + # 4) initialize OperationsSession() + # for now we'll skip this step right now as there is some + # old representation style code that needs to be updated.z + @staticmethod def filter_lifecycle( representations: list[Representation]) -> list[Representation]: diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index 3874cc43d4..1262266943 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -2,26 +2,25 @@ from __future__ import annotations import base64 -from typing import TYPE_CHECKING +from pathlib import Path import pyblish.api import pytest from ayon_core.pipeline.traits import ( FileLocation, - GapPolicy, Image, + MimeType, Persistent, PixelBased, Representation, + Sequence, + Transient, ) # Tagged, # TemplatePath, from ayon_core.plugins.publish.integrate_traits import IntegrateTraits -from pipeline.traits import MimeType, Sequence - -if TYPE_CHECKING: - from pathlib import Path +from ayon_core.settings import get_project_settings PNG_FILE_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVR4AWNgAAAAAgABc3UBGAAAAABJRU5ErkJggg==" # noqa: E501 SEQUENCE_LENGTH = 10 @@ -47,11 +46,48 @@ def sequence_files(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: @pytest.fixture() def mock_context( + project: object, single_file: Path, sequence_files: list[Path]) -> pyblish.api.Context: - """Return a mock instance.""" + """Return a mock instance. + + This is mocking pyblish context for testing. It is using real AYON project + thanks to the ``project`` fixture. + + This returns following data:: + + project_name: str + project_code: str + project_root_folders: dict[str, str] + folder: IdNamePair + task: IdNamePair + product: IdNamePair + version: IdNamePair + representations: List[IdNamePair] + links: List[str] + + Args: + project (object): The project info. It is `ProjectInfo` object + returned by pytest fixture. + single_file (Path): The path to a single image file. + sequence_files (list[Path]): The paths to a sequence of image files. + + """ context = pyblish.api.Context() + context.data["projectName"] = project.project_name + context.data["hostName"] = "test_host" + context.data["project_settings"] = get_project_settings( + project.project_name) + instance = context.create_instance("mock_instance") + instance.data["anatomyData"] = { + "project": project.project_name, + "task": { + "name": project.task.name, + "type": "test" # pytest-ayon doesn't return the task type yet + } + } + instance.data["productType"] = "test_product" instance.data["integrate"] = True instance.data["farm"] = False @@ -71,12 +107,7 @@ def mock_context( frame_start=1, frame_end=SEQUENCE_LENGTH, frame_padding=4, - gaps_policy=GapPolicy.forbidden, frame_regex=r"^img\.(\d{4})\.png$", - step=1, - frame_start_handle=0, - frame_end_handle=0, - frame_list=None ), FileLocation( file_path=sequence_files[0], @@ -93,9 +124,41 @@ def mock_context( return context def test_get_template_name(mock_context: pyblish.api.Context) -> None: - """Test get_template_name.""" + """Test get_template_name. + + TODO (antirotor): this will always return "default" probably, if + there are no studio overrides. To test this properly, we need + to set up the studio overrides in the test environment. + + """ integrator = IntegrateTraits() template_name = integrator.get_template_name( - mock_context["mock_instance"]) + mock_context[0]) - assert template_name == "mock_instance" + assert template_name == "default" + +def test_filter_lifecycle() -> None: + """Test filter_lifecycle.""" + integrator = IntegrateTraits() + persistent_representation = Representation( + name="test", + traits=[ + Persistent(), + FileLocation( + file_path=Path("test"), + file_size=1234), + Image(), + MimeType(mime_type="image/png"), + ]) + transient_representation = Representation( + name="test", + traits=[ + Transient(), + Image(), + MimeType(mime_type="image/png"), + ]) + filtered = integrator.filter_lifecycle( + [persistent_representation, transient_representation]) + + assert len(filtered) == 1 + assert filtered[0] == persistent_representation From 664d83962e8de3dffa3681a5d27531a0efa11da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 25 Oct 2024 17:12:18 +0200 Subject: [PATCH 024/781] :art: added id and name to representation also added versionless trait id processing and trait validation --- client/ayon_core/pipeline/traits/content.py | 20 + client/ayon_core/pipeline/traits/trait.py | 403 +++++++++++++++--- pyproject.toml | 2 +- .../ayon_core/pipeline/traits/lib/__init__.py | 25 ++ .../ayon_core/pipeline/traits/test_traits.py | 90 ++++ 5 files changed, 490 insertions(+), 50 deletions(-) create mode 100644 tests/client/ayon_core/pipeline/traits/lib/__init__.py diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index b2bf62805f..480d1657f5 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -113,3 +113,23 @@ class Bundle(TraitBase): def to_representation(self) -> Representation: """Convert to a representation.""" return Representation(traits=self.items) + + +class Fragment(TraitBase): + """Fragment trait model. + + This model represents a fragment trait. A fragment is a part of + a larger entity that is represented by a representation. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + parent (str): Parent representation id. + + """ + + name: ClassVar[str] = "Fragment" + description: ClassVar[str] = "Fragment Trait" + id: ClassVar[str] = "ayon.content.Fragment.v1" + parent: str = Field(..., title="Parent Representation Id") diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 90be38225b..b97ec08cc6 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -2,15 +2,33 @@ from __future__ import annotations import inspect +import re import sys import uuid from abc import ABC, abstractmethod -from collections import OrderedDict -from functools import lru_cache +from functools import cached_property, lru_cache from typing import ClassVar, Optional, Type, Union import pydantic.alias_generators -from pydantic import AliasGenerator, BaseModel, ConfigDict +from pydantic import ( + AliasGenerator, + BaseModel, + ConfigDict, +) + + +def _get_version_from_id(_id: str) -> int: + """Get version from ID. + + Args: + _id (str): ID. + + Returns: + int: Version. + + """ + match = re.search(r"v(\d+)$", _id) + return int(match[1]) if match else None class TraitBase(ABC, BaseModel): @@ -47,6 +65,35 @@ class TraitBase(ABC, BaseModel): """Abstract attribute for description.""" ... + @property + @cached_property + def version(self) -> Union[int, None]: + # sourcery skip: use-named-expression + """Get trait version from ID. + + This assumes Trait ID ends with `.v{version}`. If not, it will + return None. + + """ + version_regex = r"v(\d+)$" + match = re.search(version_regex, self.id) + return int(match[1]) if match else None + + def validate(self, representation: Representation) -> bool: + """Validate the trait. + + This method should be implemented in the derived classes to validate + the trait data. It can be used by traits to validate against other + traits in the representation. + + Args: + representation (Representation): Representation instance. + + Raises: + ValueError: If the trait is invalid within representation. + + """ + return True class Representation: @@ -70,40 +117,9 @@ class Representation: name: str representation_id: str - @lru_cache(maxsize=64) # noqa: B019 - def _get_trait_class(self, trait_id: str) -> Union[Type[TraitBase], None]: - """Get the trait class with corresponding to given ID. - - This method will search for the trait class in all the modules except - the blacklisted modules. There is some issue in Pydantic where - ``issubclass`` is not working properly so we are excluding explicitly - modules with offending classes. This list can be updated as needed to - speed up the search. - - Args: - trait_id (str): Trait ID. - - Returns: - Type[TraitBase]: Trait class. - - """ - modules = sys.modules.copy() - filtered_modules = modules.copy() - for module_name in modules: - for bl_module in self._module_blacklist: - if module_name.startswith(bl_module): - filtered_modules.pop(module_name) - - for module in filtered_modules.values(): - if not module: - continue - for _, klass in inspect.getmembers(module, inspect.isclass): - if inspect.isclass(klass) and \ - issubclass(klass, TraitBase) and \ - klass.id == trait_id: - return klass - return None - + def __hash__(self): + """Return hash of the representation ID.""" + return hash(self.representation_id) def add_trait(self, trait: TraitBase, *, exists_ok: bool=False) -> None: """Add a trait to the Representation. @@ -275,6 +291,7 @@ class Representation: return self._data[trait.id] if self._data.get(trait.id) else None def get_trait_by_id(self, trait_id: str) -> Union[TraitBase, None]: + # sourcery skip: use-named-expression """Get a trait from the representation by id. Args: @@ -284,12 +301,18 @@ class Representation: TraitBase: Trait instance. """ - trait_class = self._get_trait_class(trait_id) - if not trait_class: - error_msg = f"Trait model with ID {trait_id} not found." - raise ValueError(error_msg) + version = _get_version_from_id(trait_id) + if version: + return self._data.get(trait_id) - return self._data[trait_id] if self._data.get(trait_id) else None + return next( + ( + self._data.get(trait_id) + for trait_id in self._data + if trait_id.startswith(trait_id) + ), + None, + ) def get_traits(self, traits: Optional[list[Type[TraitBase]]]=None) -> dict: @@ -338,13 +361,11 @@ class Representation: dict: Traits data dictionary. """ - result = OrderedDict() - for trait_id, trait in self._data.items(): - if not trait or not trait_id: - continue - result[trait_id] = OrderedDict(trait.dict()) - - return result + return { + trait_id: trait.dict() + for trait_id, trait in self._data.items() + if trait and trait_id + } def __len__(self): """Return the length of the data.""" @@ -368,3 +389,287 @@ class Representation: if traits: for trait in traits: self.add_trait(trait) + + @staticmethod + def _get_version_from_id(trait_id: str) -> Union[int, None]: + # sourcery skip: use-named-expression + """Check if the trait has version specified. + + Args: + trait_id (str): Trait ID. + + Returns: + int: Trait version. + None: If the trait id does not have a version. + + """ + version_regex = r"v(\d+)$" + match = re.search(version_regex, trait_id) + return int(match[1]) if match else None + + def __eq__(self, other: Representation) -> bool: # noqa: PLR0911 + """Check if the representation is equal to another. + + Args: + other (Representation): Representation to compare. + + Returns: + bool: True if the representations are equal, False otherwise. + + """ + if not isinstance(other, Representation): + return False + + if self.name != other.name: + return False + + # number of traits + if len(self) != len(other): + return False + + for trait_id, trait in self._data.items(): + if trait_id not in other._data: + return False + if trait != other._data[trait_id]: + return False + for key, value in trait.dict().items(): + if value != other._data[trait_id].dict().get(key): + return False + + return True + + @classmethod + @lru_cache(maxsize=64) + def _get_possible_trait_classes_from_modules( + cls, + trait_id: str) -> set[type[TraitBase]]: + """Get possible trait classes from modules. + + Args: + trait_id (str): Trait ID. + + Returns: + set[type[TraitBase]]: Set of trait classes. + + """ + modules = sys.modules.copy() + filtered_modules = modules.copy() + for module_name in modules: + for bl_module in cls._module_blacklist: + if module_name.startswith(bl_module): + filtered_modules.pop(module_name) + + trait_candidates = set() + for module in filtered_modules.values(): + if not module: + continue + for _, klass in inspect.getmembers(module, inspect.isclass): + if inspect.isclass(klass) \ + and issubclass(klass, TraitBase) \ + and str(klass.id).startswith(trait_id): + trait_candidates.add(klass) + return trait_candidates + + @classmethod + @lru_cache(maxsize=64) + def _get_trait_class( + cls, trait_id: str) -> Union[Type[TraitBase], None]: + """Get the trait class with corresponding to given ID. + + This method will search for the trait class in all the modules except + the blacklisted modules. There is some issue in Pydantic where + ``issubclass`` is not working properly so we are excluding explicitly + modules with offending classes. This list can be updated as needed to + speed up the search. + + Args: + trait_id (str): Trait ID. + + Returns: + Type[TraitBase]: Trait class. + + Raises: + LooseMatchingTraitError: If the trait is found with a loose + matching criteria. This exception will include the trait + class that was found and the expected trait ID. Additional + downstream logic must decide how to handle this error. + + """ + version = cls._get_version_from_id(trait_id) + + trait_candidates = cls._get_possible_trait_classes_from_modules( + trait_id + ) + + for trait_class in trait_candidates: + if trait_class.id == trait_id: + # we found direct match + return trait_class + + # if we didn't find direct match, we will search for the highest + # version of the trait. + if not version: + # sourcery skip: use-named-expression + trait_versions = [ + trait_class for trait_class in trait_candidates + if re.match( + rf"{trait_id}.v(\d+)$", str(trait_class.id)) + ] + if trait_versions: + def _get_version_by_id(trait_klass: Type[TraitBase]) -> int: + match = re.search(r"v(\d+)$", str(trait_klass.id)) + return int(match[1]) if match else 0 + + error = LooseMatchingTraitError( + "Found trait that might match.") + error.found_trait = max( + trait_versions, key=_get_version_by_id) + error.expected_id = trait_id + raise error + + return None + + @classmethod + def get_trait_class_by_trait_id(cls, trait_id: str) -> type[TraitBase]: + """Get the trait class for the given trait ID. + + Args: + trait_id (str): Trait ID. + + Returns: + type[TraitBase]: Trait class. + + Raises: + IncompatibleTraitVersionError: If the trait version is incompatible + with the current version of the trait. + UpgradableTraitError: If the trait can upgrade existing data + meant for older versions of the trait. + ValueError: If the trait model with the given ID is not found. + + """ + trait_class = None + try: + trait_class = cls._get_trait_class(trait_id=trait_id) + except LooseMatchingTraitError as e: + requested_version = _get_version_from_id(trait_id) + found_version = _get_version_from_id(e.found_trait.id) + + if not requested_version: + trait_class = e.found_trait + + else: + if requested_version > found_version: + error_msg = ( + f"Requested trait version {requested_version} is " + f"higher than the found trait version {found_version}." + ) + raise IncompatibleTraitVersionError(error_msg) from e + + if requested_version < found_version and hasattr( + e.found_trait, "upgrade"): + error_msg = ( + "Requested trait version " + f"{requested_version} is lower " + f"than the found trait version {found_version}." + ) + error = UpgradableTraitError(error_msg) + error.trait = e.found_trait + raise error from e + return trait_class + + @classmethod + def from_dict( + cls, + name: str, + representation_id: Optional[str]=None, + trait_data: Optional[dict] = None) -> Representation: + """Create a representation from a dictionary. + + Args: + name (str): Representation name. + representation_id (str, optional): Representation ID. + trait_data (dict): Representation data. Dictionary with keys + as trait ids and values as trait data. Example:: + + { + "ayon.2d.PixelBased.v1": { + "display_window_width": 1920, + "display_window_height": 1080 + }, + "ayon.2d.Planar.v1": { + "channels": 3 + } + } + + Returns: + Representation: Representation instance. + + """ + traits = [] + for trait_id, value in trait_data.items(): + if not isinstance(value, dict): + msg = ( + f"Invalid trait data for trait ID {trait_id}. " + "Trait data must be a dictionary." + ) + raise TypeError(msg) + + try: + trait_class = cls.get_trait_class_by_trait_id(trait_id) + except UpgradableTraitError as e: + # we found newer version of trait, we will upgrade the data + if hasattr(e.trait, "upgrade"): + traits.append(e.trait.upgrade(value)) + else: + msg = ( + f"Newer version of trait {e.trait.id} found " + f"for requested {trait_id} but without " + "upgrade method." + ) + raise IncompatibleTraitVersionError(msg) from e + else: + if not trait_class: + error_msg = f"Trait model with ID {trait_id} not found." + raise ValueError(error_msg) + + traits.append(trait_class(**value)) + + return cls( + name=name, representation_id=representation_id, traits=traits) + + + +class IncompatibleTraitVersionError(Exception): + """Incompatible trait version exception. + + This exception is raised when the trait version is incompatible with the + current version of the trait. + """ + + +class UpgradableTraitError(Exception): + """Upgradable trait version exception. + + This exception is raised when the trait can upgrade existing data + meant for older versions of the trait. It must implement `upgrade` + method that will take old trait data as argument to handle the upgrade. + """ + + trait: TraitBase + old_data: dict + +class LooseMatchingTraitError(Exception): + """Loose matching trait exception. + + This exception is raised when the trait is found with a loose matching + criteria. + """ + + found_trait: TraitBase + expected_id: str + +class TraitValidationError(Exception): + """Trait validation error exception. + + This exception is raised when the trait validation fails. + """ diff --git a/pyproject.toml b/pyproject.toml index 641faf2536..fdaec51a58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,7 @@ exclude = [ [tool.ruff.lint.per-file-ignores] "client/ayon_core/lib/__init__.py" = ["E402"] -"tests/*.py" = ["S101"] +"tests/*.py" = ["S101", "PLR2004"] # allow asserts and magical values [tool.ruff.format] # Like Black, use double quotes for strings. diff --git a/tests/client/ayon_core/pipeline/traits/lib/__init__.py b/tests/client/ayon_core/pipeline/traits/lib/__init__.py new file mode 100644 index 0000000000..d7ea7ae0ad --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/lib/__init__.py @@ -0,0 +1,25 @@ +"""Metadata traits.""" +from typing import ClassVar + +from ayon_core.pipeline.traits import TraitBase + + +class NewTestTrait(TraitBase): + """New Test trait model. + + This model represents a tagged trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + """ + + name: ClassVar[str] = "New Test Trait" + description: ClassVar[str] = ( + "This test trait is used for testing updating." + ) + id: ClassVar[str] = "ayon.test.NewTestTrait.v999" + + +__all__ = ["NewTestTrait"] diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index fc38e8fb56..8a48d6eef8 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -14,6 +14,7 @@ from ayon_core.pipeline.traits import ( Representation, TraitBase, ) +from pipeline.traits import Overscan REPRESENTATION_DATA = { FileLocation.id: { @@ -32,6 +33,15 @@ REPRESENTATION_DATA = { }, } +class UpgradedImage(Image): + """Upgraded image class.""" + id = "ayon.2d.Image.v2" + + @classmethod + def upgrade(cls, data: dict) -> UpgradedImage: # noqa: ARG003 + """Upgrade the trait.""" + return cls() + class InvalidTrait: """Invalid trait class.""" foo = "bar" @@ -62,6 +72,8 @@ def test_representation_errors(representation: Representation) -> None: def test_representation_traits(representation: Representation) -> None: """Test setting and getting traits.""" + assert representation.get_trait_by_id("ayon.2d.PixelBased").version == 1 + assert len(representation) == len(REPRESENTATION_DATA) assert representation.get_trait_by_id(FileLocation.id) assert representation.get_trait_by_id(Image.id) @@ -152,6 +164,7 @@ def test_trait_removing(representation: Representation) -> None: representation.remove_trait(Image) + def test_getting_traits_data(representation: Representation) -> None: """Test getting a batch of traits.""" result = representation.get_traits_by_ids( @@ -218,3 +231,80 @@ def test_bundles() -> None: assert sub_representation.get_trait(trait=MimeType).mime_type in [ "image/jpeg", "image/tiff" ] + +def test_get_version_from_id() -> None: + """Test getting version from trait ID.""" + assert Image().version == 1 + + class TestOverscan(Overscan): + id = "ayon.2d.Overscan.v2" + + assert TestOverscan( + left=0, + right=0, + top=0, + bottom=0 + ).version == 2 + + class TestMimeType(MimeType): + id = "ayon.content.MimeType" + + assert TestMimeType(mime_type="foo/bar").version is None + + +def test_from_dict() -> None: + """Test creating representation from dictionary.""" + traits_data = { + "ayon.content.FileLocation.v1": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + "ayon.2d.Image.v1": {}, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + + assert len(representation) == 2 + assert representation.get_trait_by_id("ayon.content.FileLocation.v1") + assert representation.get_trait_by_id("ayon.2d.Image.v1") + + traits_data = { + "ayon.content.FileLocation.v999": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + } + + with pytest.raises(ValueError, match="Trait model with ID .* not found."): + representation = Representation.from_dict( + "test", trait_data=traits_data) + + traits_data = { + "ayon.content.FileLocation": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + + assert len(representation) == 1 + assert representation.get_trait_by_id("ayon.content.FileLocation.v1") + + # this won't work right now because we would need to somewhat mock + # the import + """ + from .lib import NewTestTrait + + traits_data = { + "ayon.test.NewTestTrait.v1": {}, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + """ From edefade158adae27d49a0055c7a54d39242a5399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 25 Oct 2024 17:12:18 +0200 Subject: [PATCH 025/781] :art: added id and name to representation also added versionless trait id processing and trait validation --- client/ayon_core/pipeline/traits/content.py | 20 + client/ayon_core/pipeline/traits/trait.py | 403 +++++++++++++++--- pyproject.toml | 2 +- .../ayon_core/pipeline/traits/lib/__init__.py | 25 ++ .../ayon_core/pipeline/traits/test_traits.py | 90 ++++ 5 files changed, 490 insertions(+), 50 deletions(-) create mode 100644 tests/client/ayon_core/pipeline/traits/lib/__init__.py diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index b2bf62805f..480d1657f5 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -113,3 +113,23 @@ class Bundle(TraitBase): def to_representation(self) -> Representation: """Convert to a representation.""" return Representation(traits=self.items) + + +class Fragment(TraitBase): + """Fragment trait model. + + This model represents a fragment trait. A fragment is a part of + a larger entity that is represented by a representation. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + parent (str): Parent representation id. + + """ + + name: ClassVar[str] = "Fragment" + description: ClassVar[str] = "Fragment Trait" + id: ClassVar[str] = "ayon.content.Fragment.v1" + parent: str = Field(..., title="Parent Representation Id") diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 90be38225b..b97ec08cc6 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -2,15 +2,33 @@ from __future__ import annotations import inspect +import re import sys import uuid from abc import ABC, abstractmethod -from collections import OrderedDict -from functools import lru_cache +from functools import cached_property, lru_cache from typing import ClassVar, Optional, Type, Union import pydantic.alias_generators -from pydantic import AliasGenerator, BaseModel, ConfigDict +from pydantic import ( + AliasGenerator, + BaseModel, + ConfigDict, +) + + +def _get_version_from_id(_id: str) -> int: + """Get version from ID. + + Args: + _id (str): ID. + + Returns: + int: Version. + + """ + match = re.search(r"v(\d+)$", _id) + return int(match[1]) if match else None class TraitBase(ABC, BaseModel): @@ -47,6 +65,35 @@ class TraitBase(ABC, BaseModel): """Abstract attribute for description.""" ... + @property + @cached_property + def version(self) -> Union[int, None]: + # sourcery skip: use-named-expression + """Get trait version from ID. + + This assumes Trait ID ends with `.v{version}`. If not, it will + return None. + + """ + version_regex = r"v(\d+)$" + match = re.search(version_regex, self.id) + return int(match[1]) if match else None + + def validate(self, representation: Representation) -> bool: + """Validate the trait. + + This method should be implemented in the derived classes to validate + the trait data. It can be used by traits to validate against other + traits in the representation. + + Args: + representation (Representation): Representation instance. + + Raises: + ValueError: If the trait is invalid within representation. + + """ + return True class Representation: @@ -70,40 +117,9 @@ class Representation: name: str representation_id: str - @lru_cache(maxsize=64) # noqa: B019 - def _get_trait_class(self, trait_id: str) -> Union[Type[TraitBase], None]: - """Get the trait class with corresponding to given ID. - - This method will search for the trait class in all the modules except - the blacklisted modules. There is some issue in Pydantic where - ``issubclass`` is not working properly so we are excluding explicitly - modules with offending classes. This list can be updated as needed to - speed up the search. - - Args: - trait_id (str): Trait ID. - - Returns: - Type[TraitBase]: Trait class. - - """ - modules = sys.modules.copy() - filtered_modules = modules.copy() - for module_name in modules: - for bl_module in self._module_blacklist: - if module_name.startswith(bl_module): - filtered_modules.pop(module_name) - - for module in filtered_modules.values(): - if not module: - continue - for _, klass in inspect.getmembers(module, inspect.isclass): - if inspect.isclass(klass) and \ - issubclass(klass, TraitBase) and \ - klass.id == trait_id: - return klass - return None - + def __hash__(self): + """Return hash of the representation ID.""" + return hash(self.representation_id) def add_trait(self, trait: TraitBase, *, exists_ok: bool=False) -> None: """Add a trait to the Representation. @@ -275,6 +291,7 @@ class Representation: return self._data[trait.id] if self._data.get(trait.id) else None def get_trait_by_id(self, trait_id: str) -> Union[TraitBase, None]: + # sourcery skip: use-named-expression """Get a trait from the representation by id. Args: @@ -284,12 +301,18 @@ class Representation: TraitBase: Trait instance. """ - trait_class = self._get_trait_class(trait_id) - if not trait_class: - error_msg = f"Trait model with ID {trait_id} not found." - raise ValueError(error_msg) + version = _get_version_from_id(trait_id) + if version: + return self._data.get(trait_id) - return self._data[trait_id] if self._data.get(trait_id) else None + return next( + ( + self._data.get(trait_id) + for trait_id in self._data + if trait_id.startswith(trait_id) + ), + None, + ) def get_traits(self, traits: Optional[list[Type[TraitBase]]]=None) -> dict: @@ -338,13 +361,11 @@ class Representation: dict: Traits data dictionary. """ - result = OrderedDict() - for trait_id, trait in self._data.items(): - if not trait or not trait_id: - continue - result[trait_id] = OrderedDict(trait.dict()) - - return result + return { + trait_id: trait.dict() + for trait_id, trait in self._data.items() + if trait and trait_id + } def __len__(self): """Return the length of the data.""" @@ -368,3 +389,287 @@ class Representation: if traits: for trait in traits: self.add_trait(trait) + + @staticmethod + def _get_version_from_id(trait_id: str) -> Union[int, None]: + # sourcery skip: use-named-expression + """Check if the trait has version specified. + + Args: + trait_id (str): Trait ID. + + Returns: + int: Trait version. + None: If the trait id does not have a version. + + """ + version_regex = r"v(\d+)$" + match = re.search(version_regex, trait_id) + return int(match[1]) if match else None + + def __eq__(self, other: Representation) -> bool: # noqa: PLR0911 + """Check if the representation is equal to another. + + Args: + other (Representation): Representation to compare. + + Returns: + bool: True if the representations are equal, False otherwise. + + """ + if not isinstance(other, Representation): + return False + + if self.name != other.name: + return False + + # number of traits + if len(self) != len(other): + return False + + for trait_id, trait in self._data.items(): + if trait_id not in other._data: + return False + if trait != other._data[trait_id]: + return False + for key, value in trait.dict().items(): + if value != other._data[trait_id].dict().get(key): + return False + + return True + + @classmethod + @lru_cache(maxsize=64) + def _get_possible_trait_classes_from_modules( + cls, + trait_id: str) -> set[type[TraitBase]]: + """Get possible trait classes from modules. + + Args: + trait_id (str): Trait ID. + + Returns: + set[type[TraitBase]]: Set of trait classes. + + """ + modules = sys.modules.copy() + filtered_modules = modules.copy() + for module_name in modules: + for bl_module in cls._module_blacklist: + if module_name.startswith(bl_module): + filtered_modules.pop(module_name) + + trait_candidates = set() + for module in filtered_modules.values(): + if not module: + continue + for _, klass in inspect.getmembers(module, inspect.isclass): + if inspect.isclass(klass) \ + and issubclass(klass, TraitBase) \ + and str(klass.id).startswith(trait_id): + trait_candidates.add(klass) + return trait_candidates + + @classmethod + @lru_cache(maxsize=64) + def _get_trait_class( + cls, trait_id: str) -> Union[Type[TraitBase], None]: + """Get the trait class with corresponding to given ID. + + This method will search for the trait class in all the modules except + the blacklisted modules. There is some issue in Pydantic where + ``issubclass`` is not working properly so we are excluding explicitly + modules with offending classes. This list can be updated as needed to + speed up the search. + + Args: + trait_id (str): Trait ID. + + Returns: + Type[TraitBase]: Trait class. + + Raises: + LooseMatchingTraitError: If the trait is found with a loose + matching criteria. This exception will include the trait + class that was found and the expected trait ID. Additional + downstream logic must decide how to handle this error. + + """ + version = cls._get_version_from_id(trait_id) + + trait_candidates = cls._get_possible_trait_classes_from_modules( + trait_id + ) + + for trait_class in trait_candidates: + if trait_class.id == trait_id: + # we found direct match + return trait_class + + # if we didn't find direct match, we will search for the highest + # version of the trait. + if not version: + # sourcery skip: use-named-expression + trait_versions = [ + trait_class for trait_class in trait_candidates + if re.match( + rf"{trait_id}.v(\d+)$", str(trait_class.id)) + ] + if trait_versions: + def _get_version_by_id(trait_klass: Type[TraitBase]) -> int: + match = re.search(r"v(\d+)$", str(trait_klass.id)) + return int(match[1]) if match else 0 + + error = LooseMatchingTraitError( + "Found trait that might match.") + error.found_trait = max( + trait_versions, key=_get_version_by_id) + error.expected_id = trait_id + raise error + + return None + + @classmethod + def get_trait_class_by_trait_id(cls, trait_id: str) -> type[TraitBase]: + """Get the trait class for the given trait ID. + + Args: + trait_id (str): Trait ID. + + Returns: + type[TraitBase]: Trait class. + + Raises: + IncompatibleTraitVersionError: If the trait version is incompatible + with the current version of the trait. + UpgradableTraitError: If the trait can upgrade existing data + meant for older versions of the trait. + ValueError: If the trait model with the given ID is not found. + + """ + trait_class = None + try: + trait_class = cls._get_trait_class(trait_id=trait_id) + except LooseMatchingTraitError as e: + requested_version = _get_version_from_id(trait_id) + found_version = _get_version_from_id(e.found_trait.id) + + if not requested_version: + trait_class = e.found_trait + + else: + if requested_version > found_version: + error_msg = ( + f"Requested trait version {requested_version} is " + f"higher than the found trait version {found_version}." + ) + raise IncompatibleTraitVersionError(error_msg) from e + + if requested_version < found_version and hasattr( + e.found_trait, "upgrade"): + error_msg = ( + "Requested trait version " + f"{requested_version} is lower " + f"than the found trait version {found_version}." + ) + error = UpgradableTraitError(error_msg) + error.trait = e.found_trait + raise error from e + return trait_class + + @classmethod + def from_dict( + cls, + name: str, + representation_id: Optional[str]=None, + trait_data: Optional[dict] = None) -> Representation: + """Create a representation from a dictionary. + + Args: + name (str): Representation name. + representation_id (str, optional): Representation ID. + trait_data (dict): Representation data. Dictionary with keys + as trait ids and values as trait data. Example:: + + { + "ayon.2d.PixelBased.v1": { + "display_window_width": 1920, + "display_window_height": 1080 + }, + "ayon.2d.Planar.v1": { + "channels": 3 + } + } + + Returns: + Representation: Representation instance. + + """ + traits = [] + for trait_id, value in trait_data.items(): + if not isinstance(value, dict): + msg = ( + f"Invalid trait data for trait ID {trait_id}. " + "Trait data must be a dictionary." + ) + raise TypeError(msg) + + try: + trait_class = cls.get_trait_class_by_trait_id(trait_id) + except UpgradableTraitError as e: + # we found newer version of trait, we will upgrade the data + if hasattr(e.trait, "upgrade"): + traits.append(e.trait.upgrade(value)) + else: + msg = ( + f"Newer version of trait {e.trait.id} found " + f"for requested {trait_id} but without " + "upgrade method." + ) + raise IncompatibleTraitVersionError(msg) from e + else: + if not trait_class: + error_msg = f"Trait model with ID {trait_id} not found." + raise ValueError(error_msg) + + traits.append(trait_class(**value)) + + return cls( + name=name, representation_id=representation_id, traits=traits) + + + +class IncompatibleTraitVersionError(Exception): + """Incompatible trait version exception. + + This exception is raised when the trait version is incompatible with the + current version of the trait. + """ + + +class UpgradableTraitError(Exception): + """Upgradable trait version exception. + + This exception is raised when the trait can upgrade existing data + meant for older versions of the trait. It must implement `upgrade` + method that will take old trait data as argument to handle the upgrade. + """ + + trait: TraitBase + old_data: dict + +class LooseMatchingTraitError(Exception): + """Loose matching trait exception. + + This exception is raised when the trait is found with a loose matching + criteria. + """ + + found_trait: TraitBase + expected_id: str + +class TraitValidationError(Exception): + """Trait validation error exception. + + This exception is raised when the trait validation fails. + """ diff --git a/pyproject.toml b/pyproject.toml index 641faf2536..fdaec51a58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,7 @@ exclude = [ [tool.ruff.lint.per-file-ignores] "client/ayon_core/lib/__init__.py" = ["E402"] -"tests/*.py" = ["S101"] +"tests/*.py" = ["S101", "PLR2004"] # allow asserts and magical values [tool.ruff.format] # Like Black, use double quotes for strings. diff --git a/tests/client/ayon_core/pipeline/traits/lib/__init__.py b/tests/client/ayon_core/pipeline/traits/lib/__init__.py new file mode 100644 index 0000000000..d7ea7ae0ad --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/lib/__init__.py @@ -0,0 +1,25 @@ +"""Metadata traits.""" +from typing import ClassVar + +from ayon_core.pipeline.traits import TraitBase + + +class NewTestTrait(TraitBase): + """New Test trait model. + + This model represents a tagged trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + """ + + name: ClassVar[str] = "New Test Trait" + description: ClassVar[str] = ( + "This test trait is used for testing updating." + ) + id: ClassVar[str] = "ayon.test.NewTestTrait.v999" + + +__all__ = ["NewTestTrait"] diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index fc38e8fb56..8a48d6eef8 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -14,6 +14,7 @@ from ayon_core.pipeline.traits import ( Representation, TraitBase, ) +from pipeline.traits import Overscan REPRESENTATION_DATA = { FileLocation.id: { @@ -32,6 +33,15 @@ REPRESENTATION_DATA = { }, } +class UpgradedImage(Image): + """Upgraded image class.""" + id = "ayon.2d.Image.v2" + + @classmethod + def upgrade(cls, data: dict) -> UpgradedImage: # noqa: ARG003 + """Upgrade the trait.""" + return cls() + class InvalidTrait: """Invalid trait class.""" foo = "bar" @@ -62,6 +72,8 @@ def test_representation_errors(representation: Representation) -> None: def test_representation_traits(representation: Representation) -> None: """Test setting and getting traits.""" + assert representation.get_trait_by_id("ayon.2d.PixelBased").version == 1 + assert len(representation) == len(REPRESENTATION_DATA) assert representation.get_trait_by_id(FileLocation.id) assert representation.get_trait_by_id(Image.id) @@ -152,6 +164,7 @@ def test_trait_removing(representation: Representation) -> None: representation.remove_trait(Image) + def test_getting_traits_data(representation: Representation) -> None: """Test getting a batch of traits.""" result = representation.get_traits_by_ids( @@ -218,3 +231,80 @@ def test_bundles() -> None: assert sub_representation.get_trait(trait=MimeType).mime_type in [ "image/jpeg", "image/tiff" ] + +def test_get_version_from_id() -> None: + """Test getting version from trait ID.""" + assert Image().version == 1 + + class TestOverscan(Overscan): + id = "ayon.2d.Overscan.v2" + + assert TestOverscan( + left=0, + right=0, + top=0, + bottom=0 + ).version == 2 + + class TestMimeType(MimeType): + id = "ayon.content.MimeType" + + assert TestMimeType(mime_type="foo/bar").version is None + + +def test_from_dict() -> None: + """Test creating representation from dictionary.""" + traits_data = { + "ayon.content.FileLocation.v1": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + "ayon.2d.Image.v1": {}, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + + assert len(representation) == 2 + assert representation.get_trait_by_id("ayon.content.FileLocation.v1") + assert representation.get_trait_by_id("ayon.2d.Image.v1") + + traits_data = { + "ayon.content.FileLocation.v999": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + } + + with pytest.raises(ValueError, match="Trait model with ID .* not found."): + representation = Representation.from_dict( + "test", trait_data=traits_data) + + traits_data = { + "ayon.content.FileLocation": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + + assert len(representation) == 1 + assert representation.get_trait_by_id("ayon.content.FileLocation.v1") + + # this won't work right now because we would need to somewhat mock + # the import + """ + from .lib import NewTestTrait + + traits_data = { + "ayon.test.NewTestTrait.v1": {}, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + """ From 282edd96954b51e5d2a65b5f6989f26855a05d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Thu, 31 Oct 2024 18:36:15 +0100 Subject: [PATCH 026/781] Update client/ayon_core/pipeline/traits/time.py Co-authored-by: Roy Nieterau --- client/ayon_core/pipeline/traits/time.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 9dec1ac320..840462faeb 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -23,6 +23,7 @@ class GapPolicy(Enum): hold = auto() black = auto() + class Clip(TraitBase): """Clip trait model. From f988294e2f7233f18a92ef736c68005e1461a489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 4 Nov 2024 16:09:10 +0100 Subject: [PATCH 027/781] :wrench: wip on the integrator --- .../plugins/publish/integrate_traits.py | 354 +++++++++++++++++- 1 file changed, 351 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index b607e527b2..44dd7e8e06 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -1,13 +1,93 @@ """Integrate representations with traits.""" -import logging +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, List import pyblish.api +from ayon_api import ( + get_attributes_for_type, + get_product_by_name, + # get_representations, + get_version_by_name, +) +from ayon_api.operations import ( + OperationsSession, + new_product_entity, + # new_representation_entity, + new_version_entity, +) from ayon_core.pipeline.publish import ( get_publish_template_name, ) from ayon_core.pipeline.traits import Persistent, Representation +if TYPE_CHECKING: + import logging + + from pipeline import Anatomy + + +def get_instance_families(instance: pyblish.api.Instance) -> List[str]: + """Get all families of the instance. + + Todo: + Move to the library. + + Args: + instance (pyblish.api.Instance): Instance to get families from. + + Returns: + List[str]: List of families. + + """ + family = instance.data.get("family") + families = [] + if family: + families.append(family) + + for _family in (instance.data.get("families") or []): + if _family not in families: + families.append(_family) + + return families + + +def get_changed_attributes( + old_entity: dict, new_entity: dict) -> (dict[str, Any]): + """Prepare changes for entity update. + + Todo: + Move to the library. + + Args: + old_entity (dict[str, Any]): Existing entity. + new_entity (dict[str, Any]): New entity. + + Returns: + dict[str, Any]: Changes that have new entity. + + """ + changes = {} + for key in set(new_entity.keys()): + if key == "attrib": + continue + + if key in new_entity and new_entity[key] != old_entity.get(key): + changes[key] = new_entity[key] + continue + + attrib_changes = {} + if "attrib" in new_entity: + for key, value in new_entity["attrib"].items(): + if value != old_entity["attrib"].get(key): + attrib_changes[key] = value + if attrib_changes: + changes["attrib"] = attrib_changes + return changes + + + class IntegrateTraits(pyblish.api.InstancePlugin): """Integrate representations with traits.""" @@ -55,8 +135,21 @@ class IntegrateTraits(pyblish.api.InstancePlugin): # template_name = self.get_template_name(instance) # 4) initialize OperationsSession() - # for now we'll skip this step right now as there is some - # old representation style code that needs to be updated.z + op_session = OperationsSession() + + # 5) Prepare product + product_entity = self.prepare_product(instance, op_session) + + # 6) Prepare version + version_entity = self.prepare_version( + instance, op_session, product_entity + ) + instance.data["versionEntity"] = version_entity + + # 7) Get transfers from representations + # 8) Transfer files + # 9) Commit the session to AYON + # 10) Finalize represetations - add integrated path Trait etc. @staticmethod def filter_lifecycle( @@ -105,3 +198,258 @@ class IntegrateTraits(pyblish.api.InstancePlugin): project_settings=context.data["project_settings"], logger=self.log ) + + def prepare_product( + self, + instance: pyblish.api.Instance, + op_session: OperationsSession) -> dict: + """Prepare product for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + op_session (OperationsSession): Operations session. + + Returns: + dict: Product entity. + + """ + project_name = instance.context.data["projectName"] + folder_entity = instance.data["folderEntity"] + product_name = instance.data["productName"] + product_type = instance.data["productType"] + self.log.debug("Product: %s", product_name) + + # Get existing product if it exists + existing_product_entity = get_product_by_name( + project_name, product_name, folder_entity["id"] + ) + + # Define product data + data = { + "families": get_instance_families(instance) + } + attributes = {} + + product_group = instance.data.get("productGroup") + if product_group: + attributes["productGroup"] = product_group + elif existing_product_entity: + # Preserve previous product group if new version does not set it + product_group = existing_product_entity.get("attrib", {}).get( + "productGroup" + ) + if product_group is not None: + attributes["productGroup"] = product_group + + product_id = existing_product_entity["id"] if existing_product_entity else None # noqa: E501 + product_entity = new_product_entity( + product_name, + product_type, + folder_entity["id"], + data=data, + attribs=attributes, + entity_id=product_id + ) + + if existing_product_entity is None: + # Create a new product + self.log.info( + "Product '%s' not found, creating ...", + product_name + ) + op_session.create_entity( + project_name, "product", product_entity + ) + + else: + # Update existing product data with new data and set in database. + # We also change the found product in-place so we don't need to + # re-query the product afterward + update_data = get_changed_attributes( + existing_product_entity, product_entity + ) + op_session.update_entity( + project_name, + "product", + product_entity["id"], + update_data + ) + + self.log.debug("Prepared product: %s", product_name) + return product_entity + + def prepare_version( + self, + instance: pyblish.api.Instance, + op_session: OperationsSession, + product_entity: dict) -> dict: + """Prepare version for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + op_session (OperationsSession): Operations session. + product_entity (dict): Product entity. + + Returns: + dict: Version entity. + + """ + project_name = instance.context.data["projectName"] + version_number = instance.data["version"] + task_entity = instance.data.get("taskEntity") + task_id = task_entity["id"] if task_entity else None + existing_version = get_version_by_name( + project_name, + version_number, + product_entity["id"] + ) + version_id = existing_version["id"] if existing_version else None + all_version_data = self.get_version_data_from_instance(instance) + version_data = {} + version_attributes = {} + attr_defs = self._get_attributes_for_type(instance.context, "version") + for key, value in all_version_data.items(): + if key in attr_defs: + version_attributes[key] = value + else: + version_data[key] = value + + version_entity = new_version_entity( + version_number, + product_entity["id"], + task_id=task_id, + status=instance.data.get("status"), + data=version_data, + attribs=version_attributes, + entity_id=version_id, + ) + + if existing_version: + self.log.debug("Updating existing version ...") + update_data = get_changed_attributes( + existing_version, version_entity) + op_session.update_entity( + project_name, + "version", + version_entity["id"], + update_data + ) + else: + self.log.debug("Creating new version ...") + op_session.create_entity( + project_name, "version", version_entity + ) + + self.log.debug( + "Prepared version: v%s", + "{:03d}".format(version_entity["version"]) + ) + + return version_entity + + def get_version_data_from_instance( + self, instance: pyblish.api.Instance) -> dict: + """Get version data from the Instance. + + Args: + instance (pyblish.api.Instance): the current instance + being published. + + Returns: + dict: the required information for ``version["data"]`` + + """ + context = instance.context + + # create relative source path for DB + if "source" in instance.data: + source = instance.data["source"] + else: + source = context.data["currentFile"] + anatomy = instance.context.data["anatomy"] + source = self.get_rootless_path(anatomy, source) + self.log.debug("Source: %s", source) + + version_data = { + "families": get_instance_families(instance), + "time": context.data["time"], + "author": context.data["user"], + "source": source, + "comment": instance.data["comment"], + "machine": context.data.get("machine"), + "fps": instance.data.get("fps", context.data.get("fps")) + } + + intent_value = context.data.get("intent") + if intent_value and isinstance(intent_value, dict): + intent_value = intent_value.get("value") + + if intent_value: + version_data["intent"] = intent_value + + # Include optional data if present in + optionals = [ + "frameStart", "frameEnd", "step", + "handleEnd", "handleStart", "sourceHashes" + ] + for key in optionals: + if key in instance.data: + version_data[key] = instance.data[key] + + # Include instance.data[versionData] directly + version_data_instance = instance.data.get("versionData") + if version_data_instance: + version_data.update(version_data_instance) + + return version_data + + def get_rootless_path(self, anatomy: Anatomy, path: str) -> str: + r"""Get rootless variant of the path. + + Returns, if possible, path without absolute portion from the root + (e.g. 'c:\' or '/opt/..'). This is basically wrapper for the + meth:`Anatomy.find_root_template_from_path` method that displays + warning if root path is not found. + + This information is platform dependent and shouldn't be captured. + For example:: + + 'c:/projects/MyProject1/Assets/publish...' + will be transformed to: + '{root}/MyProject1/Assets...' + + Args: + anatomy (Anatomy): Project anatomy. + path (str): Absolute path. + + Returns: + str: Path where root path is replaced by formatting string. + + """ + success, rootless_path = anatomy.find_root_template_from_path(path) + if success: + path = rootless_path + else: + self.log.warning(( + 'Could not find root path for remapping "%s".' + " This may cause issues on farm." + ),path) + return path + + def get_attributes_by_type( + self, context: pyblish.api.Context) -> dict: + """Gets AYON attributes from the given context.""" + attributes = context.data.get("ayonAttributes") + if attributes is None: + attributes = { + key: get_attributes_for_type(key) + for key in ( + "project", + "folder", + "product", + "version", + "representation", + ) + } + context.data["ayonAttributes"] = attributes + return attributes From 8698c815d10053b9b1f004bfea4c95f0847a0097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 4 Nov 2024 16:14:02 +0100 Subject: [PATCH 028/781] :burn: remove unnecessary init files --- client/ayon_core/plugins/__init__.py | 1 - client/ayon_core/plugins/publish/__init__.py | 1 - 2 files changed, 2 deletions(-) delete mode 100644 client/ayon_core/plugins/__init__.py delete mode 100644 client/ayon_core/plugins/publish/__init__.py diff --git a/client/ayon_core/plugins/__init__.py b/client/ayon_core/plugins/__init__.py deleted file mode 100644 index 376c7e5a4d..0000000000 --- a/client/ayon_core/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""AYON Core plugins.""" diff --git a/client/ayon_core/plugins/publish/__init__.py b/client/ayon_core/plugins/publish/__init__.py deleted file mode 100644 index 86b6c901bc..0000000000 --- a/client/ayon_core/plugins/publish/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""AYON Core publish plugins.""" From ba49b659209a352780df2e492441b007ae6b66b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 4 Nov 2024 16:19:31 +0100 Subject: [PATCH 029/781] :fire: remove integrator from this branch --- .../plugins/publish/integrate_traits.py | 107 ------------ .../ayon_core/plugins/publish/__init__.py | 1 - .../plugins/publish/test_integrate_traits.py | 164 ------------------ 3 files changed, 272 deletions(-) delete mode 100644 client/ayon_core/plugins/publish/integrate_traits.py delete mode 100644 tests/client/ayon_core/plugins/publish/__init__.py delete mode 100644 tests/client/ayon_core/plugins/publish/test_integrate_traits.py diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py deleted file mode 100644 index b607e527b2..0000000000 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Integrate representations with traits.""" -import logging - -import pyblish.api - -from ayon_core.pipeline.publish import ( - get_publish_template_name, -) -from ayon_core.pipeline.traits import Persistent, Representation - - -class IntegrateTraits(pyblish.api.InstancePlugin): - """Integrate representations with traits.""" - - label = "Integrate Asset" - order = pyblish.api.IntegratorOrder - log: logging.Logger - - def process(self, instance: pyblish.api.Instance) -> None: - """Integrate representations with traits. - - Args: - instance (pyblish.api.Instance): Instance to process. - - """ - # 1) skip farm and integrate == False - - if not instance.data.get("integrate"): - self.log.debug("Instance is marked to skip integrating. Skipping") - return - - if instance.data.get("farm"): - self.log.debug( - "Instance is marked to be processed on farm. Skipping") - return - - # TODO (antirotor): Find better name for the key # noqa: FIX002, TD003 - if not instance.data.get("representations_with_traits"): - self.log.debug( - "Instance has no representations with traits. Skipping") - return - - # 2) filter representations based on LifeCycle traits - instance.data["representations_with_traits"] = self.filter_lifecycle( - instance.data["representations_with_traits"] - ) - - representations = instance.data["representations_with_traits"] - if not representations: - self.log.debug( - "Instance has no persistent representations. Skipping") - return - - # 3) get anatomy template name - # template_name = self.get_template_name(instance) - - # 4) initialize OperationsSession() - # for now we'll skip this step right now as there is some - # old representation style code that needs to be updated.z - - @staticmethod - def filter_lifecycle( - representations: list[Representation]) -> list[Representation]: - """Filter representations based on LifeCycle traits. - - Args: - representations (list): List of representations. - - Returns: - list: Filtered representations. - - """ - return [ - representation - for representation in representations - if representation.contains_trait(Persistent) - ] - - def get_template_name(self, instance: pyblish.api.Instance) -> str: - """Return anatomy template name to use for integration. - - Args: - instance (pyblish.api.Instance): Instance to process. - - Returns: - str: Anatomy template name - - """ - # Anatomy data is pre-filled by Collectors - context = instance.context - project_name = context.data["projectName"] - - # Task can be optional in anatomy data - host_name = context.data["hostName"] - anatomy_data = instance.data["anatomyData"] - product_type = instance.data["productType"] - task_info = anatomy_data.get("task") or {} - - return get_publish_template_name( - project_name, - host_name, - product_type, - task_name=task_info.get("name"), - task_type=task_info.get("type"), - project_settings=context.data["project_settings"], - logger=self.log - ) diff --git a/tests/client/ayon_core/plugins/publish/__init__.py b/tests/client/ayon_core/plugins/publish/__init__.py deleted file mode 100644 index 58e6112045..0000000000 --- a/tests/client/ayon_core/plugins/publish/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test for pyblish plugins.""" diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py deleted file mode 100644 index 1262266943..0000000000 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Tests for the representation traits.""" -from __future__ import annotations - -import base64 -from pathlib import Path - -import pyblish.api -import pytest -from ayon_core.pipeline.traits import ( - FileLocation, - Image, - MimeType, - Persistent, - PixelBased, - Representation, - Sequence, - Transient, -) - -# Tagged, -# TemplatePath, -from ayon_core.plugins.publish.integrate_traits import IntegrateTraits -from ayon_core.settings import get_project_settings - -PNG_FILE_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVR4AWNgAAAAAgABc3UBGAAAAABJRU5ErkJggg==" # noqa: E501 -SEQUENCE_LENGTH = 10 - -@pytest.fixture(scope="session") -def single_file(tmp_path_factory: pytest.TempPathFactory) -> Path: - """Return a temporary image file.""" - filename = tmp_path_factory.mktemp("single") / "img.png" - with open(filename, "wb") as f: - f.write(base64.b64decode(PNG_FILE_B64)) - return filename - -@pytest.fixture(scope="session") -def sequence_files(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: - """Return a sequence of temporary image files.""" - files = [] - for i in range(SEQUENCE_LENGTH): - filename = tmp_path_factory.mktemp("sequence") / f"img.{i:04d}.png" - with open(filename, "wb") as f: - f.write(base64.b64decode(PNG_FILE_B64)) - files.append(filename) - return files - -@pytest.fixture() -def mock_context( - project: object, - single_file: Path, - sequence_files: list[Path]) -> pyblish.api.Context: - """Return a mock instance. - - This is mocking pyblish context for testing. It is using real AYON project - thanks to the ``project`` fixture. - - This returns following data:: - - project_name: str - project_code: str - project_root_folders: dict[str, str] - folder: IdNamePair - task: IdNamePair - product: IdNamePair - version: IdNamePair - representations: List[IdNamePair] - links: List[str] - - Args: - project (object): The project info. It is `ProjectInfo` object - returned by pytest fixture. - single_file (Path): The path to a single image file. - sequence_files (list[Path]): The paths to a sequence of image files. - - """ - context = pyblish.api.Context() - context.data["projectName"] = project.project_name - context.data["hostName"] = "test_host" - context.data["project_settings"] = get_project_settings( - project.project_name) - - instance = context.create_instance("mock_instance") - instance.data["anatomyData"] = { - "project": project.project_name, - "task": { - "name": project.task.name, - "type": "test" # pytest-ayon doesn't return the task type yet - } - } - instance.data["productType"] = "test_product" - - instance.data["integrate"] = True - instance.data["farm"] = False - - instance.data["representations_with_traits"] = [ - Representation(name="test_single", traits=[ - Persistent(), - FileLocation( - file_path=single_file, - file_size=len(base64.b64decode(PNG_FILE_B64))), - Image(), - MimeType(mime_type="image/png"), - ]), - Representation(name="test_sequence", traits=[ - Persistent(), - Sequence( - frame_start=1, - frame_end=SEQUENCE_LENGTH, - frame_padding=4, - frame_regex=r"^img\.(\d{4})\.png$", - ), - FileLocation( - file_path=sequence_files[0], - file_size=len(base64.b64decode(PNG_FILE_B64))), - Image(), - PixelBased( - display_window_width=1920, - display_window_height=1080, - pixel_aspect_ratio=1.0), - MimeType(mime_type="image/png"), - ]), - ] - - return context - -def test_get_template_name(mock_context: pyblish.api.Context) -> None: - """Test get_template_name. - - TODO (antirotor): this will always return "default" probably, if - there are no studio overrides. To test this properly, we need - to set up the studio overrides in the test environment. - - """ - integrator = IntegrateTraits() - template_name = integrator.get_template_name( - mock_context[0]) - - assert template_name == "default" - -def test_filter_lifecycle() -> None: - """Test filter_lifecycle.""" - integrator = IntegrateTraits() - persistent_representation = Representation( - name="test", - traits=[ - Persistent(), - FileLocation( - file_path=Path("test"), - file_size=1234), - Image(), - MimeType(mime_type="image/png"), - ]) - transient_representation = Representation( - name="test", - traits=[ - Transient(), - Image(), - MimeType(mime_type="image/png"), - ]) - filtered = integrator.filter_lifecycle( - [persistent_representation, transient_representation]) - - assert len(filtered) == 1 - assert filtered[0] == persistent_representation From b0bd488e4cede9996c81910bcbf0b28616832d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 4 Nov 2024 19:22:02 +0100 Subject: [PATCH 030/781] :recycle: add traits and docstrings sync traits declared in #909 --- client/ayon_core/pipeline/traits/__init__.py | 32 ++++++- client/ayon_core/pipeline/traits/color.py | 31 ++++++ client/ayon_core/pipeline/traits/content.py | 95 +++++++++++++++++-- .../ayon_core/pipeline/traits/cryptography.py | 42 ++++++++ client/ayon_core/pipeline/traits/lifecycle.py | 31 +++++- client/ayon_core/pipeline/traits/meta.py | 13 ++- .../pipeline/traits/three_dimensional.py | 60 +++++++++++- client/ayon_core/pipeline/traits/time.py | 72 +++++++++++--- .../pipeline/traits/two_dimensional.py | 21 ++-- 9 files changed, 354 insertions(+), 43 deletions(-) create mode 100644 client/ayon_core/pipeline/traits/color.py create mode 100644 client/ayon_core/pipeline/traits/cryptography.py diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index bc9b1d69de..a12c55e41a 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -1,15 +1,26 @@ """Trait classes for the pipeline.""" +from .color import ColorManaged from .content import ( Bundle, Compressed, FileLocation, + Fragment, + LocatableContent, MimeType, RootlessLocation, ) +from .cryptography import DigitallySigned, GPGSigned from .lifecycle import Persistent, Transient from .meta import Tagged, TemplatePath -from .three_dimensional import Spatial -from .time import Clip, GapPolicy, Sequence, SMPTETimecode +from .three_dimensional import Geometry, IESProfile, Lighting, Shader, Spatial +from .time import ( + FrameRanged, + GapPolicy, + Handles, + Sequence, + SMPTETimecode, + Static, +) from .trait import Representation, TraitBase from .two_dimensional import ( UDIM, @@ -31,6 +42,15 @@ __all__ = [ "FileLocation", "MimeType", "RootlessLocation", + "Fragment", + "LocatableContent", + + # color + "ColorManaged", + + # cryptography + "DigitallySigned", + "GPGSigned", # life cycle "Persistent", @@ -50,10 +70,16 @@ __all__ = [ "UDIM", # three-dimensional + "Geometry", + "IESProfile", + "Lighting", + "Shader", "Spatial", # time - "Clip", + "FrameRanged", + "Static", + "Handles", "GapPolicy", "Sequence", "SMPTETimecode", diff --git a/client/ayon_core/pipeline/traits/color.py b/client/ayon_core/pipeline/traits/color.py new file mode 100644 index 0000000000..9d1fcd913c --- /dev/null +++ b/client/ayon_core/pipeline/traits/color.py @@ -0,0 +1,31 @@ +"""Color management related traits.""" +from __future__ import annotations + +from typing import ClassVar, Optional + +from pydantic import Field + +from .trait import TraitBase + + +class ColorManaged(TraitBase): + """Color managed trait. + + Holds color management information. Can be used with Image related + traits to define color space and config. + + Sync with OpenAssetIO MediaCreation Traits. + + Attributes: + color_space (str): An OCIO colorspace name available + in the "current" OCIO context. + config (str): An OCIO config name defining color space. + """ + id: ClassVar[str] = "ayon.color.ColorManaged.v1" + name: ClassVar[str] = "ColorManaged" + description: ClassVar[str] = "Color Managed trait." + color_space: str = Field( + ..., + description="Color space." + ) + config: Optional[str] = Field(None, description="Color config.") diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 480d1657f5..3f08cc2214 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -13,13 +13,17 @@ from .trait import Representation, TraitBase class MimeType(TraitBase): """MimeType trait model. - This model represents a mime type trait. + This model represents a mime type trait. For example, image/jpeg. + It is used to describe the type of content in a representation regardless + of the file extension. + + For more information, see RFC 2046 and RFC 4288 (and related RFCs). Attributes: name (str): Trait name. description (str): Trait description. id (str): id should be namespaced trait name with version - mime_type (str): Mime type. + mime_type (str): Mime type like image/jpeg. """ @@ -28,10 +32,35 @@ class MimeType(TraitBase): id: ClassVar[str] = "ayon.content.MimeType.v1" mime_type: str = Field(..., title="Mime Type") -class FileLocation(TraitBase): +class LocatableContent(TraitBase): + """LocatableContent trait model. + + This model represents a locatable content trait. Locatable content + is content that has a location. It doesn't have to be a file - it could + be a URL or some other location. + + Sync with OpenAssetIO MediaCreation Traits. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + location (str): Location. + + """ + + name: ClassVar[str] = "LocatableContent" + description: ClassVar[str] = "LocatableContent Trait Model" + id: ClassVar[str] = "ayon.content.LocatableContent.v1" + location: str = Field(..., title="Location") + is_templated: Optional[bool] = Field(None, title="Is Templated") + +class FileLocation(LocatableContent): """FileLocation trait model. - This model represents a file location trait. + This model represents a file path. It is a specialization of the + LocatableContent trait. It is adding optional file size and file hash + for easy access to file information. Attributes: name (str): Trait name. @@ -46,14 +75,22 @@ class FileLocation(TraitBase): name: ClassVar[str] = "FileLocation" description: ClassVar[str] = "FileLocation Trait Model" id: ClassVar[str] = "ayon.content.FileLocation.v1" - file_path: Path = Field(..., title="File Path") - file_size: int = Field(..., title="File Size") + file_path: Path = Field(..., title="File Path", alias="location") + file_size: int = Field(None, title="File Size") file_hash: Optional[str] = Field(None, title="File Hash") class RootlessLocation(TraitBase): """RootlessLocation trait model. - This model represents a rootless location trait. + RootlessLocation trait is a trait that represents a file path that is + without specific root. To obtain absolute path, the root needs to be + resolved by AYON. Rootless path can be used on multiple platforms. + + Example:: + + RootlessLocation( + rootless_path="{root[work]}/project/asset/asset.jpg" + ) Attributes: name (str): Trait name. @@ -72,7 +109,12 @@ class RootlessLocation(TraitBase): class Compressed(TraitBase): """Compressed trait model. - This model represents a compressed trait. + This trait can hold information about compressed content. What type + of compression is used. + + Example:: + + Compressed("gzip") Attributes: name (str): Trait name. @@ -96,6 +138,29 @@ class Bundle(TraitBase): a collection of representations that are part of a single entity. + Example:: + + Bundle( + items=[ + [ + Representation( + traits=[ + MimeType(mime_type="image/jpeg"), + FileLocation(file_path="/path/to/file.jpg") + ] + ) + ], + [ + Representation( + traits=[ + MimeType(mime_type="image/png"), + FileLocation(file_path="/path/to/file.png") + ] + ) + ] + ] + ) + Attributes: name (str): Trait name. description (str): Trait description. @@ -119,7 +184,19 @@ class Fragment(TraitBase): """Fragment trait model. This model represents a fragment trait. A fragment is a part of - a larger entity that is represented by a representation. + a larger entity that is represented by another representation. + + Example:: + + main_representation = Representation(name="parent", + traits=[], + ) + fragment_representation = Representation( + name="fragment", + traits=[ + Fragment(parent=main_representation.id), + ] + ) Attributes: name (str): Trait name. diff --git a/client/ayon_core/pipeline/traits/cryptography.py b/client/ayon_core/pipeline/traits/cryptography.py new file mode 100644 index 0000000000..4fa9e64c2f --- /dev/null +++ b/client/ayon_core/pipeline/traits/cryptography.py @@ -0,0 +1,42 @@ +"""Cryptography traits.""" +from __future__ import annotations + +from typing import ClassVar, Optional + +from pydantic import Field + +from .trait import TraitBase + + +class DigitallySigned(TraitBase): + """Digitally signed trait. + + This type trait means that the data is digitally signed. + + Attributes: + signature (str): Digital signature. + """ + id: ClassVar[str] = "ayon.cryptography.DigitallySigned.v1" + name: ClassVar[str] = "DigitallySigned" + description: ClassVar[str] = "Digitally signed trait." + + +class GPGSigned(DigitallySigned): + """GPG signed trait. + + This trait holds GPG signed data. + + Attributes: + signature (str): GPG signature. + """ + id: ClassVar[str] = "ayon.cryptography.GPGSigned.v1" + name: ClassVar[str] = "GPGSigned" + description: ClassVar[str] = "GPG signed trait." + signed_data: str = Field( + ..., + description="Signed data." + ) + clear_text: Optional[str] = Field( + None, + description="Clear text." + ) diff --git a/client/ayon_core/pipeline/traits/lifecycle.py b/client/ayon_core/pipeline/traits/lifecycle.py index 2877a4d396..fed620da9b 100644 --- a/client/ayon_core/pipeline/traits/lifecycle.py +++ b/client/ayon_core/pipeline/traits/lifecycle.py @@ -1,30 +1,44 @@ """Lifecycle traits.""" from typing import ClassVar +from . import Representation from .trait import TraitBase class Transient(TraitBase): """Transient trait model. - This model represents a transient trait. + Transient trait marks representation as transient. Such representations + are not persisted in the system. Attributes: name (str): Trait name. description (str): Trait description. id (str): id should be namespaced trait name with version - tags (List[str]): Tags. """ name: ClassVar[str] = "Transient" description: ClassVar[str] = "Transient Trait Model" id: ClassVar[str] = "ayon.lifecycle.Transient.v1" + def validate(self, representation: Representation) -> bool: + """Validate representation is not Persistent. + + Args: + representation (Representation): Representation model. + + Returns: + bool: True if representation is valid, False otherwise. + """ + return not representation.contains_trait(Persistent) + class Persistent(TraitBase): """Persistent trait model. - This model represents a persistent trait. + Persistent trait is opposite to transient trait. It marks representation + as persistent. Such representations are persisted in the system (e.g. in + the database). Attributes: name (str): Trait name. @@ -35,3 +49,14 @@ class Persistent(TraitBase): name: ClassVar[str] = "Persistent" description: ClassVar[str] = "Persistent Trait Model" id: ClassVar[str] = "ayon.lifecycle.Persistent.v1" + + def validate(self, representation: Representation) -> bool: + """Validate representation is not Transient. + + Args: + representation (Representation): Representation model. + + Returns: + bool: True if representation is valid, False otherwise. + """ + return not representation.contains_trait(Transient) diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py index 36ad5e8b0f..745b065798 100644 --- a/client/ayon_core/pipeline/traits/meta.py +++ b/client/ayon_core/pipeline/traits/meta.py @@ -9,7 +9,11 @@ from .trait import TraitBase class Tagged(TraitBase): """Tagged trait model. - This model represents a tagged trait. + This trait can hold list of tags. + + Example:: + + Tagged(tags=["tag1", "tag2"]) Attributes: name (str): Trait name. @@ -28,12 +32,17 @@ class TemplatePath(TraitBase): """TemplatePath trait model. This model represents a template path with formatting data. + Template path can be Anatomy template and data is used to format it. + + Example:: + + TemplatePath(template="path/{key}/file", data={"key": "to"}) Attributes: name (str): Trait name. description (str): Trait description. id (str): id should be namespaced trait name with version - template_path (str): Template path. + template (str): Template path. data (dict[str]): Formatting data. """ diff --git a/client/ayon_core/pipeline/traits/three_dimensional.py b/client/ayon_core/pipeline/traits/three_dimensional.py index 0638700ab7..eb27797ed2 100644 --- a/client/ayon_core/pipeline/traits/three_dimensional.py +++ b/client/ayon_core/pipeline/traits/three_dimensional.py @@ -1,4 +1,4 @@ -"""Two-dimensional image traits.""" +"""3D traits.""" from typing import ClassVar from pydantic import Field @@ -9,6 +9,17 @@ from .trait import TraitBase class Spatial(TraitBase): """Spatial trait model. + Trait describing spatial information. Up axis valid strings are + "Y", "Z", "X". Handedness valid strings are "left", "right". Meters per + unit is a float value. + + Example:: + + Spatial(up_axis="Y", handedness="right", meters_per_unit=1.0) + + Todo: + * Add value validation for up_axis and handedness. + Attributes: up_axis (str): Up axis. handedness (str): Handedness. @@ -21,3 +32,50 @@ class Spatial(TraitBase): up_axis: str = Field(..., title="Up axis") handedness: str = Field(..., title="Handedness") meters_per_unit: float = Field(..., title="Meters per unit") + + +class Geometry(TraitBase): + """Geometry type trait model. + + Type trait for geometry data. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.Geometry.v1" + name: ClassVar[str] = "Geometry" + description: ClassVar[str] = "Geometry trait model." + +class Shader(TraitBase): + """Shader trait model. + + Type trait for shader data. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.Shader.v1" + name: ClassVar[str] = "Shader" + description: ClassVar[str] = "Shader trait model." + +class Lighting(TraitBase): + """Lighting trait model. + + Type trait for lighting data. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.Lighting.v1" + name: ClassVar[str] = "Lighting" + description: ClassVar[str] = "Lighting trait model." + +class IESProfile(TraitBase): + """IES profile (IES-LM-64) type trait model. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.IESProfile.v1" + name: ClassVar[str] = "IESProfile" + description: ClassVar[str] = "IES profile trait model." diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 840462faeb..05bcb1602c 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -12,6 +12,8 @@ from .trait import TraitBase class GapPolicy(Enum): """Gap policy enumeration. + This type defines how to handle gaps in sequence. + Attributes: forbidden (int): Gaps are forbidden. missing (int): Gaps are interpreted as missing frames. @@ -23,11 +25,12 @@ class GapPolicy(Enum): hold = auto() black = auto() +class FrameRanged(TraitBase): + """Frame ranged trait model. -class Clip(TraitBase): - """Clip trait model. + Model representing a frame ranged trait. - Model representing a clip trait. + Sync with OpenAssetIO MediaCreation Traits. Attributes: name (str): Trait name. @@ -35,6 +38,37 @@ class Clip(TraitBase): id (str): id should be namespaced trait name with version frame_start (int): Frame start. frame_end (int): Frame end. + frame_in (int): Frame in. + frame_out (int): Frame out. + frames_per_second (int): Frames per second. + step (int): Step. + + """ + name: ClassVar[str] = "FrameRanged" + description: ClassVar[str] = "Frame Ranged Trait" + id: ClassVar[str] = "ayon.time.FrameRanged.v1" + frame_start: int = Field( + ..., title="Start Frame", alias="start_frame") + frame_end: int = Field( + ..., title="Frame Start", alias="end_frame") + frame_in: int = Field(..., title="In Frame", alias="in_frame") + frame_out: int = Field(..., title="Out Frame", alias="out_frame") + frames_per_second: int = Field( + ..., title="Frames Per Second", alias="fps") + step: Optional[int] = Field(1, title="Step") + + +class Handles(TraitBase): + """Handles trait model. + + Handles define the range of frames that are included or excluded + from the sequence. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + inclusive (bool): Handles are inclusive. frame_start_handle (int): Frame start handle. frame_end_handle (int): Frame end handle. @@ -42,22 +76,24 @@ class Clip(TraitBase): name: ClassVar[str] = "Clip" description: ClassVar[str] = "Clip Trait" id: ClassVar[str] = "ayon.time.Clip.v1" - frame_start: int = Field(..., title="Frame Start") - frame_end: int = Field(..., title="Frame End") - frame_start_handle: Optional[int] = Field(0, title="Frame Start Handle") - frame_end_handle: Optional[int] = Field(0, title="Frame End Handle") + inclusive: Optional[bool] = Field( + False, title="Handles are inclusive") # noqa: FBT003 + frame_start_handle: Optional[int] = Field( + 0, title="Frame Start Handle") + frame_end_handle: Optional[int] = Field( + 0, title="Frame End Handle") -class Sequence(Clip): +class Sequence(FrameRanged, Handles): """Sequence trait model. - This model represents a sequence trait. Based on the Clip trait, - adding handling for steps, gaps policy and frame padding. + This model represents a sequence trait. Based on the FrameRanged trait + and Handles, adding support for gaps policy, frame padding and frame + list specification. Regex is used to match frame numbers. Attributes: name (str): Trait name. description (str): Trait description. id (str): id should be namespaced trait name with version - step (int): Frame step. gaps_policy (GapPolicy): Gaps policy - how to handle gaps in sequence. frame_padding (int): Frame padding. @@ -70,7 +106,6 @@ class Sequence(Clip): name: ClassVar[str] = "Sequence" description: ClassVar[str] = "Sequence Trait Model" id: ClassVar[str] = "ayon.time.Sequence.v1" - step: Optional[int] = Field(1, title="Step") gaps_policy: GapPolicy = Field( GapPolicy.forbidden, title="Gaps Policy") frame_padding: int = Field(..., title="Frame Padding") @@ -80,8 +115,19 @@ class Sequence(Clip): # Do we need one for drop and non-drop frame? class SMPTETimecode(TraitBase): - """Timecode trait model.""" + """SMPTE Timecode trait model.""" name: ClassVar[str] = "Timecode" description: ClassVar[str] = "SMPTE Timecode Trait" id: ClassVar[str] = "ayon.time.SMPTETimecode.v1" timecode: str = Field(..., title="SMPTE Timecode HH:MM:SS:FF") + + +class Static(TraitBase): + """Static time trait. + + Used to define static time (single frame). + + """ + name: ClassVar[str] = "Static" + description: ClassVar[str] = "Static Time Trait" + id: ClassVar[str] = "ayon.time.Static.v1" diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py index 69e129ec41..93d21a9bc3 100644 --- a/client/ayon_core/pipeline/traits/two_dimensional.py +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -9,7 +9,7 @@ from .trait import TraitBase class Image(TraitBase): """Image trait model. - This model represents an image trait. + Type trait model for image. Attributes: name (str): Trait name. @@ -26,7 +26,7 @@ class Image(TraitBase): class PixelBased(TraitBase): """PixelBased trait model. - This model represents a pixel based trait. + Pixel related trait for image data. Attributes: name (str): Trait name. @@ -51,9 +51,10 @@ class Planar(TraitBase): This model represents an Image with planar configuration. - TODO (antirotor): Is this really a planar configuration? As with - bitplanes and everything? If it serves as differentiator for - Deep images, should it be named differently? Like Raster? + Todo: + * (antirotor): Is this really a planar configuration? As with + bitplanes and everything? If it serves as differentiator for + Deep images, should it be named differently? Like Raster? Attributes: name (str): Trait name. @@ -72,29 +73,25 @@ class Planar(TraitBase): class Deep(TraitBase): """Deep trait model. - This model represents a deep image trait. + Type trait model for deep EXR images. Attributes: name (str): Trait name. description (str): Trait description. id (str): id should be namespaced trait name with version - deep_data_type (str): Deep data type. """ name: ClassVar[str] = "Deep" description: ClassVar[str] = "Deep Trait Model" id: ClassVar[str] = "ayon.2d.Deep.v1" - deep_data_type: str = Field(..., title="Deep Data Type") - - - class Overscan(TraitBase): """Overscan trait model. - This model represents an overscan (or underscan) trait. + This model represents an overscan (or underscan) trait. Defines the + extra pixels around the image. Attributes: name (str): Trait name. From fbeef7f7c2b95de01105dcf857ee07dc551cab42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 5 Nov 2024 09:50:49 +0100 Subject: [PATCH 031/781] :dog: run ruff action only on changed files --- .github/workflows/pr_linting.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr_linting.yml b/.github/workflows/pr_linting.yml index 3d2431b69a..8daf16fad0 100644 --- a/.github/workflows/pr_linting.yml +++ b/.github/workflows/pr_linting.yml @@ -22,3 +22,5 @@ jobs: steps: - uses: actions/checkout@v4 - uses: chartboost/ruff-action@v1 + with: + changed-files: "true" From 9e7dac2ba5d00f3e0470781f648f2f262cab7e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 5 Nov 2024 13:46:49 +0100 Subject: [PATCH 032/781] :recycle: switch ruff to official GH action --- .github/workflows/pr_linting.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr_linting.yml b/.github/workflows/pr_linting.yml index 8daf16fad0..896d5b7f4d 100644 --- a/.github/workflows/pr_linting.yml +++ b/.github/workflows/pr_linting.yml @@ -21,6 +21,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: chartboost/ruff-action@v1 + - uses: astral-sh/ruff-action@v1 with: changed-files: "true" From deae44dbc15c3449ff6405a8841744a72d12932d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 6 Nov 2024 15:44:42 +0100 Subject: [PATCH 033/781] :recycle: fix tests and trait version --- client/ayon_core/pipeline/traits/content.py | 7 +++-- client/ayon_core/pipeline/traits/lifecycle.py | 5 ++-- client/ayon_core/pipeline/traits/trait.py | 29 +++++++++---------- .../ayon_core/pipeline/traits/test_traits.py | 9 +++--- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 3f08cc2214..47a7e208bd 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -55,7 +55,7 @@ class LocatableContent(TraitBase): location: str = Field(..., title="Location") is_templated: Optional[bool] = Field(None, title="Is Templated") -class FileLocation(LocatableContent): +class FileLocation(TraitBase): """FileLocation trait model. This model represents a file path. It is a specialization of the @@ -75,10 +75,11 @@ class FileLocation(LocatableContent): name: ClassVar[str] = "FileLocation" description: ClassVar[str] = "FileLocation Trait Model" id: ClassVar[str] = "ayon.content.FileLocation.v1" - file_path: Path = Field(..., title="File Path", alias="location") - file_size: int = Field(None, title="File Size") + file_path: Path = Field(..., title="File Path") + file_size: Optional[int] = Field(None, title="File Size") file_hash: Optional[str] = Field(None, title="File Hash") + class RootlessLocation(TraitBase): """RootlessLocation trait model. diff --git a/client/ayon_core/pipeline/traits/lifecycle.py b/client/ayon_core/pipeline/traits/lifecycle.py index fed620da9b..39fe04e3cb 100644 --- a/client/ayon_core/pipeline/traits/lifecycle.py +++ b/client/ayon_core/pipeline/traits/lifecycle.py @@ -1,7 +1,6 @@ """Lifecycle traits.""" from typing import ClassVar -from . import Representation from .trait import TraitBase @@ -21,7 +20,7 @@ class Transient(TraitBase): description: ClassVar[str] = "Transient Trait Model" id: ClassVar[str] = "ayon.lifecycle.Transient.v1" - def validate(self, representation: Representation) -> bool: + def validate(self, representation) -> bool: # noqa: ANN001 """Validate representation is not Persistent. Args: @@ -50,7 +49,7 @@ class Persistent(TraitBase): description: ClassVar[str] = "Persistent Trait Model" id: ClassVar[str] = "ayon.lifecycle.Persistent.v1" - def validate(self, representation: Representation) -> bool: + def validate(self, representation) -> bool: # noqa: ANN001 """Validate representation is not Transient. Args: diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index b97ec08cc6..6c37f40a45 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -6,7 +6,7 @@ import re import sys import uuid from abc import ABC, abstractmethod -from functools import cached_property, lru_cache +from functools import lru_cache from typing import ClassVar, Optional, Type, Union import pydantic.alias_generators @@ -65,20 +65,6 @@ class TraitBase(ABC, BaseModel): """Abstract attribute for description.""" ... - @property - @cached_property - def version(self) -> Union[int, None]: - # sourcery skip: use-named-expression - """Get trait version from ID. - - This assumes Trait ID ends with `.v{version}`. If not, it will - return None. - - """ - version_regex = r"v(\d+)$" - match = re.search(version_regex, self.id) - return int(match[1]) if match else None - def validate(self, representation: Representation) -> bool: """Validate the trait. @@ -95,6 +81,19 @@ class TraitBase(ABC, BaseModel): """ return True + @classmethod + def get_version(cls) -> Optional[int]: + # sourcery skip: use-named-expression + """Get trait version from ID. + + This assumes Trait ID ends with `.v{version}`. If not, it will + return None. + + """ + version_regex = r"v(\d+)$" + match = re.search(version_regex, str(cls.id)) + return int(match[1]) if match else None + class Representation: """Representation of products. diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index 8a48d6eef8..43d8301e00 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -72,7 +72,8 @@ def test_representation_errors(representation: Representation) -> None: def test_representation_traits(representation: Representation) -> None: """Test setting and getting traits.""" - assert representation.get_trait_by_id("ayon.2d.PixelBased").version == 1 + assert representation.get_trait_by_id( + "ayon.2d.PixelBased").get_version() == 1 assert len(representation) == len(REPRESENTATION_DATA) assert representation.get_trait_by_id(FileLocation.id) @@ -234,7 +235,7 @@ def test_bundles() -> None: def test_get_version_from_id() -> None: """Test getting version from trait ID.""" - assert Image().version == 1 + assert Image().get_version() == 1 class TestOverscan(Overscan): id = "ayon.2d.Overscan.v2" @@ -244,12 +245,12 @@ def test_get_version_from_id() -> None: right=0, top=0, bottom=0 - ).version == 2 + ).get_version() == 2 class TestMimeType(MimeType): id = "ayon.content.MimeType" - assert TestMimeType(mime_type="foo/bar").version is None + assert TestMimeType(mime_type="foo/bar").get_version() is None def test_from_dict() -> None: From 4d31c5ee6ec77411400729c14c4d07808298cb5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 6 Nov 2024 17:38:35 +0100 Subject: [PATCH 034/781] :fire: remove FrameRanged field aliases --- client/ayon_core/pipeline/traits/time.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 05bcb1602c..751eef3fb5 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -30,7 +30,12 @@ class FrameRanged(TraitBase): Model representing a frame ranged trait. - Sync with OpenAssetIO MediaCreation Traits. + Sync with OpenAssetIO MediaCreation Traits. For compatibility with + OpenAssetIO, we'll need to handle different names of attributes: + + * frame_start -> start_frame + * frame_end -> end_frame + ... Attributes: name (str): Trait name. @@ -48,13 +53,13 @@ class FrameRanged(TraitBase): description: ClassVar[str] = "Frame Ranged Trait" id: ClassVar[str] = "ayon.time.FrameRanged.v1" frame_start: int = Field( - ..., title="Start Frame", alias="start_frame") + ..., title="Start Frame") frame_end: int = Field( - ..., title="Frame Start", alias="end_frame") - frame_in: int = Field(..., title="In Frame", alias="in_frame") - frame_out: int = Field(..., title="Out Frame", alias="out_frame") + ..., title="Frame Start") + frame_in: int = Field(..., title="In Frame") + frame_out: int = Field(..., title="Out Frame") frames_per_second: int = Field( - ..., title="Frames Per Second", alias="fps") + ..., title="Frames Per Second") step: Optional[int] = Field(1, title="Step") From 39a92397431ba946f47975d852d5437632a2bcb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 7 Nov 2024 17:05:24 +0100 Subject: [PATCH 035/781] :wrench: wip use FileLocation for transfers --- .../ayon_core/plugins/publish/integrate_traits.py | 14 +++++++++++++- .../plugins/publish/test_integrate_traits.py | 3 +++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index f7f3f47489..ca79bf1518 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -20,6 +20,7 @@ from ayon_core.pipeline.publish import ( get_publish_template_name, ) from ayon_core.pipeline.traits import Persistent, Representation +from pipeline.traits import FileLocation if TYPE_CHECKING: import logging @@ -146,6 +147,12 @@ class IntegrateTraits(pyblish.api.InstancePlugin): instance.data["versionEntity"] = version_entity # 7) Get transfers from representations + for representation in representations: + # this should test version-less FileLocation probably + if representation.contains_trait(FileLocation): + self.log.debug( + "Representation: %s", representation) + # 8) Transfer files # 9) Commit the session to AYON # 10) Finalize represetations - add integrated path Trait etc. @@ -306,7 +313,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): all_version_data = self.get_version_data_from_instance(instance) version_data = {} version_attributes = {} - attr_defs = self._get_attributes_for_type(instance.context, "version") + attr_defs = self.get_attributes_for_type(instance.context, "version") for key, value in all_version_data.items(): if key in attr_defs: version_attributes[key] = value @@ -435,6 +442,11 @@ class IntegrateTraits(pyblish.api.InstancePlugin): ),path) return path + def get_attributes_for_type( + self, context: pyblish.api.Context, entity_type: str) -> dict: + """Get AYON attributes for the given entity type.""" + return self.get_attributes_by_type(context)[entity_type] + def get_attributes_by_type( self, context: pyblish.api.Context) -> dict: """Gets AYON attributes from the given context.""" diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index 1262266943..009cc3b7ff 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -108,6 +108,9 @@ def mock_context( frame_end=SEQUENCE_LENGTH, frame_padding=4, frame_regex=r"^img\.(\d{4})\.png$", + frame_in=0, + frame_out=SEQUENCE_LENGTH - 1, + frames_per_second=25 ), FileLocation( file_path=sequence_files[0], From b9e430e2a560fa29c21c1cfe578ea253c2fb687d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 8 Nov 2024 09:46:04 +0100 Subject: [PATCH 036/781] :recycle: make fields optional, fps data type --- client/ayon_core/pipeline/traits/time.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 751eef3fb5..7a614e1a94 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -2,12 +2,16 @@ from __future__ import annotations from enum import Enum, auto -from typing import ClassVar, Optional +from typing import TYPE_CHECKING, ClassVar, Optional, Union from pydantic import Field from .trait import TraitBase +if TYPE_CHECKING: + from decimal import Decimal + from fractions import Fraction + class GapPolicy(Enum): """Gap policy enumeration. @@ -45,7 +49,7 @@ class FrameRanged(TraitBase): frame_end (int): Frame end. frame_in (int): Frame in. frame_out (int): Frame out. - frames_per_second (int): Frames per second. + frames_per_second (float, Fraction, Decimal): Frames per second. step (int): Step. """ @@ -56,9 +60,9 @@ class FrameRanged(TraitBase): ..., title="Start Frame") frame_end: int = Field( ..., title="Frame Start") - frame_in: int = Field(..., title="In Frame") - frame_out: int = Field(..., title="Out Frame") - frames_per_second: int = Field( + frame_in: Optional[int] = Field(None, title="In Frame") + frame_out: Optional[int] = Field(None, title="Out Frame") + frames_per_second: Union[float, Fraction, Decimal] = Field( ..., title="Frames Per Second") step: Optional[int] = Field(1, title="Step") From db5d997ce74ca264b7e0be996966534c4e2363e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 8 Nov 2024 09:46:47 +0100 Subject: [PATCH 037/781] :art: add `get_versionless_id()` helper (and test) --- client/ayon_core/pipeline/traits/trait.py | 13 ++++++++++++ .../ayon_core/pipeline/traits/test_traits.py | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 6c37f40a45..22e7fc6d64 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -94,6 +94,16 @@ class TraitBase(ABC, BaseModel): match = re.search(version_regex, str(cls.id)) return int(match[1]) if match else None + @classmethod + def get_versionless_id(cls) -> str: + """Get trait ID without version. + + Returns: + str: Trait ID without version. + + """ + return re.sub(r"\.v\d+$", "", str(cls.id)) + class Representation: """Representation of products. @@ -416,6 +426,9 @@ class Representation: bool: True if the representations are equal, False otherwise. """ + if self.representation_id != other.representation_id: + return False + if not isinstance(other, Representation): return False diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index 43d8301e00..8a6e210b78 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -252,6 +252,26 @@ def test_get_version_from_id() -> None: assert TestMimeType(mime_type="foo/bar").get_version() is None +def test_get_versionless_id() -> None: + """Test getting versionless trait ID.""" + assert Image().get_versionless_id() == "ayon.2d.Image" + + class TestOverscan(Overscan): + id = "ayon.2d.Overscan.v2" + + assert TestOverscan( + left=0, + right=0, + top=0, + bottom=0 + ).get_versionless_id() == "ayon.2d.Overscan" + + class TestMimeType(MimeType): + id = "ayon.content.MimeType" + + assert TestMimeType(mime_type="foo/bar").get_versionless_id() == \ + "ayon.content.MimeType" + def test_from_dict() -> None: """Test creating representation from dictionary.""" From 521a211e38d304c68a358e24660f7240b5aae04e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 8 Nov 2024 16:53:51 +0100 Subject: [PATCH 038/781] :wrench: WIP preparing template data --- .../plugins/publish/integrate_traits.py | 136 +++++++++++++++++- 1 file changed, 131 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index ca79bf1518..915d4e433f 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -1,6 +1,7 @@ """Integrate representations with traits.""" from __future__ import annotations +import copy from typing import TYPE_CHECKING, Any, List import pyblish.api @@ -17,10 +18,16 @@ from ayon_api.operations import ( new_version_entity, ) from ayon_core.pipeline.publish import ( + PublishError, get_publish_template_name, ) -from ayon_core.pipeline.traits import Persistent, Representation -from pipeline.traits import FileLocation +from ayon_core.pipeline.traits import ( + ColorManaged, + FileLocation, + Persistent, + Representation, +) +from pipeline.traits import PixelBased if TYPE_CHECKING: import logging @@ -131,8 +138,8 @@ class IntegrateTraits(pyblish.api.InstancePlugin): "Instance has no persistent representations. Skipping") return - # 3) get anatomy template name - # template_name = self.get_template_name(instance) + # 3) get anatomy template + template = self.get_template(instance) # 4) initialize OperationsSession() op_session = OperationsSession() @@ -146,13 +153,62 @@ class IntegrateTraits(pyblish.api.InstancePlugin): ) instance.data["versionEntity"] = version_entity + instance_template_data = {} + # handle {originalDirname} requested in the template + if "{originalDirname}" in template: + instance_template_data = { + "originalDirname": self._get_relative_to_root_original_dirname( + instance) + } + # 6.5) prepare template and data to format it + for representation in representations: + template_data = self.get_template_date_from_representation( + representation, instance) + # add instance based template data + template_data.update(instance_template_data) + if "{originalBasename}" in template: + # Remove 'frame' from template data because original frame + # number will be used. + template_data.pop("frame", None) + # WIP: use trait logic to get original frame range + + + # 7) Get transfers from representations for representation in representations: # this should test version-less FileLocation probably - if representation.contains_trait(FileLocation): + if representation.contains_trait_by_id( + FileLocation.get_versionless_id()): self.log.debug( "Representation: %s", representation) + def _get_relative_to_root_original_dirname( + self, instance: pyblish.api.Instance) -> str: + """Get path stripped of root of the original directory name. + + If `originalDirname` or `stagingDir` is set in instance data, + this will return it as rootless path. The path must reside + within the project directory. + """ + original_directory = ( + instance.data.get("originalDirname") or + instance.data.get("stagingDir")) + anatomy = instance.context.data["anatomy"] + + _rootless = self.get_rootless_path(anatomy, original_directory) + # this check works because _rootless will be the same as + # original_directory if the original_directory cannot be transformed + # to the rootless path. + if _rootless == original_directory: + msg = ( + f"Destination path '{original_directory}' must " + "be in project directory.") + raise PublishError(msg) + # the root is at the beginning - {root[work]}/rest/of/the/path + relative_path_start = _rootless.rfind("}") + 2 + return _rootless[relative_path_start:] + + # 8) Transfer files # 9) Commit the session to AYON # 10) Finalize represetations - add integrated path Trait etc. @@ -205,6 +261,23 @@ class IntegrateTraits(pyblish.api.InstancePlugin): logger=self.log ) + def get_template(self, instance: pyblish.api.Instance) -> str: + """Return anatomy template name to use for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + + Returns: + str: Anatomy template name + + """ + # Anatomy data is pre-filled by Collectors + template_name = self.get_template_name(instance) + anatomy = instance.context.data["anatomy"] + publish_template = anatomy.get_template_item("publish", template_name) + path_template_obj = publish_template["path"] + return path_template_obj.template.replace("\\", "/") + def prepare_product( self, instance: pyblish.api.Instance, @@ -464,3 +537,56 @@ class IntegrateTraits(pyblish.api.InstancePlugin): } context.data["ayonAttributes"] = attributes return attributes + + def get_template_date_from_representation( + self, + representation: Representation, + instance: pyblish.api.Instance) -> dict: + """Get template data from representation. + + Using representation traits and data on instance + prepare data for formatting template. + + Args: + representation (Representation): Representation to process. + instance (pyblish.api.Instance): Instance to process. + + Returns: + dict: Template data. + + """ + template_data = copy.deepcopy(instance.data["anatomyData"]) + template_data["representation"] = representation.name + + # add colorspace data to template data + if representation.contains_trait(ColorManaged): + colorspace_data: ColorManaged = representation.get_trait( + ColorManaged) + + template_data["colorspace"] = { + "colorspace": colorspace_data.color_space, + "config": colorspace_data.config + } + + # add explicit list of traits properties to template data + # there must be some better way to handle this + try: + # resolution from PixelBased trait + template_data["resolution_width"] = representation.get_trait( + PixelBased).display_window_width + template_data["resolution_height"] = representation.get_trait( + PixelBased).display_window_height + # get fps from representation traits + # is this the right way? Isn't it going against the + # trait abstraction? + traits = representation.get_traits() + for trait in traits.values(): + if hasattr(trait, "frames_per_second"): + template_data["fps"] = trait.fps + + # Note: handle "output" and "originalBasename" + + except ValueError as e: + self.log.debug("Missing traits: %s", e) + + return template_data From e4377e8f07f9cfb179a3d292dfa4b29e5fa3bdd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 8 Nov 2024 17:49:11 +0100 Subject: [PATCH 039/781] :recycle: raise exception if trait not found instead of returning None Raising exception is more pythonic than returning just None. Also some changes in return type annotations. --- client/ayon_core/pipeline/traits/trait.py | 40 ++++++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 22e7fc6d64..20ad9a1316 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -7,7 +7,7 @@ import sys import uuid from abc import ABC, abstractmethod from functools import lru_cache -from typing import ClassVar, Optional, Type, Union +from typing import ClassVar, Optional, Type, TypeVar, Union import pydantic.alias_generators from pydantic import ( @@ -105,6 +105,9 @@ class TraitBase(ABC, BaseModel): return re.sub(r"\.v\d+$", "", str(cls.id)) +T = TypeVar("T", bound=TraitBase) + + class Representation: """Representation of products. @@ -287,7 +290,7 @@ class Representation: self.contains_trait_by_id(trait_id) for trait_id in trait_ids ) - def get_trait(self, trait: Type[TraitBase]) -> Union[TraitBase, None]: + def get_trait(self, trait: Type[T]) -> Union[T]: """Get a trait from the representation. Args: @@ -296,10 +299,17 @@ class Representation: Returns: TraitBase: Trait instance. - """ - return self._data[trait.id] if self._data.get(trait.id) else None + Raises: + ValueError: If the trait is not found. - def get_trait_by_id(self, trait_id: str) -> Union[TraitBase, None]: + """ + try: + return self._data[trait.id] + except KeyError as e: + msg = f"Trait with ID {trait.id} not found." + raise ValueError(msg) from e + + def get_trait_by_id(self, trait_id: str) -> Union[T]: # sourcery skip: use-named-expression """Get a trait from the representation by id. @@ -309,12 +319,19 @@ class Representation: Returns: TraitBase: Trait instance. + Raises: + ValueError: If the trait is not found. + """ version = _get_version_from_id(trait_id) if version: - return self._data.get(trait_id) + try: + return self._data[trait_id] + except KeyError as e: + msg = f"Trait with ID {trait_id} not found." + raise ValueError(msg) from e - return next( + result = next( ( self._data.get(trait_id) for trait_id in self._data @@ -322,9 +339,14 @@ class Representation: ), None, ) + if not result: + msg = f"Trait with ID {trait_id} not found." + raise ValueError(msg) + return result def get_traits(self, - traits: Optional[list[Type[TraitBase]]]=None) -> dict: + traits: Optional[list[Type[TraitBase]]]=None + ) -> dict[str, T]: """Get a list of traits from the representation. If no trait IDs or traits are provided, all traits will be returned. @@ -346,7 +368,7 @@ class Representation: result[trait.id] = self.get_trait(trait=trait) return result - def get_traits_by_ids(self, trait_ids: list[str]) -> dict: + def get_traits_by_ids(self, trait_ids: list[str]) -> dict[str, T]: """Get a list of traits from the representation by their id. If no trait IDs or traits are provided, all traits will be returned. From 0e18aee030174a85b06ab20e0d9bd0ab952bae5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 11 Nov 2024 14:05:19 +0100 Subject: [PATCH 040/781] :art: add utilities package --- client/ayon_core/pipeline/traits/utils.py | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 client/ayon_core/pipeline/traits/utils.py diff --git a/client/ayon_core/pipeline/traits/utils.py b/client/ayon_core/pipeline/traits/utils.py new file mode 100644 index 0000000000..702bea3a16 --- /dev/null +++ b/client/ayon_core/pipeline/traits/utils.py @@ -0,0 +1,38 @@ +"""Utility functions for traits.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clique import assemble + +from ayon_core.pipeline.traits import Sequence + +if TYPE_CHECKING: + from pathlib import Path + + +def ge_sequence_from_files(paths: list[Path]) -> Sequence: + """Get original frame range from files. + + Note that this cannot guess frame rate, so it's set to 25. + + Args: + paths (list[Path]): List of file paths. + + Returns: + Sequence: Sequence trait. + + """ + col = assemble([path.as_posix() for path in paths])[0][0] + sorted_frames = sorted(col.indexes) + # First frame used for end value + first_frame = sorted_frames[0] + # Get last frame for padding + last_frame = sorted_frames[-1] + # Use padding from collection of length of last frame as string + padding = max(col.padding, len(str(last_frame))) + + return Sequence( + frame_start=first_frame, frame_end=last_frame, frame_padding=padding, + frames_per_second=25 + ) From 5a8138c0e74cb2badbd7c84683685a6cb84a185b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 11 Nov 2024 16:01:24 +0100 Subject: [PATCH 041/781] :bug: fix typo --- client/ayon_core/pipeline/traits/__init__.py | 6 ++++++ client/ayon_core/pipeline/traits/utils.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index a12c55e41a..ec0a7d90b0 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -30,6 +30,9 @@ from .two_dimensional import ( PixelBased, Planar, ) +from .utils import ( + get_sequence_from_files, +) __all__ = [ # base @@ -83,4 +86,7 @@ __all__ = [ "GapPolicy", "Sequence", "SMPTETimecode", + + # utils + "get_sequence_from_files", ] diff --git a/client/ayon_core/pipeline/traits/utils.py b/client/ayon_core/pipeline/traits/utils.py index 702bea3a16..cd75443cd0 100644 --- a/client/ayon_core/pipeline/traits/utils.py +++ b/client/ayon_core/pipeline/traits/utils.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from pathlib import Path -def ge_sequence_from_files(paths: list[Path]) -> Sequence: +def get_sequence_from_files(paths: list[Path]) -> Sequence: """Get original frame range from files. Note that this cannot guess frame rate, so it's set to 25. From d2285276288843eda92f2e83faec12e70cfad888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 11 Nov 2024 16:30:11 +0100 Subject: [PATCH 042/781] :art: add file locations --- client/ayon_core/pipeline/traits/content.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 47a7e208bd..f018b3e73b 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -79,6 +79,25 @@ class FileLocation(TraitBase): file_size: Optional[int] = Field(None, title="File Size") file_hash: Optional[str] = Field(None, title="File Hash") +class FileLocations(TraitBase): + """FileLocation trait model. + + This model represents a file path. It is a specialization of the + LocatableContent trait. It is adding optional file size and file hash + for easy access to file information. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + file_paths (list of FileLocation): File locations. + + """ + + name: ClassVar[str] = "FileLocations" + description: ClassVar[str] = "FileLocations Trait Model" + id: ClassVar[str] = "ayon.content.FileLocations.v1" + file_paths: list[FileLocation] = Field(..., title="File Path") class RootlessLocation(TraitBase): """RootlessLocation trait model. From f589cb933ca926cd94b022848c83942c723ba9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 12 Nov 2024 17:12:54 +0100 Subject: [PATCH 043/781] :recycle: change validations validation on trait are now raising exception instead of returning just bool, to pass validation error. Also added `validate()`to representation - this runs it on all traits. --- client/ayon_core/pipeline/traits/__init__.py | 11 ++- client/ayon_core/pipeline/traits/content.py | 67 ++++++++++++++++++- client/ayon_core/pipeline/traits/lifecycle.py | 16 +++-- client/ayon_core/pipeline/traits/time.py | 16 ++++- client/ayon_core/pipeline/traits/trait.py | 49 +++++++++++--- client/ayon_core/pipeline/traits/utils.py | 38 +++++++++++ .../ayon_core/pipeline/traits/test_traits.py | 58 ++++++++++++++++ 7 files changed, 237 insertions(+), 18 deletions(-) create mode 100644 client/ayon_core/pipeline/traits/utils.py diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index a12c55e41a..d8b74a4c70 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -4,6 +4,7 @@ from .content import ( Bundle, Compressed, FileLocation, + FileLocations, Fragment, LocatableContent, MimeType, @@ -21,7 +22,7 @@ from .time import ( SMPTETimecode, Static, ) -from .trait import Representation, TraitBase +from .trait import MissingTraitError, Representation, TraitBase from .two_dimensional import ( UDIM, Deep, @@ -30,16 +31,21 @@ from .two_dimensional import ( PixelBased, Planar, ) +from .utils import ( + get_sequence_from_files, +) __all__ = [ # base "Representation", "TraitBase", + "MissingTraitError", # content "Bundle", "Compressed", "FileLocation", + "FileLocations", "MimeType", "RootlessLocation", "Fragment", @@ -83,4 +89,7 @@ __all__ = [ "GapPolicy", "Sequence", "SMPTETimecode", + + # utils + "get_sequence_from_files", ] diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index f018b3e73b..eb156f44e1 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -7,7 +7,14 @@ from typing import ClassVar, Optional from pydantic import Field -from .trait import Representation, TraitBase +from .time import Sequence +from .trait import ( + MissingTraitError, + Representation, + TraitBase, + TraitValidationError, + get_sequence_from_files, +) class MimeType(TraitBase): @@ -99,6 +106,64 @@ class FileLocations(TraitBase): id: ClassVar[str] = "ayon.content.FileLocations.v1" file_paths: list[FileLocation] = Field(..., title="File Path") + def validate(self, representation: Representation) -> bool: + """Validate the trait. + + This method validates the trait against others in the representation. + In particular, it checks that the sequence trait is present and if + so, it will compare the frame range to the file paths. + + Args: + representation (Representation): Representation to validate. + + Returns: + bool: True if the trait is valid, False otherwise + + """ + if len(self.file_paths) == 0: + # If there are no file paths, we can't validate + msg = "No file locations defined (empty list)" + raise TraitValidationError(self.name, msg) + + tmp_seq: Sequence = get_sequence_from_files( + [f.file_path for f in self.file_paths]) + + if len(self.file_paths) != \ + tmp_seq.frame_end - tmp_seq.frame_start: + # If the number of file paths does not match the frame range, + # the trait is invalid + msg = ( + f"Number of file locations ({len(self.file_paths)}) " + "does not match frame range " + f"({tmp_seq.frame_end - tmp_seq.frame_start})" + ) + raise TraitValidationError(self.name, msg) + + try: + sequence: Sequence = representation.get_trait(Sequence) + + if sequence.frame_start != tmp_seq.frame_start or \ + sequence.frame_end != tmp_seq.frame_end or \ + sequence.frame_padding != tmp_seq.frame_padding: + # If the frame range does not match the sequence trait, the + # trait is invalid. Note that we don't check the frame rate + # because it is not stored in the file paths and is not + # determined by `get_sequence_from_files`. + msg = ( + "Frame range " + f"({sequence.frame_start}-{sequence.frame_end}) " + "in sequence trait does not match " + "frame range " + f"({tmp_seq.frame_start}-{tmp_seq.frame_end}) " + "defined in files." + ) + raise TraitValidationError(self.name, msg) + + except MissingTraitError: + # If there is no sequence trait, we can't validate it + pass + + class RootlessLocation(TraitBase): """RootlessLocation trait model. diff --git a/client/ayon_core/pipeline/traits/lifecycle.py b/client/ayon_core/pipeline/traits/lifecycle.py index 39fe04e3cb..b5cede3bb1 100644 --- a/client/ayon_core/pipeline/traits/lifecycle.py +++ b/client/ayon_core/pipeline/traits/lifecycle.py @@ -1,7 +1,7 @@ """Lifecycle traits.""" from typing import ClassVar -from .trait import TraitBase +from .trait import TraitBase, TraitValidationError class Transient(TraitBase): @@ -20,7 +20,7 @@ class Transient(TraitBase): description: ClassVar[str] = "Transient Trait Model" id: ClassVar[str] = "ayon.lifecycle.Transient.v1" - def validate(self, representation) -> bool: # noqa: ANN001 + def validate(self, representation) -> None: # noqa: ANN001 """Validate representation is not Persistent. Args: @@ -29,7 +29,9 @@ class Transient(TraitBase): Returns: bool: True if representation is valid, False otherwise. """ - return not representation.contains_trait(Persistent) + if representation.contains_trait(Persistent): + msg = "Representation is marked as both Persistent and Transient." + raise TraitValidationError(self.name, msg) class Persistent(TraitBase): @@ -49,13 +51,13 @@ class Persistent(TraitBase): description: ClassVar[str] = "Persistent Trait Model" id: ClassVar[str] = "ayon.lifecycle.Persistent.v1" - def validate(self, representation) -> bool: # noqa: ANN001 + def validate(self, representation) -> None: # noqa: ANN001 """Validate representation is not Transient. Args: representation (Representation): Representation model. - Returns: - bool: True if representation is valid, False otherwise. """ - return not representation.contains_trait(Transient) + if representation.contains_trait(Persistent): + msg = "Representation is marked as both Persistent and Transient." + raise TraitValidationError(self.name, msg) diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 7a614e1a94..1b2f211468 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -6,7 +6,8 @@ from typing import TYPE_CHECKING, ClassVar, Optional, Union from pydantic import Field -from .trait import TraitBase +from .content import FileLocations +from .trait import MissingTraitError, Representation, TraitBase if TYPE_CHECKING: from decimal import Decimal @@ -121,6 +122,19 @@ class Sequence(FrameRanged, Handles): frame_regex: str = Field(..., title="Frame Regex") frame_list: Optional[str] = Field(None, title="Frame List") + def validate(self, representation: Representation) -> None: + """Validate the trait.""" + if not super().validate(representation): + return False + + # if there is FileLocations trait, run validation + # on it as well + try: + file_locs: FileLocations = representation.get_trait( + FileLocations) + file_locs.validate(representation) + except MissingTraitError: + pass # Do we need one for drop and non-drop frame? class SMPTETimecode(TraitBase): diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 20ad9a1316..15e48b6f5a 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -65,7 +65,7 @@ class TraitBase(ABC, BaseModel): """Abstract attribute for description.""" ... - def validate(self, representation: Representation) -> bool: + def validate(self, representation: Representation) -> None: """Validate the trait. This method should be implemented in the derived classes to validate @@ -76,10 +76,11 @@ class TraitBase(ABC, BaseModel): representation (Representation): Representation instance. Raises: - ValueError: If the trait is invalid within representation. + TraitValidationError: If the trait is invalid + within representation. """ - return True + return @classmethod def get_version(cls) -> Optional[int]: @@ -300,14 +301,14 @@ class Representation: TraitBase: Trait instance. Raises: - ValueError: If the trait is not found. + MissingTraitError: If the trait is not found. """ try: return self._data[trait.id] except KeyError as e: msg = f"Trait with ID {trait.id} not found." - raise ValueError(msg) from e + raise MissingTraitError(msg) from e def get_trait_by_id(self, trait_id: str) -> Union[T]: # sourcery skip: use-named-expression @@ -320,7 +321,7 @@ class Representation: TraitBase: Trait instance. Raises: - ValueError: If the trait is not found. + MissingTraitError: If the trait is not found. """ version = _get_version_from_id(trait_id) @@ -329,7 +330,7 @@ class Representation: return self._data[trait_id] except KeyError as e: msg = f"Trait with ID {trait_id} not found." - raise ValueError(msg) from e + raise MissingTraitError(msg) from e result = next( ( @@ -341,7 +342,7 @@ class Representation: ) if not result: msg = f"Trait with ID {trait_id} not found." - raise ValueError(msg) + raise MissingTraitError(msg) return result def get_traits(self, @@ -672,6 +673,18 @@ class Representation: name=name, representation_id=representation_id, traits=traits) + def validate(self) -> bool: + """Validate the representation. + + This method will validate all the traits in the representation. + + Returns: + bool: True if the representation is valid, False otherwise. + + """ + return all(trait.validate(self) for trait in self._data.values()) + + class IncompatibleTraitVersionError(Exception): """Incompatible trait version exception. @@ -707,3 +720,23 @@ class TraitValidationError(Exception): This exception is raised when the trait validation fails. """ + + def __init__(self, scope: str, message: str): + """Initialize the exception. + + We could determine the scope from the stack in the future, + provided the scope is always Trait name. + + Args: + scope (str): Scope of the error. + message (str): Error message. + + """ + super().__init__(f"{scope}: {message}") + + +class MissingTraitError(TypeError): + """Missing trait error exception. + + This exception is raised when the trait is missing. + """ diff --git a/client/ayon_core/pipeline/traits/utils.py b/client/ayon_core/pipeline/traits/utils.py new file mode 100644 index 0000000000..cd75443cd0 --- /dev/null +++ b/client/ayon_core/pipeline/traits/utils.py @@ -0,0 +1,38 @@ +"""Utility functions for traits.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clique import assemble + +from ayon_core.pipeline.traits import Sequence + +if TYPE_CHECKING: + from pathlib import Path + + +def get_sequence_from_files(paths: list[Path]) -> Sequence: + """Get original frame range from files. + + Note that this cannot guess frame rate, so it's set to 25. + + Args: + paths (list[Path]): List of file paths. + + Returns: + Sequence: Sequence trait. + + """ + col = assemble([path.as_posix() for path in paths])[0][0] + sorted_frames = sorted(col.indexes) + # First frame used for end value + first_frame = sorted_frames[0] + # Get last frame for padding + last_frame = sorted_frames[-1] + # Use padding from collection of length of last frame as string + padding = max(col.padding, len(str(last_frame))) + + return Sequence( + frame_start=first_frame, frame_end=last_frame, frame_padding=padding, + frames_per_second=25 + ) diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index 8a6e210b78..7b183a1104 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -7,11 +7,13 @@ import pytest from ayon_core.pipeline.traits import ( Bundle, FileLocation, + FileLocations, Image, MimeType, PixelBased, Planar, Representation, + Sequence, TraitBase, ) from pipeline.traits import Overscan @@ -329,3 +331,59 @@ def test_from_dict() -> None: representation = Representation.from_dict( "test", trait_data=traits_data) """ + +def test_file_locations_validation() -> None: + """Test FileLocations trait validation.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1050) + ] + + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list) + ]) + + file_locations_trait: FileLocations = FileLocations( + file_paths=file_locations_list) + + # this should be valid trait + assert file_locations_trait.validate(representation) is True + + # add valid sequence trait + sequence_trait = Sequence( + frame_start=1001, + frame_end=1050, + frame_padding=4, + frames_per_second=25 + ) + representation.add_trait(sequence_trait) + + # it should still validate fine + assert file_locations_trait.validate(representation) is True + + # create empty file locations trait + empty_file_locations_trait = FileLocations(file_paths=[]) + representation = Representation(name="test", traits=[ + empty_file_locations_trait + ]) + assert empty_file_locations_trait.validate( + representation) is False + + # create valid file locations trait but with not matching sequence + # trait + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list) + ]) + invalid_sequence_trait = Sequence( + frame_start=1001, + frame_end=1051, + frame_padding=4, + frames_per_second=25 + ) + + representation.add_trait(invalid_sequence_trait) + assert file_locations_trait.validate(representation) is False From b64e0340d976ee35ff5d3bb4d71b0e8189696ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 12 Nov 2024 23:40:59 +0100 Subject: [PATCH 044/781] :recycle: refactor to remove circular imports add `FileLocations` validations --- client/ayon_core/pipeline/traits/__init__.py | 3 +- client/ayon_core/pipeline/traits/content.py | 25 +- .../pipeline/traits/representation.py | 609 ++++++++++++++++++ client/ayon_core/pipeline/traits/time.py | 30 +- client/ayon_core/pipeline/traits/trait.py | 597 +---------------- client/ayon_core/pipeline/traits/utils.py | 14 +- tests/__init__.py | 1 + .../ayon_core/pipeline/traits/test_traits.py | 27 +- tests/conftest.py | 3 +- 9 files changed, 668 insertions(+), 641 deletions(-) create mode 100644 client/ayon_core/pipeline/traits/representation.py create mode 100644 tests/__init__.py diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index d8b74a4c70..16ee7d6975 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -13,6 +13,7 @@ from .content import ( from .cryptography import DigitallySigned, GPGSigned from .lifecycle import Persistent, Transient from .meta import Tagged, TemplatePath +from .representation import Representation from .three_dimensional import Geometry, IESProfile, Lighting, Shader, Spatial from .time import ( FrameRanged, @@ -22,7 +23,7 @@ from .time import ( SMPTETimecode, Static, ) -from .trait import MissingTraitError, Representation, TraitBase +from .trait import MissingTraitError, TraitBase from .two_dimensional import ( UDIM, Deep, diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index eb156f44e1..1563d3b96a 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -7,14 +7,14 @@ from typing import ClassVar, Optional from pydantic import Field -from .time import Sequence +from .representation import Representation +from .time import FrameRanged from .trait import ( MissingTraitError, - Representation, TraitBase, TraitValidationError, - get_sequence_from_files, ) +from .utils import get_sequence_from_files class MimeType(TraitBase): @@ -125,26 +125,25 @@ class FileLocations(TraitBase): msg = "No file locations defined (empty list)" raise TraitValidationError(self.name, msg) - tmp_seq: Sequence = get_sequence_from_files( + tmp_frame_ranged: FrameRanged = get_sequence_from_files( [f.file_path for f in self.file_paths]) - if len(self.file_paths) != \ - tmp_seq.frame_end - tmp_seq.frame_start: + if len(self.file_paths) - 1 != \ + tmp_frame_ranged.frame_end - tmp_frame_ranged.frame_start: # If the number of file paths does not match the frame range, # the trait is invalid msg = ( - f"Number of file locations ({len(self.file_paths)}) " + f"Number of file locations ({len(self.file_paths) - 1}) " "does not match frame range " - f"({tmp_seq.frame_end - tmp_seq.frame_start})" + f"({tmp_frame_ranged.frame_end - tmp_frame_ranged.frame_start})" ) raise TraitValidationError(self.name, msg) try: - sequence: Sequence = representation.get_trait(Sequence) + sequence: FrameRanged = representation.get_trait(FrameRanged) - if sequence.frame_start != tmp_seq.frame_start or \ - sequence.frame_end != tmp_seq.frame_end or \ - sequence.frame_padding != tmp_seq.frame_padding: + if sequence.frame_start != tmp_frame_ranged.frame_start or \ + sequence.frame_end != tmp_frame_ranged.frame_end: # If the frame range does not match the sequence trait, the # trait is invalid. Note that we don't check the frame rate # because it is not stored in the file paths and is not @@ -154,7 +153,7 @@ class FileLocations(TraitBase): f"({sequence.frame_start}-{sequence.frame_end}) " "in sequence trait does not match " "frame range " - f"({tmp_seq.frame_start}-{tmp_seq.frame_end}) " + f"({tmp_frame_ranged.frame_start}-{tmp_frame_ranged.frame_end}) " "defined in files." ) raise TraitValidationError(self.name, msg) diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py new file mode 100644 index 0000000000..acd78a6ce5 --- /dev/null +++ b/client/ayon_core/pipeline/traits/representation.py @@ -0,0 +1,609 @@ +"""Defines the base trait model and representation.""" +from __future__ import annotations + +import inspect +import re +import sys +import uuid +from functools import lru_cache +from typing import ClassVar, Optional, Type, TypeVar, Union + +from .trait import ( + IncompatibleTraitVersionError, + LooseMatchingTraitError, + MissingTraitError, + TraitBase, + UpgradableTraitError, +) + +T = TypeVar("T", bound=TraitBase) + + +def _get_version_from_id(_id: str) -> int: + """Get version from ID. + + Args: + _id (str): ID. + + Returns: + int: Version. + + """ + match = re.search(r"v(\d+)$", _id) + return int(match[1]) if match else None + + +class Representation: + """Representation of products. + + Representation defines collection of individual properties that describe + the specific "form" of the product. Each property is represented by a + trait therefore the Representation is a collection of traits. + + It holds methods to add, remove, get, and check for the existence of a + trait in the representation. It also provides a method to get all the + + Arguments: + name (str): Representation name. Must be unique within instance. + representation_id (str): Representation ID. + + """ + _data: dict + _module_blacklist: ClassVar[list[str]] = [ + "_", "builtins", "pydantic"] + name: str + representation_id: str + + def __hash__(self): + """Return hash of the representation ID.""" + return hash(self.representation_id) + + def add_trait(self, trait: TraitBase, *, exists_ok: bool=False) -> None: + """Add a trait to the Representation. + + Args: + trait (TraitBase): Trait to add. + exists_ok (bool, optional): If True, do not raise an error if the + trait already exists. Defaults to False. + + Raises: + ValueError: If the trait ID is not provided or the trait already + exists. + + """ + if not hasattr(trait, "id"): + error_msg = f"Invalid trait {trait} - ID is required." + raise ValueError(error_msg) + if trait.id in self._data and not exists_ok: + error_msg = f"Trait with ID {trait.id} already exists." + raise ValueError(error_msg) + self._data[trait.id] = trait + + def add_traits( + self, traits: list[TraitBase], *, exists_ok: bool=False) -> None: + """Add a list of traits to the Representation. + + Args: + traits (list[TraitBase]): List of traits to add. + exists_ok (bool, optional): If True, do not raise an error if the + trait already exists. Defaults to False. + + """ + for trait in traits: + self.add_trait(trait, exists_ok=exists_ok) + + def remove_trait(self, trait: Type[TraitBase]) -> None: + """Remove a trait from the data. + + Args: + trait (TraitBase, optional): Trait class. + + Raises: + ValueError: If the trait is not found. + + """ + try: + self._data.pop(trait.id) + except KeyError as e: + error_msg = f"Trait with ID {trait.id} not found." + raise ValueError(error_msg) from e + + def remove_trait_by_id(self, trait_id: str) -> None: + """Remove a trait from the data by its ID. + + Args: + trait_id (str): Trait ID. + + Raises: + ValueError: If the trait is not found. + + """ + try: + self._data.pop(trait_id) + except KeyError as e: + error_msg = f"Trait with ID {trait_id} not found." + raise ValueError(error_msg) from e + + def remove_traits(self, traits: list[Type[TraitBase]]) -> None: + """Remove a list of traits from the Representation. + + If no trait IDs or traits are provided, all traits will be removed. + + Args: + traits (list[TraitBase]): List of trait classes. + + """ + if not traits: + self._data = {} + return + + for trait in traits: + self.remove_trait(trait) + + def remove_traits_by_id(self, trait_ids: list[str]) -> None: + """Remove a list of traits from the Representation by their ID. + + If no trait IDs or traits are provided, all traits will be removed. + + Args: + trait_ids (list[str], optional): List of trait IDs. + + """ + for trait_id in trait_ids: + self.remove_trait_by_id(trait_id) + + + def has_traits(self) -> bool: + """Check if the Representation has any traits. + + Returns: + bool: True if the Representation has any traits, False otherwise. + + """ + return bool(self._data) + + def contains_trait(self, trait: Type[TraitBase]) -> bool: + """Check if the trait exists in the Representation. + + Args: + trait (TraitBase): Trait class. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + return bool(self._data.get(trait.id)) + + def contains_trait_by_id(self, trait_id: str) -> bool: + """Check if the trait exists using trait id. + + Args: + trait_id (str): Trait ID. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + return bool(self._data.get(trait_id)) + + def contains_traits(self, traits: list[Type[TraitBase]]) -> bool: + """Check if the traits exist. + + Args: + traits (list[TraitBase], optional): List of trait classes. + + Returns: + bool: True if all traits exist, False otherwise. + + """ + return all(self.contains_trait(trait=trait) for trait in traits) + + def contains_traits_by_id(self, trait_ids: list[str]) -> bool: + """Check if the traits exist by id. + + If no trait IDs or traits are provided, it will check if the + representation has any traits. + + Args: + trait_ids (list[str]): List of trait IDs. + + Returns: + bool: True if all traits exist, False otherwise. + + """ + return all( + self.contains_trait_by_id(trait_id) for trait_id in trait_ids + ) + + def get_trait(self, trait: Type[T]) -> Union[T]: + """Get a trait from the representation. + + Args: + trait (TraitBase, optional): Trait class. + + Returns: + TraitBase: Trait instance. + + Raises: + MissingTraitError: If the trait is not found. + + """ + try: + return self._data[trait.id] + except KeyError as e: + msg = f"Trait with ID {trait.id} not found." + raise MissingTraitError(msg) from e + + def get_trait_by_id(self, trait_id: str) -> Union[T]: + # sourcery skip: use-named-expression + """Get a trait from the representation by id. + + Args: + trait_id (str): Trait ID. + + Returns: + TraitBase: Trait instance. + + Raises: + MissingTraitError: If the trait is not found. + + """ + version = _get_version_from_id(trait_id) + if version: + try: + return self._data[trait_id] + except KeyError as e: + msg = f"Trait with ID {trait_id} not found." + raise MissingTraitError(msg) from e + + result = next( + ( + self._data.get(trait_id) + for trait_id in self._data + if trait_id.startswith(trait_id) + ), + None, + ) + if not result: + msg = f"Trait with ID {trait_id} not found." + raise MissingTraitError(msg) + return result + + def get_traits(self, + traits: Optional[list[Type[TraitBase]]]=None + ) -> dict[str, T]: + """Get a list of traits from the representation. + + If no trait IDs or traits are provided, all traits will be returned. + + Args: + traits (list[TraitBase], optional): List of trait classes. + + Returns: + dict: Dictionary of traits. + + """ + result = {} + if not traits: + for trait_id in self._data: + result[trait_id] = self.get_trait_by_id(trait_id=trait_id) + return result + + for trait in traits: + result[trait.id] = self.get_trait(trait=trait) + return result + + def get_traits_by_ids(self, trait_ids: list[str]) -> dict[str, T]: + """Get a list of traits from the representation by their id. + + If no trait IDs or traits are provided, all traits will be returned. + + Args: + trait_ids (list[str]): List of trait IDs. + + Returns: + dict: Dictionary of traits. + + """ + return { + trait_id: self.get_trait_by_id(trait_id) + for trait_id in trait_ids + } + + def traits_as_dict(self) -> dict: + """Return the traits from Representation data as a dictionary. + + Returns: + dict: Traits data dictionary. + + """ + return { + trait_id: trait.model_dump() + for trait_id, trait in self._data.items() + if trait and trait_id + } + + def __len__(self): + """Return the length of the data.""" + return len(self._data) + + def __init__( + self, + name: str, + representation_id: Optional[str]=None, + traits: Optional[list[TraitBase]]=None): + """Initialize the data. + + Args: + name (str): Representation name. Must be unique within instance. + representation_id (str, optional): Representation ID. + traits (list[TraitBase], optional): List of traits. + """ + self.name = name + self.representation_id = representation_id or uuid.uuid4().hex + self._data = {} + if traits: + for trait in traits: + self.add_trait(trait) + + @staticmethod + def _get_version_from_id(trait_id: str) -> Union[int, None]: + # sourcery skip: use-named-expression + """Check if the trait has version specified. + + Args: + trait_id (str): Trait ID. + + Returns: + int: Trait version. + None: If the trait id does not have a version. + + """ + version_regex = r"v(\d+)$" + match = re.search(version_regex, trait_id) + return int(match[1]) if match else None + + def __eq__(self, other: Representation) -> bool: # noqa: PLR0911 + """Check if the representation is equal to another. + + Args: + other (Representation): Representation to compare. + + Returns: + bool: True if the representations are equal, False otherwise. + + """ + if self.representation_id != other.representation_id: + return False + + if not isinstance(other, Representation): + return False + + if self.name != other.name: + return False + + # number of traits + if len(self) != len(other): + return False + + for trait_id, trait in self._data.items(): + if trait_id not in other._data: + return False + if trait != other._data[trait_id]: + return False + for key, value in trait.model_dump().items(): + if value != other._data[trait_id].model_dump().get(key): + return False + + return True + + @classmethod + @lru_cache(maxsize=64) + def _get_possible_trait_classes_from_modules( + cls, + trait_id: str) -> set[type[TraitBase]]: + """Get possible trait classes from modules. + + Args: + trait_id (str): Trait ID. + + Returns: + set[type[TraitBase]]: Set of trait classes. + + """ + modules = sys.modules.copy() + filtered_modules = modules.copy() + for module_name in modules: + for bl_module in cls._module_blacklist: + if module_name.startswith(bl_module): + filtered_modules.pop(module_name) + + trait_candidates = set() + for module in filtered_modules.values(): + if not module: + continue + for _, klass in inspect.getmembers(module, inspect.isclass): + if inspect.isclass(klass) \ + and issubclass(klass, TraitBase) \ + and str(klass.id).startswith(trait_id): + trait_candidates.add(klass) + return trait_candidates + + @classmethod + @lru_cache(maxsize=64) + def _get_trait_class( + cls, trait_id: str) -> Union[Type[TraitBase], None]: + """Get the trait class with corresponding to given ID. + + This method will search for the trait class in all the modules except + the blacklisted modules. There is some issue in Pydantic where + ``issubclass`` is not working properly so we are excluding explicitly + modules with offending classes. This list can be updated as needed to + speed up the search. + + Args: + trait_id (str): Trait ID. + + Returns: + Type[TraitBase]: Trait class. + + Raises: + LooseMatchingTraitError: If the trait is found with a loose + matching criteria. This exception will include the trait + class that was found and the expected trait ID. Additional + downstream logic must decide how to handle this error. + + """ + version = cls._get_version_from_id(trait_id) + + trait_candidates = cls._get_possible_trait_classes_from_modules( + trait_id + ) + + for trait_class in trait_candidates: + if trait_class.id == trait_id: + # we found direct match + return trait_class + + # if we didn't find direct match, we will search for the highest + # version of the trait. + if not version: + # sourcery skip: use-named-expression + trait_versions = [ + trait_class for trait_class in trait_candidates + if re.match( + rf"{trait_id}.v(\d+)$", str(trait_class.id)) + ] + if trait_versions: + def _get_version_by_id(trait_klass: Type[TraitBase]) -> int: + match = re.search(r"v(\d+)$", str(trait_klass.id)) + return int(match[1]) if match else 0 + + error = LooseMatchingTraitError( + "Found trait that might match.") + error.found_trait = max( + trait_versions, key=_get_version_by_id) + error.expected_id = trait_id + raise error + + return None + + @classmethod + def get_trait_class_by_trait_id(cls, trait_id: str) -> type[TraitBase]: + """Get the trait class for the given trait ID. + + Args: + trait_id (str): Trait ID. + + Returns: + type[TraitBase]: Trait class. + + Raises: + IncompatibleTraitVersionError: If the trait version is incompatible + with the current version of the trait. + UpgradableTraitError: If the trait can upgrade existing data + meant for older versions of the trait. + ValueError: If the trait model with the given ID is not found. + + """ + trait_class = None + try: + trait_class = cls._get_trait_class(trait_id=trait_id) + except LooseMatchingTraitError as e: + requested_version = _get_version_from_id(trait_id) + found_version = _get_version_from_id(e.found_trait.id) + + if not requested_version: + trait_class = e.found_trait + + else: + if requested_version > found_version: + error_msg = ( + f"Requested trait version {requested_version} is " + f"higher than the found trait version {found_version}." + ) + raise IncompatibleTraitVersionError(error_msg) from e + + if requested_version < found_version and hasattr( + e.found_trait, "upgrade"): + error_msg = ( + "Requested trait version " + f"{requested_version} is lower " + f"than the found trait version {found_version}." + ) + error = UpgradableTraitError(error_msg) + error.trait = e.found_trait + raise error from e + return trait_class + + @classmethod + def from_dict( + cls, + name: str, + representation_id: Optional[str]=None, + trait_data: Optional[dict] = None) -> Representation: + """Create a representation from a dictionary. + + Args: + name (str): Representation name. + representation_id (str, optional): Representation ID. + trait_data (dict): Representation data. Dictionary with keys + as trait ids and values as trait data. Example:: + + { + "ayon.2d.PixelBased.v1": { + "display_window_width": 1920, + "display_window_height": 1080 + }, + "ayon.2d.Planar.v1": { + "channels": 3 + } + } + + Returns: + Representation: Representation instance. + + """ + traits = [] + for trait_id, value in trait_data.items(): + if not isinstance(value, dict): + msg = ( + f"Invalid trait data for trait ID {trait_id}. " + "Trait data must be a dictionary." + ) + raise TypeError(msg) + + try: + trait_class = cls.get_trait_class_by_trait_id(trait_id) + except UpgradableTraitError as e: + # we found newer version of trait, we will upgrade the data + if hasattr(e.trait, "upgrade"): + traits.append(e.trait.upgrade(value)) + else: + msg = ( + f"Newer version of trait {e.trait.id} found " + f"for requested {trait_id} but without " + "upgrade method." + ) + raise IncompatibleTraitVersionError(msg) from e + else: + if not trait_class: + error_msg = f"Trait model with ID {trait_id} not found." + raise ValueError(error_msg) + + traits.append(trait_class(**value)) + + return cls( + name=name, representation_id=representation_id, traits=traits) + + + def validate(self) -> bool: + """Validate the representation. + + This method will validate all the traits in the representation. + + Returns: + bool: True if the representation is valid, False otherwise. + + """ + return all(trait.validate(self) for trait in self._data.values()) diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 1b2f211468..16aeaba2d1 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -2,16 +2,15 @@ from __future__ import annotations from enum import Enum, auto -from typing import TYPE_CHECKING, ClassVar, Optional, Union +from typing import TYPE_CHECKING, Annotated, ClassVar, Optional, Union -from pydantic import Field +from pydantic import Field, PlainSerializer -from .content import FileLocations -from .trait import MissingTraitError, Representation, TraitBase +from .representation import Representation +from .trait import MissingTraitError, TraitBase if TYPE_CHECKING: from decimal import Decimal - from fractions import Fraction class GapPolicy(Enum): @@ -42,6 +41,13 @@ class FrameRanged(TraitBase): * frame_end -> end_frame ... + Note: frames_per_second is a string to allow various precision + formats. FPS is a floating point number, but it can be also + represented as a fraction (e.g. "30000/1001") or as a decimal + or even as irrational number. We need to support all these + formats. To work with FPS, we'll need some helper function + to convert FPS to Decimal from string. + Attributes: name (str): Trait name. description (str): Trait description. @@ -50,7 +56,7 @@ class FrameRanged(TraitBase): frame_end (int): Frame end. frame_in (int): Frame in. frame_out (int): Frame out. - frames_per_second (float, Fraction, Decimal): Frames per second. + frames_per_second (str): Frames per second. step (int): Step. """ @@ -63,8 +69,7 @@ class FrameRanged(TraitBase): ..., title="Frame Start") frame_in: Optional[int] = Field(None, title="In Frame") frame_out: Optional[int] = Field(None, title="Out Frame") - frames_per_second: Union[float, Fraction, Decimal] = Field( - ..., title="Frames Per Second") + frames_per_second: str = Field(..., title="Frames Per Second") step: Optional[int] = Field(1, title="Step") @@ -83,9 +88,9 @@ class Handles(TraitBase): frame_end_handle (int): Frame end handle. """ - name: ClassVar[str] = "Clip" - description: ClassVar[str] = "Clip Trait" - id: ClassVar[str] = "ayon.time.Clip.v1" + name: ClassVar[str] = "Handles" + description: ClassVar[str] = "Handles Trait" + id: ClassVar[str] = "ayon.time.Handles.v1" inclusive: Optional[bool] = Field( False, title="Handles are inclusive") # noqa: FBT003 frame_start_handle: Optional[int] = Field( @@ -93,7 +98,7 @@ class Handles(TraitBase): frame_end_handle: Optional[int] = Field( 0, title="Frame End Handle") -class Sequence(FrameRanged, Handles): +class Sequence(TraitBase): """Sequence trait model. This model represents a sequence trait. Based on the FrameRanged trait @@ -130,6 +135,7 @@ class Sequence(FrameRanged, Handles): # if there is FileLocations trait, run validation # on it as well try: + from .content import FileLocations file_locs: FileLocations = representation.get_trait( FileLocations) file_locs.validate(representation) diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 15e48b6f5a..1f0c72cd9d 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -1,13 +1,9 @@ """Defines the base trait model and representation.""" from __future__ import annotations -import inspect import re -import sys -import uuid from abc import ABC, abstractmethod -from functools import lru_cache -from typing import ClassVar, Optional, Type, TypeVar, Union +from typing import TYPE_CHECKING, Optional import pydantic.alias_generators from pydantic import ( @@ -16,19 +12,8 @@ from pydantic import ( ConfigDict, ) - -def _get_version_from_id(_id: str) -> int: - """Get version from ID. - - Args: - _id (str): ID. - - Returns: - int: Version. - - """ - match = re.search(r"v(\d+)$", _id) - return int(match[1]) if match else None +if TYPE_CHECKING: + from .representation import Representation class TraitBase(ABC, BaseModel): @@ -106,584 +91,8 @@ class TraitBase(ABC, BaseModel): return re.sub(r"\.v\d+$", "", str(cls.id)) -T = TypeVar("T", bound=TraitBase) -class Representation: - """Representation of products. - - Representation defines collection of individual properties that describe - the specific "form" of the product. Each property is represented by a - trait therefore the Representation is a collection of traits. - - It holds methods to add, remove, get, and check for the existence of a - trait in the representation. It also provides a method to get all the - - Arguments: - name (str): Representation name. Must be unique within instance. - representation_id (str): Representation ID. - - """ - _data: dict - _module_blacklist: ClassVar[list[str]] = [ - "_", "builtins", "pydantic"] - name: str - representation_id: str - - def __hash__(self): - """Return hash of the representation ID.""" - return hash(self.representation_id) - - def add_trait(self, trait: TraitBase, *, exists_ok: bool=False) -> None: - """Add a trait to the Representation. - - Args: - trait (TraitBase): Trait to add. - exists_ok (bool, optional): If True, do not raise an error if the - trait already exists. Defaults to False. - - Raises: - ValueError: If the trait ID is not provided or the trait already - exists. - - """ - if not hasattr(trait, "id"): - error_msg = f"Invalid trait {trait} - ID is required." - raise ValueError(error_msg) - if trait.id in self._data and not exists_ok: - error_msg = f"Trait with ID {trait.id} already exists." - raise ValueError(error_msg) - self._data[trait.id] = trait - - def add_traits( - self, traits: list[TraitBase], *, exists_ok: bool=False) -> None: - """Add a list of traits to the Representation. - - Args: - traits (list[TraitBase]): List of traits to add. - exists_ok (bool, optional): If True, do not raise an error if the - trait already exists. Defaults to False. - - """ - for trait in traits: - self.add_trait(trait, exists_ok=exists_ok) - - def remove_trait(self, trait: Type[TraitBase]) -> None: - """Remove a trait from the data. - - Args: - trait (TraitBase, optional): Trait class. - - Raises: - ValueError: If the trait is not found. - - """ - try: - self._data.pop(trait.id) - except KeyError as e: - error_msg = f"Trait with ID {trait.id} not found." - raise ValueError(error_msg) from e - - def remove_trait_by_id(self, trait_id: str) -> None: - """Remove a trait from the data by its ID. - - Args: - trait_id (str): Trait ID. - - Raises: - ValueError: If the trait is not found. - - """ - try: - self._data.pop(trait_id) - except KeyError as e: - error_msg = f"Trait with ID {trait_id} not found." - raise ValueError(error_msg) from e - - def remove_traits(self, traits: list[Type[TraitBase]]) -> None: - """Remove a list of traits from the Representation. - - If no trait IDs or traits are provided, all traits will be removed. - - Args: - traits (list[TraitBase]): List of trait classes. - - """ - if not traits: - self._data = {} - return - - for trait in traits: - self.remove_trait(trait) - - def remove_traits_by_id(self, trait_ids: list[str]) -> None: - """Remove a list of traits from the Representation by their ID. - - If no trait IDs or traits are provided, all traits will be removed. - - Args: - trait_ids (list[str], optional): List of trait IDs. - - """ - for trait_id in trait_ids: - self.remove_trait_by_id(trait_id) - - - def has_traits(self) -> bool: - """Check if the Representation has any traits. - - Returns: - bool: True if the Representation has any traits, False otherwise. - - """ - return bool(self._data) - - def contains_trait(self, trait: Type[TraitBase]) -> bool: - """Check if the trait exists in the Representation. - - Args: - trait (TraitBase): Trait class. - - Returns: - bool: True if the trait exists, False otherwise. - - """ - return bool(self._data.get(trait.id)) - - def contains_trait_by_id(self, trait_id: str) -> bool: - """Check if the trait exists using trait id. - - Args: - trait_id (str): Trait ID. - - Returns: - bool: True if the trait exists, False otherwise. - - """ - return bool(self._data.get(trait_id)) - - def contains_traits(self, traits: list[Type[TraitBase]]) -> bool: - """Check if the traits exist. - - Args: - traits (list[TraitBase], optional): List of trait classes. - - Returns: - bool: True if all traits exist, False otherwise. - - """ - return all(self.contains_trait(trait=trait) for trait in traits) - - def contains_traits_by_id(self, trait_ids: list[str]) -> bool: - """Check if the traits exist by id. - - If no trait IDs or traits are provided, it will check if the - representation has any traits. - - Args: - trait_ids (list[str]): List of trait IDs. - - Returns: - bool: True if all traits exist, False otherwise. - - """ - return all( - self.contains_trait_by_id(trait_id) for trait_id in trait_ids - ) - - def get_trait(self, trait: Type[T]) -> Union[T]: - """Get a trait from the representation. - - Args: - trait (TraitBase, optional): Trait class. - - Returns: - TraitBase: Trait instance. - - Raises: - MissingTraitError: If the trait is not found. - - """ - try: - return self._data[trait.id] - except KeyError as e: - msg = f"Trait with ID {trait.id} not found." - raise MissingTraitError(msg) from e - - def get_trait_by_id(self, trait_id: str) -> Union[T]: - # sourcery skip: use-named-expression - """Get a trait from the representation by id. - - Args: - trait_id (str): Trait ID. - - Returns: - TraitBase: Trait instance. - - Raises: - MissingTraitError: If the trait is not found. - - """ - version = _get_version_from_id(trait_id) - if version: - try: - return self._data[trait_id] - except KeyError as e: - msg = f"Trait with ID {trait_id} not found." - raise MissingTraitError(msg) from e - - result = next( - ( - self._data.get(trait_id) - for trait_id in self._data - if trait_id.startswith(trait_id) - ), - None, - ) - if not result: - msg = f"Trait with ID {trait_id} not found." - raise MissingTraitError(msg) - return result - - def get_traits(self, - traits: Optional[list[Type[TraitBase]]]=None - ) -> dict[str, T]: - """Get a list of traits from the representation. - - If no trait IDs or traits are provided, all traits will be returned. - - Args: - traits (list[TraitBase], optional): List of trait classes. - - Returns: - dict: Dictionary of traits. - - """ - result = {} - if not traits: - for trait_id in self._data: - result[trait_id] = self.get_trait_by_id(trait_id=trait_id) - return result - - for trait in traits: - result[trait.id] = self.get_trait(trait=trait) - return result - - def get_traits_by_ids(self, trait_ids: list[str]) -> dict[str, T]: - """Get a list of traits from the representation by their id. - - If no trait IDs or traits are provided, all traits will be returned. - - Args: - trait_ids (list[str]): List of trait IDs. - - Returns: - dict: Dictionary of traits. - - """ - return { - trait_id: self.get_trait_by_id(trait_id) - for trait_id in trait_ids - } - - def traits_as_dict(self) -> dict: - """Return the traits from Representation data as a dictionary. - - Returns: - dict: Traits data dictionary. - - """ - return { - trait_id: trait.dict() - for trait_id, trait in self._data.items() - if trait and trait_id - } - - def __len__(self): - """Return the length of the data.""" - return len(self._data) - - def __init__( - self, - name: str, - representation_id: Optional[str]=None, - traits: Optional[list[TraitBase]]=None): - """Initialize the data. - - Args: - name (str): Representation name. Must be unique within instance. - representation_id (str, optional): Representation ID. - traits (list[TraitBase], optional): List of traits. - """ - self.name = name - self.representation_id = representation_id or uuid.uuid4().hex - self._data = {} - if traits: - for trait in traits: - self.add_trait(trait) - - @staticmethod - def _get_version_from_id(trait_id: str) -> Union[int, None]: - # sourcery skip: use-named-expression - """Check if the trait has version specified. - - Args: - trait_id (str): Trait ID. - - Returns: - int: Trait version. - None: If the trait id does not have a version. - - """ - version_regex = r"v(\d+)$" - match = re.search(version_regex, trait_id) - return int(match[1]) if match else None - - def __eq__(self, other: Representation) -> bool: # noqa: PLR0911 - """Check if the representation is equal to another. - - Args: - other (Representation): Representation to compare. - - Returns: - bool: True if the representations are equal, False otherwise. - - """ - if self.representation_id != other.representation_id: - return False - - if not isinstance(other, Representation): - return False - - if self.name != other.name: - return False - - # number of traits - if len(self) != len(other): - return False - - for trait_id, trait in self._data.items(): - if trait_id not in other._data: - return False - if trait != other._data[trait_id]: - return False - for key, value in trait.dict().items(): - if value != other._data[trait_id].dict().get(key): - return False - - return True - - @classmethod - @lru_cache(maxsize=64) - def _get_possible_trait_classes_from_modules( - cls, - trait_id: str) -> set[type[TraitBase]]: - """Get possible trait classes from modules. - - Args: - trait_id (str): Trait ID. - - Returns: - set[type[TraitBase]]: Set of trait classes. - - """ - modules = sys.modules.copy() - filtered_modules = modules.copy() - for module_name in modules: - for bl_module in cls._module_blacklist: - if module_name.startswith(bl_module): - filtered_modules.pop(module_name) - - trait_candidates = set() - for module in filtered_modules.values(): - if not module: - continue - for _, klass in inspect.getmembers(module, inspect.isclass): - if inspect.isclass(klass) \ - and issubclass(klass, TraitBase) \ - and str(klass.id).startswith(trait_id): - trait_candidates.add(klass) - return trait_candidates - - @classmethod - @lru_cache(maxsize=64) - def _get_trait_class( - cls, trait_id: str) -> Union[Type[TraitBase], None]: - """Get the trait class with corresponding to given ID. - - This method will search for the trait class in all the modules except - the blacklisted modules. There is some issue in Pydantic where - ``issubclass`` is not working properly so we are excluding explicitly - modules with offending classes. This list can be updated as needed to - speed up the search. - - Args: - trait_id (str): Trait ID. - - Returns: - Type[TraitBase]: Trait class. - - Raises: - LooseMatchingTraitError: If the trait is found with a loose - matching criteria. This exception will include the trait - class that was found and the expected trait ID. Additional - downstream logic must decide how to handle this error. - - """ - version = cls._get_version_from_id(trait_id) - - trait_candidates = cls._get_possible_trait_classes_from_modules( - trait_id - ) - - for trait_class in trait_candidates: - if trait_class.id == trait_id: - # we found direct match - return trait_class - - # if we didn't find direct match, we will search for the highest - # version of the trait. - if not version: - # sourcery skip: use-named-expression - trait_versions = [ - trait_class for trait_class in trait_candidates - if re.match( - rf"{trait_id}.v(\d+)$", str(trait_class.id)) - ] - if trait_versions: - def _get_version_by_id(trait_klass: Type[TraitBase]) -> int: - match = re.search(r"v(\d+)$", str(trait_klass.id)) - return int(match[1]) if match else 0 - - error = LooseMatchingTraitError( - "Found trait that might match.") - error.found_trait = max( - trait_versions, key=_get_version_by_id) - error.expected_id = trait_id - raise error - - return None - - @classmethod - def get_trait_class_by_trait_id(cls, trait_id: str) -> type[TraitBase]: - """Get the trait class for the given trait ID. - - Args: - trait_id (str): Trait ID. - - Returns: - type[TraitBase]: Trait class. - - Raises: - IncompatibleTraitVersionError: If the trait version is incompatible - with the current version of the trait. - UpgradableTraitError: If the trait can upgrade existing data - meant for older versions of the trait. - ValueError: If the trait model with the given ID is not found. - - """ - trait_class = None - try: - trait_class = cls._get_trait_class(trait_id=trait_id) - except LooseMatchingTraitError as e: - requested_version = _get_version_from_id(trait_id) - found_version = _get_version_from_id(e.found_trait.id) - - if not requested_version: - trait_class = e.found_trait - - else: - if requested_version > found_version: - error_msg = ( - f"Requested trait version {requested_version} is " - f"higher than the found trait version {found_version}." - ) - raise IncompatibleTraitVersionError(error_msg) from e - - if requested_version < found_version and hasattr( - e.found_trait, "upgrade"): - error_msg = ( - "Requested trait version " - f"{requested_version} is lower " - f"than the found trait version {found_version}." - ) - error = UpgradableTraitError(error_msg) - error.trait = e.found_trait - raise error from e - return trait_class - - @classmethod - def from_dict( - cls, - name: str, - representation_id: Optional[str]=None, - trait_data: Optional[dict] = None) -> Representation: - """Create a representation from a dictionary. - - Args: - name (str): Representation name. - representation_id (str, optional): Representation ID. - trait_data (dict): Representation data. Dictionary with keys - as trait ids and values as trait data. Example:: - - { - "ayon.2d.PixelBased.v1": { - "display_window_width": 1920, - "display_window_height": 1080 - }, - "ayon.2d.Planar.v1": { - "channels": 3 - } - } - - Returns: - Representation: Representation instance. - - """ - traits = [] - for trait_id, value in trait_data.items(): - if not isinstance(value, dict): - msg = ( - f"Invalid trait data for trait ID {trait_id}. " - "Trait data must be a dictionary." - ) - raise TypeError(msg) - - try: - trait_class = cls.get_trait_class_by_trait_id(trait_id) - except UpgradableTraitError as e: - # we found newer version of trait, we will upgrade the data - if hasattr(e.trait, "upgrade"): - traits.append(e.trait.upgrade(value)) - else: - msg = ( - f"Newer version of trait {e.trait.id} found " - f"for requested {trait_id} but without " - "upgrade method." - ) - raise IncompatibleTraitVersionError(msg) from e - else: - if not trait_class: - error_msg = f"Trait model with ID {trait_id} not found." - raise ValueError(error_msg) - - traits.append(trait_class(**value)) - - return cls( - name=name, representation_id=representation_id, traits=traits) - - - def validate(self) -> bool: - """Validate the representation. - - This method will validate all the traits in the representation. - - Returns: - bool: True if the representation is valid, False otherwise. - - """ - return all(trait.validate(self) for trait in self._data.values()) - class IncompatibleTraitVersionError(Exception): diff --git a/client/ayon_core/pipeline/traits/utils.py b/client/ayon_core/pipeline/traits/utils.py index cd75443cd0..54386fe8ca 100644 --- a/client/ayon_core/pipeline/traits/utils.py +++ b/client/ayon_core/pipeline/traits/utils.py @@ -5,13 +5,13 @@ from typing import TYPE_CHECKING from clique import assemble -from ayon_core.pipeline.traits import Sequence +from ayon_core.pipeline.traits.time import FrameRanged if TYPE_CHECKING: from pathlib import Path -def get_sequence_from_files(paths: list[Path]) -> Sequence: +def get_sequence_from_files(paths: list[Path]) -> FrameRanged: """Get original frame range from files. Note that this cannot guess frame rate, so it's set to 25. @@ -20,7 +20,7 @@ def get_sequence_from_files(paths: list[Path]) -> Sequence: paths (list[Path]): List of file paths. Returns: - Sequence: Sequence trait. + FrameRanged: FrameRanged trait. """ col = assemble([path.as_posix() for path in paths])[0][0] @@ -30,9 +30,9 @@ def get_sequence_from_files(paths: list[Path]) -> Sequence: # Get last frame for padding last_frame = sorted_frames[-1] # Use padding from collection of length of last frame as string - padding = max(col.padding, len(str(last_frame))) + # padding = max(col.padding, len(str(last_frame))) - return Sequence( - frame_start=first_frame, frame_end=last_frame, frame_padding=padding, - frames_per_second=25 + return FrameRanged( + frame_start=first_frame, frame_end=last_frame, + frames_per_second="25.0" ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..d420712d8b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests.""" diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index 7b183a1104..f72a1169c0 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -8,15 +8,17 @@ from ayon_core.pipeline.traits import ( Bundle, FileLocation, FileLocations, + FrameRanged, Image, MimeType, + Overscan, PixelBased, Planar, Representation, Sequence, TraitBase, ) -from pipeline.traits import Overscan +from ayon_core.pipeline.traits.trait import TraitValidationError REPRESENTATION_DATA = { FileLocation.id: { @@ -340,7 +342,7 @@ def test_file_locations_validation() -> None: file_size=1024, file_hash=None, ) - for frame in range(1001, 1050) + for frame in range(1001, 1051) ] representation = Representation(name="test", traits=[ @@ -351,39 +353,40 @@ def test_file_locations_validation() -> None: file_paths=file_locations_list) # this should be valid trait - assert file_locations_trait.validate(representation) is True + file_locations_trait.validate(representation) - # add valid sequence trait - sequence_trait = Sequence( + # add valid FrameRanged trait + sequence_trait = FrameRanged( frame_start=1001, frame_end=1050, frame_padding=4, - frames_per_second=25 + frames_per_second="25" ) representation.add_trait(sequence_trait) # it should still validate fine - assert file_locations_trait.validate(representation) is True + file_locations_trait.validate(representation) # create empty file locations trait empty_file_locations_trait = FileLocations(file_paths=[]) representation = Representation(name="test", traits=[ empty_file_locations_trait ]) - assert empty_file_locations_trait.validate( - representation) is False + with pytest.raises(TraitValidationError): + empty_file_locations_trait.validate(representation) # create valid file locations trait but with not matching sequence # trait representation = Representation(name="test", traits=[ FileLocations(file_paths=file_locations_list) ]) - invalid_sequence_trait = Sequence( + invalid_sequence_trait = FrameRanged( frame_start=1001, frame_end=1051, frame_padding=4, - frames_per_second=25 + frames_per_second="25" ) representation.add_trait(invalid_sequence_trait) - assert file_locations_trait.validate(representation) is False + with pytest.raises(TraitValidationError): + file_locations_trait.validate(representation) diff --git a/tests/conftest.py b/tests/conftest.py index a3c46a9dd7..33c29d13f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +"""conftest.py: pytest configuration file.""" import sys from pathlib import Path @@ -5,5 +6,3 @@ client_path = Path(__file__).resolve().parent.parent / "client" # add client path to sys.path sys.path.append(str(client_path)) - -print(f"Added {client_path} to sys.path") From 794160f8e31c9f96e5fb19c946fb35ecb6af67d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 13 Nov 2024 14:10:01 +0100 Subject: [PATCH 045/781] :dog: fix ruff issues --- client/ayon_core/pipeline/traits/content.py | 4 ++-- client/ayon_core/pipeline/traits/time.py | 7 +++---- tests/client/ayon_core/pipeline/traits/test_traits.py | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 1563d3b96a..01fe60f0ca 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -135,7 +135,7 @@ class FileLocations(TraitBase): msg = ( f"Number of file locations ({len(self.file_paths) - 1}) " "does not match frame range " - f"({tmp_frame_ranged.frame_end - tmp_frame_ranged.frame_start})" + f"({tmp_frame_ranged.frame_end - tmp_frame_ranged.frame_start})" # noqa: E501 ) raise TraitValidationError(self.name, msg) @@ -153,7 +153,7 @@ class FileLocations(TraitBase): f"({sequence.frame_start}-{sequence.frame_end}) " "in sequence trait does not match " "frame range " - f"({tmp_frame_ranged.frame_start}-{tmp_frame_ranged.frame_end}) " + f"({tmp_frame_ranged.frame_start}-{tmp_frame_ranged.frame_end}) " # noqa: E501 "defined in files." ) raise TraitValidationError(self.name, msg) diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 16aeaba2d1..5ce8d02426 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -2,15 +2,14 @@ from __future__ import annotations from enum import Enum, auto -from typing import TYPE_CHECKING, Annotated, ClassVar, Optional, Union +from typing import TYPE_CHECKING, ClassVar, Optional -from pydantic import Field, PlainSerializer +from pydantic import Field -from .representation import Representation from .trait import MissingTraitError, TraitBase if TYPE_CHECKING: - from decimal import Decimal + from .representation import Representation class GapPolicy(Enum): diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index f72a1169c0..830a58915a 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -50,7 +50,7 @@ class InvalidTrait: """Invalid trait class.""" foo = "bar" -@pytest.fixture() +@pytest.fixture def representation() -> Representation: """Return a traits data instance.""" return Representation(name="test", traits=[ From fc30b854f0650a636861f920e10941ce388e2c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 13 Nov 2024 14:11:25 +0100 Subject: [PATCH 046/781] :dog: remove unused import --- tests/client/ayon_core/pipeline/traits/test_traits.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index 830a58915a..42bb523118 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -15,7 +15,6 @@ from ayon_core.pipeline.traits import ( PixelBased, Planar, Representation, - Sequence, TraitBase, ) from ayon_core.pipeline.traits.trait import TraitValidationError From 61b11c809629653e98fb02027d8ae3c0b8f5446c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 18 Nov 2024 16:32:47 +0100 Subject: [PATCH 047/781] :art: WIP get transfers from representation --- .../plugins/publish/integrate_traits.py | 74 +++++++++++++++++-- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 915d4e433f..8687370b3d 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -27,10 +27,12 @@ from ayon_core.pipeline.traits import ( Persistent, Representation, ) -from pipeline.traits import PixelBased +from pipeline.traits import MissingTraitError, PixelBased +from pipeline.traits.content import FileLocations if TYPE_CHECKING: import logging + from pathlib import Path from pipeline import Anatomy @@ -132,7 +134,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): instance.data["representations_with_traits"] ) - representations = instance.data["representations_with_traits"] + representations: list[Representation] = instance.data["representations_with_traits"] # noqa: E501 if not representations: self.log.debug( "Instance has no persistent representations. Skipping") @@ -154,6 +156,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): instance.data["versionEntity"] = version_entity instance_template_data = {} + transfers = [] # handle {originalDirname} requested in the template if "{originalDirname}" in template: instance_template_data = { @@ -162,7 +165,10 @@ class IntegrateTraits(pyblish.api.InstancePlugin): } # 6.5) prepare template and data to format it for representation in representations: - template_data = self.get_template_date_from_representation( + + # validate representation first + representation.validate() + template_data = self.get_template_data_from_representation( representation, instance) # add instance based template data template_data.update(instance_template_data) @@ -171,8 +177,11 @@ class IntegrateTraits(pyblish.api.InstancePlugin): # number will be used. template_data.pop("frame", None) # WIP: use trait logic to get original frame range + # check if files listes in FileLocations trait match frames + # in sequence - + transfers += self.get_transfers_from_representation( + representation, template, template_data) # 7) Get transfers from representations for representation in representations: @@ -538,7 +547,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): context.data["ayonAttributes"] = attributes return attributes - def get_template_date_from_representation( + def get_template_data_from_representation( self, representation: Representation, instance: pyblish.api.Instance) -> dict: @@ -586,7 +595,60 @@ class IntegrateTraits(pyblish.api.InstancePlugin): # Note: handle "output" and "originalBasename" - except ValueError as e: + except MissingTraitError as e: self.log.debug("Missing traits: %s", e) return template_data + + def get_transfers_from_representation( + self, + representation: Representation, + template: str, + template_data: dict) -> list: + """Get transfers from representation. + + Args: + representation (Representation): Representation to process. + template (str): Template to format. + template_data (dict): Template data. + + Returns: + list: List of transfers. + + """ + transfers = [] + # check if representation contains traits with files + if not representation.contains_traits( + [FileLocation, FileLocations]): + return transfers + + files: list[Path] = [] + + if representation.contains_trait(FileLocations): + files = [ + location.file_path + for location in representation.get_trait( + FileLocations).file_paths + ] + elif representation.contains_trait(FileLocation): + file_location: FileLocation = representation.get_trait( + FileLocation) + files = [file_location.file_path] + + template_data_copy = copy.deepcopy(template_data) + for file in files: + if "{originalBasename}" in template: + template_data_copy["originalBasename"] = file.stem + """ + dst = path_template_obj.format_strict(template_data) + src = os.path.join(stagingdir, src_file_name) + """ + if representation.contains_trait_by_id( + FileLocation.get_versionless_id()): + file_location: FileLocation = representation.get_trait_by_id( + FileLocation.get_versionless_id()) + """ + transfers += self.get_transfers_from_file_location( + file_location, template, template_data) + """ + return transfers From 4cd5996261c9fe6a307483fa587509912ccd1444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 22 Nov 2024 14:40:44 +0100 Subject: [PATCH 048/781] :bug: fix tests --- .../plugins/publish/test_integrate_traits.py | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index 009cc3b7ff..11922f6c9a 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -8,6 +8,8 @@ import pyblish.api import pytest from ayon_core.pipeline.traits import ( FileLocation, + FileLocations, + FrameRanged, Image, MimeType, Persistent, @@ -92,6 +94,13 @@ def mock_context( instance.data["integrate"] = True instance.data["farm"] = False + _file_size = len(base64.b64decode(PNG_FILE_B64)) + file_locations = [ + FileLocation( + file_path=f, + file_size=_file_size) + for f in sequence_files] + instance.data["representations_with_traits"] = [ Representation(name="test_single", traits=[ Persistent(), @@ -103,18 +112,20 @@ def mock_context( ]), Representation(name="test_sequence", traits=[ Persistent(), - Sequence( + FrameRanged( frame_start=1, frame_end=SEQUENCE_LENGTH, - frame_padding=4, - frame_regex=r"^img\.(\d{4})\.png$", frame_in=0, frame_out=SEQUENCE_LENGTH - 1, - frames_per_second=25 + frames_per_second="25", + ), + Sequence( + frame_padding=4, + frame_regex=r"^img\.(\d{4})\.png$", + ), + FileLocations( + file_paths=file_locations, ), - FileLocation( - file_path=sequence_files[0], - file_size=len(base64.b64decode(PNG_FILE_B64))), Image(), PixelBased( display_window_width=1920, From 13e56429bada4ba61003b5ae0b2ef912e5656f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 22 Nov 2024 17:42:45 +0100 Subject: [PATCH 049/781] :art: add get padding from files --- client/ayon_core/pipeline/traits/time.py | 19 +++++++++++++++++ .../ayon_core/pipeline/traits/test_traits.py | 21 +++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 5ce8d02426..83534aac18 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -4,11 +4,15 @@ from __future__ import annotations from enum import Enum, auto from typing import TYPE_CHECKING, ClassVar, Optional +import clique from pydantic import Field from .trait import MissingTraitError, TraitBase if TYPE_CHECKING: + from pathlib import Path + + from .content import FileLocations from .representation import Representation @@ -141,6 +145,21 @@ class Sequence(TraitBase): except MissingTraitError: pass + @staticmethod + def get_frame_padding(file_locations: FileLocations) -> int: + """Get frame padding.""" + files: list[Path] = [ + file.file_path.as_posix() + for file in file_locations.file_paths + ] + src_collections, _ = clique.assemble(files) + + src_collection = src_collections[0] + destination_indexes = list(src_collection.indexes) + # Use last frame for minimum padding + # - that should cover both 'udim' and 'frame' minimum padding + return len(str(destination_indexes[-1])) + # Do we need one for drop and non-drop frame? class SMPTETimecode(TraitBase): """SMPTE Timecode trait model.""" diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index 42bb523118..514066d4ef 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -15,6 +15,7 @@ from ayon_core.pipeline.traits import ( PixelBased, Planar, Representation, + Sequence, TraitBase, ) from ayon_core.pipeline.traits.trait import TraitValidationError @@ -358,7 +359,6 @@ def test_file_locations_validation() -> None: sequence_trait = FrameRanged( frame_start=1001, frame_end=1050, - frame_padding=4, frames_per_second="25" ) representation.add_trait(sequence_trait) @@ -382,10 +382,27 @@ def test_file_locations_validation() -> None: invalid_sequence_trait = FrameRanged( frame_start=1001, frame_end=1051, - frame_padding=4, frames_per_second="25" ) representation.add_trait(invalid_sequence_trait) with pytest.raises(TraitValidationError): file_locations_trait.validate(representation) + +def test_sequence_get_frame_padding() -> None: + """Test getting frame padding from FileLocations trait.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list) + ]) + + assert Sequence.get_frame_padding( + file_locations=representation.get_trait(FileLocations)) == 4 From dc2607906502f6dfa59eb6f6865d8c42f18a9f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 26 Nov 2024 23:29:39 +0100 Subject: [PATCH 050/781] :bug: handle `ABCMeta.__issubclass__()` bug? issubclass in ABCMeta doesn't handle `GenericAlias` correctly --- .../pipeline/traits/representation.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py index acd78a6ce5..34aadd590a 100644 --- a/client/ayon_core/pipeline/traits/representation.py +++ b/client/ayon_core/pipeline/traits/representation.py @@ -6,6 +6,7 @@ import re import sys import uuid from functools import lru_cache +from types import GenericAlias from typing import ClassVar, Optional, Type, TypeVar, Union from .trait import ( @@ -50,7 +51,8 @@ class Representation: """ _data: dict _module_blacklist: ClassVar[list[str]] = [ - "_", "builtins", "pydantic"] + "_", "builtins", "pydantic", + ] name: str representation_id: str @@ -422,9 +424,19 @@ class Representation: for module in filtered_modules.values(): if not module: continue - for _, klass in inspect.getmembers(module, inspect.isclass): - if inspect.isclass(klass) \ - and issubclass(klass, TraitBase) \ + + for attr_name in dir(module): + klass = getattr(module, attr_name) + if not inspect.isclass(klass): + continue + # this needs to be done because of the bug? in + # python ABCMeta, where ``issubclass`` is not working + # if it hits the GenericAlias (that is in fact + # tuple[int, int]). This is added to the scope by + # ``types`` module. + if type(klass) is GenericAlias: + continue + if issubclass(klass, TraitBase) \ and str(klass.id).startswith(trait_id): trait_candidates.add(klass) return trait_candidates From 97fe8ac294fe1d28e9f02282b16982741d566f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 26 Nov 2024 23:30:23 +0100 Subject: [PATCH 051/781] :art: handle frame specs --- client/ayon_core/pipeline/traits/content.py | 46 +++++- client/ayon_core/pipeline/traits/time.py | 154 ++++++++++++++++-- .../ayon_core/pipeline/traits/test_traits.py | 62 +++++++ 3 files changed, 243 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 01fe60f0ca..cf31669336 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -1,14 +1,14 @@ """Content traits for the pipeline.""" from __future__ import annotations -# TCH003 is there because Path in TYPECHECKING will fail in tests -from pathlib import Path # noqa: TCH003 +# TC003 is there because Path in TYPECHECKING will fail in tests +from pathlib import Path # noqa: TC003 from typing import ClassVar, Optional from pydantic import Field from .representation import Representation -from .time import FrameRanged +from .time import FrameRanged, Sequence from .trait import ( MissingTraitError, TraitBase, @@ -106,7 +106,7 @@ class FileLocations(TraitBase): id: ClassVar[str] = "ayon.content.FileLocations.v1" file_paths: list[FileLocation] = Field(..., title="File Path") - def validate(self, representation: Representation) -> bool: + def validate(self, representation: Representation) -> None: """Validate the trait. This method validates the trait against others in the representation. @@ -120,6 +120,7 @@ class FileLocations(TraitBase): bool: True if the trait is valid, False otherwise """ + super().validate(representation) if len(self.file_paths) == 0: # If there are no file paths, we can't validate msg = "No file locations defined (empty list)" @@ -128,6 +129,33 @@ class FileLocations(TraitBase): tmp_frame_ranged: FrameRanged = get_sequence_from_files( [f.file_path for f in self.file_paths]) + frames_from_spec = None + try: + sequence: Sequence = representation.get_trait(Sequence) + if sequence.frame_spec: + frames_from_spec: list[int] = sequence.get_frame_list( + self, sequence.frame_regex) + + except MissingTraitError: + # If there is no sequence trait, we can't validate it + pass + if frames_from_spec: + if len(frames_from_spec) != len(self.file_paths) : + # If the number of file paths does not match the frame range, + # the trait is invalid + msg = ( + f"Number of file locations ({len(self.file_paths)}) " + "does not match frame range defined by frame spec " + "on Sequence trait: " + f"({len(frames_from_spec)})" + ) + raise TraitValidationError(self.name, msg) + # if there is frame spec on the Sequence trait + # we should not validate the frame range from the files. + # the rest is validated by Sequence validators. + return + + if len(self.file_paths) - 1 != \ tmp_frame_ranged.frame_end - tmp_frame_ranged.frame_start: # If the number of file paths does not match the frame range, @@ -140,17 +168,17 @@ class FileLocations(TraitBase): raise TraitValidationError(self.name, msg) try: - sequence: FrameRanged = representation.get_trait(FrameRanged) + frame_ranged: FrameRanged = representation.get_trait(FrameRanged) - if sequence.frame_start != tmp_frame_ranged.frame_start or \ - sequence.frame_end != tmp_frame_ranged.frame_end: + if frame_ranged.frame_start != tmp_frame_ranged.frame_start or \ + frame_ranged.frame_end != tmp_frame_ranged.frame_end: # If the frame range does not match the sequence trait, the # trait is invalid. Note that we don't check the frame rate # because it is not stored in the file paths and is not # determined by `get_sequence_from_files`. msg = ( "Frame range " - f"({sequence.frame_start}-{sequence.frame_end}) " + f"({frame_ranged.frame_start}-{frame_ranged.frame_end}) " "in sequence trait does not match " "frame range " f"({tmp_frame_ranged.frame_start}-{tmp_frame_ranged.frame_end}) " # noqa: E501 @@ -159,7 +187,7 @@ class FileLocations(TraitBase): raise TraitValidationError(self.name, msg) except MissingTraitError: - # If there is no sequence trait, we can't validate it + # If there is no frame_ranged trait, we can't validate it pass diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 83534aac18..eacaa72235 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -7,9 +7,10 @@ from typing import TYPE_CHECKING, ClassVar, Optional import clique from pydantic import Field -from .trait import MissingTraitError, TraitBase +from .trait import MissingTraitError, TraitBase, TraitValidationError if TYPE_CHECKING: + import re from pathlib import Path from .content import FileLocations @@ -117,7 +118,7 @@ class Sequence(TraitBase): frame_padding (int): Frame padding. frame_regex (str): Frame regex - regular expression to match frame numbers. - frame_list (str): Frame list specification of frames. This takes + frame_spec (str): Frame list specification of frames. This takes string like "1-10,20-30,40-50" etc. """ @@ -127,13 +128,12 @@ class Sequence(TraitBase): gaps_policy: GapPolicy = Field( GapPolicy.forbidden, title="Gaps Policy") frame_padding: int = Field(..., title="Frame Padding") - frame_regex: str = Field(..., title="Frame Regex") - frame_list: Optional[str] = Field(None, title="Frame List") + frame_regex: Optional[str] = Field(None, title="Frame Regex") + frame_spec: Optional[str] = Field(None, title="Frame Specification") def validate(self, representation: Representation) -> None: """Validate the trait.""" - if not super().validate(representation): - return False + super().validate(representation) # if there is FileLocations trait, run validation # on it as well @@ -142,24 +142,158 @@ class Sequence(TraitBase): file_locs: FileLocations = representation.get_trait( FileLocations) file_locs.validate(representation) + # validate if file locations on representation + # matches the frame list (if any) + self.validate_frame_list(file_locs) + self.validate_frame_padding(file_locs) except MissingTraitError: pass + def validate_frame_list( + self, file_locations: FileLocations) -> None: + """Validate frame list. + + This will take FileLocations trait and validate if the + file locations match the frame list specification. + + For example, if frame list is "1-10,20-30,40-50", then + the frame numbers in the file locations should match + these frames. + + It will skip the validation if frame list is not provided. + + Args: + file_locations (FileLocations): File locations trait. + + Raises: + TraitValidationError: If frame list does not match + the expected frames. + + """ + if self.frame_spec is None: + return + + frames: list[int] = self.get_frame_list( + file_locations, self.frame_regex) + + expected_frames = self.list_spec_to_frames(self.frame_spec) + if set(frames) != set(expected_frames): + msg = ( + "Frame list does not match the expected frames. " + f"Expected: {expected_frames}, Found: {frames}" + ) + raise TraitValidationError(self.name, msg) + + def validate_frame_padding( + self, file_locations: FileLocations) -> None: + """Validate frame padding. + + This will take FileLocations trait and validate if the + frame padding matches the expected frame padding. + + Args: + file_locations (FileLocations): File locations trait. + + Raises: + TraitValidationError: If frame padding does not match + the expected frame padding. + + """ + expected_padding = self.get_frame_padding(file_locations) + if self.frame_padding != expected_padding: + msg = ( + "Frame padding does not match the expected frame padding. " + f"Expected: {expected_padding}, Found: {self.frame_padding}" + ) + raise TraitValidationError(msg) + @staticmethod - def get_frame_padding(file_locations: FileLocations) -> int: - """Get frame padding.""" + def list_spec_to_frames(list_spec: str) -> list[int]: + """Convert list specification to frames.""" + frames = [] + segments = list_spec.split(",") + for segment in segments: + ranges = segment.split("-") + if len(ranges) == 1: + if not ranges[0].isdigit(): + msg = ( + "Invalid frame number " + f"in the list: {ranges[0]}" + ) + raise ValueError(msg) + frames.append(int(ranges[0])) + continue + start, end = segment.split("-") + start, end = int(start), int(end) + frames.extend(range(start, end + 1)) + return frames + + + @staticmethod + def _get_collection( + file_locations: FileLocations, + regex: Optional[re.Pattern] = None) -> clique.Collection: + r"""Get collection from file locations. + + Args: + file_locations (FileLocations): File locations trait. + regex (Optional[re.Pattern]): Regular expression to match + frame numbers. This is passed to ``clique.assemble()``. + Default clique pattern is:: + + \.(?P(?P0*)\d+)\.\D+\d?$ + + Returns: + clique.Collection: Collection instance. + + Raises: + ValueError: If zero or multiple collections found. + + """ + patterns = None if not regex else [regex] files: list[Path] = [ file.file_path.as_posix() for file in file_locations.file_paths ] - src_collections, _ = clique.assemble(files) + src_collections, _ = clique.assemble(files, patterns=patterns) + if len(src_collections) != 1: + msg = ( + f"Zero or multiple collections found: {len(src_collections)} " + "expected 1" + ) + raise ValueError(msg) + return src_collections[0] - src_collection = src_collections[0] + @staticmethod + def get_frame_padding(file_locations: FileLocations) -> int: + """Get frame padding.""" + src_collection = Sequence._get_collection(file_locations) destination_indexes = list(src_collection.indexes) # Use last frame for minimum padding # - that should cover both 'udim' and 'frame' minimum padding return len(str(destination_indexes[-1])) + @staticmethod + def get_frame_list( + file_locations: FileLocations, + regex: Optional[re.Pattern] = None, + ) -> list[int]: + r"""Get frame list. + + Args: + file_locations (FileLocations): File locations trait. + regex (Optional[re.Pattern]): Regular expression to match + frame numbers. This is passed to ``clique.assemble()``. + Default clique pattern is:: + + \.(?P(?P0*)\d+)\.\D+\d?$ + Returns: + list[int]: List of frame numbers. + + """ + src_collection = Sequence._get_collection(file_locations, regex) + return list(src_collection.indexes) + # Do we need one for drop and non-drop frame? class SMPTETimecode(TraitBase): """SMPTE Timecode trait model.""" diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index 514066d4ef..319401b91e 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -406,3 +406,65 @@ def test_sequence_get_frame_padding() -> None: assert Sequence.get_frame_padding( file_locations=representation.get_trait(FileLocations)) == 4 + +def test_sequence_validations() -> None: + """Test Sequence trait validation.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1010 + 1) # because range is zero based + ] + + file_locations_list += [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1015, 1020 + 1) + ] + + file_locations_list += [ + FileLocation + ( + file_path=Path("/path/to/file.1100.exr"), + file_size=1024, + file_hash=None, + ) + ] + + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list), + FrameRanged( + frame_start=1001, + frame_end=1100, frames_per_second="25"), + Sequence( + frame_padding=4, + frame_spec="1001-1010,1015-1020,1100") + ]) + + representation.get_trait(Sequence).validate(representation) + + + + +def test_list_spec_to_frames() -> None: + """Test converting list specification to frames.""" + assert Sequence.list_spec_to_frames("1-10,20-30,55") == [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 55 + ] + assert Sequence.list_spec_to_frames("1,2,3,4,5") == [ + 1, 2, 3, 4, 5 + ] + assert Sequence.list_spec_to_frames("1-10") == [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 + ] + assert Sequence.list_spec_to_frames("1") == [1] + with pytest.raises( + ValueError, + match="Invalid frame number in the list: .*"): + Sequence.list_spec_to_frames("a") From be20a9f69626240bbb941e7661d5866a97b736cc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 29 Nov 2024 10:14:47 +0100 Subject: [PATCH 052/781] 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 32d82e47e64a18f241ce36427bc6ae97db9e2965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 29 Nov 2024 11:27:41 +0100 Subject: [PATCH 053/781] :art: add persistent property to trait persistent drives if the trait should be integrated or not. Difference between Persistent trait and persistent attribute is that the trait drives the lifecycle of the representation but the attribute drives lifecycle of trait. --- client/ayon_core/pipeline/traits/trait.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 1f0c72cd9d..d377ab8846 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -10,6 +10,7 @@ from pydantic import ( AliasGenerator, BaseModel, ConfigDict, + Field, ) if TYPE_CHECKING: @@ -32,6 +33,10 @@ class TraitBase(ABC, BaseModel): ) ) + persitent: bool = Field( + default=True, title="Persitent", + description="Whether the trait is persistent (integrated) or not.") + @property @abstractmethod def id(self) -> str: From 60f10feeeea54606dd278175237da2a27001aab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 29 Nov 2024 11:28:19 +0100 Subject: [PATCH 054/781] :art: add dict-like behavior to Representation that and some tests --- .../pipeline/traits/representation.py | 67 ++++++++++++++++++- .../ayon_core/pipeline/traits/test_traits.py | 15 ++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py index 34aadd590a..756c4dfb67 100644 --- a/client/ayon_core/pipeline/traits/representation.py +++ b/client/ayon_core/pipeline/traits/representation.py @@ -1,6 +1,7 @@ """Defines the base trait model and representation.""" from __future__ import annotations +import contextlib import inspect import re import sys @@ -49,7 +50,7 @@ class Representation: representation_id (str): Representation ID. """ - _data: dict + _data: dict[str, T] _module_blacklist: ClassVar[list[str]] = [ "_", "builtins", "pydantic", ] @@ -60,6 +61,70 @@ class Representation: """Return hash of the representation ID.""" return hash(self.representation_id) + def __getitem__(self, key: str) -> T: + """Get the trait by ID. + + Args: + key (str): Trait ID. + + Returns: + TraitBase: Trait instance. + + Raises: + MissingTraitError: If the trait is not found. + + """ + return self.get_trait_by_id(key) + + def __setitem__(self, key: str, value: T) -> None: + """Set the trait by ID. + + Args: + key (str): Trait ID. + value (TraitBase): Trait instance. + + """ + with contextlib.suppress(KeyError): + self._data.pop(key) + + self.add_trait(value) + + def __delitem__(self, key: str) -> None: + """Remove the trait by ID. + + Args: + key (str): Trait ID. + + Raises: + ValueError: If the trait is not found. + + """ + self.remove_trait_by_id(key) + + def __contains__(self, key: str) -> bool: + """Check if the trait exists by ID. + + Args: + key (str): Trait ID. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + return self.contains_trait_by_id(key) + + def __iter__(self): + """Return the trait ID iterator.""" + return iter(self._data) + + def __str__(self): + """Return the representation name.""" + return self.name + + def items(self) -> dict[str, T]: + """Return the traits as items.""" + return self._data.items() + def add_trait(self, trait: TraitBase, *, exists_ok: bool=False) -> None: """Add a trait to the Representation. diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index 319401b91e..b8fa3962cc 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -25,15 +25,18 @@ REPRESENTATION_DATA = { "file_path": Path("/path/to/file"), "file_size": 1024, "file_hash": None, + "persitent": True, }, - Image.id: {}, + Image.id: {"persitent": True}, PixelBased.id: { "display_window_width": 1920, "display_window_height": 1080, "pixel_aspect_ratio": 1.0, + "persitent": True, }, Planar.id: { "planar_configuration": "RGB", + "persitent": True, }, } @@ -168,6 +171,16 @@ def test_trait_removing(representation: Representation) -> None: ValueError, match=f"Trait with ID {Image.id} not found."): representation.remove_trait(Image) +def test_representation_dict_properties(representation: Representation) -> None: + """Test representation as dictionary.""" + representation = Representation(name="test") + representation[Image.id] = Image() + assert Image.id in representation + image = representation[Image.id] + assert image == Image() + for trait_id, trait in representation.items(): + assert trait_id == Image.id + assert trait == Image() def test_getting_traits_data(representation: Representation) -> None: From 265b1816e88b38288f5486517098cf0fcf981238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Sat, 30 Nov 2024 00:49:04 +0100 Subject: [PATCH 055/781] :recycle: simplify equality check and validation --- client/ayon_core/pipeline/traits/representation.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py index 756c4dfb67..d365ea1ed2 100644 --- a/client/ayon_core/pipeline/traits/representation.py +++ b/client/ayon_core/pipeline/traits/representation.py @@ -430,7 +430,7 @@ class Representation: match = re.search(version_regex, trait_id) return int(match[1]) if match else None - def __eq__(self, other: Representation) -> bool: # noqa: PLR0911 + def __eq__(self, other: object) -> bool: # noqa: PLR0911 """Check if the representation is equal to another. Args: @@ -440,10 +440,10 @@ class Representation: bool: True if the representations are equal, False otherwise. """ - if self.representation_id != other.representation_id: + if not isinstance(other, Representation): return False - if not isinstance(other, Representation): + if self.representation_id != other.representation_id: return False if self.name != other.name: @@ -458,9 +458,6 @@ class Representation: return False if trait != other._data[trait_id]: return False - for key, value in trait.model_dump().items(): - if value != other._data[trait_id].model_dump().get(key): - return False return True @@ -683,4 +680,5 @@ class Representation: bool: True if the representation is valid, False otherwise. """ - return all(trait.validate(self) for trait in self._data.values()) + for trait in self._data.values(): + trait.validate(self) From 5e0509ca488ddb6254f0fae19b5bae9a2835345f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Sat, 30 Nov 2024 00:49:33 +0100 Subject: [PATCH 056/781] :art: enhance range validations --- client/ayon_core/pipeline/traits/content.py | 149 ++++++++++++++++---- client/ayon_core/pipeline/traits/time.py | 97 +++++++++++-- 2 files changed, 205 insertions(+), 41 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index cf31669336..36b373036c 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -1,6 +1,8 @@ """Content traits for the pipeline.""" from __future__ import annotations +import contextlib + # TC003 is there because Path in TYPECHECKING will fail in tests from pathlib import Path # noqa: TC003 from typing import ClassVar, Optional @@ -8,7 +10,7 @@ from typing import ClassVar, Optional from pydantic import Field from .representation import Representation -from .time import FrameRanged, Sequence +from .time import FrameRanged, Handles, Sequence from .trait import ( MissingTraitError, TraitBase, @@ -125,20 +127,55 @@ class FileLocations(TraitBase): # If there are no file paths, we can't validate msg = "No file locations defined (empty list)" raise TraitValidationError(self.name, msg) + if representation.contains_trait(FrameRanged): + self._validate_frame_range(representation) + def _validate_frame_range(self, representation: Representation) -> None: + """Validate the frame range against the file paths. + + If the representation contains a FrameRanged trait, this method will + validate the frame range against the file paths. If the frame range + does not match the file paths, the trait is invalid. It takes into + account the Handles and Sequence traits. + + Args: + representation (Representation): Representation to validate. + + Raises: + TraitValidationError: If the trait is invalid within the + representation. + + """ tmp_frame_ranged: FrameRanged = get_sequence_from_files( [f.file_path for f in self.file_paths]) frames_from_spec = None - try: + with contextlib.suppress(MissingTraitError): sequence: Sequence = representation.get_trait(Sequence) if sequence.frame_spec: frames_from_spec: list[int] = sequence.get_frame_list( self, sequence.frame_regex) - except MissingTraitError: - # If there is no sequence trait, we can't validate it - pass + frame_start_with_handles, frame_end_with_handles = \ + self._get_frame_info_with_handles(representation, frames_from_spec) + + if frame_start_with_handles \ + and tmp_frame_ranged.frame_start != frame_start_with_handles: + # If the detected frame range does not match the combined + # FrameRanged and Handles trait, the + # trait is invalid. + msg = ( + f"Frame range defined by {self.name} " + f"({tmp_frame_ranged.frame_start}-" + f"{tmp_frame_ranged.frame_end}) " + "in files does not match " + "frame range " + f"({frame_start_with_handles}-" + f"{frame_end_with_handles}) defined in FrameRanged trait." + ) + + raise TraitValidationError(self.name, msg) + if frames_from_spec: if len(frames_from_spec) != len(self.file_paths) : # If the number of file paths does not match the frame range, @@ -155,40 +192,94 @@ class FileLocations(TraitBase): # the rest is validated by Sequence validators. return + length_with_handles: int = ( + frame_end_with_handles - frame_start_with_handles + 1 + ) - if len(self.file_paths) - 1 != \ - tmp_frame_ranged.frame_end - tmp_frame_ranged.frame_start: + if len(self.file_paths) != length_with_handles: # If the number of file paths does not match the frame range, # the trait is invalid msg = ( - f"Number of file locations ({len(self.file_paths) - 1}) " + f"Number of file locations ({len(self.file_paths)}) " "does not match frame range " - f"({tmp_frame_ranged.frame_end - tmp_frame_ranged.frame_start})" # noqa: E501 + f"({length_with_handles})" ) raise TraitValidationError(self.name, msg) - try: - frame_ranged: FrameRanged = representation.get_trait(FrameRanged) + frame_ranged: FrameRanged = representation.get_trait(FrameRanged) - if frame_ranged.frame_start != tmp_frame_ranged.frame_start or \ - frame_ranged.frame_end != tmp_frame_ranged.frame_end: - # If the frame range does not match the sequence trait, the - # trait is invalid. Note that we don't check the frame rate - # because it is not stored in the file paths and is not - # determined by `get_sequence_from_files`. - msg = ( - "Frame range " - f"({frame_ranged.frame_start}-{frame_ranged.frame_end}) " - "in sequence trait does not match " - "frame range " - f"({tmp_frame_ranged.frame_start}-{tmp_frame_ranged.frame_end}) " # noqa: E501 - "defined in files." - ) - raise TraitValidationError(self.name, msg) + if frame_start_with_handles != tmp_frame_ranged.frame_start or \ + frame_end_with_handles != tmp_frame_ranged.frame_end: + # If the frame range does not match the FrameRanged trait, the + # trait is invalid. Note that we don't check the frame rate + # because it is not stored in the file paths and is not + # determined by `get_sequence_from_files`. + msg = ( + "Frame range " + f"({frame_ranged.frame_start}-{frame_ranged.frame_end}) " + "in sequence trait does not match " + "frame range " + f"({tmp_frame_ranged.frame_start}-" + f"{tmp_frame_ranged.frame_end}) " + ) + raise TraitValidationError(self.name, msg) - except MissingTraitError: - # If there is no frame_ranged trait, we can't validate it - pass + def _get_frame_info_with_handles( + self, + representation: Representation, + frames_from_spec: list[int]) -> tuple[int, int]: + """Get the frame range with handles from the representation. + + This will return frame start and frame end with handles calculated + in if there actually is the Handles trait in the representation. + + Args: + representation (Representation): Representation to get the frame + range from. + frames_from_spec (list[int]): List of frames from the frame spec. + This list is modified in place to take into + account the handles. + + Mutates: + frames_from_spec: List of frames from the frame spec. + + Returns: + tuple[int, int]: Start and end frame with handles. + + """ + frame_start = frame_end = 0 + frame_start_handle = frame_end_handle = 0 + # If there is no sequence trait, we can't validate it + if frames_from_spec and representation.contains_trait(FrameRanged): + # if there is no FrameRanged trait (but really there should be) + # we can use the frame range from the frame spec + frame_start = min(frames_from_spec) + frame_end = max(frames_from_spec) + + # Handle the frame range + with contextlib.suppress(MissingTraitError): + frame_start = representation.get_trait(FrameRanged).frame_start + frame_end = representation.get_trait(FrameRanged).frame_end + + # Handle the handles :P + with contextlib.suppress(MissingTraitError): + handles: Handles = representation.get_trait(Handles) + if not handles.inclusive: + # if handless are exclusive, we need to adjust the frame range + frame_start_handle = handles.frame_start_handle + frame_end_handle = handles.frame_end_handle + if frames_from_spec: + frames_from_spec.extend( + range(frame_start - frame_start_handle, frame_start) + ) + frames_from_spec.extend( + range(frame_end + 1, frame_end_handle + frame_end + 1) + ) + + frame_start_with_handles = frame_start - frame_start_handle + frame_end_with_handles = frame_end + frame_end_handle + + return frame_start_with_handles, frame_end_with_handles class RootlessLocation(TraitBase): diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index eacaa72235..0ab7cfaa9e 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -1,6 +1,7 @@ """Temporal (time related) traits.""" from __future__ import annotations +import contextlib from enum import Enum, auto from typing import TYPE_CHECKING, ClassVar, Optional @@ -137,20 +138,62 @@ class Sequence(TraitBase): # if there is FileLocations trait, run validation # on it as well - try: - from .content import FileLocations - file_locs: FileLocations = representation.get_trait( - FileLocations) - file_locs.validate(representation) - # validate if file locations on representation - # matches the frame list (if any) - self.validate_frame_list(file_locs) - self.validate_frame_padding(file_locs) - except MissingTraitError: - pass + + with contextlib.suppress(MissingTraitError): + self._validate_file_locations(representation) + + def _validate_file_locations(self, representation: Representation) -> None: + """Validate file locations trait. + + If along with the Sequence trait, there is a FileLocations trait, + then we need to validate if the file locations match the frame + list specification. + + Args: + representation (Representation): Representation instance. + + Raises: + TraitValidationError: If file locations do not match the + frame list specification + + """ + from .content import FileLocations + file_locs: FileLocations = representation.get_trait( + FileLocations) + # validate if file locations on representation + # matches the frame list (if any) + # we need to extend the expected frames with Handles + frame_start = None + frame_end = None + handles_frame_start = None + handles_frame_end = None + with contextlib.suppress(MissingTraitError): + handles: Handles = representation.get_trait(Handles) + # if handles are inclusive, they should be already + # accounted in the FrameRaged frame spec + if not handles.inclusive: + handles_frame_start = handles.frame_start_handle + handles_frame_end = handles.frame_end_handle + with contextlib.suppress(MissingTraitError): + frame_ranged: FrameRanged = representation.get_trait( + FrameRanged) + frame_start = frame_ranged.frame_start + frame_end = frame_ranged.frame_end + self.validate_frame_list( + file_locs, + frame_start, + frame_end, + handles_frame_start, + handles_frame_end) + self.validate_frame_padding(file_locs) def validate_frame_list( - self, file_locations: FileLocations) -> None: + self, + file_locations: FileLocations, + frame_start: Optional[int] = None, + frame_end: Optional[int] = None, + handles_frame_start: Optional[int] = None, + handles_frame_end: Optional[int] = None) -> None: """Validate frame list. This will take FileLocations trait and validate if the @@ -164,6 +207,10 @@ class Sequence(TraitBase): Args: file_locations (FileLocations): File locations trait. + frame_start (Optional[int]): Frame start. + frame_end (Optional[int]): Frame end. + handles_frame_start (Optional[int]): Frame start handle. + handles_frame_end (Optional[int]): Frame end handle. Raises: TraitValidationError: If frame list does not match @@ -177,6 +224,32 @@ class Sequence(TraitBase): file_locations, self.frame_regex) expected_frames = self.list_spec_to_frames(self.frame_spec) + if frame_start is None or frame_end is None: + if min(expected_frames) != frame_start: + msg = ( + "Frame start does not match the expected frame start. " + f"Expected: {frame_start}, Found: {min(expected_frames)}" + ) + raise TraitValidationError(self.name, msg) + + if max(expected_frames) != frame_end: + msg = ( + "Frame end does not match the expected frame end. " + f"Expected: {frame_end}, Found: {max(expected_frames)}" + ) + raise TraitValidationError(self.name, msg) + + # we need to extend the expected frames with Handles + if handles_frame_start is not None: + expected_frames.extend( + range( + min(frames) - handles_frame_start, min(frames) + 1)) + + if handles_frame_end is not None: + expected_frames.extend( + range( + max(frames), max(frames) + handles_frame_end + 1)) + if set(frames) != set(expected_frames): msg = ( "Frame list does not match the expected frames. " From 799b0bca855b53a0352ad115a403db1aacb2c1c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Sat, 30 Nov 2024 00:49:54 +0100 Subject: [PATCH 057/781] :alembic: split test to more files --- .../pipeline/traits/test_content_traits.py | 119 +++++++++ .../pipeline/traits/test_time_traits.py | 228 +++++++++++++++++ .../ayon_core/pipeline/traits/test_traits.py | 230 +++++------------- 3 files changed, 405 insertions(+), 172 deletions(-) create mode 100644 tests/client/ayon_core/pipeline/traits/test_content_traits.py create mode 100644 tests/client/ayon_core/pipeline/traits/test_time_traits.py diff --git a/tests/client/ayon_core/pipeline/traits/test_content_traits.py b/tests/client/ayon_core/pipeline/traits/test_content_traits.py new file mode 100644 index 0000000000..d41e9076c3 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_content_traits.py @@ -0,0 +1,119 @@ +"""Tests for the content traits.""" +from __future__ import annotations + +from pathlib import Path + +import pytest +from ayon_core.pipeline.traits import ( + Bundle, + FileLocation, + FileLocations, + FrameRanged, + Image, + MimeType, + PixelBased, + Planar, + Representation, +) +from ayon_core.pipeline.traits.trait import TraitValidationError + + +def test_bundles() -> None: + """Test bundle trait.""" + diffuse_texture = [ + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + FileLocation( + file_path=Path("/path/to/diffuse.jpg"), + file_size=1024, + file_hash=None), + MimeType(mime_type="image/jpeg"), + ] + bump_texture = [ + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + FileLocation( + file_path=Path("/path/to/bump.tif"), + file_size=1024, + file_hash=None), + MimeType(mime_type="image/tiff"), + ] + bundle = Bundle(items=[diffuse_texture, bump_texture]) + representation = Representation(name="test_bundle", traits=[bundle]) + + if representation.contains_trait(trait=Bundle): + assert representation.get_trait(trait=Bundle).items == [ + diffuse_texture, bump_texture + ] + + for item in representation.get_trait(trait=Bundle).items: + sub_representation = Representation(name="test", traits=item) + assert sub_representation.contains_trait(trait=Image) + assert sub_representation.get_trait(trait=MimeType).mime_type in [ + "image/jpeg", "image/tiff" + ] + + +def test_file_locations_validation() -> None: + """Test FileLocations trait validation.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list) + ]) + + file_locations_trait: FileLocations = FileLocations( + file_paths=file_locations_list) + + # this should be valid trait + file_locations_trait.validate(representation) + + # add valid FrameRanged trait + sequence_trait = FrameRanged( + frame_start=1001, + frame_end=1050, + frames_per_second="25" + ) + representation.add_trait(sequence_trait) + + # it should still validate fine + file_locations_trait.validate(representation) + + # create empty file locations trait + empty_file_locations_trait = FileLocations(file_paths=[]) + representation = Representation(name="test", traits=[ + empty_file_locations_trait + ]) + with pytest.raises(TraitValidationError): + empty_file_locations_trait.validate(representation) + + # create valid file locations trait but with not matching sequence + # trait + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list) + ]) + invalid_sequence_trait = FrameRanged( + frame_start=1001, + frame_end=1051, + frames_per_second="25" + ) + + representation.add_trait(invalid_sequence_trait) + with pytest.raises(TraitValidationError): + file_locations_trait.validate(representation) + diff --git a/tests/client/ayon_core/pipeline/traits/test_time_traits.py b/tests/client/ayon_core/pipeline/traits/test_time_traits.py new file mode 100644 index 0000000000..b903c9eae5 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_time_traits.py @@ -0,0 +1,228 @@ +"""Tests for the time related traits.""" +from __future__ import annotations + +from pathlib import Path + +import pytest +from ayon_core.pipeline.traits import ( + FileLocation, + FileLocations, + FrameRanged, + Handles, + Representation, + Sequence, +) +from ayon_core.pipeline.traits.trait import TraitValidationError + + +def test_sequence_validations() -> None: + """Test Sequence trait validation.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1010 + 1) # because range is zero based + ] + + file_locations_list += [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1015, 1020 + 1) + ] + + file_locations_list += [ + FileLocation + ( + file_path=Path("/path/to/file.1100.exr"), + file_size=1024, + file_hash=None, + ) + ] + + representation = Representation(name="test_1", traits=[ + FileLocations(file_paths=file_locations_list), + FrameRanged( + frame_start=1001, + frame_end=1100, frames_per_second="25"), + Sequence( + frame_padding=4, + frame_spec="1001-1010,1015-1020,1100") + ]) + + representation.get_trait(Sequence).validate(representation) + + # here we set handles and set them as inclusive, so this should pass + representation = Representation(name="test_2", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1100 + 1) # because range is zero based + ]), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=True + ), + FrameRanged( + frame_start=1001, + frame_end=1100, frames_per_second="25"), + Sequence(frame_padding=4) + ]) + + representation.validate() + + # do the same but set handles as exclusive + representation = Representation(name="test_3", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(996, 1105 + 1) # because range is zero based + ]), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ), + FrameRanged( + frame_start=1001, + frame_end=1100, frames_per_second="25"), + Sequence(frame_padding=4) + ]) + + representation.validate() + + # invalid representation with file range not extended for handles + representation = Representation(name="test_4", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1050 + 1) # because range is zero based + ]), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ), + FrameRanged( + frame_start=1001, + frame_end=1050, frames_per_second="25"), + Sequence(frame_padding=4) + ]) + + with pytest.raises(TraitValidationError): + representation.validate() + + # invalid representation with frame spec not matching the files + del representation + representation = Representation(name="test_5", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1050 + 1) # because range is zero based + ]), + FrameRanged( + frame_start=1001, + frame_end=1050, frames_per_second="25"), + Sequence(frame_padding=4, frame_spec="1001-1010,1012-2000") + ]) + with pytest.raises(TraitValidationError): + representation.validate() + + representation = Representation(name="test_6", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1050 + 1) # because range is zero based + ]), + Sequence(frame_padding=4, frame_spec="1-1010,1012-1050"), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ) + ]) + with pytest.raises(TraitValidationError): + representation.validate() + + representation = Representation(name="test_6", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(996, 1050 + 1) # because range is zero based + ]), + Sequence(frame_padding=4, frame_spec="1001-1010,1012-2000"), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ) + ]) + with pytest.raises(TraitValidationError): + representation.validate() + + + +def test_list_spec_to_frames() -> None: + """Test converting list specification to frames.""" + assert Sequence.list_spec_to_frames("1-10,20-30,55") == [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 55 + ] + assert Sequence.list_spec_to_frames("1,2,3,4,5") == [ + 1, 2, 3, 4, 5 + ] + assert Sequence.list_spec_to_frames("1-10") == [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 + ] + test_list = list(range(1001, 1011)) + test_list += list(range(1012, 2001)) + assert Sequence.list_spec_to_frames("1001-1010,1012-2000") == test_list + + assert Sequence.list_spec_to_frames("1") == [1] + with pytest.raises( + ValueError, + match="Invalid frame number in the list: .*"): + Sequence.list_spec_to_frames("a") + + +def test_sequence_get_frame_padding() -> None: + """Test getting frame padding from FileLocations trait.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list) + ]) + + assert Sequence.get_frame_padding( + file_locations=representation.get_trait(FileLocations)) == 4 + diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index b8fa3962cc..e4033c1d28 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -7,18 +7,14 @@ import pytest from ayon_core.pipeline.traits import ( Bundle, FileLocation, - FileLocations, - FrameRanged, Image, MimeType, Overscan, PixelBased, Planar, Representation, - Sequence, TraitBase, ) -from ayon_core.pipeline.traits.trait import TraitValidationError REPRESENTATION_DATA = { FileLocation.id: { @@ -171,7 +167,8 @@ def test_trait_removing(representation: Representation) -> None: ValueError, match=f"Trait with ID {Image.id} not found."): representation.remove_trait(Image) -def test_representation_dict_properties(representation: Representation) -> None: +def test_representation_dict_properties( + representation: Representation) -> None: """Test representation as dictionary.""" representation = Representation(name="test") representation[Image.id] = Image() @@ -207,49 +204,6 @@ def test_traits_data_to_dict(representation: Representation) -> None: assert result == REPRESENTATION_DATA -def test_bundles() -> None: - """Test bundle trait.""" - diffuse_texture = [ - Image(), - PixelBased( - display_window_width=1920, - display_window_height=1080, - pixel_aspect_ratio=1.0), - Planar(planar_configuration="RGB"), - FileLocation( - file_path=Path("/path/to/diffuse.jpg"), - file_size=1024, - file_hash=None), - MimeType(mime_type="image/jpeg"), - ] - bump_texture = [ - Image(), - PixelBased( - display_window_width=1920, - display_window_height=1080, - pixel_aspect_ratio=1.0), - Planar(planar_configuration="RGB"), - FileLocation( - file_path=Path("/path/to/bump.tif"), - file_size=1024, - file_hash=None), - MimeType(mime_type="image/tiff"), - ] - bundle = Bundle(items=[diffuse_texture, bump_texture]) - representation = Representation(name="test_bundle", traits=[bundle]) - - if representation.contains_trait(trait=Bundle): - assert representation.get_trait(trait=Bundle).items == [ - diffuse_texture, bump_texture - ] - - for item in representation.get_trait(trait=Bundle).items: - sub_representation = Representation(name="test", traits=item) - assert sub_representation.contains_trait(trait=Image) - assert sub_representation.get_trait(trait=MimeType).mime_type in [ - "image/jpeg", "image/tiff" - ] - def test_get_version_from_id() -> None: """Test getting version from trait ID.""" assert Image().get_version() == 1 @@ -347,137 +301,69 @@ def test_from_dict() -> None: "test", trait_data=traits_data) """ -def test_file_locations_validation() -> None: - """Test FileLocations trait validation.""" - file_locations_list = [ - FileLocation( - file_path=Path(f"/path/to/file.{frame}.exr"), - file_size=1024, - file_hash=None, - ) - for frame in range(1001, 1051) - ] +def test_representation_equality() -> None: - representation = Representation(name="test", traits=[ - FileLocations(file_paths=file_locations_list) + # rep_a and rep_b are equal + rep_a = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + rep_b = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), ]) - file_locations_trait: FileLocations = FileLocations( - file_paths=file_locations_list) - - # this should be valid trait - file_locations_trait.validate(representation) - - # add valid FrameRanged trait - sequence_trait = FrameRanged( - frame_start=1001, - frame_end=1050, - frames_per_second="25" - ) - representation.add_trait(sequence_trait) - - # it should still validate fine - file_locations_trait.validate(representation) - - # create empty file locations trait - empty_file_locations_trait = FileLocations(file_paths=[]) - representation = Representation(name="test", traits=[ - empty_file_locations_trait - ]) - with pytest.raises(TraitValidationError): - empty_file_locations_trait.validate(representation) - - # create valid file locations trait but with not matching sequence - # trait - representation = Representation(name="test", traits=[ - FileLocations(file_paths=file_locations_list) - ]) - invalid_sequence_trait = FrameRanged( - frame_start=1001, - frame_end=1051, - frames_per_second="25" - ) - - representation.add_trait(invalid_sequence_trait) - with pytest.raises(TraitValidationError): - file_locations_trait.validate(representation) - -def test_sequence_get_frame_padding() -> None: - """Test getting frame padding from FileLocations trait.""" - file_locations_list = [ - FileLocation( - file_path=Path(f"/path/to/file.{frame}.exr"), - file_size=1024, - file_hash=None, - ) - for frame in range(1001, 1051) - ] - - representation = Representation(name="test", traits=[ - FileLocations(file_paths=file_locations_list) + # rep_c has different value for planar_configuration then rep_a and rep_b + rep_c = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGBA"), ]) - assert Sequence.get_frame_padding( - file_locations=representation.get_trait(FileLocations)) == 4 - -def test_sequence_validations() -> None: - """Test Sequence trait validation.""" - file_locations_list = [ - FileLocation( - file_path=Path(f"/path/to/file.{frame}.exr"), - file_size=1024, - file_hash=None, - ) - for frame in range(1001, 1010 + 1) # because range is zero based - ] - - file_locations_list += [ - FileLocation( - file_path=Path(f"/path/to/file.{frame}.exr"), - file_size=1024, - file_hash=None, - ) - for frame in range(1015, 1020 + 1) - ] - - file_locations_list += [ - FileLocation - ( - file_path=Path("/path/to/file.1100.exr"), - file_size=1024, - file_hash=None, - ) - ] - - representation = Representation(name="test", traits=[ - FileLocations(file_paths=file_locations_list), - FrameRanged( - frame_start=1001, - frame_end=1100, frames_per_second="25"), - Sequence( - frame_padding=4, - frame_spec="1001-1010,1015-1020,1100") + rep_d = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + ]) + rep_e = Representation(name="foo", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + ]) + rep_f = Representation(name="foo", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Planar(planar_configuration="RGBA"), ]) - representation.get_trait(Sequence).validate(representation) + + # lets assume ids are the same (because ids are randomly generated) + rep_b.representation_id = rep_d.representation_id = rep_a.representation_id + rep_c.representation_id = rep_e.representation_id = rep_a.representation_id + rep_f.representation_id = rep_a.representation_id + assert rep_a == rep_b + + # because of the trait value difference + assert rep_a != rep_c + # because of the type difference + assert rep_a != "foo" + # because of the trait count difference + assert rep_a != rep_d + # because of the name difference + assert rep_d != rep_e + # because of the trait difference + assert rep_d != rep_f - -def test_list_spec_to_frames() -> None: - """Test converting list specification to frames.""" - assert Sequence.list_spec_to_frames("1-10,20-30,55") == [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, - 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 55 - ] - assert Sequence.list_spec_to_frames("1,2,3,4,5") == [ - 1, 2, 3, 4, 5 - ] - assert Sequence.list_spec_to_frames("1-10") == [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 - ] - assert Sequence.list_spec_to_frames("1") == [1] - with pytest.raises( - ValueError, - match="Invalid frame number in the list: .*"): - Sequence.list_spec_to_frames("a") From 90d8df89d33012123169450fa3c7765673878da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Sat, 30 Nov 2024 01:11:06 +0100 Subject: [PATCH 058/781] :dog: add missing docstring --- tests/client/ayon_core/pipeline/traits/test_traits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index e4033c1d28..b87e996e03 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -302,7 +302,7 @@ def test_from_dict() -> None: """ def test_representation_equality() -> None: - + """Test representation equality.""" # rep_a and rep_b are equal rep_a = Representation(name="test", traits=[ FileLocation(file_path=Path("/path/to/file"), file_size=1024), From 4948cddb581a3c73ab890a5720429e2095577f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Sat, 30 Nov 2024 01:11:29 +0100 Subject: [PATCH 059/781] :art: FileLocations only with Sequence or Bundle --- client/ayon_core/pipeline/traits/content.py | 11 ++++++ .../pipeline/traits/test_content_traits.py | 34 +++++++++++++++---- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 36b373036c..ba14960db2 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -129,6 +129,17 @@ class FileLocations(TraitBase): raise TraitValidationError(self.name, msg) if representation.contains_trait(FrameRanged): self._validate_frame_range(representation) + if not representation.contains_trait(Sequence) \ + and not representation.contains_trait(Bundle): + # we have multiple files, but it is not a sequence or bundle + # what it it then? If the files are not related to each other + # then this representation is invalid. + msg = ( + "Multiple file locations defined, but no Sequence or Bundle " + "trait defined. If the files are not related to each other, " + "the representation is invalid." + ) + raise TraitValidationError(self.name, msg) def _validate_frame_range(self, representation: Representation) -> None: """Validate the frame range against the file paths. diff --git a/tests/client/ayon_core/pipeline/traits/test_content_traits.py b/tests/client/ayon_core/pipeline/traits/test_content_traits.py index d41e9076c3..065c17a7bb 100644 --- a/tests/client/ayon_core/pipeline/traits/test_content_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_content_traits.py @@ -14,6 +14,7 @@ from ayon_core.pipeline.traits import ( PixelBased, Planar, Representation, + Sequence, ) from ayon_core.pipeline.traits.trait import TraitValidationError @@ -74,7 +75,8 @@ def test_file_locations_validation() -> None: ] representation = Representation(name="test", traits=[ - FileLocations(file_paths=file_locations_list) + FileLocations(file_paths=file_locations_list), + Sequence(frame_padding=4), ]) file_locations_trait: FileLocations = FileLocations( @@ -84,12 +86,12 @@ def test_file_locations_validation() -> None: file_locations_trait.validate(representation) # add valid FrameRanged trait - sequence_trait = FrameRanged( + frameranged_trait = FrameRanged( frame_start=1001, frame_end=1050, frames_per_second="25" ) - representation.add_trait(sequence_trait) + representation.add_trait(frameranged_trait) # it should still validate fine file_locations_trait.validate(representation) @@ -102,10 +104,11 @@ def test_file_locations_validation() -> None: with pytest.raises(TraitValidationError): empty_file_locations_trait.validate(representation) - # create valid file locations trait but with not matching sequence - # trait + # create valid file locations trait but with not matching + # frame range trait representation = Representation(name="test", traits=[ - FileLocations(file_paths=file_locations_list) + FileLocations(file_paths=file_locations_list), + Sequence(frame_padding=4), ]) invalid_sequence_trait = FrameRanged( frame_start=1001, @@ -117,3 +120,22 @@ def test_file_locations_validation() -> None: with pytest.raises(TraitValidationError): file_locations_trait.validate(representation) + # invalid representation with mutliple file locations but + # unrelated to either Sequence or Bundle traits + representation = Representation(name="test", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path("/path/to/file_foo.exr"), + file_size=1024, + file_hash=None, + ), + FileLocation( + file_path=Path("/path/to/anotherfile.obj"), + file_size=1234, + file_hash=None, + ) + ]) + ]) + + with pytest.raises(TraitValidationError): + representation.validate() From c9d4716dfad1e922ad29c8a0275da067ea0e0b0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Sat, 30 Nov 2024 01:26:15 +0100 Subject: [PATCH 060/781] :art: add `UDIM` to `FileLocations` relationships --- client/ayon_core/pipeline/traits/content.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index ba14960db2..6f7b4f9101 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -16,6 +16,7 @@ from .trait import ( TraitBase, TraitValidationError, ) +from .two_dimensional import UDIM from .utils import get_sequence_from_files @@ -130,14 +131,15 @@ class FileLocations(TraitBase): if representation.contains_trait(FrameRanged): self._validate_frame_range(representation) if not representation.contains_trait(Sequence) \ - and not representation.contains_trait(Bundle): + and not representation.contains_trait(Bundle) \ + and not representation.contains_trait(UDIM): # we have multiple files, but it is not a sequence or bundle - # what it it then? If the files are not related to each other - # then this representation is invalid. + # or UDIM tile set what it it then? If the files are not related + # to each other then this representation is invalid. msg = ( "Multiple file locations defined, but no Sequence or Bundle " - "trait defined. If the files are not related to each other, " - "the representation is invalid." + "or UDIM trait defined. If the files are not related to " + "each other, the representation is invalid." ) raise TraitValidationError(self.name, msg) From 5c8d11198a6f719446f59c4da4c5f70805ed7dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Sat, 30 Nov 2024 01:26:30 +0100 Subject: [PATCH 061/781] :dog: fix indent --- .../ayon_core/pipeline/traits/test_time_traits.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/client/ayon_core/pipeline/traits/test_time_traits.py b/tests/client/ayon_core/pipeline/traits/test_time_traits.py index b903c9eae5..900c4e5842 100644 --- a/tests/client/ayon_core/pipeline/traits/test_time_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_time_traits.py @@ -82,13 +82,13 @@ def test_sequence_validations() -> None: # do the same but set handles as exclusive representation = Representation(name="test_3", traits=[ FileLocations(file_paths=[ - FileLocation( - file_path=Path(f"/path/to/file.{frame}.exr"), - file_size=1024, - file_hash=None, - ) - for frame in range(996, 1105 + 1) # because range is zero based - ]), + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(996, 1105 + 1) # because range is zero based + ]), Handles( frame_start_handle=5, frame_end_handle=5, From cf3242bbda341f953bf1a77dc593cd8e5d316664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Sat, 30 Nov 2024 01:39:45 +0100 Subject: [PATCH 062/781] :fire: revert the assumption that `FileLocations relates to `Bundles` --- client/ayon_core/pipeline/traits/content.py | 27 ++++++++------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 6f7b4f9101..08dee58383 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -131,13 +131,12 @@ class FileLocations(TraitBase): if representation.contains_trait(FrameRanged): self._validate_frame_range(representation) if not representation.contains_trait(Sequence) \ - and not representation.contains_trait(Bundle) \ and not representation.contains_trait(UDIM): - # we have multiple files, but it is not a sequence or bundle + # we have multiple files, but it is not a sequence # or UDIM tile set what it it then? If the files are not related # to each other then this representation is invalid. msg = ( - "Multiple file locations defined, but no Sequence or Bundle " + "Multiple file locations defined, but no Sequence " "or UDIM trait defined. If the files are not related to " "each other, the representation is invalid." ) @@ -351,28 +350,22 @@ class Bundle(TraitBase): This model list of independent Representation traits that are bundled together. This is useful for representing - a collection of representations that are part of a single - entity. + a collection of sub-entities that are part of a single + entity. You can easily reconstruct representations from + the bundle. Example:: Bundle( items=[ [ - Representation( - traits=[ - MimeType(mime_type="image/jpeg"), - FileLocation(file_path="/path/to/file.jpg") - ] - ) + MimeType(mime_type="image/jpeg"), + FileLocation(file_path="/path/to/file.jpg") ], [ - Representation( - traits=[ - MimeType(mime_type="image/png"), - FileLocation(file_path="/path/to/file.png") - ] - ) + + MimeType(mime_type="image/png"), + FileLocation(file_path="/path/to/file.png") ] ] ) From f4169769ace98c398350b7f53531c2f137bf6721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Sun, 1 Dec 2024 14:51:47 +0100 Subject: [PATCH 063/781] :recycle: expose trait validation error --- client/ayon_core/pipeline/traits/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index 16ee7d6975..59579b5bd3 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -23,7 +23,11 @@ from .time import ( SMPTETimecode, Static, ) -from .trait import MissingTraitError, TraitBase +from .trait import ( + MissingTraitError, + TraitBase, + TraitValidationError, +) from .two_dimensional import ( UDIM, Deep, @@ -41,6 +45,7 @@ __all__ = [ "Representation", "TraitBase", "MissingTraitError", + "TraitValidationError", # content "Bundle", From 595a3546f37ca82ca30d24407ff5ddc8da1689ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Sun, 1 Dec 2024 16:11:23 +0100 Subject: [PATCH 064/781] :art: add helpers for getting files --- client/ayon_core/pipeline/traits/content.py | 48 ++++++++++++++++++- client/ayon_core/pipeline/traits/time.py | 13 ++++- .../pipeline/traits/test_content_traits.py | 40 ++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 08dee58383..2808449590 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -2,10 +2,11 @@ from __future__ import annotations import contextlib +import re # TC003 is there because Path in TYPECHECKING will fail in tests from pathlib import Path # noqa: TC003 -from typing import ClassVar, Optional +from typing import ClassVar, Generator, Optional from pydantic import Field @@ -109,6 +110,51 @@ class FileLocations(TraitBase): id: ClassVar[str] = "ayon.content.FileLocations.v1" file_paths: list[FileLocation] = Field(..., title="File Path") + def get_files(self) -> Generator[Path, None, None]: + """Get all file paths from the trait. + + This method will return all file paths from the trait. + + Yeilds: + Path: List of file paths. + + """ + for file_location in self.file_paths: + yield file_location.file_path + + def get_file_for_frame( + self, + frame: int, + sequence_trait: Optional[Sequence] = None, + ) -> Optional[FileLocation]: + """Get file location for a frame. + + This method will return the file location for a given frame. If the + frame is not found in the file paths, it will return None. + + Args: + frame (int): Frame to get the file location for. + sequence_trait (Sequence): Sequence trait to get the + frame range specs from. + + Returns: + Optional[FileLocation]: File location for the frame. + + """ + frame_regex = r"\.(?P(?P0*)\d+)\.\D+\d?$" + if sequence_trait and sequence_trait.frame_regex: + frame_regex = sequence_trait.frame_regex + + re.compile(frame_regex) + + for file_path in self.get_files(): + result = re.search(frame_regex, file_path.name) + if result: + frame_index = int(result.group("frame")) + if frame_index == frame: + return file_path + return None + def validate(self, representation: Representation) -> None: """Validate the trait. diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 0ab7cfaa9e..22a5c16c13 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -6,7 +6,7 @@ from enum import Enum, auto from typing import TYPE_CHECKING, ClassVar, Optional import clique -from pydantic import Field +from pydantic import Field, field_validator from .trait import MissingTraitError, TraitBase, TraitValidationError @@ -118,7 +118,7 @@ class Sequence(TraitBase): sequence. frame_padding (int): Frame padding. frame_regex (str): Frame regex - regular expression to match - frame numbers. + frame numbers. Must include 'frame' named group. frame_spec (str): Frame list specification of frames. This takes string like "1-10,20-30,40-50" etc. @@ -132,6 +132,15 @@ class Sequence(TraitBase): frame_regex: Optional[str] = Field(None, title="Frame Regex") frame_spec: Optional[str] = Field(None, title="Frame Specification") + @field_validator("frame_regex") + @classmethod + def validate_frame_regex(cls, v: Optional[str]) -> str: + """Validate frame regex.""" + if v is not None and "?P" not in v: + msg = "Frame regex must include 'frame' named group" + raise ValueError(msg) + return v + def validate(self, representation: Representation) -> None: """Validate the trait.""" super().validate(representation) diff --git a/tests/client/ayon_core/pipeline/traits/test_content_traits.py b/tests/client/ayon_core/pipeline/traits/test_content_traits.py index 065c17a7bb..106b119a66 100644 --- a/tests/client/ayon_core/pipeline/traits/test_content_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_content_traits.py @@ -139,3 +139,43 @@ def test_file_locations_validation() -> None: with pytest.raises(TraitValidationError): representation.validate() + +def test_get_file_from_frame() -> None: + """Test get_file_from_frame method.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + file_locations_trait: FileLocations = FileLocations( + file_paths=file_locations_list) + + assert file_locations_trait.get_file_for_frame(frame=1001) == \ + file_locations_list[0].file_path + assert file_locations_trait.get_file_for_frame(frame=1050) == \ + file_locations_list[-1].file_path + assert file_locations_trait.get_file_for_frame(frame=1100) is None + + # test with custom regex + sequence = Sequence( + frame_padding=4, + frame_regex=r"boo_(?P\d+)\.exr") + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/boo_{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + file_locations_trait: FileLocations = FileLocations( + file_paths=file_locations_list) + + assert file_locations_trait.get_file_for_frame( + frame=1001, sequence_trait=sequence) == \ + file_locations_list[0].file_path From cc543fb6a20a2c08ad1d0d694732d76901684a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Sun, 1 Dec 2024 22:24:15 +0100 Subject: [PATCH 065/781] :bug: return Path instead of FileLocation --- client/ayon_core/pipeline/traits/content.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 2808449590..b711efc351 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -126,7 +126,7 @@ class FileLocations(TraitBase): self, frame: int, sequence_trait: Optional[Sequence] = None, - ) -> Optional[FileLocation]: + ) -> Optional[Path]: """Get file location for a frame. This method will return the file location for a given frame. If the @@ -138,7 +138,7 @@ class FileLocations(TraitBase): frame range specs from. Returns: - Optional[FileLocation]: File location for the frame. + Optional[Path]: File location for the frame. """ frame_regex = r"\.(?P(?P0*)\d+)\.\D+\d?$" From cf195c44a691f9c8630ecd50fa7185477fbeea71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Sun, 1 Dec 2024 23:00:44 +0100 Subject: [PATCH 066/781] :recycle: refactor to return `FileLocation` again --- client/ayon_core/pipeline/traits/content.py | 14 ++++++-------- .../pipeline/traits/test_content_traits.py | 19 ++++++++++--------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index b711efc351..2652a8e50c 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -82,7 +82,6 @@ class FileLocation(TraitBase): file_hash (str): File hash. """ - name: ClassVar[str] = "FileLocation" description: ClassVar[str] = "FileLocation Trait Model" id: ClassVar[str] = "ayon.content.FileLocation.v1" @@ -122,11 +121,11 @@ class FileLocations(TraitBase): for file_location in self.file_paths: yield file_location.file_path - def get_file_for_frame( + def get_file_location_for_frame( self, frame: int, sequence_trait: Optional[Sequence] = None, - ) -> Optional[Path]: + ) -> Optional[FileLocation]: """Get file location for a frame. This method will return the file location for a given frame. If the @@ -138,7 +137,7 @@ class FileLocations(TraitBase): frame range specs from. Returns: - Optional[Path]: File location for the frame. + Optional[FileLocation]: File location for the frame. """ frame_regex = r"\.(?P(?P0*)\d+)\.\D+\d?$" @@ -146,13 +145,12 @@ class FileLocations(TraitBase): frame_regex = sequence_trait.frame_regex re.compile(frame_regex) - - for file_path in self.get_files(): - result = re.search(frame_regex, file_path.name) + for location in self.file_paths: + result = re.search(frame_regex, location.file_path.name) if result: frame_index = int(result.group("frame")) if frame_index == frame: - return file_path + return location return None def validate(self, representation: Representation) -> None: diff --git a/tests/client/ayon_core/pipeline/traits/test_content_traits.py b/tests/client/ayon_core/pipeline/traits/test_content_traits.py index 106b119a66..d6f379a9c7 100644 --- a/tests/client/ayon_core/pipeline/traits/test_content_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_content_traits.py @@ -140,8 +140,9 @@ def test_file_locations_validation() -> None: with pytest.raises(TraitValidationError): representation.validate() -def test_get_file_from_frame() -> None: - """Test get_file_from_frame method.""" + +def test_get_file_location_from_frame() -> None: + """Test get_file_location_from_frame method.""" file_locations_list = [ FileLocation( file_path=Path(f"/path/to/file.{frame}.exr"), @@ -154,11 +155,11 @@ def test_get_file_from_frame() -> None: file_locations_trait: FileLocations = FileLocations( file_paths=file_locations_list) - assert file_locations_trait.get_file_for_frame(frame=1001) == \ - file_locations_list[0].file_path - assert file_locations_trait.get_file_for_frame(frame=1050) == \ - file_locations_list[-1].file_path - assert file_locations_trait.get_file_for_frame(frame=1100) is None + assert file_locations_trait.get_file_location_for_frame(frame=1001) == \ + file_locations_list[0] + assert file_locations_trait.get_file_location_for_frame(frame=1050) == \ + file_locations_list[-1] + assert file_locations_trait.get_file_location_for_frame(frame=1100) is None # test with custom regex sequence = Sequence( @@ -176,6 +177,6 @@ def test_get_file_from_frame() -> None: file_locations_trait: FileLocations = FileLocations( file_paths=file_locations_list) - assert file_locations_trait.get_file_for_frame( + assert file_locations_trait.get_file_location_for_frame( frame=1001, sequence_trait=sequence) == \ - file_locations_list[0].file_path + file_locations_list[0] From 5748c1593fc824210dcf34f01bc379c2fdbaefd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 2 Dec 2024 00:14:49 +0100 Subject: [PATCH 067/781] :art: udims as a list --- .../pipeline/traits/two_dimensional.py | 46 +++++++++++++++++-- .../traits/test_two_dimesional_traits.py | 43 +++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py index 93d21a9bc3..2cd27340ad 100644 --- a/client/ayon_core/pipeline/traits/two_dimensional.py +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -1,10 +1,15 @@ """Two-dimensional image traits.""" -from typing import ClassVar +from __future__ import annotations -from pydantic import Field +import re +from typing import TYPE_CHECKING, ClassVar, Optional + +from pydantic import Field, field_validator from .trait import TraitBase +if TYPE_CHECKING: + from .content import FileLocation, FileLocations class Image(TraitBase): """Image trait model. @@ -129,4 +134,39 @@ class UDIM(TraitBase): name: ClassVar[str] = "UDIM" description: ClassVar[str] = "UDIM Trait" id: ClassVar[str] = "ayon.2d.UDIM.v1" - udim: int = Field(..., title="UDIM") + udim: list[int] = Field(..., title="UDIM") + udim_regex: Optional[str] = Field( + r"(?:\.|_)(?P\d+)\.\D+\d?$", title="UDIM Regex") + + @field_validator("udim_regex") + @classmethod + def validate_frame_regex(cls, v: Optional[str]) -> str: + """Validate udim regex.""" + if v is not None and "?P" not in v: + msg = "UDIM regex must include 'udim' named group" + raise ValueError(msg) + return v + + def get_file_location_for_udim( + self, + file_locations: FileLocations, + udim: int, + ) -> Optional[FileLocation]: + """Get file location for UDIM. + + Args: + file_locations (FileLocations): File locations. + udim (int): UDIM value. + + Returns: + Optional[FileLocation]: File location. + + """ + pattern = re.compile(self.udim_regex) + for location in file_locations.file_paths: + result = re.search(pattern, location.file_path.name) + if result: + udim_index = int(result.group("udim")) + if udim_index == udim: + return location + return None diff --git a/tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py b/tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py new file mode 100644 index 0000000000..9428d86a39 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py @@ -0,0 +1,43 @@ +"""Tests for the 2d related traits.""" +from __future__ import annotations + +from pathlib import Path + +from ayon_core.pipeline.traits import ( + UDIM, + FileLocation, + FileLocations, + Representation, +) + + +def test_get_file_location_for_udim() -> None: + """Test get_file_location_for_udim.""" + file_locations_list = [ + FileLocation( + file_path=Path("/path/to/file.1001.exr"), + file_size=1024, + file_hash=None, + ), + FileLocation( + file_path=Path("/path/to/file.1002.exr"), + file_size=1024, + file_hash=None, + ), + FileLocation( + file_path=Path("/path/to/file.1003.exr"), + file_size=1024, + file_hash=None, + ), + ] + + representation = Representation(name="test_1", traits=[ + FileLocations(file_paths=file_locations_list), + UDIM(udim=[1001, 1002, 1003]), + ]) + + udim_trait = representation.get_trait(UDIM) + assert udim_trait.get_file_location_for_udim( + file_locations=representation.get_trait(FileLocations), + udim=1001 + ) == file_locations_list[0] From 0d98ff479dca0beb4495daf9594ba6a9637d7c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 2 Dec 2024 00:15:01 +0100 Subject: [PATCH 068/781] :bug: compile regex --- client/ayon_core/pipeline/traits/content.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 2652a8e50c..0ca0cbb3e1 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -144,7 +144,7 @@ class FileLocations(TraitBase): if sequence_trait and sequence_trait.frame_regex: frame_regex = sequence_trait.frame_regex - re.compile(frame_regex) + frame_regex = re.compile(frame_regex) for location in self.file_paths: result = re.search(frame_regex, location.file_path.name) if result: From 891a06553011f5242eeaf2e8f4ba45a47557427f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 2 Dec 2024 00:21:14 +0100 Subject: [PATCH 069/781] :art: add helper function --- .../pipeline/traits/two_dimensional.py | 17 +++++++++++++++++ .../traits/test_two_dimesional_traits.py | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py index 2cd27340ad..a05748b710 100644 --- a/client/ayon_core/pipeline/traits/two_dimensional.py +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -170,3 +170,20 @@ class UDIM(TraitBase): if udim_index == udim: return location return None + + def get_udim_from_file_location( + self, file_location: FileLocation) -> Optional[int]: + """Get UDIM from file location. + + Args: + file_location (FileLocation): File location. + + Returns: + Optional[int]: UDIM value. + + """ + pattern = re.compile(self.udim_regex) + result = re.search(pattern, file_location.file_path.name) + if result: + return int(result.group("udim")) + return None diff --git a/tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py b/tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py index 9428d86a39..328bd83469 100644 --- a/tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py @@ -41,3 +41,22 @@ def test_get_file_location_for_udim() -> None: file_locations=representation.get_trait(FileLocations), udim=1001 ) == file_locations_list[0] + +def test_get_udim_from_file_location() -> None: + """Test get_udim_from_file_location.""" + file_location_1 = FileLocation( + file_path=Path("/path/to/file.1001.exr"), + file_size=1024, + file_hash=None, + ) + + file_location_2 = FileLocation( + file_path=Path("/path/to/file.xxxxx.exr"), + file_size=1024, + file_hash=None, + ) + assert UDIM(udim=[1001]).get_udim_from_file_location( + file_location_1) == 1001 + + assert UDIM(udim=[1001]).get_udim_from_file_location( + file_location_2) is None From 88f4b5e7090a1d70eb5c8b203f47690b59fd5ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 2 Dec 2024 00:39:20 +0100 Subject: [PATCH 070/781] :wrench: work on template determination --- .../plugins/publish/integrate_traits.py | 223 ++++++++++++++++-- 1 file changed, 199 insertions(+), 24 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 8687370b3d..d58abc4a62 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -1,7 +1,10 @@ """Integrate representations with traits.""" from __future__ import annotations +import contextlib import copy +from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Any, List import pyblish.api @@ -22,19 +25,42 @@ from ayon_core.pipeline.publish import ( get_publish_template_name, ) from ayon_core.pipeline.traits import ( + UDIM, + Bundle, ColorManaged, FileLocation, + FileLocations, + FrameRanged, + MissingTraitError, Persistent, + PixelBased, Representation, + Sequence, + TemplatePath, + TraitValidationError, ) -from pipeline.traits import MissingTraitError, PixelBased -from pipeline.traits.content import FileLocations if TYPE_CHECKING: import logging - from pathlib import Path - from pipeline import Anatomy + from ayon_core.pipeline import Anatomy + + +@dataclass +class TransferItem: + """Represents single transfer item. + + Source file path, destination file path, template that was used to + construct the destination path, template data that was used in the + template, size of the file, checksum of the file. + """ + source: Path + destination: Path + size: int + checksum: str + template: str + template_data: dict[str, Any] + def get_instance_families(instance: pyblish.api.Instance) -> List[str]: @@ -105,9 +131,13 @@ class IntegrateTraits(pyblish.api.InstancePlugin): order = pyblish.api.IntegratorOrder log: logging.Logger - def process(self, instance: pyblish.api.Instance) -> None: + def process(self, instance: pyblish.api.Instance) -> None: # noqa: C901, PLR0915, PLR0912 """Integrate representations with traits. + Todo: + Refactor this method to be more readable and maintainable. + Remove corresponding noqa codes. + Args: instance (pyblish.api.Instance): Instance to process. @@ -141,7 +171,8 @@ class IntegrateTraits(pyblish.api.InstancePlugin): return # 3) get anatomy template - template = self.get_template(instance) + anatomy: Anatomy = instance.context.data["anatomy"] + template: str = self.get_publish_template(instance) # 4) initialize OperationsSession() op_session = OperationsSession() @@ -155,30 +186,160 @@ class IntegrateTraits(pyblish.api.InstancePlugin): ) instance.data["versionEntity"] = version_entity - instance_template_data = {} - transfers = [] - # handle {originalDirname} requested in the template + instance_template_data: dict[str, str] = {} + transfers: list[TransferItem] = [] + """ + # WIP: This is a draft of the logic that should be implemented + # to handle {originalDirname} in the template + if "{originalDirname}" in template: instance_template_data = { "originalDirname": self._get_relative_to_root_original_dirname( instance) } + """ # 6.5) prepare template and data to format it for representation in representations: - # validate representation first - representation.validate() + # validate representation first, this will go through all traits + # and check if they are valid + try: + representation.validate() + except TraitValidationError as e: + msg = f"Representation '{representation.name}' is invalid: {e}" + raise PublishError(msg) from e + template_data = self.get_template_data_from_representation( representation, instance) # add instance based template data template_data.update(instance_template_data) - if "{originalBasename}" in template: - # Remove 'frame' from template data because original frame - # number will be used. + path_template_object = self.get_publish_template_object( + instance)["path"] + + # If representation has FileLocations trait (list of files) + # it can be either Sequence, Bundle or UDIM tile set. + # We do not allow unrelated files in the single representation. + if representation.contains_trait(FileLocations): + # handle sequence + # note: we do not support yet frame sequence of multiple UDIM + # tiles in the same representation + if representation.contains_trait(Sequence): + sequence: Sequence = representation.get_trait(Sequence) + + # get the padding from the sequence if the padding on the + # template is higher, us the one from the template + dst_padding = representation.get_trait( + Sequence).frame_padding + frames: list[int] = sequence.get_frame_list( + representation.get_trait(FileLocations), + regex=sequence.frame_regex) + template_padding = anatomy.templates_obj.frame_padding + if template_padding > dst_padding: + dst_padding = template_padding + + # go through all frames in the sequence + # find their corresponding file locations + # format their template and add them to transfers + for frame in frames: + template_data["frame"] = frame + template_filled = path_template_object.format_strict( + template_data + ) + file_loc: FileLocation = representation.get_trait( + FileLocations).get_file_location_for_frame( + frame, sequence) + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size, + checksum=file_loc.file_hash, + template=template, + template_data=template_data, + ) + ) + + elif representation.contains_trait(UDIM) and \ + not representation.contains_trait(Sequence): + # handle UDIM not in sequence + udim: UDIM = representation.get_trait(UDIM) + for file_loc in representation.get_trait( + FileLocations).file_paths: + template_data["udim"] = ( + udim.get_udim_from_file_location(file_loc) + ) + template_filled = template.format(**template_data) + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size, + checksum=file_loc.file_hash, + template=template, + template_data=template_data, + ) + ) + else: + # This should never happen because the representation + # validation should catch this. + msg = ( + "Representation contains FileLocations trait, but " + "is not a Sequence or UDIM." + ) + raise PublishError(msg) + elif representation.contains_trait(FileLocation): + # single file representation template_data.pop("frame", None) - # WIP: use trait logic to get original frame range - # check if files listes in FileLocations trait match frames - # in sequence + with contextlib.suppress(MissingTraitError): + udim = representation.get_trait(UDIM) + template_data["udim"] = udim.udim[0] + + template_filled = path_template_object.format_strict( + template_data + ) + file_loc: FileLocation = representation.get_trait(FileLocation) + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size, + checksum=file_loc.file_hash, + template=template, + template_data=template_data, + ) + ) + elif representation.contains_trait(Bundle): + # handle Bundle + # go through all files in the bundle + pass + + # add TemplatePath trait to the representation + representation.add_trait(TemplatePath( + template=template, + data=template_data + )) + + # format destination path for different types of representations + # in Sequence, we need to handle frame numbering, its padding and + # also the case where it is a UDIM sequence. Note that sequence + # can be non-contiguous. + + # -------------------------------- + + # single file representation or list of non-sequential files is + # simple if representation contains FileLocations trait, + # it is a list of files. there is no hard constrain there, + # but those files should be of the same type ideally - described + # by the same traits. + + if representation.contains_trait(Sequence): + # handle template for sequence - this is mostly about + # determining template data for the "udim" and for the "frame". + # Assumption is that the Sequence trait already has the correct + # frame range set. We just need to recalculate to include + # the handles. + + ... transfers += self.get_transfers_from_representation( representation, template, template_data) @@ -270,7 +431,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): logger=self.log ) - def get_template(self, instance: pyblish.api.Instance) -> str: + def get_publish_template(self, instance: pyblish.api.Instance) -> str: """Return anatomy template name to use for integration. Args: @@ -287,6 +448,24 @@ class IntegrateTraits(pyblish.api.InstancePlugin): path_template_obj = publish_template["path"] return path_template_obj.template.replace("\\", "/") + def get_publish_template_object( + self, instance: pyblish.api.Instance) -> object: + """Return anatomy template object to use for integration. + + Note: What is the actual type of the object? + + Args: + instance (pyblish.api.Instance): Instance to process. + + Returns: + object: Anatomy template object + + """ + # Anatomy data is pre-filled by Collectors + template_name = self.get_template_name(instance) + anatomy = instance.context.data["anatomy"] + return anatomy.get_template_item("publish", template_name) + def prepare_product( self, instance: pyblish.api.Instance, @@ -586,12 +765,8 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template_data["resolution_height"] = representation.get_trait( PixelBased).display_window_height # get fps from representation traits - # is this the right way? Isn't it going against the - # trait abstraction? - traits = representation.get_traits() - for trait in traits.values(): - if hasattr(trait, "frames_per_second"): - template_data["fps"] = trait.fps + template_data["fps"] = representation.get_trait( + FrameRanged).frames_per_second # Note: handle "output" and "originalBasename" From 828c522b10c5a31edab659f3e785032371637e00 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 3 Dec 2024 10:43:45 +0100 Subject: [PATCH 071/781] :bug: fix typo --- client/ayon_core/pipeline/traits/trait.py | 4 ++-- tests/client/ayon_core/pipeline/traits/test_traits.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index d377ab8846..fd6e17f30d 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -33,8 +33,8 @@ class TraitBase(ABC, BaseModel): ) ) - persitent: bool = Field( - default=True, title="Persitent", + persistent: bool = Field( + default=True, title="Persistent", description="Whether the trait is persistent (integrated) or not.") @property diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index b87e996e03..a1e5a4d219 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -21,18 +21,18 @@ REPRESENTATION_DATA = { "file_path": Path("/path/to/file"), "file_size": 1024, "file_hash": None, - "persitent": True, + "persistent": True, }, - Image.id: {"persitent": True}, + Image.id: {"persistent": True}, PixelBased.id: { "display_window_width": 1920, "display_window_height": 1080, "pixel_aspect_ratio": 1.0, - "persitent": True, + "persistent": True, }, Planar.id: { "planar_configuration": "RGB", - "persitent": True, + "persistent": True, }, } From e83541abdc7f2092e5635a6829ede67fd1587bc6 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 3 Dec 2024 17:37:45 +0100 Subject: [PATCH 072/781] :art: add Variant trait --- client/ayon_core/pipeline/traits/__init__.py | 3 ++- client/ayon_core/pipeline/traits/meta.py | 22 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index 59579b5bd3..c6280fc7fd 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -12,7 +12,7 @@ from .content import ( ) from .cryptography import DigitallySigned, GPGSigned from .lifecycle import Persistent, Transient -from .meta import Tagged, TemplatePath +from .meta import Tagged, TemplatePath, Variant from .representation import Representation from .three_dimensional import Geometry, IESProfile, Lighting, Shader, Spatial from .time import ( @@ -71,6 +71,7 @@ __all__ = [ # meta "Tagged", "TemplatePath", + "Variant", # two-dimensional "Compressed", diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py index 745b065798..b240e7a4da 100644 --- a/client/ayon_core/pipeline/traits/meta.py +++ b/client/ayon_core/pipeline/traits/meta.py @@ -51,3 +51,25 @@ class TemplatePath(TraitBase): id: ClassVar[str] = "ayon.meta.TemplatePath.v1" template: str = Field(..., title="Template Path") data: dict = Field(..., title="Formatting Data") + +class Variant(TraitBase): + """Variant trait model. + + This model represents a variant of the representation. + + Example:: + + Variant(variant="high") + Variant(variant="prores444) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + variant (str): Variant name. + """ + + name: ClassVar[str] = "Variant" + description: ClassVar[str] = "Variant Trait Model" + id: ClassVar[str] = "ayon.meta.Variant.v1" + variant: str = Field(..., title="Variant") From da95746718e7025dac6cd1668670efd1d7f5387a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 4 Dec 2024 18:07:21 +0100 Subject: [PATCH 073/781] :memo: add readme for traits --- client/ayon_core/pipeline/traits/README.md | 283 +++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 client/ayon_core/pipeline/traits/README.md diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md new file mode 100644 index 0000000000..4387109cbe --- /dev/null +++ b/client/ayon_core/pipeline/traits/README.md @@ -0,0 +1,283 @@ +# Representations and traits + +## Introduction + +The Representation is the lowest level entity, describing the concrete data chunk that +pipeline can act on. It can be specific file or just a set of metadata. Idea is that one +product version can have multiple representations - **Image** product can be jpeg or tiff, both formats are representation of the same source. + +### Brief look into the past (and current state) + +So far, representation was defined as dict-like structure: +```python +{ + "name": "foo", + "ext": "exr", + "files": ["foo_001.exr", "foo_002.exr"], + "stagingDir": "/bar/dir" +} +``` + +This is minimal form, but it can have additional keys like `frameStart`, `fps`, `resolutionWidth`, and more. Thare is also `tags` key that can hold `review`, `thumbnail`, `delete`, `toScanline` and other tag that are controlling the processing. + +This will be *"translated"* to similar structure in database: + +```python +{ + "name": "foo", + "version_id": "...", + "files": [ + { + "id": ... + "hash": ... + "name": "foo_001.exr", + "path": "{root[work]}/bar/dir/foo_001.exr" + "size": 1234, + "hash_type": "...", + }, + ... + ], + "attrib": { + "path": "root/bar/dir/foo_001.exr", + "template": "{root[work]}/{project[name]}..." + }, + "data": { + "context": { + "ext": "exr", + "root": {...} + ... + }, + "active": True" + ... + +} +``` + +There are also some assumptions and limitations - like that if `files` in the +representation are list they need to be sequence of files (it can't be a bunch of +unrelated files). + +This system is very flexible in one way, but it lack few very important things: + +- it is not clearly defined - you can add easily keys, values, tags but without +unforeseeable +consequences +- it cannot handle "bundles" - multiple files that needs to be versioned together and +belong together +- it cannot describe important information that you can't get from the file itself or +it is very pricy (like axis orientation and units from alembic files) + + +### New Representation model + +The idea about new representation model is obviously around solving points mentioned +above and also adding some benefits, like consistent IDE hints, typing, built-in + validators and much more. + +### Design + +The new representation is "just" a dictionary of traits. Trait can be anything provided +it is based on `TraitBase`. It shouldn't really duplicate information that is +available in a moment of loading (or any usage) by other means. It should contain +information that couldn't be determined by the file, or the AYON context. Some of +those traits are aligned with [OpenAssetIO Media Creation](https://github.com/OpenAssetIO/OpenAssetIO-MediaCreation) with hopes of maintained compatibility (it +should be easy enough to convert between OpenAssetIO Traits and AOYN Traits). + +#### Details: Representation + +`Representation` has methods to deal with adding, removing, getting +traits. It has all the usual stuff like `get_trait()`, `add_trait()`, +`remove_trait()`, etc. But it also has plural forms so you can get/set +several traits at the same time with `get_traits()` and so on. +`Representation` also behaves like dictionary. so you can access/set +traits in the same way as you would do with dict: + +```python +# import Image trait +from ayon_core.pipeline.traits import Image, Tagged, Representation + + +# create new representation with name "foo" and add Image trait to it +rep = Representation(name="foo", traits=[Image()]) + +# you can add another trait like so +rep.add_trait(Tagged(tags=["tag"])) + +# or you can +rep[Tagged.id] = Tagged(tags=["tag"]) + +# and getting them in analogous +image = rep.get_trait(Image) + +# or +image = rep[Image.id] +``` + +> [!NOTE] +> Trait and their ids - every Trait has its id as s string with +> version appended - so **Image** has `ayon.2d.Image.v1`. This is is used on +> several places (you see its use above for indexing traits). When querying, +> you can also omit the version at the end and it will try its best to find +> the latest possible version. More on that in [Traits]() + +You can construct the `Representation` from dictionary (for example +serialized as JSON) using `Representation.from_dict()`, or you can +serialize `Representation` to dict to store with `Representation.traits_as_dict()`. + +Every time representation is created, new id is generated. You can pass existing +id when creating new representation instance. + +##### Equality + +Two Representations are equal if: +- their names are the same +- their IDs are the same +- they have the same traits +- the traits have the same values + +##### Validation + +Representation has `validate()` method that will run `validate()` on +all it's traits. + +#### Details: Traits + +As mentioned there are several traits defined directly in **ayon-core**. They are namespaced +to different packages based on their use: + +| namespace | trait | description +|---|---|--- +| color | ColorManaged | hold color management information +| content | MimeType | use MIME type (RFC 2046) to describe content (like image/jpeg) +| | LocatableContent | describe some location (file or URI) +| | FileLocation | path to file, with size and checksum +| | FileLocations | list of `FileLocation` +| | RootlessLocation | Path where root is replaced with AYON root token +| | Compressed | describes compression (of file or other) +| | Bundle | list of list of Traits - compound of inseparable "sub-representations" +| | Fragment | compound type marking the representation as a part of larger group of representations +| cryptography | DigitallySigned | Type traits marking data to be digitally signed +| | PGPSigned | Representation is signed by [PGP](https://www.openpgp.org/) +| lifecycle | Transient | Marks the representation to be temporary - not to be stored. +| | Persistent | Representation should be integrated (stored). Opposite of Transient. +| meta | Tagged | holds list of tag strings. +| | TemplatePath | Template consisted of tokens/keys and data to be used to resolve the template into string +| | Variant | Used to differentiate between data variants of the same output (mp4 as h.264 and h.265 for example) +| three dimensional | Spatial | Spatial information like up-axis, units and handedness. +| | Geometry | Type trait to mark the representation as a geometry. +| | Shader | Type trait to mark the representation as a Shader. +| | Lighting | Type trait to mark the representation as Lighting. +| | IESProfile | States that the representation is IES Profile +| time | FrameRanged | Contains start and end frame information with in and out. +| | Handless | define additional frames at the end or beginning and if those frames are inclusive of the range or not. +| | Sequence | Describes sequence of frames and how the frames are defined in that sequence. +| | SMPTETimecode | Adds timecode information in SMPTE format. +| | Static | Marks the content as not time-variant. +| two dimensional | Image | Type traits of image. +| | PixelBased | Defines resolution and pixel aspect for the image data. +| | Planar | Whether pixel data is in planar configuration or packed. +| | Deep | Image encodes deep pixel data. +| | Overscan | holds overscan/underscan information (added pixels to bottom/sides) +| | UDIM | Representation is UDIM tile set + +Traits are [Pydantic models](https://docs.pydantic.dev/latest/) with optional +validation and helper methods. If they implement `TraitBase.validate(Representation)` method, they can validate against all other traits +in the representation if needed. They can also implement pydantic form of +data validators. + +> [!NOTE] +> Every trait has id, name and some human readable description. Every trait +> also has `persistent` property that is by default set to True. This +> Controls whether this trait should be stored with the persistent representation +> or not. Useful for traits to be used just to control the publishing process. + +## Examples + +Create simple image representation to be integrated by AYON: + +```python +from pathlib import Path +from ayon_core.pipeline.traits import ( + FileLocation, + Image, + PixelBased, + Persistent, + Representation + Static +) + +rep = Representation(name="reference image", traits=[ + FileLocation( + file_path=Path("/foo/bar/baz.exr"), + file_size=1234, + file_hash="sha256:..." + ), + Image(), + PixelBased( + display_window_width="1920", + display_window_height="1080", + pixel_aspect_ratio=1.0 + ), + Persistent(), + Static() +]) + +# validate the representation + +try: + rep.validate() +except TraitValidationError as e: + print(f"Representation {rep.name} is invalid: {e}") + +``` + +To work with the resolution of such representation: + +```python + +try: + width = rep.get_trait(PixelBased).display_window_width + height = rep[PixelBased.id].display_window_height +except MissingTraitError: + print(f"resolution isn't set on {rep.name}") +``` + +Accessing non-existent traits will result in exception. To test if +representation has some specific trait, you can use `.contains_trait()` method. + + +You can also prepare the whole representation data as a dict and +create it from it: + +```python +rep_dict = { + "ayon.content.FileLocation.v1": { + "file_path": Path("/path/to/file"), + "file_size": 1024, + "file_hash": None, + }, + "ayon.two_dimensional.Image": {}, + "ayon.two_dimensional.PixelBased": { + "display_window_width": 1920, + "display_window_height": 1080, + "pixel_aspect_ratio": 1.0, + }, + "ayon.two_dimensional.Planar": { + "planar_configuration": "RGB", + } +} + +rep = Representation.from_dict(name="image", rep_dict) + +``` + +## Future + +Apart of some new additions to traits if needed, there are few thing that needs to be done. + +### Traits plugin system + +Traits are now ordinary python classes, but to extend its usability more, it would be good to +have addon level API to expose traits defined by individual addons. This API would then be used not +only by discovery logic but also by the AYON server that can display and work with the information +defined by them. \ No newline at end of file From 718cad7c82215b0240f1325310279a9f527c85f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 4 Dec 2024 18:08:37 +0100 Subject: [PATCH 074/781] :bug: fix PGPSigned trait name GPGSigned to PGPSigned --- client/ayon_core/pipeline/traits/__init__.py | 4 ++-- client/ayon_core/pipeline/traits/cryptography.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index c6280fc7fd..e3ca610df1 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -10,7 +10,7 @@ from .content import ( MimeType, RootlessLocation, ) -from .cryptography import DigitallySigned, GPGSigned +from .cryptography import DigitallySigned, PGPSigned from .lifecycle import Persistent, Transient from .meta import Tagged, TemplatePath, Variant from .representation import Representation @@ -62,7 +62,7 @@ __all__ = [ # cryptography "DigitallySigned", - "GPGSigned", + "PGPSigned", # life cycle "Persistent", diff --git a/client/ayon_core/pipeline/traits/cryptography.py b/client/ayon_core/pipeline/traits/cryptography.py index 4fa9e64c2f..42abee779c 100644 --- a/client/ayon_core/pipeline/traits/cryptography.py +++ b/client/ayon_core/pipeline/traits/cryptography.py @@ -21,17 +21,17 @@ class DigitallySigned(TraitBase): description: ClassVar[str] = "Digitally signed trait." -class GPGSigned(DigitallySigned): - """GPG signed trait. +class PGPSigned(DigitallySigned): + """PGP signed trait. - This trait holds GPG signed data. + This trait holds PGP (RFC-4880) signed data. Attributes: - signature (str): GPG signature. + signature (str): PGP signature. """ - id: ClassVar[str] = "ayon.cryptography.GPGSigned.v1" - name: ClassVar[str] = "GPGSigned" - description: ClassVar[str] = "GPG signed trait." + id: ClassVar[str] = "ayon.cryptography.PGPSigned.v1" + name: ClassVar[str] = "PGPSigned" + description: ClassVar[str] = "PGP signed trait." signed_data: str = Field( ..., description="Signed data." From c60ffcd31ca19d3ef9a44f4eae0f5200f4f84f8c Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 5 Dec 2024 14:41:26 +0100 Subject: [PATCH 075/781] :recycle: move template processing to individual methods --- .../plugins/publish/integrate_traits.py | 365 +++++++++++------- 1 file changed, 228 insertions(+), 137 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index d58abc4a62..29588f156b 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -20,6 +20,7 @@ from ayon_api.operations import ( # new_representation_entity, new_version_entity, ) +from ayon_core.pipeline.anatomy.templates import AnatomyStringTemplate from ayon_core.pipeline.publish import ( PublishError, get_publish_template_name, @@ -38,6 +39,8 @@ from ayon_core.pipeline.traits import ( Sequence, TemplatePath, TraitValidationError, + Transient, + Variant, ) if TYPE_CHECKING: @@ -46,7 +49,7 @@ if TYPE_CHECKING: from ayon_core.pipeline import Anatomy -@dataclass +@dataclass(frozen=True) class TransferItem: """Represents single transfer item. @@ -62,6 +65,17 @@ class TransferItem: template_data: dict[str, Any] +@dataclass +class TemplateItem: + """Represents single template item. + + Template path, template data that was used in the template. + """ + anatomy: Anatomy + template: str + template_data: dict[str, Any] + template_object: AnatomyStringTemplate + def get_instance_families(instance: pyblish.api.Instance) -> List[str]: """Get all families of the instance. @@ -131,7 +145,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): order = pyblish.api.IntegratorOrder log: logging.Logger - def process(self, instance: pyblish.api.Instance) -> None: # noqa: C901, PLR0915, PLR0912 + def process(self, instance: pyblish.api.Instance) -> None: """Integrate representations with traits. Todo: @@ -187,17 +201,8 @@ class IntegrateTraits(pyblish.api.InstancePlugin): instance.data["versionEntity"] = version_entity instance_template_data: dict[str, str] = {} - transfers: list[TransferItem] = [] - """ - # WIP: This is a draft of the logic that should be implemented - # to handle {originalDirname} in the template - if "{originalDirname}" in template: - instance_template_data = { - "originalDirname": self._get_relative_to_root_original_dirname( - instance) - } - """ + transfers: list[TransferItem] = [] # 6.5) prepare template and data to format it for representation in representations: @@ -213,144 +218,61 @@ class IntegrateTraits(pyblish.api.InstancePlugin): representation, instance) # add instance based template data template_data.update(instance_template_data) - path_template_object = self.get_publish_template_object( - instance)["path"] - # If representation has FileLocations trait (list of files) - # it can be either Sequence, Bundle or UDIM tile set. - # We do not allow unrelated files in the single representation. + # treat Variant as `output` in template data + with contextlib.suppress(MissingTraitError): + template_data["output"] = ( + representation.get_trait(Variant).variant + ) + + template_item = TemplateItem( + anatomy=anatomy, + template=template, + template_data=template_data, + template_object=self.get_publish_template_object(instance) + ) + if representation.contains_trait(FileLocations): - # handle sequence - # note: we do not support yet frame sequence of multiple UDIM + # If representation has FileLocations trait (list of files) + # it can be either Sequence or UDIM tile set. + # We do not allow unrelated files in the single representation. + # Note: we do not support yet frame sequence of multiple UDIM # tiles in the same representation - if representation.contains_trait(Sequence): - sequence: Sequence = representation.get_trait(Sequence) - - # get the padding from the sequence if the padding on the - # template is higher, us the one from the template - dst_padding = representation.get_trait( - Sequence).frame_padding - frames: list[int] = sequence.get_frame_list( - representation.get_trait(FileLocations), - regex=sequence.frame_regex) - template_padding = anatomy.templates_obj.frame_padding - if template_padding > dst_padding: - dst_padding = template_padding - - # go through all frames in the sequence - # find their corresponding file locations - # format their template and add them to transfers - for frame in frames: - template_data["frame"] = frame - template_filled = path_template_object.format_strict( - template_data - ) - file_loc: FileLocation = representation.get_trait( - FileLocations).get_file_location_for_frame( - frame, sequence) - transfers.append( - TransferItem( - source=file_loc.file_path, - destination=Path(template_filled), - size=file_loc.file_size, - checksum=file_loc.file_hash, - template=template, - template_data=template_data, - ) - ) - - elif representation.contains_trait(UDIM) and \ - not representation.contains_trait(Sequence): - # handle UDIM not in sequence - udim: UDIM = representation.get_trait(UDIM) - for file_loc in representation.get_trait( - FileLocations).file_paths: - template_data["udim"] = ( - udim.get_udim_from_file_location(file_loc) - ) - template_filled = template.format(**template_data) - transfers.append( - TransferItem( - source=file_loc.file_path, - destination=Path(template_filled), - size=file_loc.file_size, - checksum=file_loc.file_hash, - template=template, - template_data=template_data, - ) - ) - else: - # This should never happen because the representation - # validation should catch this. - msg = ( - "Representation contains FileLocations trait, but " - "is not a Sequence or UDIM." - ) - raise PublishError(msg) + self.get_transfers_from_file_locations( + representation, template_item, transfers + ) elif representation.contains_trait(FileLocation): - # single file representation - template_data.pop("frame", None) - with contextlib.suppress(MissingTraitError): - udim = representation.get_trait(UDIM) - template_data["udim"] = udim.udim[0] + # This is just a single file representation + self.get_transfers_from_file_location( + representation, template_item, transfers + ) - template_filled = path_template_object.format_strict( - template_data - ) - file_loc: FileLocation = representation.get_trait(FileLocation) - transfers.append( - TransferItem( - source=file_loc.file_path, - destination=Path(template_filled), - size=file_loc.file_size, - checksum=file_loc.file_hash, - template=template, - template_data=template_data, - ) - ) elif representation.contains_trait(Bundle): - # handle Bundle - # go through all files in the bundle + # Bundle groups multiple "sub-representations" together. + # It has list of lists with traits, some might be + # FileLocations,but some might be "file-less" representations + # or even other bundles. + bundle: Bundle = representation.get_trait(Bundle) + for idx, sub_representation_traits in enumerate(bundle.items): + sub_representation = Representation( + name=f"{representation.name}_{idx}", + traits=sub_representation_traits) + # sub presentation transient: + sub_representation.add_trait(Transient()) + if sub_representation.contains_trait(FileLocations): + ... + pass # add TemplatePath trait to the representation representation.add_trait(TemplatePath( - template=template, - data=template_data + template=template_item.template, + data=template_item.template_data )) - # format destination path for different types of representations - # in Sequence, we need to handle frame numbering, its padding and - # also the case where it is a UDIM sequence. Note that sequence - # can be non-contiguous. - - # -------------------------------- - - # single file representation or list of non-sequential files is - # simple if representation contains FileLocations trait, - # it is a list of files. there is no hard constrain there, - # but those files should be of the same type ideally - described - # by the same traits. - - if representation.contains_trait(Sequence): - # handle template for sequence - this is mostly about - # determining template data for the "udim" and for the "frame". - # Assumption is that the Sequence trait already has the correct - # frame range set. We just need to recalculate to include - # the handles. - - ... - transfers += self.get_transfers_from_representation( representation, template, template_data) - # 7) Get transfers from representations - for representation in representations: - # this should test version-less FileLocation probably - if representation.contains_trait_by_id( - FileLocation.get_versionless_id()): - self.log.debug( - "Representation: %s", representation) def _get_relative_to_root_original_dirname( self, instance: pyblish.api.Instance) -> str: @@ -449,7 +371,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): return path_template_obj.template.replace("\\", "/") def get_publish_template_object( - self, instance: pyblish.api.Instance) -> object: + self, instance: pyblish.api.Instance) -> AnatomyStringTemplate: """Return anatomy template object to use for integration. Note: What is the actual type of the object? @@ -775,8 +697,8 @@ class IntegrateTraits(pyblish.api.InstancePlugin): return template_data + @staticmethod def get_transfers_from_representation( - self, representation: Representation, template: str, template_data: dict) -> list: @@ -827,3 +749,172 @@ class IntegrateTraits(pyblish.api.InstancePlugin): file_location, template, template_data) """ return transfers + + @staticmethod + def get_transfers_from_file_locations( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem]) -> None: + """Get transfers from FileLocations trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + if representation.contains_trait(Sequence): + IntegrateTraits.get_transfers_from_sequence( + representation, template_item, transfers + ) + + elif representation.contains_trait(UDIM) and \ + not representation.contains_trait(Sequence): + # handle UDIM not in sequence + IntegrateTraits.get_transfers_from_udim( + representation, template_item, transfers + ) + + else: + # This should never happen because the representation + # validation should catch this. + msg = ( + "Representation contains FileLocations trait, but " + "is not a Sequence or UDIM." + ) + raise PublishError(msg) + + + @staticmethod + def get_transfers_from_sequence( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from Sequence trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + sequence: Sequence = representation.get_trait(Sequence) + path_template_object = template_item.template_object["path"] + + # get the padding from the sequence if the padding on the + # template is higher, us the one from the template + dst_padding = representation.get_trait( + Sequence).frame_padding + frames: list[int] = sequence.get_frame_list( + representation.get_trait(FileLocations), + regex=sequence.frame_regex) + template_padding = template_item.anatomy.templates_obj.frame_padding + if template_padding > dst_padding: + dst_padding = template_padding + + # go through all frames in the sequence + # find their corresponding file locations + # format their template and add them to transfers + for frame in frames: + template_item.template_data["frame"] = frame + template_filled = path_template_object.format_strict( + template_item.template_data + ) + file_loc: FileLocation = representation.get_trait( + FileLocations).get_file_location_for_frame( + frame, sequence) + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size, + checksum=file_loc.file_hash, + template=template_item.template, + template_data=template_item.template_data, + ) + ) + + @staticmethod + def get_transfers_from_udim( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from UDIM trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + udim: UDIM = representation.get_trait(UDIM) + for file_loc in representation.get_trait( + FileLocations).file_paths: + template_item.template_data["udim"] = ( + udim.get_udim_from_file_location(file_loc) + ) + template_filled = template_item.template.format( + **template_item.template_data) + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size, + checksum=file_loc.file_hash, + template=template_item.template, + template_data=template_item.template_data, + ) + ) + + @staticmethod + def get_transfers_from_file_location( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from FileLocation trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + path_template_object = template_item.template_object["path"] + template_item.template_data.pop("frame", None) + with contextlib.suppress(MissingTraitError): + udim = representation.get_trait(UDIM) + template_item.template_data["udim"] = udim.udim[0] + + template_filled = path_template_object.format_strict( + template_item.template_data + ) + file_loc: FileLocation = representation.get_trait(FileLocation) + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size, + checksum=file_loc.file_hash, + template=template_item.template, + template_data=template_item.template_data.copy(), + ) + ) From fc313c6ccf5287d7e1b70440ae4f7f4b0ad0eb2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 5 Dec 2024 15:06:05 +0100 Subject: [PATCH 076/781] :twisted_rightwards_arrows: merging changes --- .../plugins/publish/integrate_traits.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 29588f156b..997ba6a774 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -20,7 +20,6 @@ from ayon_api.operations import ( # new_representation_entity, new_version_entity, ) -from ayon_core.pipeline.anatomy.templates import AnatomyStringTemplate from ayon_core.pipeline.publish import ( PublishError, get_publish_template_name, @@ -47,6 +46,9 @@ if TYPE_CHECKING: import logging from ayon_core.pipeline import Anatomy + from ayon_core.pipeline.anatomy.templates import ( + TemplateItem as AnatomyTemplateItem, + ) @dataclass(frozen=True) @@ -74,7 +76,7 @@ class TemplateItem: anatomy: Anatomy template: str template_data: dict[str, Any] - template_object: AnatomyStringTemplate + template_object: AnatomyTemplateItem def get_instance_families(instance: pyblish.api.Instance) -> List[str]: @@ -145,7 +147,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): order = pyblish.api.IntegratorOrder log: logging.Logger - def process(self, instance: pyblish.api.Instance) -> None: + def process(self, instance: pyblish.api.Instance) -> None: # noqa: C901 """Integrate representations with traits. Todo: @@ -184,8 +186,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): "Instance has no persistent representations. Skipping") return - # 3) get anatomy template - anatomy: Anatomy = instance.context.data["anatomy"] + # 3) get template and template data template: str = self.get_publish_template(instance) # 4) initialize OperationsSession() @@ -226,7 +227,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): ) template_item = TemplateItem( - anatomy=anatomy, + anatomy=instance.context.data["anatomy"], template=template, template_data=template_data, template_object=self.get_publish_template_object(instance) @@ -262,8 +263,6 @@ class IntegrateTraits(pyblish.api.InstancePlugin): if sub_representation.contains_trait(FileLocations): ... - pass - # add TemplatePath trait to the representation representation.add_trait(TemplatePath( template=template_item.template, @@ -371,7 +370,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): return path_template_obj.template.replace("\\", "/") def get_publish_template_object( - self, instance: pyblish.api.Instance) -> AnatomyStringTemplate: + self, instance: pyblish.api.Instance) -> AnatomyTemplateItem: """Return anatomy template object to use for integration. Note: What is the actual type of the object? @@ -380,7 +379,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): instance (pyblish.api.Instance): Instance to process. Returns: - object: Anatomy template object + AnatomyTemplateItem: Anatomy template object """ # Anatomy data is pre-filled by Collectors From 5ab0f82a3a2eda94de62199fedc4ae367f555086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 9 Dec 2024 16:25:21 +0100 Subject: [PATCH 077/781] :art: add traits addon interface --- client/ayon_core/addon/__init__.py | 2 + client/ayon_core/addon/interfaces.py | 216 +++++++++++++-------- client/ayon_core/pipeline/traits/README.md | 42 +++- 3 files changed, 175 insertions(+), 85 deletions(-) diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index 6a7ce8a3cb..fd25dcb9d9 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -6,6 +6,7 @@ from .interfaces import ( ITrayAction, ITrayService, IHostAddon, + ITraits, ) from .base import ( @@ -30,6 +31,7 @@ __all__ = ( "ITrayAction", "ITrayService", "IHostAddon", + "ITraits", "ProcessPreparationError", "ProcessContext", diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index b273e7839b..5e2d1b13d7 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -1,13 +1,24 @@ +from __future__ import annotations + +import logging from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Callable, Optional, Type from ayon_core import resources +if TYPE_CHECKING: + from qtpy import QtWidgets + + from ayon_core.addon import AddonsManager + from ayon_core.pipeline.traits import TraitBase + from ayon_core.tools.tray import TrayManager + class _AYONInterfaceMeta(ABCMeta): - """AYONInterface meta class to print proper string.""" + """AYONInterface metaclass to print proper string.""" def __str__(self): - return "<'AYONInterface.{}'>".format(self.__name__) + return f"<'AYONInterface.{self.__name__}'>" def __repr__(self): return str(self) @@ -24,7 +35,11 @@ class AYONInterface(metaclass=_AYONInterfaceMeta): in the interface. By default, interface does not have any abstract parts. """ - pass + log = None + + def __init__(self): + """Initialize interface.""" + self.log = logging.getLogger(self.__class__.__name__) class IPluginPaths(AYONInterface): @@ -38,10 +53,25 @@ class IPluginPaths(AYONInterface): """ @abstractmethod - def get_plugin_paths(self): - pass + def get_plugin_paths(self) -> dict[str, list[str]]: + """Return plugin paths for addon. - def _get_plugin_paths_by_type(self, plugin_type): + Returns: + dict[str, list[str]]: Plugin paths for addon. + + """ + + def _get_plugin_paths_by_type( + self, plugin_type: str) -> list[str]: + """Get plugin paths by type. + + Args: + plugin_type (str): Type of plugin paths to get. + + Returns: + list[str]: List of plugin paths. + + """ paths = self.get_plugin_paths() if not paths or plugin_type not in paths: return [] @@ -54,7 +84,7 @@ class IPluginPaths(AYONInterface): paths = [paths] return paths - def get_create_plugin_paths(self, host_name): + def get_create_plugin_paths(self, host_name: str) -> list[str]: """Receive create plugin paths. Give addons ability to add create plugin paths based on host name. @@ -65,11 +95,11 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + """ return self._get_plugin_paths_by_type("create") - def get_load_plugin_paths(self, host_name): + def get_load_plugin_paths(self, host_name: str) -> list[str]: """Receive load plugin paths. Give addons ability to add load plugin paths based on host name. @@ -80,11 +110,11 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + """ return self._get_plugin_paths_by_type("load") - def get_publish_plugin_paths(self, host_name): + def get_publish_plugin_paths(self, host_name: str) -> list[str]: """Receive publish plugin paths. Give addons ability to add publish plugin paths based on host name. @@ -95,11 +125,11 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + """ return self._get_plugin_paths_by_type("publish") - def get_inventory_action_paths(self, host_name): + def get_inventory_action_paths(self, host_name: str) -> list[str]: """Receive inventory action paths. Give addons ability to add inventory action plugin paths. @@ -110,76 +140,84 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + """ return self._get_plugin_paths_by_type("inventory") class ITrayAddon(AYONInterface): """Addon has special procedures when used in Tray tool. - IMPORTANT: - The addon. still must be usable if is not used in tray even if - would do nothing. - """ + Important: + The addon. still must be usable if is not used in tray even if it + would do nothing. + """ tray_initialized = False - _tray_manager = None + manager: AddonsManager = None + _tray_manager: TrayManager = None @abstractmethod - def tray_init(self): + def tray_init(self) -> None: """Initialization part of tray implementation. Triggered between `initialization` and `connect_with_addons`. This is where GUIs should be loaded or tray specific parts should be - prepared. + prepared + """ - - pass + raise NotImplementedError @abstractmethod - def tray_menu(self, tray_menu): + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: """Add addon's action to tray menu.""" + raise NotImplementedError - pass @abstractmethod - def tray_start(self): + def tray_start(self) -> None: """Start procedure in tray tool.""" - - pass + raise NotImplementedError @abstractmethod - def tray_exit(self): + def tray_exit(self) -> None: """Cleanup method which is executed on tray shutdown. This is place where all threads should be shut. + """ + raise NotImplementedError - pass + def execute_in_main_thread(self, callback: Callable) -> None: + """Pushes callback to the queue or process 'callback' on a main thread. - def execute_in_main_thread(self, callback): - """ Pushes callback to the queue or process 'callback' on a main thread + Some callbacks need to be processed on main thread (menu actions + must be added on main thread else they won't get triggered etc.) + + Args: + callback (Callable): Function to be executed on main thread - Some callbacks need to be processed on main thread (menu actions - must be added on main thread or they won't get triggered etc.) """ - if not self.tray_initialized: - # TODO Called without initialized tray, still main thread needed + # TODO: Called without initialized tray, still main thread needed try: callback() - except Exception: + except Exception: # noqa: BLE001 self.log.warning( - "Failed to execute {} in main thread".format(callback), - exc_info=True) + "Failed to execute %s callback in main thread", + str(callback), exc_info=True) return - self.manager.tray_manager.execute_in_main_thread(callback) + self._tray_manager.tray_manager.execute_in_main_thread(callback) - def show_tray_message(self, title, message, icon=None, msecs=None): + def show_tray_message( + self, + title: str, + message: str, + icon: Optional[QtWidgets.QSystemTrayIcon]=None, + msecs: Optional[int]=None) -> None: """Show tray message. Args: @@ -190,11 +228,11 @@ class ITrayAddon(AYONInterface): msecs (int): Duration of message visibility in milliseconds. Default is 10000 msecs, may differ by Qt version. """ - if self._tray_manager: self._tray_manager.show_tray_message(title, message, icon, msecs) - def add_doubleclick_callback(self, callback): + def add_doubleclick_callback(self, callback: Callable) -> None: + """Add callback to be triggered on tray icon double click.""" if hasattr(self.manager, "add_doubleclick_callback"): self.manager.add_doubleclick_callback(self, callback) @@ -216,16 +254,17 @@ class ITrayAction(ITrayAddon): @property @abstractmethod - def label(self): + def label(self) -> str: """Service label showed in menu.""" - pass + raise NotImplementedError @abstractmethod - def on_action_trigger(self): + def on_action_trigger(self) -> None: """What happens on actions click.""" - pass + raise NotImplementedError - def tray_menu(self, tray_menu): + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: + """Add action to tray menu.""" from qtpy import QtWidgets if self.admin_action: @@ -242,14 +281,17 @@ class ITrayAction(ITrayAddon): action.triggered.connect(self.on_action_trigger) self._action_item = action - def tray_start(self): + def tray_start(self) -> None: + """Start procedure in tray tool.""" return - def tray_exit(self): + def tray_exit(self) -> None: + """Cleanup method which is executed on tray shutdown.""" return @staticmethod - def admin_submenu(tray_menu): + def admin_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu: + """Get or create admin submenu.""" if ITrayAction._admin_submenu is None: from qtpy import QtWidgets @@ -260,20 +302,21 @@ class ITrayAction(ITrayAddon): class ITrayService(ITrayAddon): + """Tray service Interface.""" # Module's property - menu_action = None + menu_action: QtWidgets.QAction = None # Class properties - _services_submenu = None - _icon_failed = None - _icon_running = None - _icon_idle = None + _services_submenu: QtWidgets.QMenu = None + _icon_failed: QtWidgets.QIcon = None + _icon_running: QtWidgets.QIcon = None + _icon_idle: QtWidgets.QIcon = None @property @abstractmethod - def label(self): + def label(self) -> str: """Service label showed in menu.""" - pass + raise NotImplementedError # TODO be able to get any sort of information to show/print # @abstractmethod @@ -281,7 +324,8 @@ class ITrayService(ITrayAddon): # pass @staticmethod - def services_submenu(tray_menu): + def services_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu: + """Get or create services submenu.""" if ITrayService._services_submenu is None: from qtpy import QtWidgets @@ -291,13 +335,15 @@ class ITrayService(ITrayAddon): return ITrayService._services_submenu @staticmethod - def add_service_action(action): + def add_service_action(action: QtWidgets.QAction) -> None: + """Add service action to services submenu.""" ITrayService._services_submenu.addAction(action) if not ITrayService._services_submenu.menuAction().isVisible(): ITrayService._services_submenu.menuAction().setVisible(True) @staticmethod - def _load_service_icons(): + def _load_service_icons() -> None: + """Load service icons.""" from qtpy import QtGui ITrayService._failed_icon = QtGui.QIcon( @@ -311,24 +357,28 @@ class ITrayService(ITrayAddon): ) @staticmethod - def get_icon_running(): + def get_icon_running() -> QtWidgets.QIcon: + """Get running icon.""" if ITrayService._icon_running is None: ITrayService._load_service_icons() return ITrayService._icon_running @staticmethod - def get_icon_idle(): + def get_icon_idle() -> QtWidgets.QIcon: + """Get idle icon.""" if ITrayService._icon_idle is None: ITrayService._load_service_icons() return ITrayService._icon_idle @staticmethod - def get_icon_failed(): - if ITrayService._failed_icon is None: + def get_icon_failed() -> QtWidgets.QIcon: + """Get failed icon.""" + if ITrayService._icon_failed is None: ITrayService._load_service_icons() - return ITrayService._failed_icon + return ITrayService._icon_failed - def tray_menu(self, tray_menu): + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: + """Add service to tray menu.""" from qtpy import QtWidgets action = QtWidgets.QAction( @@ -341,21 +391,18 @@ class ITrayService(ITrayAddon): self.set_service_running_icon() - def set_service_running_icon(self): + def set_service_running_icon(self) -> None: """Change icon of an QAction to green circle.""" - if self.menu_action: self.menu_action.setIcon(self.get_icon_running()) - def set_service_failed_icon(self): + def set_service_failed_icon(self) -> None: """Change icon of an QAction to red circle.""" - if self.menu_action: self.menu_action.setIcon(self.get_icon_failed()) - def set_service_idle_icon(self): + def set_service_idle_icon(self) -> None: """Change icon of an QAction to orange circle.""" - if self.menu_action: self.menu_action.setIcon(self.get_icon_idle()) @@ -365,18 +412,31 @@ class IHostAddon(AYONInterface): @property @abstractmethod - def host_name(self): + def host_name(self) -> str: """Name of host which addon represents.""" + raise NotImplementedError - pass - - def get_workfile_extensions(self): + def get_workfile_extensions(self) -> list[str]: """Define workfile extensions for host. Not all hosts support workfiles thus this is optional implementation. Returns: List[str]: Extensions used for workfiles with dot. - """ + """ return [] + + +class ITraits(AYONInterface): + """Interface for traits.""" + + @abstractmethod + def get_addon_traits(self) -> list[Type[TraitBase]]: + """Get trait classes for the addon. + + Returns: + list[Type[TraitBase]]: Traits for the addon. + + """ + raise NotImplementedError diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md index 4387109cbe..676aabb029 100644 --- a/client/ayon_core/pipeline/traits/README.md +++ b/client/ayon_core/pipeline/traits/README.md @@ -271,13 +271,41 @@ rep = Representation.from_dict(name="image", rep_dict) ``` -## Future -Apart of some new additions to traits if needed, there are few thing that needs to be done. +## Addon specific traits -### Traits plugin system +Addon can define its own traits. To do so, it needs to implement `ITraits` interface: -Traits are now ordinary python classes, but to extend its usability more, it would be good to -have addon level API to expose traits defined by individual addons. This API would then be used not -only by discovery logic but also by the AYON server that can display and work with the information -defined by them. \ No newline at end of file +```python +from ayon_core.pipeline.traits import TraitBase +from ayon_core.addon import ( + AYONAddon, + ITraits, +) + +class MyTraitFoo(TraitBase): + id = "myaddon.mytrait.foo.v1" + name = "My Trait Foo" + description = "This is my trait foo" + persistent = True + + +class MyTraitBar(TraitBase): + id = "myaddon.mytrait.bar.v1" + name = "My Trait Bar" + description = "This is my trait bar" + persistent = True + + +class MyAddon(AYONAddon, ITraits): + def __init__(self): + super().__init__() + + def get_addon_traits(self): + return [ + MyTraitFoo, + MyTraitBar, + ] + + +``` From 34051efcfc73d83b70a25b4c34f5d9d366d4fa07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 10 Dec 2024 09:28:40 +0100 Subject: [PATCH 078/781] :art: handle bundle templates --- .../plugins/publish/integrate_traits.py | 159 ++++++++++-------- 1 file changed, 85 insertions(+), 74 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 997ba6a774..447be6665c 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -58,6 +58,16 @@ class TransferItem: Source file path, destination file path, template that was used to construct the destination path, template data that was used in the template, size of the file, checksum of the file. + + Attributes: + source (Path): Source file path. + destination (Path): Destination file path. + size (int): Size of the file. + checksum (str): Checksum of the file. + template (str): Template path. + template_data (dict[str, Any]): Template data. + representation (Representation): Reference to representation + """ source: Path destination: Path @@ -65,6 +75,7 @@ class TransferItem: checksum: str template: str template_data: dict[str, Any] + representation: Representation @dataclass @@ -72,6 +83,12 @@ class TemplateItem: """Represents single template item. Template path, template data that was used in the template. + + Attributes: + anatomy (Anatomy): Anatomy object. + template (str): Template path. + template_data (dict[str, Any]): Template data. + template_object (AnatomyTemplateItem): Template object """ anatomy: Anatomy template: str @@ -147,7 +164,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): order = pyblish.api.IntegratorOrder log: logging.Logger - def process(self, instance: pyblish.api.Instance) -> None: # noqa: C901 + def process(self, instance: pyblish.api.Instance) -> None: """Integrate representations with traits. Todo: @@ -204,7 +221,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): instance_template_data: dict[str, str] = {} transfers: list[TransferItem] = [] - # 6.5) prepare template and data to format it + # prepare template and data to format it for representation in representations: # validate representation first, this will go through all traits @@ -230,7 +247,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): anatomy=instance.context.data["anatomy"], template=template, template_data=template_data, - template_object=self.get_publish_template_object(instance) + template_object=self.get_publish_template_object(instance), ) if representation.contains_trait(FileLocations): @@ -250,27 +267,12 @@ class IntegrateTraits(pyblish.api.InstancePlugin): elif representation.contains_trait(Bundle): # Bundle groups multiple "sub-representations" together. - # It has list of lists with traits, some might be + # It has a list of lists with traits, some might be # FileLocations,but some might be "file-less" representations # or even other bundles. - bundle: Bundle = representation.get_trait(Bundle) - for idx, sub_representation_traits in enumerate(bundle.items): - sub_representation = Representation( - name=f"{representation.name}_{idx}", - traits=sub_representation_traits) - # sub presentation transient: - sub_representation.add_trait(Transient()) - if sub_representation.contains_trait(FileLocations): - ... - - # add TemplatePath trait to the representation - representation.add_trait(TemplatePath( - template=template_item.template, - data=template_item.template_data - )) - - transfers += self.get_transfers_from_representation( - representation, template, template_data) + self.get_transfers_from_bundle( + representation, template_item, transfers + ) def _get_relative_to_root_original_dirname( @@ -696,58 +698,6 @@ class IntegrateTraits(pyblish.api.InstancePlugin): return template_data - @staticmethod - def get_transfers_from_representation( - representation: Representation, - template: str, - template_data: dict) -> list: - """Get transfers from representation. - - Args: - representation (Representation): Representation to process. - template (str): Template to format. - template_data (dict): Template data. - - Returns: - list: List of transfers. - - """ - transfers = [] - # check if representation contains traits with files - if not representation.contains_traits( - [FileLocation, FileLocations]): - return transfers - - files: list[Path] = [] - - if representation.contains_trait(FileLocations): - files = [ - location.file_path - for location in representation.get_trait( - FileLocations).file_paths - ] - elif representation.contains_trait(FileLocation): - file_location: FileLocation = representation.get_trait( - FileLocation) - files = [file_location.file_path] - - template_data_copy = copy.deepcopy(template_data) - for file in files: - if "{originalBasename}" in template: - template_data_copy["originalBasename"] = file.stem - """ - dst = path_template_obj.format_strict(template_data) - src = os.path.join(stagingdir, src_file_name) - """ - if representation.contains_trait_by_id( - FileLocation.get_versionless_id()): - file_location: FileLocation = representation.get_trait_by_id( - FileLocation.get_versionless_id()) - """ - transfers += self.get_transfers_from_file_location( - file_location, template, template_data) - """ - return transfers @staticmethod def get_transfers_from_file_locations( @@ -820,6 +770,12 @@ class IntegrateTraits(pyblish.api.InstancePlugin): if template_padding > dst_padding: dst_padding = template_padding + # add template path and the data to resolve it + representation.add_trait(TemplatePath( + template=template_item.template, + data=template_item.template_data + )) + # go through all frames in the sequence # find their corresponding file locations # format their template and add them to transfers @@ -839,9 +795,11 @@ class IntegrateTraits(pyblish.api.InstancePlugin): checksum=file_loc.file_hash, template=template_item.template, template_data=template_item.template_data, + representation=representation, ) ) + @staticmethod def get_transfers_from_udim( representation: Representation, @@ -876,8 +834,14 @@ class IntegrateTraits(pyblish.api.InstancePlugin): checksum=file_loc.file_hash, template=template_item.template, template_data=template_item.template_data, + representation=representation, ) ) + # add template path and the data to resolve it + representation.add_trait(TemplatePath( + template=template_item.template, + data=template_item.template_data + )) @staticmethod def get_transfers_from_file_location( @@ -915,5 +879,52 @@ class IntegrateTraits(pyblish.api.InstancePlugin): checksum=file_loc.file_hash, template=template_item.template, template_data=template_item.template_data.copy(), + representation=representation, ) ) + # add template path and the data to resolve it + representation.add_trait(TemplatePath( + template=template_item.template, + data=template_item.template_data + )) + + @staticmethod + def get_transfers_from_bundle( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from Bundle trait. + + This will be called recursively for each sub-representation in the + bundle that is a Bundle itself. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + bundle: Bundle = representation.get_trait(Bundle) + for idx, sub_representation_traits in enumerate(bundle.items): + sub_representation = Representation( + name=f"{representation.name}_{idx}", + traits=sub_representation_traits) + # sub presentation transient: + sub_representation.add_trait(Transient()) + if sub_representation.contains_trait(FileLocations): + IntegrateTraits.get_transfers_from_file_locations( + sub_representation, template_item, transfers + ) + elif sub_representation.contains_trait(FileLocation): + IntegrateTraits.get_transfers_from_file_location( + sub_representation, template_item, transfers + ) + elif sub_representation.contains_trait(Bundle): + IntegrateTraits.get_transfers_from_bundle( + sub_representation, template_item, transfers + ) From b51273af8e54078e3739d131e06038f66baf7d85 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 10 Dec 2024 11:55:40 +0100 Subject: [PATCH 079/781] :art: add original location traits and fix some typos --- client/ayon_core/pipeline/traits/README.md | 27 ++++++++++++---------- client/ayon_core/pipeline/traits/meta.py | 26 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md index 676aabb029..f9d7be5de1 100644 --- a/client/ayon_core/pipeline/traits/README.md +++ b/client/ayon_core/pipeline/traits/README.md @@ -28,10 +28,10 @@ This will be *"translated"* to similar structure in database: "version_id": "...", "files": [ { - "id": ... - "hash": ... + "id": ..., + "hash": ..., "name": "foo_001.exr", - "path": "{root[work]}/bar/dir/foo_001.exr" + "path": "{root[work]}/bar/dir/foo_001.exr", "size": 1234, "hash_type": "...", }, @@ -39,15 +39,15 @@ This will be *"translated"* to similar structure in database: ], "attrib": { "path": "root/bar/dir/foo_001.exr", - "template": "{root[work]}/{project[name]}..." + "template": "{root[work]}/{project[name]}...", }, "data": { "context": { "ext": "exr", - "root": {...} + "root": {...}, ... }, - "active": True" + "active": True ... } @@ -163,6 +163,8 @@ to different packages based on their use: | meta | Tagged | holds list of tag strings. | | TemplatePath | Template consisted of tokens/keys and data to be used to resolve the template into string | | Variant | Used to differentiate between data variants of the same output (mp4 as h.264 and h.265 for example) +| | KeepOriginalLocation | Marks the representation to keep the original location of the file +| | KeepOriginalName | Marks the representation to keep the original name of the file | three dimensional | Spatial | Spatial information like up-axis, units and handedness. | | Geometry | Type trait to mark the representation as a geometry. | | Shader | Type trait to mark the representation as a Shader. @@ -202,21 +204,21 @@ from ayon_core.pipeline.traits import ( Image, PixelBased, Persistent, - Representation - Static + Representation, + Static, ) rep = Representation(name="reference image", traits=[ FileLocation( file_path=Path("/foo/bar/baz.exr"), file_size=1234, - file_hash="sha256:..." + file_hash="sha256:...", ), Image(), PixelBased( - display_window_width="1920", - display_window_height="1080", - pixel_aspect_ratio=1.0 + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0, ), Persistent(), Static() @@ -237,6 +239,7 @@ To work with the resolution of such representation: try: width = rep.get_trait(PixelBased).display_window_width + # or like this: height = rep[PixelBased.id].display_window_height except MissingTraitError: print(f"resolution isn't set on {rep.name}") diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py index b240e7a4da..c2f13b69f1 100644 --- a/client/ayon_core/pipeline/traits/meta.py +++ b/client/ayon_core/pipeline/traits/meta.py @@ -52,6 +52,7 @@ class TemplatePath(TraitBase): template: str = Field(..., title="Template Path") data: dict = Field(..., title="Formatting Data") + class Variant(TraitBase): """Variant trait model. @@ -73,3 +74,28 @@ class Variant(TraitBase): description: ClassVar[str] = "Variant Trait Model" id: ClassVar[str] = "ayon.meta.Variant.v1" variant: str = Field(..., title="Variant") + + +class KeepOriginalLocation(TraitBase): + """Keep files in its original location. + + Note: + This is not a persistent trait. + + """ + name: ClassVar[str] = "KeepOriginalLocation" + description: ClassVar[str] = "Keep Original Location Trait Model" + id: ClassVar[str] = "ayon.meta.KeepOriginalLocation.v1" + persistent = Field(False, title="Persistent") + +class KeepOriginalName(TraitBase): + """Keep files in its original name. + + Note: + This is not a persistent trait. + + """ + name: ClassVar[str] = "KeepOriginalName" + description: ClassVar[str] = "Keep Original Name Trait Model" + id: ClassVar[str] = "ayon.meta.KeepOriginalName.v1" + persistent = Field(False, title="Persistent") From 1dd3c00eb5802254a21bd5d7ea51f562a93ac366 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 10 Dec 2024 23:02:10 +0100 Subject: [PATCH 080/781] :bug: fix template data --- .../plugins/publish/integrate_traits.py | 104 +++++++++--- .../plugins/publish/test_integrate_traits.py | 156 ++++++++++++++++-- 2 files changed, 219 insertions(+), 41 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 447be6665c..a816a42c3f 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib import copy +from copy import deepcopy from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, List @@ -47,8 +48,8 @@ if TYPE_CHECKING: from ayon_core.pipeline import Anatomy from ayon_core.pipeline.anatomy.templates import ( - TemplateItem as AnatomyTemplateItem, - ) + TemplateItem as AnatomyTemplateItem, AnatomyStringTemplate, +) @dataclass(frozen=True) @@ -203,23 +204,43 @@ class IntegrateTraits(pyblish.api.InstancePlugin): "Instance has no persistent representations. Skipping") return - # 3) get template and template data - template: str = self.get_publish_template(instance) - - # 4) initialize OperationsSession() op_session = OperationsSession() - # 5) Prepare product product_entity = self.prepare_product(instance, op_session) - # 6) Prepare version version_entity = self.prepare_version( instance, op_session, product_entity ) instance.data["versionEntity"] = version_entity - instance_template_data: dict[str, str] = {} + transfers = self.get_transfers_from_representations( + instance, representations) + def get_transfers_from_representations( + self, + instance: pyblish.api.Instance, + representations: list[Representation]) -> list[TransferItem]: + """Get transfers from representations. + + This method will go through all representations and prepare transfers + based on the traits they contain. First it will validate the + representation, and then it will prepare template data for the + representation. It specifically handles FileLocations, FileLocation, + Bundle, Sequence and UDIM traits. + + Args: + instance (pyblish.api.Instance): Instance to process. + representations (list[Representation]): List of representations. + + Returns: + list[TransferItem]: List of transfers. + + Raises: + PublishError: If representation is invalid. + + """ + template: str = self.get_publish_template(instance) + instance_template_data: dict[str, str] = {} transfers: list[TransferItem] = [] # prepare template and data to format it for representation in representations: @@ -235,6 +256,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template_data = self.get_template_data_from_representation( representation, instance) # add instance based template data + template_data.update(instance_template_data) # treat Variant as `output` in template data @@ -246,7 +268,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template_item = TemplateItem( anatomy=instance.context.data["anatomy"], template=template, - template_data=template_data, + template_data=copy.deepcopy(template_data), template_object=self.get_publish_template_object(instance), ) @@ -273,7 +295,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): self.get_transfers_from_bundle( representation, template_item, transfers ) - + return transfers def _get_relative_to_root_original_dirname( self, instance: pyblish.api.Instance) -> str: @@ -668,6 +690,8 @@ class IntegrateTraits(pyblish.api.InstancePlugin): """ template_data = copy.deepcopy(instance.data["anatomyData"]) template_data["representation"] = representation.name + template_data["version"] = instance.data["version"] + template_data["hierarchy"] = instance.data["hierarchy"] # add colorspace data to template data if representation.contains_trait(ColorManaged): @@ -770,23 +794,26 @@ class IntegrateTraits(pyblish.api.InstancePlugin): if template_padding > dst_padding: dst_padding = template_padding - # add template path and the data to resolve it - representation.add_trait(TemplatePath( - template=template_item.template, - data=template_item.template_data - )) - # go through all frames in the sequence # find their corresponding file locations # format their template and add them to transfers for frame in frames: - template_item.template_data["frame"] = frame - template_filled = path_template_object.format_strict( - template_item.template_data - ) file_loc: FileLocation = representation.get_trait( FileLocations).get_file_location_for_frame( frame, sequence) + + template_item.template_data["frame"] = frame + template_item.template_data["ext"] = ( + file_loc.file_path.suffix + ) + template_filled = path_template_object.format_strict( + template_item.template_data + ) + + # add used values to the template data + used_values: dict = template_filled.used_values + template_item.template_data.update(used_values) + transfers.append( TransferItem( source=file_loc.file_path, @@ -799,6 +826,12 @@ class IntegrateTraits(pyblish.api.InstancePlugin): ) ) + # add template path and the data to resolve it + representation.add_trait(TemplatePath( + template=template_item.template, + data=template_item.template_data + )) + @staticmethod def get_transfers_from_udim( @@ -819,13 +852,23 @@ class IntegrateTraits(pyblish.api.InstancePlugin): """ udim: UDIM = representation.get_trait(UDIM) + path_template_object: AnatomyStringTemplate = ( + template_item.template_object["path"] + ) for file_loc in representation.get_trait( FileLocations).file_paths: template_item.template_data["udim"] = ( udim.get_udim_from_file_location(file_loc) ) - template_filled = template_item.template.format( - **template_item.template_data) + + template_filled = path_template_object.format_strict( + template_item.template_data + ) + + # add used values to the template data + used_values: dict = template_filled.used_values + template_item.template_data.update(used_values) + transfers.append( TransferItem( source=file_loc.file_path, @@ -861,7 +904,12 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template_item (TemplateItem): Template item. """ - path_template_object = template_item.template_object["path"] + path_template_object: AnatomyStringTemplate = ( + template_item.template_object["path"] + ) + template_item.template_data["ext"] = ( + representation.get_trait(FileLocation).file_path.suffix + ) template_item.template_data.pop("frame", None) with contextlib.suppress(MissingTraitError): udim = representation.get_trait(UDIM) @@ -870,6 +918,11 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template_filled = path_template_object.format_strict( template_item.template_data ) + + # add used values to the template data + used_values: dict = template_filled.used_values + template_item.template_data.update(used_values) + file_loc: FileLocation = representation.get_trait(FileLocation) transfers.append( TransferItem( @@ -878,7 +931,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): size=file_loc.file_size, checksum=file_loc.file_hash, template=template_item.template, - template_data=template_item.template_data.copy(), + template_data=template_item.template_data, representation=representation, ) ) @@ -928,3 +981,4 @@ class IntegrateTraits(pyblish.api.InstancePlugin): IntegrateTraits.get_transfers_from_bundle( sub_representation, template_item, transfers ) + diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index 11922f6c9a..a54d0a3cfa 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -2,10 +2,16 @@ from __future__ import annotations import base64 +import time from pathlib import Path import pyblish.api import pytest +import pytest_ayon +from ayon_api.operations import ( + OperationsSession, +) +from ayon_core.pipeline.anatomy import Anatomy from ayon_core.pipeline.traits import ( FileLocation, FileLocations, @@ -18,6 +24,7 @@ from ayon_core.pipeline.traits import ( Sequence, Transient, ) +from ayon_core.pipeline.version_start import get_versioning_start # Tagged, # TemplatePath, @@ -26,6 +33,8 @@ from ayon_core.settings import get_project_settings PNG_FILE_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVR4AWNgAAAAAgABc3UBGAAAAABJRU5ErkJggg==" # noqa: E501 SEQUENCE_LENGTH = 10 +CURRENT_TIME = time.time() + @pytest.fixture(scope="session") def single_file(tmp_path_factory: pytest.TempPathFactory) -> Path: @@ -48,7 +57,7 @@ def sequence_files(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: @pytest.fixture() def mock_context( - project: object, + project: pytest_ayon.ProjectInfo, single_file: Path, sequence_files: list[Path]) -> pyblish.api.Context: """Return a mock instance. @@ -56,18 +65,6 @@ def mock_context( This is mocking pyblish context for testing. It is using real AYON project thanks to the ``project`` fixture. - This returns following data:: - - project_name: str - project_code: str - project_root_folders: dict[str, str] - folder: IdNamePair - task: IdNamePair - product: IdNamePair - version: IdNamePair - representations: List[IdNamePair] - links: List[str] - Args: project (object): The project info. It is `ProjectInfo` object returned by pytest fixture. @@ -75,25 +72,68 @@ def mock_context( sequence_files (list[Path]): The paths to a sequence of image files. """ + anatomy = Anatomy(project.project_name) context = pyblish.api.Context() context.data["projectName"] = project.project_name context.data["hostName"] = "test_host" context.data["project_settings"] = get_project_settings( project.project_name) + context.data["anatomy"] = anatomy + context.data["time"] = CURRENT_TIME + context.data["user"] = "test_user" + context.data["machine"] = "test_machine" + context.data["fps"] = 25 instance = context.create_instance("mock_instance") + instance.data["source"] = "test_source" + instance.data["families"] = ["render"] instance.data["anatomyData"] = { - "project": project.project_name, + "project": { + "name": project.project_name, + "code": project.project_code + }, "task": { "name": project.task.name, "type": "test" # pytest-ayon doesn't return the task type yet - } + }, + "folder": { + "name": project.folder.name, + "type": "test" # pytest-ayon doesn't return the folder type yet + }, + "product": { + "name": project.product.name, + "type": "test" # pytest-ayon doesn't return the product type yet + }, + } + instance.data["folderEntity"] = project.folder_entity instance.data["productType"] = "test_product" + instance.data["productName"] = project.product.name + instance.data["anatomy"] = anatomy + instance.data["comment"] = "test_comment" instance.data["integrate"] = True instance.data["farm"] = False + parents = project.folder_entity["path"].lstrip("/").split("/") + + hierarchy = "" + if parents: + hierarchy = "/".join(parents) + + instance.data["hierarchy"] = hierarchy + + version_number = get_versioning_start( + context.data["projectName"], + instance.context.data["hostName"], + task_name=project.task.name, + task_type="test", + product_type=instance.data["productType"], + product_name=instance.data["productName"] + ) + + instance.data["version"] = version_number + _file_size = len(base64.b64decode(PNG_FILE_B64)) file_locations = [ FileLocation( @@ -121,7 +161,7 @@ def mock_context( ), Sequence( frame_padding=4, - frame_regex=r"^img\.(\d{4})\.png$", + frame_regex=r"^img\.(?P\d{4})\.png$", ), FileLocations( file_paths=file_locations, @@ -176,3 +216,87 @@ def test_filter_lifecycle() -> None: assert len(filtered) == 1 assert filtered[0] == persistent_representation + + +def test_prepare_product( + project: pytest_ayon.ProjectInfo, + mock_context: pyblish.api.Context) -> None: + """Test prepare_product.""" + integrator = IntegrateTraits() + op_session = OperationsSession() + product = integrator.prepare_product(mock_context[0], op_session) + + assert product == { + "attrib": {}, + "data": { + "families": ["default", "render"], + }, + "folderId": project.folder_entity["id"], + "name": "renderMain", + "productType": "test_product", + "id": project.product_entity["id"], + } + +def test_prepare_version( + project: pytest_ayon.ProjectInfo, + mock_context: pyblish.api.Context) -> None: + """Test prepare_version.""" + integrator = IntegrateTraits() + op_session = OperationsSession() + product = integrator.prepare_product(mock_context[0], op_session) + version = integrator.prepare_version( + mock_context[0], op_session , product) + + assert version == { + "attrib": { + "comment": "test_comment", + "families": ["default", "render"], + "fps": 25, + "machine": "test_machine", + "source": "test_source", + }, + "data": { + "author": "test_user", + "time": CURRENT_TIME, + }, + "id": project.version_entity["id"], + "productId": project.product_entity["id"], + "version": 1, + } + + +def test_get_transfers_from_representation( + mock_context: pyblish.api.Context) -> None: + """Test get_transfers_from_representation.""" + integrator = IntegrateTraits() + + instance = mock_context[0] + representations: list[Representation] = instance.data[ + "representations_with_traits"] + transfers = integrator.get_transfers_from_representations( + instance, representations) + + assert transfers == [ + { + "file_path": Path("test"), + "file_size": 1234, + "traits": [ + "Persistent", + "Image", + "MimeType" + ] + }, + { + "file_path": Path("test"), + "file_size": 1234, + "traits": [ + "Persistent", + "FrameRanged", + "Sequence", + "FileLocations", + "Image", + "PixelBased", + "MimeType" + ] + } + ] From 83dfd765e2b038959c6ac3ec2996eb132ad45818 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 10 Dec 2024 23:05:35 +0100 Subject: [PATCH 081/781] :bug: fix validation --- client/ayon_core/pipeline/traits/lifecycle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/traits/lifecycle.py b/client/ayon_core/pipeline/traits/lifecycle.py index b5cede3bb1..70fdbb86cf 100644 --- a/client/ayon_core/pipeline/traits/lifecycle.py +++ b/client/ayon_core/pipeline/traits/lifecycle.py @@ -58,6 +58,6 @@ class Persistent(TraitBase): representation (Representation): Representation model. """ - if representation.contains_trait(Persistent): + if representation.contains_trait(Transient): msg = "Representation is marked as both Persistent and Transient." raise TraitValidationError(self.name, msg) From 6d9c73dae71eb0e308408fbd816ef0f0800c4b97 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 11 Dec 2024 13:53:53 +0100 Subject: [PATCH 082/781] :bug: small fixes --- client/ayon_core/pipeline/traits/meta.py | 4 ++-- client/ayon_core/pipeline/traits/time.py | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py index c2f13b69f1..5cf5f54c36 100644 --- a/client/ayon_core/pipeline/traits/meta.py +++ b/client/ayon_core/pipeline/traits/meta.py @@ -86,7 +86,7 @@ class KeepOriginalLocation(TraitBase): name: ClassVar[str] = "KeepOriginalLocation" description: ClassVar[str] = "Keep Original Location Trait Model" id: ClassVar[str] = "ayon.meta.KeepOriginalLocation.v1" - persistent = Field(False, title="Persistent") + persistent: bool = Field(False, title="Persistent") class KeepOriginalName(TraitBase): """Keep files in its original name. @@ -98,4 +98,4 @@ class KeepOriginalName(TraitBase): name: ClassVar[str] = "KeepOriginalName" description: ClassVar[str] = "Keep Original Name Trait Model" id: ClassVar[str] = "ayon.meta.KeepOriginalName.v1" - persistent = Field(False, title="Persistent") + persistent: bool = Field(False, title="Persistent") diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 22a5c16c13..74da6ef570 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -287,7 +287,7 @@ class Sequence(TraitBase): "Frame padding does not match the expected frame padding. " f"Expected: {expected_padding}, Found: {self.frame_padding}" ) - raise TraitValidationError(msg) + raise TraitValidationError(self.name, msg) @staticmethod def list_spec_to_frames(list_spec: str) -> list[int]: @@ -350,10 +350,7 @@ class Sequence(TraitBase): def get_frame_padding(file_locations: FileLocations) -> int: """Get frame padding.""" src_collection = Sequence._get_collection(file_locations) - destination_indexes = list(src_collection.indexes) - # Use last frame for minimum padding - # - that should cover both 'udim' and 'frame' minimum padding - return len(str(destination_indexes[-1])) + return src_collection.padding @staticmethod def get_frame_list( From e466da69eb0c525e967532b8b05ba819d5fe3c69 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 11 Dec 2024 13:54:31 +0100 Subject: [PATCH 083/781] :bug: fix sequence frames --- .../client/ayon_core/plugins/publish/test_integrate_traits.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index a54d0a3cfa..41f952f962 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -48,8 +48,10 @@ def single_file(tmp_path_factory: pytest.TempPathFactory) -> Path: def sequence_files(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: """Return a sequence of temporary image files.""" files = [] + dir_name = tmp_path_factory.mktemp("sequence") for i in range(SEQUENCE_LENGTH): - filename = tmp_path_factory.mktemp("sequence") / f"img.{i:04d}.png" + frame = i + 1 + filename = dir_name / f"img.{frame:04d}.png" with open(filename, "wb") as f: f.write(base64.b64decode(PNG_FILE_B64)) files.append(filename) From b0238cba5ccbd516cc2597572ca9126016aaf904 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 12 Dec 2024 17:38:49 +0100 Subject: [PATCH 084/781] :bug: fix regex named group for frame list this is needed to align with named groups in `clique`. Frame is IMO more descriptive but aligning it simplify the code --- client/ayon_core/pipeline/traits/content.py | 4 ++-- client/ayon_core/pipeline/traits/time.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 0ca0cbb3e1..4d198813c1 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -140,7 +140,7 @@ class FileLocations(TraitBase): Optional[FileLocation]: File location for the frame. """ - frame_regex = r"\.(?P(?P0*)\d+)\.\D+\d?$" + frame_regex = r"\.(?P(?P0*)\d+)\.\D+\d?$" if sequence_trait and sequence_trait.frame_regex: frame_regex = sequence_trait.frame_regex @@ -148,7 +148,7 @@ class FileLocations(TraitBase): for location in self.file_paths: result = re.search(frame_regex, location.file_path.name) if result: - frame_index = int(result.group("frame")) + frame_index = int(result.group("index")) if frame_index == frame: return location return None diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 74da6ef570..7687a023da 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -118,7 +118,8 @@ class Sequence(TraitBase): sequence. frame_padding (int): Frame padding. frame_regex (str): Frame regex - regular expression to match - frame numbers. Must include 'frame' named group. + frame numbers. Must include 'index' named group and 'padding' + named group. frame_spec (str): Frame list specification of frames. This takes string like "1-10,20-30,40-50" etc. @@ -136,8 +137,8 @@ class Sequence(TraitBase): @classmethod def validate_frame_regex(cls, v: Optional[str]) -> str: """Validate frame regex.""" - if v is not None and "?P" not in v: - msg = "Frame regex must include 'frame' named group" + if v and any(s not in v for s in ["?P", "?P"]): + msg = "Frame regex must include 'index' and `padding named groups" raise ValueError(msg) return v From a312ac7ff2b88fed6e81baa92d669f532dcd99fe Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 12 Dec 2024 17:41:04 +0100 Subject: [PATCH 085/781] :alembic: fix the test stub for `get_transfers_from_representation` --- .../plugins/publish/integrate_traits.py | 5 +-- .../plugins/publish/test_integrate_traits.py | 35 +++++-------------- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index a816a42c3f..43f979b196 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -826,7 +826,8 @@ class IntegrateTraits(pyblish.api.InstancePlugin): ) ) - # add template path and the data to resolve it + # add template path and the data to resolve it + if not representation.contains_trait(TemplatePath): representation.add_trait(TemplatePath( template=template_item.template, data=template_item.template_data @@ -908,7 +909,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template_item.template_object["path"] ) template_item.template_data["ext"] = ( - representation.get_trait(FileLocation).file_path.suffix + representation.get_trait(FileLocation).file_path.suffix.rstrip(".") ) template_item.template_data.pop("frame", None) with contextlib.suppress(MissingTraitError): diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index 41f952f962..95c0eb52a8 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -163,7 +163,7 @@ def mock_context( ), Sequence( frame_padding=4, - frame_regex=r"^img\.(?P\d{4})\.png$", + frame_regex=r"img\.(?P(?P0*)\d{4})\.png$", ), FileLocations( file_paths=file_locations, @@ -269,7 +269,13 @@ def test_prepare_version( def test_get_transfers_from_representation( mock_context: pyblish.api.Context) -> None: - """Test get_transfers_from_representation.""" + """Test get_transfers_from_representation. + + Todo: This test will benefit massively from a proper mocking of the + context. We need to parametrize the test with different + representations and test the output of the function. + + """ integrator = IntegrateTraits() instance = mock_context[0] @@ -278,27 +284,4 @@ def test_get_transfers_from_representation( transfers = integrator.get_transfers_from_representations( instance, representations) - assert transfers == [ - { - "file_path": Path("test"), - "file_size": 1234, - "traits": [ - "Persistent", - "Image", - "MimeType" - ] - }, - { - "file_path": Path("test"), - "file_size": 1234, - "traits": [ - "Persistent", - "FrameRanged", - "Sequence", - "FileLocations", - "Image", - "PixelBased", - "MimeType" - ] - } - ] + assert len(transfers) == 11 From a08ed708d1e0aa1657a5e60d6b8b50d7411c4909 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 20 Dec 2024 20:01:25 +0100 Subject: [PATCH 086/781] :recycle: fix mypy errors --- client/ayon_core/pipeline/traits/README.md | 10 +- client/ayon_core/pipeline/traits/color.py | 2 +- client/ayon_core/pipeline/traits/content.py | 34 +- client/ayon_core/pipeline/traits/lifecycle.py | 4 +- .../pipeline/traits/representation.py | 96 +++-- client/ayon_core/pipeline/traits/time.py | 93 +++-- client/ayon_core/pipeline/traits/trait.py | 19 +- .../pipeline/traits/two_dimensional.py | 4 +- poetry.lock | 355 ++++++++++++------ pyproject.toml | 4 + .../pipeline/traits/test_content_traits.py | 15 +- .../pipeline/traits/test_time_traits.py | 3 +- 12 files changed, 407 insertions(+), 232 deletions(-) diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md index f9d7be5de1..7ac1a1f2e9 100644 --- a/client/ayon_core/pipeline/traits/README.md +++ b/client/ayon_core/pipeline/traits/README.md @@ -309,6 +309,12 @@ class MyAddon(AYONAddon, ITraits): MyTraitFoo, MyTraitBar, ] - - ``` + +## Developer notes + +### Pydantic models +If you want to use MyPy linter, you need to make sure that +optional fields typed as `Optional[Type]` needs to set default value +using `default` or `default_factory` parameter. Otherwise MyPy will +complain about missing named arguments. \ No newline at end of file diff --git a/client/ayon_core/pipeline/traits/color.py b/client/ayon_core/pipeline/traits/color.py index 9d1fcd913c..26802d51f8 100644 --- a/client/ayon_core/pipeline/traits/color.py +++ b/client/ayon_core/pipeline/traits/color.py @@ -28,4 +28,4 @@ class ColorManaged(TraitBase): ..., description="Color space." ) - config: Optional[str] = Field(None, description="Color config.") + config: Optional[str] = Field(default=None, description="Color config.") diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 4d198813c1..b89537a35a 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -64,7 +64,7 @@ class LocatableContent(TraitBase): description: ClassVar[str] = "LocatableContent Trait Model" id: ClassVar[str] = "ayon.content.LocatableContent.v1" location: str = Field(..., title="Location") - is_templated: Optional[bool] = Field(None, title="Is Templated") + is_templated: Optional[bool] = Field(default=None, title="Is Templated") class FileLocation(TraitBase): """FileLocation trait model. @@ -86,8 +86,8 @@ class FileLocation(TraitBase): description: ClassVar[str] = "FileLocation Trait Model" id: ClassVar[str] = "ayon.content.FileLocation.v1" file_path: Path = Field(..., title="File Path") - file_size: Optional[int] = Field(None, title="File Size") - file_hash: Optional[str] = Field(None, title="File Hash") + file_size: Optional[int] = Field(default=None, title="File Size") + file_hash: Optional[str] = Field(default=None, title="File Hash") class FileLocations(TraitBase): """FileLocation trait model. @@ -140,11 +140,10 @@ class FileLocations(TraitBase): Optional[FileLocation]: File location for the frame. """ - frame_regex = r"\.(?P(?P0*)\d+)\.\D+\d?$" + frame_regex = re.compile(r"\.(?P(?P0*)\d+)\.\D+\d?$") if sequence_trait and sequence_trait.frame_regex: - frame_regex = sequence_trait.frame_regex + frame_regex = sequence_trait.get_frame_pattern() - frame_regex = re.compile(frame_regex) for location in self.file_paths: result = re.search(frame_regex, location.file_path.name) if result: @@ -153,7 +152,7 @@ class FileLocations(TraitBase): return location return None - def validate(self, representation: Representation) -> None: + def validate_trait(self, representation: Representation) -> None: """Validate the trait. This method validates the trait against others in the representation. @@ -167,7 +166,7 @@ class FileLocations(TraitBase): bool: True if the trait is valid, False otherwise """ - super().validate(representation) + super().validate_trait(representation) if len(self.file_paths) == 0: # If there are no file paths, we can't validate msg = "No file locations defined (empty list)" @@ -205,12 +204,13 @@ class FileLocations(TraitBase): tmp_frame_ranged: FrameRanged = get_sequence_from_files( [f.file_path for f in self.file_paths]) - frames_from_spec = None + frames_from_spec: list[int] = [] with contextlib.suppress(MissingTraitError): sequence: Sequence = representation.get_trait(Sequence) + frame_regex = sequence.get_frame_pattern() if sequence.frame_spec: - frames_from_spec: list[int] = sequence.get_frame_list( - self, sequence.frame_regex) + frames_from_spec = sequence.get_frame_list( + self, frame_regex) frame_start_with_handles, frame_end_with_handles = \ self._get_frame_info_with_handles(representation, frames_from_spec) @@ -322,8 +322,8 @@ class FileLocations(TraitBase): handles: Handles = representation.get_trait(Handles) if not handles.inclusive: # if handless are exclusive, we need to adjust the frame range - frame_start_handle = handles.frame_start_handle - frame_end_handle = handles.frame_end_handle + frame_start_handle = handles.frame_start_handle or 0 + frame_end_handle = handles.frame_end_handle or 0 if frames_from_spec: frames_from_spec.extend( range(frame_start - frame_start_handle, frame_start) @@ -428,9 +428,11 @@ class Bundle(TraitBase): items: list[list[TraitBase]] = Field( ..., title="Bundles of traits") - def to_representation(self) -> Representation: - """Convert to a representation.""" - return Representation(traits=self.items) + def to_representations(self) -> Generator[Representation]: + """Convert bundle to representations.""" + for idx, item in enumerate(self.items): + yield Representation(name=f"{self.name} {idx}", traits=item) + class Fragment(TraitBase): diff --git a/client/ayon_core/pipeline/traits/lifecycle.py b/client/ayon_core/pipeline/traits/lifecycle.py index 70fdbb86cf..be87a86cbc 100644 --- a/client/ayon_core/pipeline/traits/lifecycle.py +++ b/client/ayon_core/pipeline/traits/lifecycle.py @@ -20,7 +20,7 @@ class Transient(TraitBase): description: ClassVar[str] = "Transient Trait Model" id: ClassVar[str] = "ayon.lifecycle.Transient.v1" - def validate(self, representation) -> None: # noqa: ANN001 + def validate_trait(self, representation) -> None: # noqa: ANN001 """Validate representation is not Persistent. Args: @@ -51,7 +51,7 @@ class Persistent(TraitBase): description: ClassVar[str] = "Persistent Trait Model" id: ClassVar[str] = "ayon.lifecycle.Persistent.v1" - def validate(self, representation) -> None: # noqa: ANN001 + def validate_trait(self, representation) -> None: # noqa: ANN001 """Validate representation is not Transient. Args: diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py index d365ea1ed2..498690dafd 100644 --- a/client/ayon_core/pipeline/traits/representation.py +++ b/client/ayon_core/pipeline/traits/representation.py @@ -8,7 +8,7 @@ import sys import uuid from functools import lru_cache from types import GenericAlias -from typing import ClassVar, Optional, Type, TypeVar, Union +from typing import ClassVar, Generic, ItemsView, Optional, Type, TypeVar, Union from .trait import ( IncompatibleTraitVersionError, @@ -16,12 +16,14 @@ from .trait import ( MissingTraitError, TraitBase, UpgradableTraitError, + TraitValidationError, ) -T = TypeVar("T", bound=TraitBase) + +T = TypeVar("T", bound="TraitBase") -def _get_version_from_id(_id: str) -> int: +def _get_version_from_id(_id: str) -> Optional[int]: """Get version from ID. Args: @@ -35,7 +37,7 @@ def _get_version_from_id(_id: str) -> int: return int(match[1]) if match else None -class Representation: +class Representation(Generic[T]): """Representation of products. Representation defines collection of individual properties that describe @@ -121,15 +123,15 @@ class Representation: """Return the representation name.""" return self.name - def items(self) -> dict[str, T]: + def items(self) -> ItemsView[str, T]: """Return the traits as items.""" - return self._data.items() + return ItemsView(self._data) - def add_trait(self, trait: TraitBase, *, exists_ok: bool=False) -> None: + def add_trait(self, trait: T, *, exists_ok: bool=False) -> None: """Add a trait to the Representation. Args: - trait (TraitBase): Trait to add. + trait (TraiBase): Trait to add. exists_ok (bool, optional): If True, do not raise an error if the trait already exists. Defaults to False. @@ -147,7 +149,7 @@ class Representation: self._data[trait.id] = trait def add_traits( - self, traits: list[TraitBase], *, exists_ok: bool=False) -> None: + self, traits: list[T], *, exists_ok: bool=False) -> None: """Add a list of traits to the Representation. Args: @@ -170,7 +172,7 @@ class Representation: """ try: - self._data.pop(trait.id) + self._data.pop(str(trait.id)) except KeyError as e: error_msg = f"Trait with ID {trait.id} not found." raise ValueError(error_msg) from e @@ -191,7 +193,7 @@ class Representation: error_msg = f"Trait with ID {trait_id} not found." raise ValueError(error_msg) from e - def remove_traits(self, traits: list[Type[TraitBase]]) -> None: + def remove_traits(self, traits: list[Type[T]]) -> None: """Remove a list of traits from the Representation. If no trait IDs or traits are provided, all traits will be removed. @@ -229,7 +231,7 @@ class Representation: """ return bool(self._data) - def contains_trait(self, trait: Type[TraitBase]) -> bool: + def contains_trait(self, trait: Type[T]) -> bool: """Check if the trait exists in the Representation. Args: @@ -239,7 +241,7 @@ class Representation: bool: True if the trait exists, False otherwise. """ - return bool(self._data.get(trait.id)) + return bool(self._data.get(str(trait.id))) def contains_trait_by_id(self, trait_id: str) -> bool: """Check if the trait exists using trait id. @@ -253,7 +255,7 @@ class Representation: """ return bool(self._data.get(trait_id)) - def contains_traits(self, traits: list[Type[TraitBase]]) -> bool: + def contains_traits(self, traits: list[Type[T]]) -> bool: """Check if the traits exist. Args: @@ -282,7 +284,7 @@ class Representation: self.contains_trait_by_id(trait_id) for trait_id in trait_ids ) - def get_trait(self, trait: Type[T]) -> Union[T]: + def get_trait(self, trait: Type[T]) -> T: """Get a trait from the representation. Args: @@ -296,12 +298,12 @@ class Representation: """ try: - return self._data[trait.id] + return self._data[str(trait.id)] except KeyError as e: msg = f"Trait with ID {trait.id} not found." raise MissingTraitError(msg) from e - def get_trait_by_id(self, trait_id: str) -> Union[T]: + def get_trait_by_id(self, trait_id: str) -> T: # sourcery skip: use-named-expression """Get a trait from the representation by id. @@ -337,7 +339,7 @@ class Representation: return result def get_traits(self, - traits: Optional[list[Type[TraitBase]]]=None + traits: Optional[list[Type[T]]]=None ) -> dict[str, T]: """Get a list of traits from the representation. @@ -350,14 +352,14 @@ class Representation: dict: Dictionary of traits. """ - result = {} + result: dict[str, T] = {} if not traits: for trait_id in self._data: result[trait_id] = self.get_trait_by_id(trait_id=trait_id) return result for trait in traits: - result[trait.id] = self.get_trait(trait=trait) + result[str(trait.id)] = self.get_trait(trait=trait) return result def get_traits_by_ids(self, trait_ids: list[str]) -> dict[str, T]: @@ -398,7 +400,7 @@ class Representation: self, name: str, representation_id: Optional[str]=None, - traits: Optional[list[TraitBase]]=None): + traits: Optional[list[T]]=None): """Initialize the data. Args: @@ -465,14 +467,14 @@ class Representation: @lru_cache(maxsize=64) def _get_possible_trait_classes_from_modules( cls, - trait_id: str) -> set[type[TraitBase]]: + trait_id: str) -> set[type[T]]: """Get possible trait classes from modules. Args: trait_id (str): Trait ID. Returns: - set[type[TraitBase]]: Set of trait classes. + set[type[T]]: Set of trait classes. """ modules = sys.modules.copy() @@ -501,12 +503,12 @@ class Representation: if issubclass(klass, TraitBase) \ and str(klass.id).startswith(trait_id): trait_candidates.add(klass) - return trait_candidates + return trait_candidates # type: ignore @classmethod @lru_cache(maxsize=64) def _get_trait_class( - cls, trait_id: str) -> Union[Type[TraitBase], None]: + cls, trait_id: str) -> Union[Type[T], None]: """Get the trait class with corresponding to given ID. This method will search for the trait class in all the modules except @@ -533,6 +535,8 @@ class Representation: trait_candidates = cls._get_possible_trait_classes_from_modules( trait_id ) + if not trait_candidates: + return None for trait_class in trait_candidates: if trait_class.id == trait_id: @@ -549,11 +553,11 @@ class Representation: rf"{trait_id}.v(\d+)$", str(trait_class.id)) ] if trait_versions: - def _get_version_by_id(trait_klass: Type[TraitBase]) -> int: + def _get_version_by_id(trait_klass: Type[T]) -> int: match = re.search(r"v(\d+)$", str(trait_klass.id)) return int(match[1]) if match else 0 - error = LooseMatchingTraitError( + error: LooseMatchingTraitError = LooseMatchingTraitError( "Found trait that might match.") error.found_trait = max( trait_versions, key=_get_version_by_id) @@ -563,7 +567,7 @@ class Representation: return None @classmethod - def get_trait_class_by_trait_id(cls, trait_id: str) -> type[TraitBase]: + def get_trait_class_by_trait_id(cls, trait_id: str) -> Type[T]: """Get the trait class for the given trait ID. Args: @@ -580,15 +584,28 @@ class Representation: ValueError: If the trait model with the given ID is not found. """ - trait_class = None try: trait_class = cls._get_trait_class(trait_id=trait_id) except LooseMatchingTraitError as e: requested_version = _get_version_from_id(trait_id) found_version = _get_version_from_id(e.found_trait.id) + if found_version is None and not requested_version: + msg = ( + "Trait found with no version and requested version " + "is not specified." + ) + raise IncompatibleTraitVersionError(msg) from e - if not requested_version: + if requested_version is None: trait_class = e.found_trait + requested_version = found_version + + if found_version is None: + msg = ( + f"Trait {e.found_trait.id} found with no version, " + "but requested version is specified." + ) + raise IncompatibleTraitVersionError(msg) from e else: if requested_version > found_version: @@ -605,7 +622,7 @@ class Representation: f"{requested_version} is lower " f"than the found trait version {found_version}." ) - error = UpgradableTraitError(error_msg) + error: UpgradableTraitError = UpgradableTraitError(error_msg) error.trait = e.found_trait raise error from e return trait_class @@ -638,6 +655,8 @@ class Representation: Representation: Representation instance. """ + if not trait_data: + trait_data = {} traits = [] for trait_id, value in trait_data.items(): if not isinstance(value, dict): @@ -671,14 +690,21 @@ class Representation: name=name, representation_id=representation_id, traits=traits) - def validate(self) -> bool: + def validate(self) -> None: """Validate the representation. This method will validate all the traits in the representation. - Returns: - bool: True if the representation is valid, False otherwise. + Raises: + TraitValidationError: If the trait is invalid within representation """ + errors = [] for trait in self._data.values(): - trait.validate(self) + try: + trait.validate_trait(self) + except TraitValidationError as e: + errors.append(str(e)) + if errors: + raise TraitValidationError( + f"representation {self.name}", "\n".join(errors)) diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 7687a023da..46771b78fd 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -3,17 +3,19 @@ from __future__ import annotations import contextlib from enum import Enum, auto -from typing import TYPE_CHECKING, ClassVar, Optional +from typing import TYPE_CHECKING, ClassVar, Optional, Union import clique from pydantic import Field, field_validator +import re +from re import Pattern from .trait import MissingTraitError, TraitBase, TraitValidationError if TYPE_CHECKING: - import re - from pathlib import Path + + from pathlib import Path from .content import FileLocations from .representation import Representation @@ -72,10 +74,10 @@ class FrameRanged(TraitBase): ..., title="Start Frame") frame_end: int = Field( ..., title="Frame Start") - frame_in: Optional[int] = Field(None, title="In Frame") - frame_out: Optional[int] = Field(None, title="Out Frame") + frame_in: Optional[int] = Field(default=None, title="In Frame") + frame_out: Optional[int] = Field(default=None, title="Out Frame") frames_per_second: str = Field(..., title="Frames Per Second") - step: Optional[int] = Field(1, title="Step") + step: Optional[int] = Field(default=1, title="Step") class Handles(TraitBase): @@ -127,24 +129,28 @@ class Sequence(TraitBase): name: ClassVar[str] = "Sequence" description: ClassVar[str] = "Sequence Trait Model" id: ClassVar[str] = "ayon.time.Sequence.v1" - gaps_policy: GapPolicy = Field( - GapPolicy.forbidden, title="Gaps Policy") + gaps_policy: Optional[GapPolicy] = Field( + default=GapPolicy.forbidden, title="Gaps Policy") frame_padding: int = Field(..., title="Frame Padding") - frame_regex: Optional[str] = Field(None, title="Frame Regex") - frame_spec: Optional[str] = Field(None, title="Frame Specification") + frame_regex: Optional[Union[Pattern, str]] = Field(default=None, title="Frame Regex") + frame_spec: Optional[str] = Field(default=None, title="Frame Specification") @field_validator("frame_regex") @classmethod - def validate_frame_regex(cls, v: Optional[str]) -> str: + def validate_frame_regex( + cls, v: Optional[Union[Pattern, str]]) -> Optional[Union[Pattern, str]]: """Validate frame regex.""" - if v and any(s not in v for s in ["?P", "?P"]): + _v = v + if v and isinstance(v, Pattern): + _v = v.pattern + if v and any(s not in _v for s in ["?P", "?P"]): msg = "Frame regex must include 'index' and `padding named groups" raise ValueError(msg) - return v + return _v - def validate(self, representation: Representation) -> None: + def validate_trait(self, representation: Representation) -> None: """Validate the trait.""" - super().validate(representation) + super().validate_trait(representation) # if there is FileLocations trait, run validation # on it as well @@ -189,13 +195,16 @@ class Sequence(TraitBase): FrameRanged) frame_start = frame_ranged.frame_start frame_end = frame_ranged.frame_end - self.validate_frame_list( - file_locs, - frame_start, - frame_end, - handles_frame_start, - handles_frame_end) + if self.frame_spec is not None: + self.validate_frame_list( + file_locs, + frame_start, + frame_end, + handles_frame_start, + handles_frame_end) + self.validate_frame_padding(file_locs) + def validate_frame_list( self, @@ -230,8 +239,18 @@ class Sequence(TraitBase): if self.frame_spec is None: return - frames: list[int] = self.get_frame_list( - file_locations, self.frame_regex) + frames: list[int] = [] + if self.frame_regex: + if isinstance(self.frame_regex, str): + frame_regex = re.compile(self.frame_regex) + elif isinstance(self.frame_regex, Pattern): + frame_regex = self.frame_regex + + frames = self.get_frame_list( + file_locations, frame_regex) + else: + frames = self.get_frame_list( + file_locations) expected_frames = self.list_spec_to_frames(self.frame_spec) if frame_start is None or frame_end is None: @@ -307,20 +326,19 @@ class Sequence(TraitBase): frames.append(int(ranges[0])) continue start, end = segment.split("-") - start, end = int(start), int(end) - frames.extend(range(start, end + 1)) + frames.extend(range(int(start), int(end) + 1)) return frames @staticmethod def _get_collection( file_locations: FileLocations, - regex: Optional[re.Pattern] = None) -> clique.Collection: + regex: Optional[Pattern] = None) -> clique.Collection: r"""Get collection from file locations. Args: file_locations (FileLocations): File locations trait. - regex (Optional[re.Pattern]): Regular expression to match + regex (Optional[Pattern]): Regular expression to match frame numbers. This is passed to ``clique.assemble()``. Default clique pattern is:: @@ -334,7 +352,7 @@ class Sequence(TraitBase): """ patterns = None if not regex else [regex] - files: list[Path] = [ + files: list[str] = [ file.file_path.as_posix() for file in file_locations.file_paths ] @@ -351,18 +369,18 @@ class Sequence(TraitBase): def get_frame_padding(file_locations: FileLocations) -> int: """Get frame padding.""" src_collection = Sequence._get_collection(file_locations) - return src_collection.padding + return len(str(max(src_collection.indexes))) @staticmethod def get_frame_list( file_locations: FileLocations, - regex: Optional[re.Pattern] = None, + regex: Optional[Pattern] = None, ) -> list[int]: r"""Get frame list. Args: file_locations (FileLocations): File locations trait. - regex (Optional[re.Pattern]): Regular expression to match + regex (Optional[Pattern]): Regular expression to match frame numbers. This is passed to ``clique.assemble()``. Default clique pattern is:: @@ -373,6 +391,19 @@ class Sequence(TraitBase): """ src_collection = Sequence._get_collection(file_locations, regex) return list(src_collection.indexes) + + def get_frame_pattern(self) -> Pattern: + """Get frame regex as pattern. + + If the regex is string, it will compile it to the pattern. + + """ + if self.frame_regex: + if isinstance(self.frame_regex, str): + return re.compile(self.frame_regex) + return self.frame_regex + return re.compile( + r"\.(?P(?P0*)\d+)\.\D+\d?$") # Do we need one for drop and non-drop frame? class SMPTETimecode(TraitBase): diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index fd6e17f30d..3997451f44 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -3,7 +3,7 @@ from __future__ import annotations import re from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Generic, Optional, TypeVar import pydantic.alias_generators from pydantic import ( @@ -17,6 +17,9 @@ if TYPE_CHECKING: from .representation import Representation +T = TypeVar("T", bound="TraitBase") + + class TraitBase(ABC, BaseModel): """Base trait model. @@ -55,7 +58,7 @@ class TraitBase(ABC, BaseModel): """Abstract attribute for description.""" ... - def validate(self, representation: Representation) -> None: + def validate_trait(self, representation: Representation) -> None: """Validate the trait. This method should be implemented in the derived classes to validate @@ -96,10 +99,6 @@ class TraitBase(ABC, BaseModel): return re.sub(r"\.v\d+$", "", str(cls.id)) - - - - class IncompatibleTraitVersionError(Exception): """Incompatible trait version exception. @@ -108,7 +107,7 @@ class IncompatibleTraitVersionError(Exception): """ -class UpgradableTraitError(Exception): +class UpgradableTraitError(Generic[T], Exception): """Upgradable trait version exception. This exception is raised when the trait can upgrade existing data @@ -116,17 +115,17 @@ class UpgradableTraitError(Exception): method that will take old trait data as argument to handle the upgrade. """ - trait: TraitBase + trait: T old_data: dict -class LooseMatchingTraitError(Exception): +class LooseMatchingTraitError(Generic[T], Exception): """Loose matching trait exception. This exception is raised when the trait is found with a loose matching criteria. """ - found_trait: TraitBase + found_trait: T expected_id: str class TraitValidationError(Exception): diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py index a05748b710..9095061bf2 100644 --- a/client/ayon_core/pipeline/traits/two_dimensional.py +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -136,11 +136,11 @@ class UDIM(TraitBase): id: ClassVar[str] = "ayon.2d.UDIM.v1" udim: list[int] = Field(..., title="UDIM") udim_regex: Optional[str] = Field( - r"(?:\.|_)(?P\d+)\.\D+\d?$", title="UDIM Regex") + default=r"(?:\.|_)(?P\d+)\.\D+\d?$", title="UDIM Regex") @field_validator("udim_regex") @classmethod - def validate_frame_regex(cls, v: Optional[str]) -> str: + def validate_frame_regex(cls, v: Optional[str]) -> Optional[str]: """Validate udim regex.""" if v is not None and "?P" not in v: msg = "UDIM regex must include 'udim' named group" diff --git a/poetry.lock b/poetry.lock index 3a188dcb4b..f07d50bd82 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "annotated-types" @@ -24,32 +24,32 @@ files = [ [[package]] name = "attrs" -version = "24.2.0" +version = "24.3.0" description = "Classes Without Boilerplate" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, - {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, + {file = "attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308"}, + {file = "attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff"}, ] [package.extras] benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] [[package]] name = "ayon-python-api" -version = "1.0.9" +version = "1.0.11" description = "AYON Python API" optional = false python-versions = "*" files = [ - {file = "ayon-python-api-1.0.9.tar.gz", hash = "sha256:d1a3d467bdcb5a27120fed59d8996e97b6ef4d56a570b5957df922fc5ef58074"}, - {file = "ayon_python_api-1.0.9-py3-none-any.whl", hash = "sha256:0e4d623befe24bfb4c0c58746f49cbfe182d48ab13cd743177d1af3702e0de43"}, + {file = "ayon-python-api-1.0.11.tar.gz", hash = "sha256:3497237c379979268c321304c7d19cb2844d699f6ac34c21293f5dac29c13531"}, + {file = "ayon_python_api-1.0.11-py3-none-any.whl", hash = "sha256:f6134c29c8c8a36cdb2882f9404dd43817bd6636d663b3cd2344de9f1fc1e4b2"}, ] [package.dependencies] @@ -59,13 +59,13 @@ Unidecode = ">=1.3.0" [[package]] name = "certifi" -version = "2024.8.30" +version = "2024.12.14" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, + {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, + {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, ] [[package]] @@ -280,13 +280,13 @@ typing = ["typing-extensions (>=4.12.2)"] [[package]] name = "identify" -version = "2.6.1" +version = "2.6.3" description = "File identification library for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"}, - {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"}, + {file = "identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd"}, + {file = "identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02"}, ] [package.extras] @@ -317,6 +317,70 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "mypy" +version = "1.14.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e971c1c667007f9f2b397ffa80fa8e1e0adccff336e5e77e74cb5f22868bee87"}, + {file = "mypy-1.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e86aaeaa3221a278c66d3d673b297232947d873773d61ca3ee0e28b2ff027179"}, + {file = "mypy-1.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1628c5c3ce823d296e41e2984ff88c5861499041cb416a8809615d0c1f41740e"}, + {file = "mypy-1.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fadb29b77fc14a0dd81304ed73c828c3e5cde0016c7e668a86a3e0dfc9f3af3"}, + {file = "mypy-1.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:3fa76988dc760da377c1e5069200a50d9eaaccf34f4ea18428a3337034ab5a44"}, + {file = "mypy-1.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6e73c8a154eed31db3445fe28f63ad2d97b674b911c00191416cf7f6459fd49a"}, + {file = "mypy-1.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:273e70fcb2e38c5405a188425aa60b984ffdcef65d6c746ea5813024b68c73dc"}, + {file = "mypy-1.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1daca283d732943731a6a9f20fdbcaa927f160bc51602b1d4ef880a6fb252015"}, + {file = "mypy-1.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7e68047bedb04c1c25bba9901ea46ff60d5eaac2d71b1f2161f33107e2b368eb"}, + {file = "mypy-1.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:7a52f26b9c9b1664a60d87675f3bae00b5c7f2806e0c2800545a32c325920bcc"}, + {file = "mypy-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d5326ab70a6db8e856d59ad4cb72741124950cbbf32e7b70e30166ba7bbf61dd"}, + {file = "mypy-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf4ec4980bec1e0e24e5075f449d014011527ae0055884c7e3abc6a99cd2c7f1"}, + {file = "mypy-1.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:390dfb898239c25289495500f12fa73aa7f24a4c6d90ccdc165762462b998d63"}, + {file = "mypy-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e026d55ddcd76e29e87865c08cbe2d0104e2b3153a523c529de584759379d3d"}, + {file = "mypy-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:585ed36031d0b3ee362e5107ef449a8b5dfd4e9c90ccbe36414ee405ee6b32ba"}, + {file = "mypy-1.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9f6f4c0b27401d14c483c622bc5105eff3911634d576bbdf6695b9a7c1ba741"}, + {file = "mypy-1.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b2280cedcb312c7a79f5001ae5325582d0d339bce684e4a529069d0e7ca1e7"}, + {file = "mypy-1.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:342de51c48bab326bfc77ce056ba08c076d82ce4f5a86621f972ed39970f94d8"}, + {file = "mypy-1.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00df23b42e533e02a6f0055e54de9a6ed491cd8b7ea738647364fd3a39ea7efc"}, + {file = "mypy-1.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8c8387e5d9dff80e7daf961df357c80e694e942d9755f3ad77d69b0957b8e3f"}, + {file = "mypy-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b16738b1d80ec4334654e89e798eb705ac0c36c8a5c4798496cd3623aa02286"}, + {file = "mypy-1.14.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10065fcebb7c66df04b05fc799a854b1ae24d9963c8bb27e9064a9bdb43aa8ad"}, + {file = "mypy-1.14.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbb7d683fa6bdecaa106e8368aa973ecc0ddb79a9eaeb4b821591ecd07e9e03c"}, + {file = "mypy-1.14.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3498cb55448dc5533e438cd13d6ddd28654559c8c4d1fd4b5ca57a31b81bac01"}, + {file = "mypy-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:c7b243408ea43755f3a21a0a08e5c5ae30eddb4c58a80f415ca6b118816e60aa"}, + {file = "mypy-1.14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:14117b9da3305b39860d0aa34b8f1ff74d209a368829a584eb77524389a9c13e"}, + {file = "mypy-1.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af98c5a958f9c37404bd4eef2f920b94874507e146ed6ee559f185b8809c44cc"}, + {file = "mypy-1.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b343a1d3989547024377c2ba0dca9c74a2428ad6ed24283c213af8dbb0710b"}, + {file = "mypy-1.14.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cdb5563c1726c85fb201be383168f8c866032db95e1095600806625b3a648cb7"}, + {file = "mypy-1.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:74e925649c1ee0a79aa7448baf2668d81cc287dc5782cff6a04ee93f40fb8d3f"}, + {file = "mypy-1.14.0-py3-none-any.whl", hash = "sha256:2238d7f93fc4027ed1efc944507683df3ba406445a2b6c96e79666a045aadfab"}, + {file = "mypy-1.14.0.tar.gz", hash = "sha256:822dbd184d4a9804df5a7d5335a68cf7662930e70b8c1bc976645d1509f9a9d6"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -330,13 +394,13 @@ files = [ [[package]] name = "packaging" -version = "24.1" +version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] @@ -401,19 +465,19 @@ files = [ [[package]] name = "pydantic" -version = "2.9.2" +version = "2.10.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, - {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, + {file = "pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d"}, + {file = "pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.23.4" -typing-extensions = {version = ">=4.6.1", markers = "python_version < \"3.13\""} +pydantic-core = "2.27.2" +typing-extensions = ">=4.12.2" [package.extras] email = ["email-validator (>=2.0.0)"] @@ -421,100 +485,111 @@ timezone = ["tzdata"] [[package]] name = "pydantic-core" -version = "2.23.4" +version = "2.27.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, - {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, - {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, - {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, - {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, - {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, - {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, - {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, - {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, - {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, - {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, - {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, - {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, - {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, - {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, - {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, - {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, - {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, - {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, - {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, - {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, - {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, - {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, - {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, - {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, - {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, - {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, - {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, - {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, - {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, - {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, - {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, - {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"}, + {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"}, ] [package.dependencies] @@ -522,13 +597,13 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pytest" -version = "8.3.3" +version = "8.3.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, - {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, + {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, + {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, ] [package.dependencies] @@ -681,13 +756,43 @@ files = [ [[package]] name = "tomli" -version = "2.0.2" +version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, - {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] [[package]] @@ -731,13 +836,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.26.6" +version = "20.28.0" description = "Virtual Python Environment builder" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, - {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, + {file = "virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0"}, + {file = "virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa"}, ] [package.dependencies] @@ -752,4 +857,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.9.1,<3.10" -content-hash = "cf5603d09b7f2409e56e0ceb13386813204a92cfbabbd23bcd8a2fad0db3aed4" +content-hash = "5fb5a45697502e537b9f6cf618d744a4cece4803ef65f6315186e83d1cd90f3a" diff --git a/pyproject.toml b/pyproject.toml index ed7fa6cc55..de9a56a38b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ ruff = "^0.3.3" pre-commit = "^4" codespell = "^2.2.6" semver = "^3.0.2" +mypy = "^1.14.0" [tool.ruff] @@ -133,6 +134,9 @@ skip = "./.*,./package/*,*/vendor/*,*/unreal/integration/*,*/aftereffects/api/ex count = true quiet-level = 3 +[tool.mypy] +mypy_path = "$MYPY_CONFIG_FILE_DIR/client" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/tests/client/ayon_core/pipeline/traits/test_content_traits.py b/tests/client/ayon_core/pipeline/traits/test_content_traits.py index d6f379a9c7..78b905bab4 100644 --- a/tests/client/ayon_core/pipeline/traits/test_content_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_content_traits.py @@ -58,7 +58,8 @@ def test_bundles() -> None: for item in representation.get_trait(trait=Bundle).items: sub_representation = Representation(name="test", traits=item) assert sub_representation.contains_trait(trait=Image) - assert sub_representation.get_trait(trait=MimeType).mime_type in [ + sub: MimeType = sub_representation.get_trait(trait=MimeType) + assert sub.mime_type in [ "image/jpeg", "image/tiff" ] @@ -83,7 +84,7 @@ def test_file_locations_validation() -> None: file_paths=file_locations_list) # this should be valid trait - file_locations_trait.validate(representation) + file_locations_trait.validate_trait(representation) # add valid FrameRanged trait frameranged_trait = FrameRanged( @@ -94,7 +95,7 @@ def test_file_locations_validation() -> None: representation.add_trait(frameranged_trait) # it should still validate fine - file_locations_trait.validate(representation) + file_locations_trait.validate_trait(representation) # create empty file locations trait empty_file_locations_trait = FileLocations(file_paths=[]) @@ -102,7 +103,7 @@ def test_file_locations_validation() -> None: empty_file_locations_trait ]) with pytest.raises(TraitValidationError): - empty_file_locations_trait.validate(representation) + empty_file_locations_trait.validate_trait(representation) # create valid file locations trait but with not matching # frame range trait @@ -118,7 +119,7 @@ def test_file_locations_validation() -> None: representation.add_trait(invalid_sequence_trait) with pytest.raises(TraitValidationError): - file_locations_trait.validate(representation) + file_locations_trait.validate_trait(representation) # invalid representation with mutliple file locations but # unrelated to either Sequence or Bundle traits @@ -164,7 +165,7 @@ def test_get_file_location_from_frame() -> None: # test with custom regex sequence = Sequence( frame_padding=4, - frame_regex=r"boo_(?P\d+)\.exr") + frame_regex=r"boo_(?P(?P0*)\d+)\.exr") file_locations_list = [ FileLocation( file_path=Path(f"/path/to/boo_{frame}.exr"), @@ -174,7 +175,7 @@ def test_get_file_location_from_frame() -> None: for frame in range(1001, 1051) ] - file_locations_trait: FileLocations = FileLocations( + file_locations_trait = FileLocations( file_paths=file_locations_list) assert file_locations_trait.get_file_location_for_frame( diff --git a/tests/client/ayon_core/pipeline/traits/test_time_traits.py b/tests/client/ayon_core/pipeline/traits/test_time_traits.py index 900c4e5842..c716266fd7 100644 --- a/tests/client/ayon_core/pipeline/traits/test_time_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_time_traits.py @@ -15,6 +15,7 @@ from ayon_core.pipeline.traits import ( from ayon_core.pipeline.traits.trait import TraitValidationError + def test_sequence_validations() -> None: """Test Sequence trait validation.""" file_locations_list = [ @@ -54,7 +55,7 @@ def test_sequence_validations() -> None: frame_spec="1001-1010,1015-1020,1100") ]) - representation.get_trait(Sequence).validate(representation) + representation.get_trait(Sequence).validate_trait(representation) # here we set handles and set them as inclusive, so this should pass representation = Representation(name="test_2", traits=[ From a4dbb31e152d82348a35ea38093b7101e019c61b Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Sun, 22 Dec 2024 01:14:09 +0100 Subject: [PATCH 087/781] :bug: fix mypy errors --- tests/client/ayon_core/pipeline/traits/test_traits.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index a1e5a4d219..42933056fa 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -16,7 +16,7 @@ from ayon_core.pipeline.traits import ( TraitBase, ) -REPRESENTATION_DATA = { +REPRESENTATION_DATA: dict = { FileLocation.id: { "file_path": Path("/path/to/file"), "file_size": 1024, @@ -127,7 +127,8 @@ def test_representation_traits(representation: Representation) -> None: repre_dict assert representation.has_traits() is True - empty_representation = Representation(name="test", traits=[]) + empty_representation: Representation = Representation( + name="test", traits=[]) assert empty_representation.has_traits() is False assert representation.contains_trait(trait=FileLocation) is True From e44901e53e4219961ef082df7c1dfa6986e84b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 22 Jan 2025 11:00:06 +0100 Subject: [PATCH 088/781] :sparkles: add SourceApplication trait --- client/ayon_core/pipeline/traits/README.md | 1 + client/ayon_core/pipeline/traits/meta.py | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md index f9d7be5de1..447fb427c6 100644 --- a/client/ayon_core/pipeline/traits/README.md +++ b/client/ayon_core/pipeline/traits/README.md @@ -165,6 +165,7 @@ to different packages based on their use: | | Variant | Used to differentiate between data variants of the same output (mp4 as h.264 and h.265 for example) | | KeepOriginalLocation | Marks the representation to keep the original location of the file | | KeepOriginalName | Marks the representation to keep the original name of the file +| | SourceApplication | Holds information about producing application, about it's version, variant and platform. | three dimensional | Spatial | Spatial information like up-axis, units and handedness. | | Geometry | Type trait to mark the representation as a geometry. | | Shader | Type trait to mark the representation as a Shader. diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py index 5cf5f54c36..2dc8ea5a27 100644 --- a/client/ayon_core/pipeline/traits/meta.py +++ b/client/ayon_core/pipeline/traits/meta.py @@ -78,7 +78,7 @@ class Variant(TraitBase): class KeepOriginalLocation(TraitBase): """Keep files in its original location. - + Note: This is not a persistent trait. @@ -86,11 +86,11 @@ class KeepOriginalLocation(TraitBase): name: ClassVar[str] = "KeepOriginalLocation" description: ClassVar[str] = "Keep Original Location Trait Model" id: ClassVar[str] = "ayon.meta.KeepOriginalLocation.v1" - persistent: bool = Field(False, title="Persistent") + persistent: bool = Field(default=False, title="Persistent") class KeepOriginalName(TraitBase): """Keep files in its original name. - + Note: This is not a persistent trait. @@ -98,4 +98,16 @@ class KeepOriginalName(TraitBase): name: ClassVar[str] = "KeepOriginalName" description: ClassVar[str] = "Keep Original Name Trait Model" id: ClassVar[str] = "ayon.meta.KeepOriginalName.v1" - persistent: bool = Field(False, title="Persistent") + persistent: bool = Field(default=False, title="Persistent") + + +class SourceApplication(TraitBase): + """Metadata about the source (producing) application.""" + + name: ClassVar[str] = "SourceApplication" + description: ClassVar[str] = "Source Application Trait Model" + id: ClassVar[str] = "ayon.meta.SourceApplication.v1" + application: str = Field(..., title="Application Name") + variant: str = Field(..., title="Application Variant (e.g. Pro)") + version: str = Field(..., title="Application Version") + platform: str = Field(..., title="Platform Name") From 6763c37fb68c6c9bb7a3c01715b9c8e341f5e25e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 29 Jan 2025 14:16:21 +0100 Subject: [PATCH 089/781] :recycle: fix some style issues --- client/ayon_core/pipeline/traits/content.py | 8 ++- .../pipeline/traits/representation.py | 55 +++++++++++-------- .../pipeline/traits/three_dimensional.py | 3 + client/ayon_core/pipeline/traits/time.py | 10 ++-- 4 files changed, 45 insertions(+), 31 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index b89537a35a..42893fc86e 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -5,7 +5,7 @@ import contextlib import re # TC003 is there because Path in TYPECHECKING will fail in tests -from pathlib import Path # noqa: TC003 +from pathlib import Path # noqa: TCH003 from typing import ClassVar, Generator, Optional from pydantic import Field @@ -43,6 +43,7 @@ class MimeType(TraitBase): id: ClassVar[str] = "ayon.content.MimeType.v1" mime_type: str = Field(..., title="Mime Type") + class LocatableContent(TraitBase): """LocatableContent trait model. @@ -66,6 +67,7 @@ class LocatableContent(TraitBase): location: str = Field(..., title="Location") is_templated: Optional[bool] = Field(default=None, title="Is Templated") + class FileLocation(TraitBase): """FileLocation trait model. @@ -89,6 +91,7 @@ class FileLocation(TraitBase): file_size: Optional[int] = Field(default=None, title="File Size") file_hash: Optional[str] = Field(default=None, title="File Hash") + class FileLocations(TraitBase): """FileLocation trait model. @@ -114,7 +117,7 @@ class FileLocations(TraitBase): This method will return all file paths from the trait. - Yeilds: + Yields: Path: List of file paths. """ @@ -432,7 +435,6 @@ class Bundle(TraitBase): """Convert bundle to representations.""" for idx, item in enumerate(self.items): yield Representation(name=f"{self.name} {idx}", traits=item) - class Fragment(TraitBase): diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py index 498690dafd..f47a9a9607 100644 --- a/client/ayon_core/pipeline/traits/representation.py +++ b/client/ayon_core/pipeline/traits/representation.py @@ -8,18 +8,25 @@ import sys import uuid from functools import lru_cache from types import GenericAlias -from typing import ClassVar, Generic, ItemsView, Optional, Type, TypeVar, Union +from typing import ( + ClassVar, + Generic, + ItemsView, + Optional, + Type, + TypeVar, + Union, +) from .trait import ( IncompatibleTraitVersionError, LooseMatchingTraitError, MissingTraitError, TraitBase, - UpgradableTraitError, TraitValidationError, + UpgradableTraitError, ) - T = TypeVar("T", bound="TraitBase") @@ -333,14 +340,14 @@ class Representation(Generic[T]): ), None, ) - if not result: + if result is None: msg = f"Trait with ID {trait_id} not found." raise MissingTraitError(msg) return result def get_traits(self, traits: Optional[list[Type[T]]]=None - ) -> dict[str, T]: + ) -> dict[str, T]: """Get a list of traits from the representation. If no trait IDs or traits are provided, all traits will be returned. @@ -407,6 +414,7 @@ class Representation(Generic[T]): name (str): Representation name. Must be unique within instance. representation_id (str, optional): Representation ID. traits (list[TraitBase], optional): List of traits. + """ self.name = name self.representation_id = representation_id or uuid.uuid4().hex @@ -607,24 +615,23 @@ class Representation(Generic[T]): ) raise IncompatibleTraitVersionError(msg) from e - else: - if requested_version > found_version: - error_msg = ( - f"Requested trait version {requested_version} is " - f"higher than the found trait version {found_version}." - ) - raise IncompatibleTraitVersionError(error_msg) from e + if requested_version > found_version: + error_msg = ( + f"Requested trait version {requested_version} is " + f"higher than the found trait version {found_version}." + ) + raise IncompatibleTraitVersionError(error_msg) from e - if requested_version < found_version and hasattr( - e.found_trait, "upgrade"): - error_msg = ( - "Requested trait version " - f"{requested_version} is lower " - f"than the found trait version {found_version}." - ) - error: UpgradableTraitError = UpgradableTraitError(error_msg) - error.trait = e.found_trait - raise error from e + if requested_version < found_version and hasattr( + e.found_trait, "upgrade"): + error_msg = ( + "Requested trait version " + f"{requested_version} is lower " + f"than the found trait version {found_version}." + ) + error: UpgradableTraitError = UpgradableTraitError(error_msg) + error.trait = e.found_trait + raise error from e return trait_class @classmethod @@ -706,5 +713,5 @@ class Representation(Generic[T]): except TraitValidationError as e: errors.append(str(e)) if errors: - raise TraitValidationError( - f"representation {self.name}", "\n".join(errors)) + msg = f"representation {self.name}", "\n".join(errors) + raise TraitValidationError(msg) diff --git a/client/ayon_core/pipeline/traits/three_dimensional.py b/client/ayon_core/pipeline/traits/three_dimensional.py index eb27797ed2..e9eb0246c1 100644 --- a/client/ayon_core/pipeline/traits/three_dimensional.py +++ b/client/ayon_core/pipeline/traits/three_dimensional.py @@ -46,6 +46,7 @@ class Geometry(TraitBase): name: ClassVar[str] = "Geometry" description: ClassVar[str] = "Geometry trait model." + class Shader(TraitBase): """Shader trait model. @@ -58,6 +59,7 @@ class Shader(TraitBase): name: ClassVar[str] = "Shader" description: ClassVar[str] = "Shader trait model." + class Lighting(TraitBase): """Lighting trait model. @@ -70,6 +72,7 @@ class Lighting(TraitBase): name: ClassVar[str] = "Lighting" description: ClassVar[str] = "Lighting trait model." + class IESProfile(TraitBase): """IES profile (IES-LM-64) type trait model. diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/time.py index 46771b78fd..7f16013927 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/time.py @@ -2,13 +2,13 @@ from __future__ import annotations import contextlib +import re from enum import Enum, auto +from re import Pattern from typing import TYPE_CHECKING, ClassVar, Optional, Union import clique from pydantic import Field, field_validator -import re -from re import Pattern from .trait import MissingTraitError, TraitBase, TraitValidationError @@ -16,6 +16,7 @@ if TYPE_CHECKING: from pathlib import Path + from .content import FileLocations from .representation import Representation @@ -391,10 +392,10 @@ class Sequence(TraitBase): """ src_collection = Sequence._get_collection(file_locations, regex) return list(src_collection.indexes) - + def get_frame_pattern(self) -> Pattern: """Get frame regex as pattern. - + If the regex is string, it will compile it to the pattern. """ @@ -405,6 +406,7 @@ class Sequence(TraitBase): return re.compile( r"\.(?P(?P0*)\d+)\.\D+\d?$") + # Do we need one for drop and non-drop frame? class SMPTETimecode(TraitBase): """SMPTE Timecode trait model.""" From 920f917f943a50fca8c8b9a02d4b2e479d6d19ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 29 Jan 2025 22:22:05 +0100 Subject: [PATCH 090/781] :recycle: rename time to temporal to avoid conflict with std and some linter changes --- client/ayon_core/addon/__init__.py | 27 +++++++++---------- client/ayon_core/addon/interfaces.py | 23 +++++++++------- client/ayon_core/pipeline/traits/__init__.py | 2 +- client/ayon_core/pipeline/traits/content.py | 2 +- .../pipeline/traits/representation.py | 17 +++++++----- .../pipeline/traits/{time.py => temporal.py} | 7 +++-- client/ayon_core/pipeline/traits/utils.py | 2 +- pyproject.toml | 4 +-- 8 files changed, 44 insertions(+), 40 deletions(-) rename client/ayon_core/pipeline/traits/{time.py => temporal.py} (99%) diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index fd25dcb9d9..f78cea0b54 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -1,28 +1,25 @@ -# -*- coding: utf-8 -*- +"""Addons for AYON.""" from . import click_wrap -from .interfaces import ( - IPluginPaths, - ITrayAddon, - ITrayAction, - ITrayService, - IHostAddon, - ITraits, -) - from .base import ( - ProcessPreparationError, - ProcessContext, - AYONAddon, AddonsManager, + AYONAddon, + ProcessContext, + ProcessPreparationError, load_addons, ) - +from .interfaces import ( + IHostAddon, + IPluginPaths, + ITraits, + ITrayAction, + ITrayAddon, + ITrayService, +) from .utils import ( ensure_addons_are_process_context_ready, ensure_addons_are_process_ready, ) - __all__ = ( "click_wrap", diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index ccfd42d246..1070828d5c 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -1,3 +1,4 @@ +"""Addon interfaces for AYON.""" from __future__ import annotations import logging @@ -17,11 +18,11 @@ if TYPE_CHECKING: class _AYONInterfaceMeta(ABCMeta): """AYONInterface metaclass to print proper string.""" - def __str__(self): - return f"<'AYONInterface.{self.__name__}'>" + def __str__(cls): + return f"<'AYONInterface.{cls.__name__}'>" - def __repr__(self): - return str(self) + def __repr__(cls): + return str(cls) class AYONInterface(metaclass=_AYONInterfaceMeta): @@ -84,7 +85,7 @@ class IPluginPaths(AYONInterface): paths = [paths] return paths - def get_launcher_action_paths(self): + def get_launcher_action_paths(self) -> list[str]: """Receive launcher actions paths. Give addons ability to add launcher actions paths. @@ -208,7 +209,8 @@ class ITrayAddon(AYONInterface): """ if not self.tray_initialized: - # TODO: Called without initialized tray, still main thread needed + # TODO (Illicit): Called without initialized tray, still + # main thread needed. try: callback() @@ -245,7 +247,8 @@ class ITrayAddon(AYONInterface): self.manager.add_doubleclick_callback(self, callback) @staticmethod - def admin_submenu(tray_menu): + def admin_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu: + """Get or create admin submenu.""" if ITrayAddon._admin_submenu is None: from qtpy import QtWidgets @@ -255,7 +258,9 @@ class ITrayAddon(AYONInterface): return ITrayAddon._admin_submenu @staticmethod - def add_action_to_admin_submenu(label, tray_menu): + def add_action_to_admin_submenu( + label: str, tray_menu: QtWidgets.QMenu) -> QtWidgets.QAction: + """Add action to admin submenu.""" from qtpy import QtWidgets menu = ITrayAddon.admin_submenu(tray_menu) @@ -330,7 +335,7 @@ class ITrayService(ITrayAddon): """Service label showed in menu.""" raise NotImplementedError - # TODO be able to get any sort of information to show/print + # TODO (Illicit): be able to get any sort of information to show/print # @abstractmethod # def get_service_info(self): # pass diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index e3ca610df1..6603990b53 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -15,7 +15,7 @@ from .lifecycle import Persistent, Transient from .meta import Tagged, TemplatePath, Variant from .representation import Representation from .three_dimensional import Geometry, IESProfile, Lighting, Shader, Spatial -from .time import ( +from .temporal import ( FrameRanged, GapPolicy, Handles, diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 42893fc86e..16c3b47920 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -11,7 +11,7 @@ from typing import ClassVar, Generator, Optional from pydantic import Field from .representation import Representation -from .time import FrameRanged, Handles, Sequence +from .temporal import FrameRanged, Handles, Sequence from .trait import ( MissingTraitError, TraitBase, diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py index f47a9a9607..8015d75d74 100644 --- a/client/ayon_core/pipeline/traits/representation.py +++ b/client/ayon_core/pipeline/traits/representation.py @@ -501,7 +501,7 @@ class Representation(Generic[T]): klass = getattr(module, attr_name) if not inspect.isclass(klass): continue - # this needs to be done because of the bug? in + # this needs to be done because of the bug? in # python ABCMeta, where ``issubclass`` is not working # if it hits the GenericAlias (that is in fact # tuple[int, int]). This is added to the scope by @@ -511,7 +511,8 @@ class Representation(Generic[T]): if issubclass(klass, TraitBase) \ and str(klass.id).startswith(trait_id): trait_candidates.add(klass) - return trait_candidates # type: ignore + # I + return trait_candidates # type: ignore[return-value] @classmethod @lru_cache(maxsize=64) @@ -632,11 +633,11 @@ class Representation(Generic[T]): error: UpgradableTraitError = UpgradableTraitError(error_msg) error.trait = e.found_trait raise error from e - return trait_class + return trait_class # type: ignore[return-value] @classmethod def from_dict( - cls, + cls: Type[Representation], name: str, representation_id: Optional[str]=None, trait_data: Optional[dict] = None) -> Representation: @@ -708,10 +709,12 @@ class Representation(Generic[T]): """ errors = [] for trait in self._data.values(): + # we do this in the loop to catch all the errors try: trait.validate_trait(self) - except TraitValidationError as e: + except TraitValidationError as e: # noqa: PERF203 errors.append(str(e)) if errors: - msg = f"representation {self.name}", "\n".join(errors) - raise TraitValidationError(msg) + msg = "\n".join(errors) + scope = self.name + raise TraitValidationError(scope, msg) diff --git a/client/ayon_core/pipeline/traits/time.py b/client/ayon_core/pipeline/traits/temporal.py similarity index 99% rename from client/ayon_core/pipeline/traits/time.py rename to client/ayon_core/pipeline/traits/temporal.py index 7f16013927..7c45eef9b9 100644 --- a/client/ayon_core/pipeline/traits/time.py +++ b/client/ayon_core/pipeline/traits/temporal.py @@ -13,7 +13,6 @@ from pydantic import Field, field_validator from .trait import MissingTraitError, TraitBase, TraitValidationError if TYPE_CHECKING: - from pathlib import Path @@ -205,9 +204,9 @@ class Sequence(TraitBase): handles_frame_end) self.validate_frame_padding(file_locs) - - def validate_frame_list( + + def validate_frame_list( # noqa: C901 self, file_locations: FileLocations, frame_start: Optional[int] = None, @@ -246,7 +245,7 @@ class Sequence(TraitBase): frame_regex = re.compile(self.frame_regex) elif isinstance(self.frame_regex, Pattern): frame_regex = self.frame_regex - + frames = self.get_frame_list( file_locations, frame_regex) else: diff --git a/client/ayon_core/pipeline/traits/utils.py b/client/ayon_core/pipeline/traits/utils.py index 54386fe8ca..2aa6173464 100644 --- a/client/ayon_core/pipeline/traits/utils.py +++ b/client/ayon_core/pipeline/traits/utils.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from clique import assemble -from ayon_core.pipeline.traits.time import FrameRanged +from ayon_core.pipeline.traits.temporal import FrameRanged if TYPE_CHECKING: from pathlib import Path diff --git a/pyproject.toml b/pyproject.toml index 6e01a3efd7..975e141842 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,8 +79,6 @@ pydocstyle.convention = "google" select = ["ALL"] ignore = [ "PTH", - "ANN101", - "ANN102", "ANN204", "COM812", "S603", @@ -91,6 +89,8 @@ ignore = [ "UP035", # .. "ARG002", "INP001", # add `__init__.py` to namespaced package + "FIX002", # FIX all TODOs + "TD003", # missing issue link ] # Allow fix for all enabled rules (when `--fix`) is provided. From 423277580c81e895bc4fdd927f80387ce013718d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 29 Jan 2025 23:02:04 +0100 Subject: [PATCH 091/781] :sparkles: add IntendedUse trait --- client/ayon_core/pipeline/traits/meta.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py index 2dc8ea5a27..00b5013f72 100644 --- a/client/ayon_core/pipeline/traits/meta.py +++ b/client/ayon_core/pipeline/traits/meta.py @@ -93,8 +93,8 @@ class KeepOriginalName(TraitBase): Note: This is not a persistent trait. - """ + name: ClassVar[str] = "KeepOriginalName" description: ClassVar[str] = "Keep Original Name Trait Model" id: ClassVar[str] = "ayon.meta.KeepOriginalName.v1" @@ -111,3 +111,18 @@ class SourceApplication(TraitBase): variant: str = Field(..., title="Application Variant (e.g. Pro)") version: str = Field(..., title="Application Version") platform: str = Field(..., title="Platform Name") + + +class IntendedUse(TraitBase): + """Intended use of the representation. + + This trait describes the intended use of the representation. It + can be used in cases, where the other traits are not enough to + describe the intended use. For example txt file with tracking + points can be used as corner pin in After Effect but not in Nuke. + """ + + name: ClassVar[str] = "IntendedUse" + description: ClassVar[str] = "Intended Use Trait Model" + id: ClassVar[str] = "ayon.meta.IntendedUse.v1" + use: str = Field(..., title="Intended Use") From 5eead5f66ac991514c42fb5dfa56ed108dcec9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 29 Jan 2025 23:02:50 +0100 Subject: [PATCH 092/781] :package: update ruff to get rid of that annoying ANN101 and ANN102 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 975e141842..8d4a501651 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ pytest = "^8.0" pytest-print = "^1.0" ayon-python-api = "^1.0" # linting dependencies -ruff = "^0.3.3" +ruff = "^0.9.3" pre-commit = "^4" codespell = "^2.2.6" semver = "^3.0.2" From fef36a7cd9964f592777838336fa3fdc5d38a680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 29 Jan 2025 23:03:20 +0100 Subject: [PATCH 093/781] :recycle: unify docstring style and signatures --- client/ayon_core/pipeline/traits/color.py | 1 + client/ayon_core/pipeline/traits/content.py | 10 ++-------- client/ayon_core/pipeline/traits/cryptography.py | 2 ++ client/ayon_core/pipeline/traits/representation.py | 14 +++++++------- client/ayon_core/pipeline/traits/temporal.py | 12 ++++++------ .../ayon_core/pipeline/traits/three_dimensional.py | 2 +- .../ayon_core/pipeline/traits/two_dimensional.py | 7 +------ 7 files changed, 20 insertions(+), 28 deletions(-) diff --git a/client/ayon_core/pipeline/traits/color.py b/client/ayon_core/pipeline/traits/color.py index 26802d51f8..b816593624 100644 --- a/client/ayon_core/pipeline/traits/color.py +++ b/client/ayon_core/pipeline/traits/color.py @@ -21,6 +21,7 @@ class ColorManaged(TraitBase): in the "current" OCIO context. config (str): An OCIO config name defining color space. """ + id: ClassVar[str] = "ayon.color.ColorManaged.v1" name: ClassVar[str] = "ColorManaged" description: ClassVar[str] = "Color Managed trait." diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 16c3b47920..75b1263ea7 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -5,7 +5,7 @@ import contextlib import re # TC003 is there because Path in TYPECHECKING will fail in tests -from pathlib import Path # noqa: TCH003 +from pathlib import Path # noqa: TC003 from typing import ClassVar, Generator, Optional from pydantic import Field @@ -35,7 +35,6 @@ class MimeType(TraitBase): description (str): Trait description. id (str): id should be namespaced trait name with version mime_type (str): Mime type like image/jpeg. - """ name: ClassVar[str] = "MimeType" @@ -58,7 +57,6 @@ class LocatableContent(TraitBase): description (str): Trait description. id (str): id should be namespaced trait name with version location (str): Location. - """ name: ClassVar[str] = "LocatableContent" @@ -82,8 +80,8 @@ class FileLocation(TraitBase): file_path (str): File path. file_size (int): File size in bytes. file_hash (str): File hash. - """ + name: ClassVar[str] = "FileLocation" description: ClassVar[str] = "FileLocation Trait Model" id: ClassVar[str] = "ayon.content.FileLocation.v1" @@ -359,7 +357,6 @@ class RootlessLocation(TraitBase): description (str): Trait description. id (str): id should be namespaced trait name with version rootless_path (str): Rootless path. - """ name: ClassVar[str] = "RootlessLocation" @@ -383,7 +380,6 @@ class Compressed(TraitBase): description (str): Trait description. id (str): id should be namespaced trait name with version compression_type (str): Compression type. - """ name: ClassVar[str] = "Compressed" @@ -422,7 +418,6 @@ class Bundle(TraitBase): description (str): Trait description. id (str): id should be namespaced trait name with version items (list[list[TraitBase]]): List of representations. - """ name: ClassVar[str] = "Bundle" @@ -460,7 +455,6 @@ class Fragment(TraitBase): description (str): Trait description. id (str): id should be namespaced trait name with version parent (str): Parent representation id. - """ name: ClassVar[str] = "Fragment" diff --git a/client/ayon_core/pipeline/traits/cryptography.py b/client/ayon_core/pipeline/traits/cryptography.py index 42abee779c..3719f3dbbf 100644 --- a/client/ayon_core/pipeline/traits/cryptography.py +++ b/client/ayon_core/pipeline/traits/cryptography.py @@ -16,6 +16,7 @@ class DigitallySigned(TraitBase): Attributes: signature (str): Digital signature. """ + id: ClassVar[str] = "ayon.cryptography.DigitallySigned.v1" name: ClassVar[str] = "DigitallySigned" description: ClassVar[str] = "Digitally signed trait." @@ -29,6 +30,7 @@ class PGPSigned(DigitallySigned): Attributes: signature (str): PGP signature. """ + id: ClassVar[str] = "ayon.cryptography.PGPSigned.v1" name: ClassVar[str] = "PGPSigned" description: ClassVar[str] = "PGP signed trait." diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py index 8015d75d74..d185c0466c 100644 --- a/client/ayon_core/pipeline/traits/representation.py +++ b/client/ayon_core/pipeline/traits/representation.py @@ -57,8 +57,8 @@ class Representation(Generic[T]): Arguments: name (str): Representation name. Must be unique within instance. representation_id (str): Representation ID. - """ + _data: dict[str, T] _module_blacklist: ClassVar[list[str]] = [ "_", "builtins", "pydantic", @@ -134,7 +134,7 @@ class Representation(Generic[T]): """Return the traits as items.""" return ItemsView(self._data) - def add_trait(self, trait: T, *, exists_ok: bool=False) -> None: + def add_trait(self, trait: T, *, exists_ok: bool = False) -> None: """Add a trait to the Representation. Args: @@ -156,7 +156,7 @@ class Representation(Generic[T]): self._data[trait.id] = trait def add_traits( - self, traits: list[T], *, exists_ok: bool=False) -> None: + self, traits: list[T], *, exists_ok: bool = False) -> None: """Add a list of traits to the Representation. Args: @@ -346,7 +346,7 @@ class Representation(Generic[T]): return result def get_traits(self, - traits: Optional[list[Type[T]]]=None + traits: Optional[list[Type[T]]] = None ) -> dict[str, T]: """Get a list of traits from the representation. @@ -406,8 +406,8 @@ class Representation(Generic[T]): def __init__( self, name: str, - representation_id: Optional[str]=None, - traits: Optional[list[T]]=None): + representation_id: Optional[str] = None, + traits: Optional[list[T]] = None): """Initialize the data. Args: @@ -639,7 +639,7 @@ class Representation(Generic[T]): def from_dict( cls: Type[Representation], name: str, - representation_id: Optional[str]=None, + representation_id: Optional[str] = None, trait_data: Optional[dict] = None) -> Representation: """Create a representation from a dictionary. diff --git a/client/ayon_core/pipeline/traits/temporal.py b/client/ayon_core/pipeline/traits/temporal.py index 7c45eef9b9..f924a8d1e1 100644 --- a/client/ayon_core/pipeline/traits/temporal.py +++ b/client/ayon_core/pipeline/traits/temporal.py @@ -14,8 +14,6 @@ from .trait import MissingTraitError, TraitBase, TraitValidationError if TYPE_CHECKING: - from pathlib import Path - from .content import FileLocations from .representation import Representation @@ -132,13 +130,16 @@ class Sequence(TraitBase): gaps_policy: Optional[GapPolicy] = Field( default=GapPolicy.forbidden, title="Gaps Policy") frame_padding: int = Field(..., title="Frame Padding") - frame_regex: Optional[Union[Pattern, str]] = Field(default=None, title="Frame Regex") - frame_spec: Optional[str] = Field(default=None, title="Frame Specification") + frame_regex: Optional[Union[Pattern, str]] = Field( + default=None, title="Frame Regex") + frame_spec: Optional[str] = Field(default=None, + title="Frame Specification") @field_validator("frame_regex") @classmethod def validate_frame_regex( - cls, v: Optional[Union[Pattern, str]]) -> Optional[Union[Pattern, str]]: + cls, v: Optional[Union[Pattern, str]] + ) -> Optional[Union[Pattern, str]]: """Validate frame regex.""" _v = v if v and isinstance(v, Pattern): @@ -419,7 +420,6 @@ class Static(TraitBase): """Static time trait. Used to define static time (single frame). - """ name: ClassVar[str] = "Static" description: ClassVar[str] = "Static Time Trait" diff --git a/client/ayon_core/pipeline/traits/three_dimensional.py b/client/ayon_core/pipeline/traits/three_dimensional.py index e9eb0246c1..67f4415f73 100644 --- a/client/ayon_core/pipeline/traits/three_dimensional.py +++ b/client/ayon_core/pipeline/traits/three_dimensional.py @@ -24,8 +24,8 @@ class Spatial(TraitBase): up_axis (str): Up axis. handedness (str): Handedness. meters_per_unit (float): Meters per unit. - """ + id: ClassVar[str] = "ayon.3d.Spatial.v1" name: ClassVar[str] = "Spatial" description: ClassVar[str] = "Spatial trait model." diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py index 9095061bf2..62d6693336 100644 --- a/client/ayon_core/pipeline/traits/two_dimensional.py +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -11,6 +11,7 @@ from .trait import TraitBase if TYPE_CHECKING: from .content import FileLocation, FileLocations + class Image(TraitBase): """Image trait model. @@ -20,7 +21,6 @@ class Image(TraitBase): name (str): Trait name. description (str): Trait description. id (str): id should be namespaced trait name with version - """ name: ClassVar[str] = "Image" @@ -40,7 +40,6 @@ class PixelBased(TraitBase): display_window_width (int): Width of the image display window. display_window_height (int): Height of the image display window. pixel_aspect_ratio (float): Pixel aspect ratio. - """ name: ClassVar[str] = "PixelBased" @@ -66,7 +65,6 @@ class Planar(TraitBase): description (str): Trait description. id (str): id should be namespaced trait name with version planar_configuration (str): Planar configuration. - """ name: ClassVar[str] = "Planar" @@ -84,7 +82,6 @@ class Deep(TraitBase): name (str): Trait name. description (str): Trait description. id (str): id should be namespaced trait name with version - """ name: ClassVar[str] = "Deep" @@ -106,7 +103,6 @@ class Overscan(TraitBase): right (int): Right overscan/underscan. top (int): Top overscan/underscan. bottom (int): Bottom overscan/underscan. - """ name: ClassVar[str] = "Overscan" @@ -128,7 +124,6 @@ class UDIM(TraitBase): description (str): Trait description. id (str): id should be namespaced trait name with version udim (int): UDIM value. - """ name: ClassVar[str] = "UDIM" From ebf67890368873212fa9fb7255ad6e6be54186bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 29 Jan 2025 23:17:32 +0100 Subject: [PATCH 094/781] :recycle: organize imports --- client/ayon_core/addon/__init__.py | 23 ++++---- client/ayon_core/pipeline/traits/__init__.py | 56 +++++++++++-------- .../pipeline/traits/test_time_traits.py | 1 - 3 files changed, 43 insertions(+), 37 deletions(-) diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index f78cea0b54..a8cf51ae25 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -21,21 +21,18 @@ from .utils import ( ) __all__ = ( - "click_wrap", - - "IPluginPaths", - "ITrayAddon", - "ITrayAction", - "ITrayService", - "IHostAddon", - "ITraits", - - "ProcessPreparationError", - "ProcessContext", "AYONAddon", "AddonsManager", - "load_addons", - + "IHostAddon", + "IPluginPaths", + "ITraits", + "ITrayAction", + "ITrayAddon", + "ITrayService", + "ProcessContext", + "ProcessPreparationError", + "click_wrap", "ensure_addons_are_process_context_ready", "ensure_addons_are_process_ready", + "load_addons", ) diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py index 6603990b53..645064d59f 100644 --- a/client/ayon_core/pipeline/traits/__init__.py +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -12,9 +12,15 @@ from .content import ( ) from .cryptography import DigitallySigned, PGPSigned from .lifecycle import Persistent, Transient -from .meta import Tagged, TemplatePath, Variant +from .meta import ( + IntendedUse, + KeepOriginalLocation, + SourceApplication, + Tagged, + TemplatePath, + Variant, +) from .representation import Representation -from .three_dimensional import Geometry, IESProfile, Lighting, Shader, Spatial from .temporal import ( FrameRanged, GapPolicy, @@ -23,6 +29,7 @@ from .temporal import ( SMPTETimecode, Static, ) +from .three_dimensional import Geometry, IESProfile, Lighting, Shader, Spatial from .trait import ( MissingTraitError, TraitBase, @@ -40,25 +47,25 @@ from .utils import ( get_sequence_from_files, ) -__all__ = [ +__all__ = [ # noqa: RUF022 # base "Representation", "TraitBase", "MissingTraitError", "TraitValidationError", + # color + "ColorManaged", + # content "Bundle", "Compressed", "FileLocation", "FileLocations", - "MimeType", - "RootlessLocation", "Fragment", "LocatableContent", - - # color - "ColorManaged", + "MimeType", + "RootlessLocation", # cryptography "DigitallySigned", @@ -69,10 +76,28 @@ __all__ = [ "Transient", # meta + "IntendedUse", + "KeepOriginalLocation", + "SourceApplication", "Tagged", "TemplatePath", "Variant", + # temporal + "FrameRanged", + "GapPolicy", + "Handles", + "Sequence", + "SMPTETimecode", + "Static", + + # three-dimensional + "Geometry", + "IESProfile", + "Lighting", + "Shader", + "Spatial", + # two-dimensional "Compressed", "Deep", @@ -82,21 +107,6 @@ __all__ = [ "Planar", "UDIM", - # three-dimensional - "Geometry", - "IESProfile", - "Lighting", - "Shader", - "Spatial", - - # time - "FrameRanged", - "Static", - "Handles", - "GapPolicy", - "Sequence", - "SMPTETimecode", - # utils "get_sequence_from_files", ] diff --git a/tests/client/ayon_core/pipeline/traits/test_time_traits.py b/tests/client/ayon_core/pipeline/traits/test_time_traits.py index c716266fd7..100e4ed2b5 100644 --- a/tests/client/ayon_core/pipeline/traits/test_time_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_time_traits.py @@ -15,7 +15,6 @@ from ayon_core.pipeline.traits import ( from ayon_core.pipeline.traits.trait import TraitValidationError - def test_sequence_validations() -> None: """Test Sequence trait validation.""" file_locations_list = [ From ba67c436f943e19abd81f61eb9e56fd2d240c61e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 29 Jan 2025 23:25:43 +0100 Subject: [PATCH 095/781] :recycle: revert back some changes --- client/ayon_core/addon/interfaces.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index 1070828d5c..bf844e2d8b 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -1,7 +1,6 @@ """Addon interfaces for AYON.""" from __future__ import annotations -import logging from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING, Callable, Optional, Type @@ -10,7 +9,6 @@ from ayon_core import resources if TYPE_CHECKING: from qtpy import QtWidgets - from ayon_core.addon import AddonsManager from ayon_core.pipeline.traits import TraitBase from ayon_core.tools.tray import TrayManager @@ -38,10 +36,6 @@ class AYONInterface(metaclass=_AYONInterfaceMeta): log = None - def __init__(self): - """Initialize interface.""" - self.log = logging.getLogger(self.__class__.__name__) - class IPluginPaths(AYONInterface): """Addon has plugin paths to return. @@ -162,7 +156,6 @@ class ITrayAddon(AYONInterface): """ tray_initialized = False - manager: AddonsManager = None _tray_manager: TrayManager = None _admin_submenu = None @@ -431,7 +424,6 @@ class IHostAddon(AYONInterface): @abstractmethod def host_name(self) -> str: """Name of host which addon represents.""" - raise NotImplementedError def get_workfile_extensions(self) -> list[str]: """Define workfile extensions for host. From 0fbee7da5ca9d7ddeed6fc484312f2df65769aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 30 Jan 2025 10:31:42 +0100 Subject: [PATCH 096/781] :recycle: add `IntendedUse` to readme --- client/ayon_core/pipeline/traits/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md index 515c6d1272..aa38d30a8e 100644 --- a/client/ayon_core/pipeline/traits/README.md +++ b/client/ayon_core/pipeline/traits/README.md @@ -166,6 +166,7 @@ to different packages based on their use: | | KeepOriginalLocation | Marks the representation to keep the original location of the file | | KeepOriginalName | Marks the representation to keep the original name of the file | | SourceApplication | Holds information about producing application, about it's version, variant and platform. +| | IntendedUse | For specifying the intended use of the representation if it cannot be easily determined by other traits. | three dimensional | Spatial | Spatial information like up-axis, units and handedness. | | Geometry | Type trait to mark the representation as a geometry. | | Shader | Type trait to mark the representation as a Shader. @@ -318,4 +319,4 @@ class MyAddon(AYONAddon, ITraits): If you want to use MyPy linter, you need to make sure that optional fields typed as `Optional[Type]` needs to set default value using `default` or `default_factory` parameter. Otherwise MyPy will -complain about missing named arguments. \ No newline at end of file +complain about missing named arguments. From 4da77e745505ce3fe92302e3a508afeed611798a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 5 Feb 2025 16:50:40 +0100 Subject: [PATCH 097/781] :recycle: remove unnecessary `NotImplementedError` --- client/ayon_core/addon/interfaces.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index bf844e2d8b..303cdf3941 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -169,18 +169,14 @@ class ITrayAddon(AYONInterface): prepared """ - raise NotImplementedError @abstractmethod def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: """Add addon's action to tray menu.""" - raise NotImplementedError - @abstractmethod def tray_start(self) -> None: """Start procedure in tray tool.""" - raise NotImplementedError @abstractmethod def tray_exit(self) -> None: @@ -189,7 +185,6 @@ class ITrayAddon(AYONInterface): This is place where all threads should be shut. """ - raise NotImplementedError def execute_in_main_thread(self, callback: Callable) -> None: """Pushes callback to the queue or process 'callback' on a main thread. @@ -282,12 +277,10 @@ class ITrayAction(ITrayAddon): @abstractmethod def label(self) -> str: """Service label showed in menu.""" - raise NotImplementedError @abstractmethod def on_action_trigger(self) -> None: """What happens on actions click.""" - raise NotImplementedError def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: """Add action to tray menu.""" @@ -326,7 +319,6 @@ class ITrayService(ITrayAddon): @abstractmethod def label(self) -> str: """Service label showed in menu.""" - raise NotImplementedError # TODO (Illicit): be able to get any sort of information to show/print # @abstractmethod @@ -448,4 +440,3 @@ class ITraits(AYONInterface): list[Type[TraitBase]]: Traits for the addon. """ - raise NotImplementedError From 5e5a27c7a953205c821d406d67a660dbdc7ddd8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 5 Feb 2025 16:59:11 +0100 Subject: [PATCH 098/781] :recycle: make fields optional --- client/ayon_core/pipeline/traits/meta.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py index 00b5013f72..2f5d3eb212 100644 --- a/client/ayon_core/pipeline/traits/meta.py +++ b/client/ayon_core/pipeline/traits/meta.py @@ -1,5 +1,7 @@ """Metadata traits.""" -from typing import ClassVar, List +from __future__ import annotations + +from typing import ClassVar, List, Optional from pydantic import Field @@ -102,15 +104,26 @@ class KeepOriginalName(TraitBase): class SourceApplication(TraitBase): - """Metadata about the source (producing) application.""" + """Metadata about the source (producing) application. + + This can be useful in cases, where this information is + needed but it cannot be determined from other means - like + .txt files used for various motion tracking applications that + must be interpreted by the loader. + + Note that this is not really connected to any logic in + ayon-applications addon. + + """ name: ClassVar[str] = "SourceApplication" description: ClassVar[str] = "Source Application Trait Model" id: ClassVar[str] = "ayon.meta.SourceApplication.v1" application: str = Field(..., title="Application Name") - variant: str = Field(..., title="Application Variant (e.g. Pro)") - version: str = Field(..., title="Application Version") - platform: str = Field(..., title="Platform Name") + variant: Optional[str] = Field(None, title="Application Variant (e.g. Pro)") + version: Optional[str] = Field(None, title="Application Version") + platform: Optional[str] = Field(None, title="Platform Name (e.g. Windows)") + host_name: Optional[str] = Field(None, title="AYON host Name if applicable") class IntendedUse(TraitBase): From 67bf458d1c92842062c97f65ee717ab7bea5ce15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 5 Feb 2025 16:59:29 +0100 Subject: [PATCH 099/781] :dog: style changes --- client/ayon_core/pipeline/traits/temporal.py | 13 ++++++++----- client/ayon_core/pipeline/traits/trait.py | 3 ++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/traits/temporal.py b/client/ayon_core/pipeline/traits/temporal.py index f924a8d1e1..d50b9e8eb8 100644 --- a/client/ayon_core/pipeline/traits/temporal.py +++ b/client/ayon_core/pipeline/traits/temporal.py @@ -29,11 +29,13 @@ class GapPolicy(Enum): hold (int): Gaps are interpreted as hold frames (last existing frames). black (int): Gaps are interpreted as black frames. """ + forbidden = auto() missing = auto() hold = auto() black = auto() + class FrameRanged(TraitBase): """Frame ranged trait model. @@ -63,8 +65,8 @@ class FrameRanged(TraitBase): frame_out (int): Frame out. frames_per_second (str): Frames per second. step (int): Step. - """ + name: ClassVar[str] = "FrameRanged" description: ClassVar[str] = "Frame Ranged Trait" id: ClassVar[str] = "ayon.time.FrameRanged.v1" @@ -91,8 +93,8 @@ class Handles(TraitBase): inclusive (bool): Handles are inclusive. frame_start_handle (int): Frame start handle. frame_end_handle (int): Frame end handle. - """ + name: ClassVar[str] = "Handles" description: ClassVar[str] = "Handles Trait" id: ClassVar[str] = "ayon.time.Handles.v1" @@ -103,6 +105,7 @@ class Handles(TraitBase): frame_end_handle: Optional[int] = Field( 0, title="Frame End Handle") + class Sequence(TraitBase): """Sequence trait model. @@ -122,8 +125,8 @@ class Sequence(TraitBase): named group. frame_spec (str): Frame list specification of frames. This takes string like "1-10,20-30,40-50" etc. - """ + name: ClassVar[str] = "Sequence" description: ClassVar[str] = "Sequence Trait Model" id: ClassVar[str] = "ayon.time.Sequence.v1" @@ -206,7 +209,6 @@ class Sequence(TraitBase): self.validate_frame_padding(file_locs) - def validate_frame_list( # noqa: C901 self, file_locations: FileLocations, @@ -330,7 +332,6 @@ class Sequence(TraitBase): frames.extend(range(int(start), int(end) + 1)) return frames - @staticmethod def _get_collection( file_locations: FileLocations, @@ -410,6 +411,7 @@ class Sequence(TraitBase): # Do we need one for drop and non-drop frame? class SMPTETimecode(TraitBase): """SMPTE Timecode trait model.""" + name: ClassVar[str] = "Timecode" description: ClassVar[str] = "SMPTE Timecode Trait" id: ClassVar[str] = "ayon.time.SMPTETimecode.v1" @@ -421,6 +423,7 @@ class Static(TraitBase): Used to define static time (single frame). """ + name: ClassVar[str] = "Static" description: ClassVar[str] = "Static Time Trait" id: ClassVar[str] = "ayon.time.Static.v1" diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 3997451f44..695ebb54c8 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -27,7 +27,6 @@ class TraitBase(ABC, BaseModel): It is using Pydantic BaseModel for serialization and validation. ``id``, ``name``, and ``description`` are abstract attributes that must be implemented in the derived classes. - """ model_config = ConfigDict( @@ -118,6 +117,7 @@ class UpgradableTraitError(Generic[T], Exception): trait: T old_data: dict + class LooseMatchingTraitError(Generic[T], Exception): """Loose matching trait exception. @@ -128,6 +128,7 @@ class LooseMatchingTraitError(Generic[T], Exception): found_trait: T expected_id: str + class TraitValidationError(Exception): """Trait validation error exception. From bdb0a10890df2c684dcbff8657d8db44f8f76ddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 5 Feb 2025 16:59:51 +0100 Subject: [PATCH 100/781] :recycle: improve comment and error handling --- client/ayon_core/pipeline/traits/utils.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/traits/utils.py b/client/ayon_core/pipeline/traits/utils.py index 2aa6173464..54cc99a261 100644 --- a/client/ayon_core/pipeline/traits/utils.py +++ b/client/ayon_core/pipeline/traits/utils.py @@ -15,6 +15,8 @@ def get_sequence_from_files(paths: list[Path]) -> FrameRanged: """Get original frame range from files. Note that this cannot guess frame rate, so it's set to 25. + This will also fail on paths that cannot be assembled into + one collection without any reminders. Args: paths (list[Path]): List of file paths. @@ -23,7 +25,15 @@ def get_sequence_from_files(paths: list[Path]) -> FrameRanged: FrameRanged: FrameRanged trait. """ - col = assemble([path.as_posix() for path in paths])[0][0] + cols, rems = assemble([path.as_posix() for path in paths]) + if rems: + msg = "Cannot assemble paths into one collection" + raise ValueError(msg) + if len(cols) != 1: + msg = "More than one collection found" + raise ValueError(msg) + col = cols[0] + sorted_frames = sorted(col.indexes) # First frame used for end value first_frame = sorted_frames[0] From 681301bf8cec23dd9797464ed86d02f21b6c2f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 5 Feb 2025 20:28:31 +0100 Subject: [PATCH 101/781] :dog: fix line length --- client/ayon_core/pipeline/traits/meta.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py index 2f5d3eb212..e21f3eb7fd 100644 --- a/client/ayon_core/pipeline/traits/meta.py +++ b/client/ayon_core/pipeline/traits/meta.py @@ -120,10 +120,14 @@ class SourceApplication(TraitBase): description: ClassVar[str] = "Source Application Trait Model" id: ClassVar[str] = "ayon.meta.SourceApplication.v1" application: str = Field(..., title="Application Name") - variant: Optional[str] = Field(None, title="Application Variant (e.g. Pro)") - version: Optional[str] = Field(None, title="Application Version") - platform: Optional[str] = Field(None, title="Platform Name (e.g. Windows)") - host_name: Optional[str] = Field(None, title="AYON host Name if applicable") + variant: Optional[str] = Field( + None, title="Application Variant (e.g. Pro)") + version: Optional[str] = Field( + None, title="Application Version") + platform: Optional[str] = Field( + None, title="Platform Name (e.g. Windows)") + host_name: Optional[str] = Field( + None, title="AYON host Name if applicable") class IntendedUse(TraitBase): From 883ea09e5de92c8e5639c3e52ce954525949ffc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 6 Feb 2025 16:05:47 +0100 Subject: [PATCH 102/781] :recycle: change too complex type `frame_regex` must be `re.Pattern` or `None` now --- client/ayon_core/pipeline/traits/temporal.py | 25 ++++++++------------ 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/client/ayon_core/pipeline/traits/temporal.py b/client/ayon_core/pipeline/traits/temporal.py index d50b9e8eb8..573f4b83b5 100644 --- a/client/ayon_core/pipeline/traits/temporal.py +++ b/client/ayon_core/pipeline/traits/temporal.py @@ -5,7 +5,7 @@ import contextlib import re from enum import Enum, auto from re import Pattern -from typing import TYPE_CHECKING, ClassVar, Optional, Union +from typing import TYPE_CHECKING, ClassVar, Optional import clique from pydantic import Field, field_validator @@ -133,7 +133,7 @@ class Sequence(TraitBase): gaps_policy: Optional[GapPolicy] = Field( default=GapPolicy.forbidden, title="Gaps Policy") frame_padding: int = Field(..., title="Frame Padding") - frame_regex: Optional[Union[Pattern, str]] = Field( + frame_regex: Optional[Pattern] = Field( default=None, title="Frame Regex") frame_spec: Optional[str] = Field(default=None, title="Frame Specification") @@ -141,16 +141,16 @@ class Sequence(TraitBase): @field_validator("frame_regex") @classmethod def validate_frame_regex( - cls, v: Optional[Union[Pattern, str]] - ) -> Optional[Union[Pattern, str]]: + cls, v: Optional[Pattern] + ) -> Optional[Pattern]: """Validate frame regex.""" - _v = v - if v and isinstance(v, Pattern): - _v = v.pattern + if v is None: + return v + _v = v.pattern if v and any(s not in _v for s in ["?P", "?P"]): msg = "Frame regex must include 'index' and `padding named groups" raise ValueError(msg) - return _v + return v def validate_trait(self, representation: Representation) -> None: """Validate the trait.""" @@ -244,13 +244,8 @@ class Sequence(TraitBase): frames: list[int] = [] if self.frame_regex: - if isinstance(self.frame_regex, str): - frame_regex = re.compile(self.frame_regex) - elif isinstance(self.frame_regex, Pattern): - frame_regex = self.frame_regex - frames = self.get_frame_list( - file_locations, frame_regex) + file_locations, self.frame_regex) else: frames = self.get_frame_list( file_locations) @@ -353,7 +348,7 @@ class Sequence(TraitBase): ValueError: If zero or multiple collections found. """ - patterns = None if not regex else [regex] + patterns = [regex] if regex else None files: list[str] = [ file.file_path.as_posix() for file in file_locations.file_paths From dcb754cb958aed65eb1bb771710e70ce5f17e063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Thu, 6 Feb 2025 16:07:52 +0100 Subject: [PATCH 103/781] :dog: remove unused ignore rule --- client/ayon_core/pipeline/traits/temporal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/traits/temporal.py b/client/ayon_core/pipeline/traits/temporal.py index 573f4b83b5..c66172a6d2 100644 --- a/client/ayon_core/pipeline/traits/temporal.py +++ b/client/ayon_core/pipeline/traits/temporal.py @@ -209,7 +209,7 @@ class Sequence(TraitBase): self.validate_frame_padding(file_locs) - def validate_frame_list( # noqa: C901 + def validate_frame_list( self, file_locations: FileLocations, frame_start: Optional[int] = None, From 94437f866a38abbcf95270f78f4600c63b34eaa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 7 Feb 2025 16:03:52 +0100 Subject: [PATCH 104/781] :wrench: wip on representation entity --- .../plugins/publish/integrate_traits.py | 62 +++++++++++++++---- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 43f979b196..09b4ca5461 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -3,7 +3,6 @@ from __future__ import annotations import contextlib import copy -from copy import deepcopy from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, List @@ -48,8 +47,11 @@ if TYPE_CHECKING: from ayon_core.pipeline import Anatomy from ayon_core.pipeline.anatomy.templates import ( - TemplateItem as AnatomyTemplateItem, AnatomyStringTemplate, -) + AnatomyStringTemplate, + ) + from ayon_core.pipeline.anatomy.templates import ( + TemplateItem as AnatomyTemplateItem, + ) @dataclass(frozen=True) @@ -97,6 +99,20 @@ class TemplateItem: template_object: AnatomyTemplateItem + +@dataclass +class RepresentationEntity: + """Representation entity data.""" + id: str + versionId: str # noqa: N815 + name: str + files: dict[str, Any] + attrib: dict[str, Any] + data: str + tags: list[str] + status: str + + def get_instance_families(instance: pyblish.api.Instance) -> List[str]: """Get all families of the instance. @@ -148,9 +164,11 @@ def get_changed_attributes( attrib_changes = {} if "attrib" in new_entity: - for key, value in new_entity["attrib"].items(): - if value != old_entity["attrib"].get(key): - attrib_changes[key] = value + attrib_changes = { + key: value + for key, value in new_entity["attrib"].items() + if value != old_entity["attrib"].get(key) + } if attrib_changes: changes["attrib"] = attrib_changes return changes @@ -170,7 +188,6 @@ class IntegrateTraits(pyblish.api.InstancePlugin): Todo: Refactor this method to be more readable and maintainable. - Remove corresponding noqa codes. Args: instance (pyblish.api.Instance): Instance to process. @@ -187,7 +204,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): "Instance is marked to be processed on farm. Skipping") return - # TODO (antirotor): Find better name for the key # noqa: FIX002, TD003 + # TODO (antirotor): Find better name for the key if not instance.data.get("representations_with_traits"): self.log.debug( "Instance has no representations with traits. Skipping") @@ -216,6 +233,14 @@ class IntegrateTraits(pyblish.api.InstancePlugin): transfers = self.get_transfers_from_representations( instance, representations) + # 8) Transfer files + for transfer in transfers: + self.log.debug( + "Transferring file: %s -> %s", + transfer.source, + transfer.destination + ) + def get_transfers_from_representations( self, instance: pyblish.api.Instance, @@ -649,7 +674,9 @@ class IntegrateTraits(pyblish.api.InstancePlugin): return path def get_attributes_for_type( - self, context: pyblish.api.Context, entity_type: str) -> dict: + self, + context: pyblish.api.Context, + entity_type: str) -> dict: """Get AYON attributes for the given entity type.""" return self.get_attributes_by_type(context)[entity_type] @@ -791,8 +818,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): representation.get_trait(FileLocations), regex=sequence.frame_regex) template_padding = template_item.anatomy.templates_obj.frame_padding - if template_padding > dst_padding: - dst_padding = template_padding + dst_padding = max(template_padding, dst_padding) # go through all frames in the sequence # find their corresponding file locations @@ -983,3 +1009,17 @@ class IntegrateTraits(pyblish.api.InstancePlugin): sub_representation, template_item, transfers ) +def create_representation_entity(representation: Representation) -> dict: + """Create representation entity. + + Args: + representation (Representation): Representation to create entity for. + + Returns: + dict: Representation entity. + + """ + return { + "name": representation.name, + "traits": representation.get_traits_data(), + } From 50e390e6125ad062d54168ae4ad119d7a50e183a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 10 Feb 2025 15:30:02 +0100 Subject: [PATCH 105/781] :dog: linter fixes --- client/ayon_core/pipeline/traits/content.py | 38 ++++++++++-------- client/ayon_core/pipeline/traits/meta.py | 1 + .../pipeline/traits/representation.py | 32 ++++++--------- client/ayon_core/pipeline/traits/temporal.py | 40 ++++++++++++++----- client/ayon_core/pipeline/traits/trait.py | 13 +++--- .../pipeline/traits/two_dimensional.py | 10 ++++- client/ayon_core/pipeline/traits/utils.py | 3 ++ pyproject.toml | 22 ++++++---- 8 files changed, 99 insertions(+), 60 deletions(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 75b1263ea7..4ee63b2b08 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -163,15 +163,16 @@ class FileLocations(TraitBase): Args: representation (Representation): Representation to validate. - Returns: - bool: True if the trait is valid, False otherwise + Raises: + TraitValidationError: If the trait is invalid within the + representation. """ super().validate_trait(representation) if len(self.file_paths) == 0: - # If there are no file paths, we can't validate - msg = "No file locations defined (empty list)" - raise TraitValidationError(self.name, msg) + # If there are no file paths, we can't validate + msg = "No file locations defined (empty list)" + raise TraitValidationError(self.name, msg) if representation.contains_trait(FrameRanged): self._validate_frame_range(representation) if not representation.contains_trait(Sequence) \ @@ -234,7 +235,7 @@ class FileLocations(TraitBase): raise TraitValidationError(self.name, msg) if frames_from_spec: - if len(frames_from_spec) != len(self.file_paths) : + if len(frames_from_spec) != len(self.file_paths): # If the number of file paths does not match the frame range, # the trait is invalid msg = ( @@ -254,14 +255,14 @@ class FileLocations(TraitBase): ) if len(self.file_paths) != length_with_handles: - # If the number of file paths does not match the frame range, - # the trait is invalid - msg = ( - f"Number of file locations ({len(self.file_paths)}) " - "does not match frame range " - f"({length_with_handles})" - ) - raise TraitValidationError(self.name, msg) + # If the number of file paths does not match the frame range, + # the trait is invalid + msg = ( + f"Number of file locations ({len(self.file_paths)}) " + "does not match frame range " + f"({length_with_handles})" + ) + raise TraitValidationError(self.name, msg) frame_ranged: FrameRanged = representation.get_trait(FrameRanged) @@ -281,8 +282,8 @@ class FileLocations(TraitBase): ) raise TraitValidationError(self.name, msg) + @staticmethod def _get_frame_info_with_handles( - self, representation: Representation, frames_from_spec: list[int]) -> tuple[int, int]: """Get the frame range with handles from the representation. @@ -427,7 +428,12 @@ class Bundle(TraitBase): ..., title="Bundles of traits") def to_representations(self) -> Generator[Representation]: - """Convert bundle to representations.""" + """Convert bundle to representations. + + Yields: + Representation: Representation of the bundle. + + """ for idx, item in enumerate(self.items): yield Representation(name=f"{self.name} {idx}", traits=item) diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py index e21f3eb7fd..0f8c175af5 100644 --- a/client/ayon_core/pipeline/traits/meta.py +++ b/client/ayon_core/pipeline/traits/meta.py @@ -90,6 +90,7 @@ class KeepOriginalLocation(TraitBase): id: ClassVar[str] = "ayon.meta.KeepOriginalLocation.v1" persistent: bool = Field(default=False, title="Persistent") + class KeepOriginalName(TraitBase): """Keep files in its original name. diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py index d185c0466c..43b0397597 100644 --- a/client/ayon_core/pipeline/traits/representation.py +++ b/client/ayon_core/pipeline/traits/representation.py @@ -44,7 +44,7 @@ def _get_version_from_id(_id: str) -> Optional[int]: return int(match[1]) if match else None -class Representation(Generic[T]): +class Representation(Generic[T]): # noqa: PLR0904 """Representation of products. Representation defines collection of individual properties that describe @@ -54,6 +54,9 @@ class Representation(Generic[T]): It holds methods to add, remove, get, and check for the existence of a trait in the representation. It also provides a method to get all the + Note: + `PLR0904` is rule for checking number of public methods in a class. + Arguments: name (str): Representation name. Must be unique within instance. representation_id (str): Representation ID. @@ -79,9 +82,6 @@ class Representation(Generic[T]): Returns: TraitBase: Trait instance. - Raises: - MissingTraitError: If the trait is not found. - """ return self.get_trait_by_id(key) @@ -104,8 +104,6 @@ class Representation(Generic[T]): Args: key (str): Trait ID. - Raises: - ValueError: If the trait is not found. """ self.remove_trait_by_id(key) @@ -138,7 +136,7 @@ class Representation(Generic[T]): """Add a trait to the Representation. Args: - trait (TraiBase): Trait to add. + trait (TraitBase): Trait to add. exists_ok (bool, optional): If True, do not raise an error if the trait already exists. Defaults to False. @@ -228,7 +226,6 @@ class Representation(Generic[T]): for trait_id in trait_ids: self.remove_trait_by_id(trait_id) - def has_traits(self) -> bool: """Check if the Representation has any traits. @@ -260,7 +257,7 @@ class Representation(Generic[T]): bool: True if the trait exists, False otherwise. """ - return bool(self._data.get(trait_id)) + return bool(self._data.get(trait_id)) def contains_traits(self, traits: list[Type[T]]) -> bool: """Check if the traits exist. @@ -366,7 +363,7 @@ class Representation(Generic[T]): return result for trait in traits: - result[str(trait.id)] = self.get_trait(trait=trait) + result[str(trait.id)] = self.get_trait(trait=trait) return result def get_traits_by_ids(self, trait_ids: list[str]) -> dict[str, T]: @@ -532,12 +529,6 @@ class Representation(Generic[T]): Returns: Type[TraitBase]: Trait class. - Raises: - LooseMatchingTraitError: If the trait is found with a loose - matching criteria. This exception will include the trait - class that was found and the expected trait ID. Additional - downstream logic must decide how to handle this error. - """ version = cls._get_version_from_id(trait_id) @@ -588,9 +579,6 @@ class Representation(Generic[T]): Raises: IncompatibleTraitVersionError: If the trait version is incompatible with the current version of the trait. - UpgradableTraitError: If the trait can upgrade existing data - meant for older versions of the trait. - ValueError: If the trait model with the given ID is not found. """ try: @@ -662,6 +650,11 @@ class Representation(Generic[T]): Returns: Representation: Representation instance. + Raises: + ValueError: If the trait model with ID is not found. + TypeError: If the trait data is not a dictionary. + IncompatibleTraitVersionError: If the trait version is incompatible + """ if not trait_data: trait_data = {} @@ -697,7 +690,6 @@ class Representation(Generic[T]): return cls( name=name, representation_id=representation_id, traits=traits) - def validate(self) -> None: """Validate the representation. diff --git a/client/ayon_core/pipeline/traits/temporal.py b/client/ayon_core/pipeline/traits/temporal.py index c66172a6d2..286336ea55 100644 --- a/client/ayon_core/pipeline/traits/temporal.py +++ b/client/ayon_core/pipeline/traits/temporal.py @@ -143,11 +143,20 @@ class Sequence(TraitBase): def validate_frame_regex( cls, v: Optional[Pattern] ) -> Optional[Pattern]: - """Validate frame regex.""" + """Validate frame regex. + + Frame regex must have index and padding named groups. + + Returns: + Optional[Pattern]: Compiled regex pattern. + + Raises: + ValueError: If frame regex does not include 'index' and 'padding' + + """ if v is None: return v - _v = v.pattern - if v and any(s not in _v for s in ["?P", "?P"]): + if v and any(s not in v.pattern for s in ["?P", "?P"]): msg = "Frame regex must include 'index' and `padding named groups" raise ValueError(msg) return v @@ -172,10 +181,6 @@ class Sequence(TraitBase): Args: representation (Representation): Representation instance. - Raises: - TraitValidationError: If file locations do not match the - frame list specification - """ from .content import FileLocations file_locs: FileLocations = representation.get_trait( @@ -309,7 +314,15 @@ class Sequence(TraitBase): @staticmethod def list_spec_to_frames(list_spec: str) -> list[int]: - """Convert list specification to frames.""" + """Convert list specification to frames. + + Returns: + list[int]: List of frame numbers. + + Raises: + ValueError: If invalid frame number in the list. + + """ frames = [] segments = list_spec.split(",") for segment in segments: @@ -364,7 +377,12 @@ class Sequence(TraitBase): @staticmethod def get_frame_padding(file_locations: FileLocations) -> int: - """Get frame padding.""" + """Get frame padding. + + Returns: + int: Frame padding. + + """ src_collection = Sequence._get_collection(file_locations) return len(str(max(src_collection.indexes))) @@ -382,6 +400,7 @@ class Sequence(TraitBase): Default clique pattern is:: \.(?P(?P0*)\d+)\.\D+\d?$ + Returns: list[int]: List of frame numbers. @@ -394,6 +413,9 @@ class Sequence(TraitBase): If the regex is string, it will compile it to the pattern. + Returns: + Pattern: Compiled regex pattern. + """ if self.frame_regex: if isinstance(self.frame_regex, str): diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 695ebb54c8..f15c525495 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -57,7 +57,7 @@ class TraitBase(ABC, BaseModel): """Abstract attribute for description.""" ... - def validate_trait(self, representation: Representation) -> None: + def validate_trait(self, representation: Representation) -> None: # noqa: PLR6301 """Validate the trait. This method should be implemented in the derived classes to validate @@ -67,10 +67,6 @@ class TraitBase(ABC, BaseModel): Args: representation (Representation): Representation instance. - Raises: - TraitValidationError: If the trait is invalid - within representation. - """ return @@ -82,6 +78,9 @@ class TraitBase(ABC, BaseModel): This assumes Trait ID ends with `.v{version}`. If not, it will return None. + Returns: + Optional[int]: Trait version + """ version_regex = r"v(\d+)$" match = re.search(version_regex, str(cls.id)) @@ -106,7 +105,7 @@ class IncompatibleTraitVersionError(Exception): """ -class UpgradableTraitError(Generic[T], Exception): +class UpgradableTraitError(Exception, Generic[T]): """Upgradable trait version exception. This exception is raised when the trait can upgrade existing data @@ -118,7 +117,7 @@ class UpgradableTraitError(Generic[T], Exception): old_data: dict -class LooseMatchingTraitError(Generic[T], Exception): +class LooseMatchingTraitError(Exception, Generic[T]): """Loose matching trait exception. This exception is raised when the trait is found with a loose matching diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py index 62d6693336..77aa5767d6 100644 --- a/client/ayon_core/pipeline/traits/two_dimensional.py +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -136,7 +136,15 @@ class UDIM(TraitBase): @field_validator("udim_regex") @classmethod def validate_frame_regex(cls, v: Optional[str]) -> Optional[str]: - """Validate udim regex.""" + """Validate udim regex. + + Returns: + Optional[str]: UDIM regex. + + Raises: + ValueError: UDIM regex must include 'udim' named group. + + """ if v is not None and "?P" not in v: msg = "UDIM regex must include 'udim' named group" raise ValueError(msg) diff --git a/client/ayon_core/pipeline/traits/utils.py b/client/ayon_core/pipeline/traits/utils.py index 54cc99a261..ef22122124 100644 --- a/client/ayon_core/pipeline/traits/utils.py +++ b/client/ayon_core/pipeline/traits/utils.py @@ -24,6 +24,9 @@ def get_sequence_from_files(paths: list[Path]) -> FrameRanged: Returns: FrameRanged: FrameRanged trait. + Raises: + ValueError: If paths cannot be assembled into one collection + """ cols, rems = assemble([path.as_posix() for path in paths]) if rems: diff --git a/pyproject.toml b/pyproject.toml index ef4e9a72f3..ac291ab636 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,23 +74,31 @@ indent-width = 4 target-version = "py39" [tool.ruff.lint] +preview = true pydocstyle.convention = "google" # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. select = ["ALL"] ignore = [ - "PTH", + "PTH", + "ANN101", # must be set in older version of ruff "ANN204", "COM812", "S603", "ERA001", "TRY003", - "UP006", # support for older python version (type vs. Type) - "UP007", # ..^ - "UP035", # .. + "UP006", # support for older python version (type vs. Type) + "UP007", # ..^ + "UP035", # .. + "UP045", # Use `X | None` for type annotations "ARG002", - "INP001", # add `__init__.py` to namespaced package - "FIX002", # FIX all TODOs - "TD003", # missing issue link + "INP001", # add `__init__.py` to namespaced package + "FIX002", # FIX all TODOs + "TD003", # missing issue link + "S404", # subprocess module is possibly insecure + "PLC0415", # import must be on top of the file + "CPY001", # missing copyright header + "UP045" + ] # Allow fix for all enabled rules (when `--fix`) is provided. From 73ea4407f1b0d165f8004ce6b747d58aaa7d11cd Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 12 Feb 2025 19:05:25 +0100 Subject: [PATCH 106/781] :sparkles: add mypy and pydantic --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2b9a1fc5ff..65132efcae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ pre-commit = "^4.0.0" clique = "^2" pyblish-base = "^1.8" attrs = "^24.2.0" +mypy = "==1.15.0" +pydantic-core = "==2.29.0" [tool.poetry.dev-dependencies] @@ -80,7 +82,7 @@ pydocstyle.convention = "google" select = ["ALL"] ignore = [ "PTH", - "ANN101", # must be set in older version of ruff + # "ANN101", # must be set in older version of ruff "ANN204", "COM812", "S603", From 86ed12ffdc0d4f384822450169ec6985f7b9630e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 12 Feb 2025 19:05:47 +0100 Subject: [PATCH 107/781] :sparkles: add poetry.lock to ignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 41389755f1..d61ed2b119 100644 --- a/.gitignore +++ b/.gitignore @@ -81,5 +81,6 @@ dump.sql .editorconfig .pre-commit-config.yaml mypy.ini +poetry.lock .github_changelog_generator From b14e0d39db35da61541ec68d908c61a7e456d131 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 12 Feb 2025 19:06:18 +0100 Subject: [PATCH 108/781] :recycle: finalize transaction --- .../plugins/publish/integrate_traits.py | 191 +++++++++++++++--- .../plugins/publish/test_integrate_traits.py | 4 +- 2 files changed, 161 insertions(+), 34 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 09b4ca5461..4fe06aa22e 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib import copy +import hashlib from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, List @@ -17,9 +18,14 @@ from ayon_api import ( from ayon_api.operations import ( OperationsSession, new_product_entity, - # new_representation_entity, + new_representation_entity, new_version_entity, ) +from ayon_api.utils import create_entity_id +from ayon_core.lib import source_hash +from ayon_core.lib.file_transaction import ( + FileTransaction, +) from ayon_core.pipeline.publish import ( PublishError, get_publish_template_name, @@ -80,6 +86,35 @@ class TransferItem: template_data: dict[str, Any] representation: Representation + @staticmethod + def get_size(file_path: Path) -> int: + """Get size of the file. + + Args: + file_path (Path): File path. + + Returns: + int: Size of the file. + + """ + return file_path.stat().st_size + + + @staticmethod + def get_checksum(file_path: Path) -> str: + """Get checksum of the file. + + Args: + file_path (Path): File path. + + Returns: + str: Checksum of the file. + + """ + return hashlib.sha256( + file_path.read_bytes() + ).hexdigest() + @dataclass class TemplateItem: @@ -99,7 +134,6 @@ class TemplateItem: template_object: AnatomyTemplateItem - @dataclass class RepresentationEntity: """Representation entity data.""" @@ -174,8 +208,6 @@ def get_changed_attributes( return changes - - class IntegrateTraits(pyblish.api.InstancePlugin): """Integrate representations with traits.""" @@ -234,12 +266,44 @@ class IntegrateTraits(pyblish.api.InstancePlugin): instance, representations) # 8) Transfer files + file_transactions = FileTransaction( + log=self.log, + # Enforce unique transfers + allow_queue_replacements=False) for transfer in transfers: self.log.debug( "Transferring file: %s -> %s", transfer.source, transfer.destination ) + file_transactions.add( + transfer.source.as_posix(), + transfer.destination.as_posix(), + mode=FileTransaction.MODE_COPY, + ) + file_transactions.process() + self.log.debug( + "Transferred files %s", [file_transactions.transferred]) + + # 9) Create representation entities + for representation in representations: + representation_entity = new_representation_entity( + representation.name, + version_entity["id"], + files=self._get_legacy_files_for_representation( + transfers, + representation, + anatomy=instance.context.data["anatomy"]), + attribs={}, + data="", + tags=[], + status="", + ) + # add traits to representation entity + representation_entity["traits"] = representation.traits_as_dict() + + # 10) Commit the session to AYON + op_session.commit() def get_transfers_from_representations( self, @@ -329,25 +393,31 @@ class IntegrateTraits(pyblish.api.InstancePlugin): If `originalDirname` or `stagingDir` is set in instance data, this will return it as rootless path. The path must reside within the project directory. + + Returns: + str: Relative path to the root of the project directory. + + Raises: + PublishError: If the path is not within the project directory. + """ original_directory = ( instance.data.get("originalDirname") or instance.data.get("stagingDir")) anatomy = instance.context.data["anatomy"] - _rootless = self.get_rootless_path(anatomy, original_directory) + rootless = self.get_rootless_path(anatomy, original_directory) # this check works because _rootless will be the same as # original_directory if the original_directory cannot be transformed # to the rootless path. - if _rootless == original_directory: + if rootless == original_directory: msg = ( f"Destination path '{original_directory}' must " "be in project directory.") raise PublishError(msg) # the root is at the beginning - {root[work]}/rest/of/the/path - relative_path_start = _rootless.rfind("}") + 2 - return _rootless[relative_path_start:] - + relative_path_start = rootless.rfind("}") + 2 + return rootless[relative_path_start:] # 8) Transfer files # 9) Commit the session to AYON @@ -670,19 +740,37 @@ class IntegrateTraits(pyblish.api.InstancePlugin): self.log.warning(( 'Could not find root path for remapping "%s".' " This may cause issues on farm." - ),path) + ), path) return path def get_attributes_for_type( self, context: pyblish.api.Context, entity_type: str) -> dict: - """Get AYON attributes for the given entity type.""" + """Get AYON attributes for the given entity type. + + Args: + context (pyblish.api.Context): Context to get attributes from. + entity_type (str): Entity type to get attributes for. + + Returns: + dict: AYON attributes for the given entity type. + + """ return self.get_attributes_by_type(context)[entity_type] + @staticmethod def get_attributes_by_type( - self, context: pyblish.api.Context) -> dict: - """Gets AYON attributes from the given context.""" + context: pyblish.api.Context) -> dict: + """Gets AYON attributes from the given context. + + Args: + context (pyblish.api.Context): Context to get attributes from. + + Returns: + dict: AYON attributes. + + """ attributes = context.data.get("ayonAttributes") if attributes is None: attributes = { @@ -749,7 +837,6 @@ class IntegrateTraits(pyblish.api.InstancePlugin): return template_data - @staticmethod def get_transfers_from_file_locations( representation: Representation, @@ -766,6 +853,9 @@ class IntegrateTraits(pyblish.api.InstancePlugin): transfers (list): List of transfers. template_item (TemplateItem): Template item. + Raises: + PublishError: If representation is invalid. + """ if representation.contains_trait(Sequence): IntegrateTraits.get_transfers_from_sequence( @@ -788,7 +878,6 @@ class IntegrateTraits(pyblish.api.InstancePlugin): ) raise PublishError(msg) - @staticmethod def get_transfers_from_sequence( representation: Representation, @@ -844,8 +933,10 @@ class IntegrateTraits(pyblish.api.InstancePlugin): TransferItem( source=file_loc.file_path, destination=Path(template_filled), - size=file_loc.file_size, - checksum=file_loc.file_hash, + size=file_loc.file_size or TransferItem.get_size( + file_loc.file_path), + checksum=file_loc.file_hash or TransferItem.get_checksum( + file_loc.file_path), template=template_item.template, template_data=template_item.template_data, representation=representation, @@ -859,7 +950,6 @@ class IntegrateTraits(pyblish.api.InstancePlugin): data=template_item.template_data )) - @staticmethod def get_transfers_from_udim( representation: Representation, @@ -900,8 +990,10 @@ class IntegrateTraits(pyblish.api.InstancePlugin): TransferItem( source=file_loc.file_path, destination=Path(template_filled), - size=file_loc.file_size, - checksum=file_loc.file_hash, + size=file_loc.file_size or TransferItem.get_size( + file_loc.file_path), + checksum=file_loc.file_hash or TransferItem.get_checksum( + file_loc.file_path), template=template_item.template, template_data=template_item.template_data, representation=representation, @@ -955,8 +1047,10 @@ class IntegrateTraits(pyblish.api.InstancePlugin): TransferItem( source=file_loc.file_path, destination=Path(template_filled), - size=file_loc.file_size, - checksum=file_loc.file_hash, + size=file_loc.file_size or TransferItem.get_size( + file_loc.file_path), + checksum=file_loc.file_hash or TransferItem.get_checksum( + file_loc.file_path), template=template_item.template, template_data=template_item.template_data, representation=representation, @@ -1009,17 +1103,48 @@ class IntegrateTraits(pyblish.api.InstancePlugin): sub_representation, template_item, transfers ) -def create_representation_entity(representation: Representation) -> dict: - """Create representation entity. + def _prepare_file_info( + self, path: Path, anatomy: Anatomy) -> dict[str, Any]: + """Prepare information for one file (asset or resource). - Args: - representation (Representation): Representation to create entity for. + Arguments: + path (Path): Destination url of published file. + anatomy (Anatomy): Project anatomy part from instance. - Returns: - dict: Representation entity. + Returns: + dict[str, Any]: Representation file info dictionary. - """ - return { - "name": representation.name, - "traits": representation.get_traits_data(), - } + """ + return { + "id": create_entity_id(), + "name": path.name, + "path": self.get_rootless_path(anatomy, path.as_posix()), + "size": path.stat().st_size, + "hash": source_hash(path.as_posix()), + "hash_type": "op3", + } + + def _get_legacy_files_for_representation( + self, + transfer_items: list[TransferItem], + representation: Representation, + anatomy: Anatomy, + ) -> list[dict[str, str]]: + """Get legacy files for a given representation. + + Returns: + list: List of legacy files. + + """ + selected: list[TransferItem] = [] + selected.extend( + item + for item in transfer_items + if item.representation == representation + ) + files: list[dict[str, str]] = [] + files.extend( + self._prepare_file_info(item.destination, anatomy) + for item in selected + ) + return files diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index 95c0eb52a8..7a0be5ed9d 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -2,6 +2,7 @@ from __future__ import annotations import base64 +import re import time from pathlib import Path @@ -163,7 +164,8 @@ def mock_context( ), Sequence( frame_padding=4, - frame_regex=r"img\.(?P(?P0*)\d{4})\.png$", + frame_regex=re.compile( + r"img\.(?P(?P0*)\d{4})\.png$"), ), FileLocations( file_paths=file_locations, From 8bcab93793de1c35b0da2907eea68a745a70a17f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 13 Feb 2025 10:05:15 +0100 Subject: [PATCH 109/781] :dog: calm ruff --- client/ayon_core/addon/interfaces.py | 34 +++++++++++++++++-- client/ayon_core/pipeline/traits/lifecycle.py | 10 ++++-- .../pipeline/traits/test_content_traits.py | 9 ++--- .../pipeline/traits/test_time_traits.py | 24 +++++++++++-- .../ayon_core/pipeline/traits/test_traits.py | 30 ++++++++++------ 5 files changed, 87 insertions(+), 20 deletions(-) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index 303cdf3941..fa7acb6380 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -83,6 +83,10 @@ class IPluginPaths(AYONInterface): """Receive launcher actions paths. Give addons ability to add launcher actions paths. + + Returns: + list[str]: List of launcher action paths. + """ return self._get_plugin_paths_by_type("actions") @@ -98,6 +102,9 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. + Returns: + list[str]: List of create plugin paths. + """ return self._get_plugin_paths_by_type("create") @@ -113,6 +120,9 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. + Returns: + list[str]: List of load plugin paths. + """ return self._get_plugin_paths_by_type("load") @@ -128,6 +138,9 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. + Returns: + list[str]: List of publish plugin paths. + """ return self._get_plugin_paths_by_type("publish") @@ -143,6 +156,9 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. + Returns: + list[str]: List of inventory action plugin paths. + """ return self._get_plugin_paths_by_type("inventory") @@ -236,7 +252,12 @@ class ITrayAddon(AYONInterface): @staticmethod def admin_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu: - """Get or create admin submenu.""" + """Get or create admin submenu. + + Returns: + QtWidgets.QMenu: Admin submenu. + + """ if ITrayAddon._admin_submenu is None: from qtpy import QtWidgets @@ -248,7 +269,16 @@ class ITrayAddon(AYONInterface): @staticmethod def add_action_to_admin_submenu( label: str, tray_menu: QtWidgets.QMenu) -> QtWidgets.QAction: - """Add action to admin submenu.""" + """Add action to admin submenu. + + Args: + label (str): Label of action. + tray_menu (QtWidgets.QMenu): Tray menu to add action to. + + Returns: + QtWidgets.QAction: Action added to admin submenu + + """ from qtpy import QtWidgets menu = ITrayAddon.admin_submenu(tray_menu) diff --git a/client/ayon_core/pipeline/traits/lifecycle.py b/client/ayon_core/pipeline/traits/lifecycle.py index be87a86cbc..2de90824e9 100644 --- a/client/ayon_core/pipeline/traits/lifecycle.py +++ b/client/ayon_core/pipeline/traits/lifecycle.py @@ -26,8 +26,10 @@ class Transient(TraitBase): Args: representation (Representation): Representation model. - Returns: - bool: True if representation is valid, False otherwise. + Raises: + TraitValidationError: If representation is marked as both + Persistent and Transient. + """ if representation.contains_trait(Persistent): msg = "Representation is marked as both Persistent and Transient." @@ -57,6 +59,10 @@ class Persistent(TraitBase): Args: representation (Representation): Representation model. + Raises: + TraitValidationError: If representation is marked + as both Persistent and Transient. + """ if representation.contains_trait(Transient): msg = "Representation is marked as both Persistent and Transient." diff --git a/tests/client/ayon_core/pipeline/traits/test_content_traits.py b/tests/client/ayon_core/pipeline/traits/test_content_traits.py index 78b905bab4..c1c8dcc25d 100644 --- a/tests/client/ayon_core/pipeline/traits/test_content_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_content_traits.py @@ -1,6 +1,7 @@ """Tests for the content traits.""" from __future__ import annotations +import re from pathlib import Path import pytest @@ -59,9 +60,9 @@ def test_bundles() -> None: sub_representation = Representation(name="test", traits=item) assert sub_representation.contains_trait(trait=Image) sub: MimeType = sub_representation.get_trait(trait=MimeType) - assert sub.mime_type in [ + assert sub.mime_type in { "image/jpeg", "image/tiff" - ] + } def test_file_locations_validation() -> None: @@ -94,7 +95,7 @@ def test_file_locations_validation() -> None: ) representation.add_trait(frameranged_trait) - # it should still validate fine + # it should still validate fine file_locations_trait.validate_trait(representation) # create empty file locations trait @@ -165,7 +166,7 @@ def test_get_file_location_from_frame() -> None: # test with custom regex sequence = Sequence( frame_padding=4, - frame_regex=r"boo_(?P(?P0*)\d+)\.exr") + frame_regex=re.compile("boo_(?P(?P0*)\d+)\.exr")) file_locations_list = [ FileLocation( file_path=Path(f"/path/to/boo_{frame}.exr"), diff --git a/tests/client/ayon_core/pipeline/traits/test_time_traits.py b/tests/client/ayon_core/pipeline/traits/test_time_traits.py index 100e4ed2b5..28ace89910 100644 --- a/tests/client/ayon_core/pipeline/traits/test_time_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_time_traits.py @@ -1,6 +1,7 @@ """Tests for the time related traits.""" from __future__ import annotations +import re from pathlib import Path import pytest @@ -183,6 +184,26 @@ def test_sequence_validations() -> None: with pytest.raises(TraitValidationError): representation.validate() + representation = Representation(name="test_7", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(996, 1050 + 1) # because range is zero based + ]), + Sequence( + frame_padding=4, + frame_regex=re.compile( + r"img\.(?P(?P0*)\d{4})\.png$")), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ) + ]) + representation.validate() def test_list_spec_to_frames() -> None: @@ -204,7 +225,7 @@ def test_list_spec_to_frames() -> None: assert Sequence.list_spec_to_frames("1") == [1] with pytest.raises( ValueError, - match="Invalid frame number in the list: .*"): + match=r"Invalid frame number in the list: .*"): Sequence.list_spec_to_frames("a") @@ -225,4 +246,3 @@ def test_sequence_get_frame_padding() -> None: assert Sequence.get_frame_padding( file_locations=representation.get_trait(FileLocations)) == 4 - diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index 42933056fa..b990c074d3 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -36,19 +36,27 @@ REPRESENTATION_DATA: dict = { }, } + class UpgradedImage(Image): """Upgraded image class.""" id = "ayon.2d.Image.v2" @classmethod def upgrade(cls, data: dict) -> UpgradedImage: # noqa: ARG003 - """Upgrade the trait.""" + """Upgrade the trait. + + Returns: + UpgradedImage: Upgraded image instance. + + """ return cls() + class InvalidTrait: """Invalid trait class.""" foo = "bar" + @pytest.fixture def representation() -> Representation: """Return a traits data instance.""" @@ -59,10 +67,11 @@ def representation() -> Representation: Planar(**REPRESENTATION_DATA[Planar.id]), ]) + def test_representation_errors(representation: Representation) -> None: """Test errors in representation.""" with pytest.raises(ValueError, - match="Invalid trait .* - ID is required."): + match=r"Invalid trait .* - ID is required."): representation.add_trait(InvalidTrait()) with pytest.raises(ValueError, @@ -70,9 +79,10 @@ def test_representation_errors(representation: Representation) -> None: representation.add_trait(Image()) with pytest.raises(ValueError, - match="Trait with ID .* not found."): + match=r"Trait with ID .* not found."): representation.remove_trait_by_id("foo") + def test_representation_traits(representation: Representation) -> None: """Test setting and getting traits.""" assert representation.get_trait_by_id( @@ -143,11 +153,12 @@ def test_representation_traits(representation: Representation) -> None: assert representation.contains_traits_by_id( trait_ids=[FileLocation.id, Bundle.id]) is False + def test_trait_removing(representation: Representation) -> None: """Test removing traits.""" - assert representation.contains_trait_by_id("nonexistent") is False + assert representation.contains_trait_by_id("nonexistent") is False with pytest.raises( - ValueError, match="Trait with ID nonexistent not found."): + ValueError, match=r"Trait with ID nonexistent not found."): representation.remove_trait_by_id("nonexistent") assert representation.contains_trait(trait=FileLocation) is True @@ -168,6 +179,7 @@ def test_trait_removing(representation: Representation) -> None: ValueError, match=f"Trait with ID {Image.id} not found."): representation.remove_trait(Image) + def test_representation_dict_properties( representation: Representation) -> None: """Test representation as dictionary.""" @@ -224,6 +236,7 @@ def test_get_version_from_id() -> None: assert TestMimeType(mime_type="foo/bar").get_version() is None + def test_get_versionless_id() -> None: """Test getting versionless trait ID.""" assert Image().get_versionless_id() == "ayon.2d.Image" @@ -271,7 +284,7 @@ def test_from_dict() -> None: }, } - with pytest.raises(ValueError, match="Trait model with ID .* not found."): + with pytest.raises(ValueError, match=r"Trait model with ID .* not found."): representation = Representation.from_dict( "test", trait_data=traits_data) @@ -302,6 +315,7 @@ def test_from_dict() -> None: "test", trait_data=traits_data) """ + def test_representation_equality() -> None: """Test representation equality.""" # rep_a and rep_b are equal @@ -348,7 +362,6 @@ def test_representation_equality() -> None: Planar(planar_configuration="RGBA"), ]) - # lets assume ids are the same (because ids are randomly generated) rep_b.representation_id = rep_d.representation_id = rep_a.representation_id rep_c.representation_id = rep_e.representation_id = rep_a.representation_id @@ -365,6 +378,3 @@ def test_representation_equality() -> None: assert rep_d != rep_e # because of the trait difference assert rep_d != rep_f - - - From dd05199aed2e7df0bf4c5239e8374b23e6fc659d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 13 Feb 2025 10:08:28 +0100 Subject: [PATCH 110/781] :dog: calm ruff even more --- client/ayon_core/addon/interfaces.py | 38 ++++++++++++++----- .../pipeline/traits/test_content_traits.py | 2 +- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index fa7acb6380..51242e1fc1 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -230,8 +230,8 @@ class ITrayAddon(AYONInterface): self, title: str, message: str, - icon: Optional[QtWidgets.QSystemTrayIcon]=None, - msecs: Optional[int]=None) -> None: + icon: Optional[QtWidgets.QSystemTrayIcon] = None, + msecs: Optional[int] = None) -> None: """Show tray message. Args: @@ -325,11 +325,11 @@ class ITrayAction(ITrayAddon): action.triggered.connect(self.on_action_trigger) self._action_item = action - def tray_start(self) -> None: + def tray_start(self) -> None: # noqa: PLR6301 """Start procedure in tray tool.""" return - def tray_exit(self) -> None: + def tray_exit(self) -> None: # noqa: PLR6301 """Cleanup method which is executed on tray shutdown.""" return @@ -357,7 +357,12 @@ class ITrayService(ITrayAddon): @staticmethod def services_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu: - """Get or create services submenu.""" + """Get or create services submenu. + + Returns: + QtWidgets.QMenu: Services submenu. + + """ if ITrayService._services_submenu is None: from qtpy import QtWidgets @@ -390,21 +395,36 @@ class ITrayService(ITrayAddon): @staticmethod def get_icon_running() -> QtWidgets.QIcon: - """Get running icon.""" + """Get running icon. + + Returns: + QtWidgets.QIcon: Returns "running" icon. + + """ if ITrayService._icon_running is None: ITrayService._load_service_icons() return ITrayService._icon_running @staticmethod def get_icon_idle() -> QtWidgets.QIcon: - """Get idle icon.""" + """Get idle icon. + + Returns: + QtWidgets.QIcon: Returns "idle" icon. + + """ if ITrayService._icon_idle is None: ITrayService._load_service_icons() return ITrayService._icon_idle @staticmethod def get_icon_failed() -> QtWidgets.QIcon: - """Get failed icon.""" + """Get failed icon. + + Returns: + QtWidgets.QIcon: Returns "failed" icon. + + """ if ITrayService._icon_failed is None: ITrayService._load_service_icons() return ITrayService._icon_failed @@ -447,7 +467,7 @@ class IHostAddon(AYONInterface): def host_name(self) -> str: """Name of host which addon represents.""" - def get_workfile_extensions(self) -> list[str]: + def get_workfile_extensions(self) -> list[str]: # noqa: PLR6301 """Define workfile extensions for host. Not all hosts support workfiles thus this is optional implementation. diff --git a/tests/client/ayon_core/pipeline/traits/test_content_traits.py b/tests/client/ayon_core/pipeline/traits/test_content_traits.py index c1c8dcc25d..4aa149b8ee 100644 --- a/tests/client/ayon_core/pipeline/traits/test_content_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_content_traits.py @@ -166,7 +166,7 @@ def test_get_file_location_from_frame() -> None: # test with custom regex sequence = Sequence( frame_padding=4, - frame_regex=re.compile("boo_(?P(?P0*)\d+)\.exr")) + frame_regex=re.compile(r"boo_(?P(?P0*)\d+)\.exr")) file_locations_list = [ FileLocation( file_path=Path(f"/path/to/boo_{frame}.exr"), From 21d1dae0f5b24a0dd1ebf8968acccb5010fc13ad Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 13 Feb 2025 10:09:51 +0100 Subject: [PATCH 111/781] :dog: calm ruff even even more --- .../ayon_core/pipeline/traits/test_two_dimesional_traits.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py b/tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py index 328bd83469..f09d2b0864 100644 --- a/tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py @@ -42,6 +42,7 @@ def test_get_file_location_for_udim() -> None: udim=1001 ) == file_locations_list[0] + def test_get_udim_from_file_location() -> None: """Test get_udim_from_file_location.""" file_location_1 = FileLocation( From 6cf34a854792ffe46451e6a8ee814a3db1a7a458 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 13 Feb 2025 10:57:39 +0100 Subject: [PATCH 112/781] :dog: linting fixes --- .../plugins/publish/integrate_traits.py | 1 - .../plugins/publish/test_integrate_traits.py | 29 ++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 4fe06aa22e..de1bd5fc16 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -99,7 +99,6 @@ class TransferItem: """ return file_path.stat().st_size - @staticmethod def get_checksum(file_path: Path) -> str: """Get checksum of the file. diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index 7a0be5ed9d..450cfb5f9f 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -5,10 +5,10 @@ import base64 import re import time from pathlib import Path +from typing import TYPE_CHECKING import pyblish.api import pytest -import pytest_ayon from ayon_api.operations import ( OperationsSession, ) @@ -32,6 +32,9 @@ from ayon_core.pipeline.version_start import get_versioning_start from ayon_core.plugins.publish.integrate_traits import IntegrateTraits from ayon_core.settings import get_project_settings +if TYPE_CHECKING: + import pytest_ayon + PNG_FILE_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVR4AWNgAAAAAgABc3UBGAAAAABJRU5ErkJggg==" # noqa: E501 SEQUENCE_LENGTH = 10 CURRENT_TIME = time.time() @@ -41,10 +44,10 @@ CURRENT_TIME = time.time() def single_file(tmp_path_factory: pytest.TempPathFactory) -> Path: """Return a temporary image file.""" filename = tmp_path_factory.mktemp("single") / "img.png" - with open(filename, "wb") as f: - f.write(base64.b64decode(PNG_FILE_B64)) + filename.write_bytes(base64.b64decode(PNG_FILE_B64)) return filename + @pytest.fixture(scope="session") def sequence_files(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: """Return a sequence of temporary image files.""" @@ -53,12 +56,12 @@ def sequence_files(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: for i in range(SEQUENCE_LENGTH): frame = i + 1 filename = dir_name / f"img.{frame:04d}.png" - with open(filename, "wb") as f: - f.write(base64.b64decode(PNG_FILE_B64)) + filename.write_bytes(base64.b64decode(PNG_FILE_B64)) files.append(filename) return files -@pytest.fixture() + +@pytest.fixture def mock_context( project: pytest_ayon.ProjectInfo, single_file: Path, @@ -120,10 +123,7 @@ def mock_context( parents = project.folder_entity["path"].lstrip("/").split("/") - hierarchy = "" - if parents: - hierarchy = "/".join(parents) - + hierarchy = "/".join(parents) if parents else "" instance.data["hierarchy"] = hierarchy version_number = get_versioning_start( @@ -137,11 +137,11 @@ def mock_context( instance.data["version"] = version_number - _file_size = len(base64.b64decode(PNG_FILE_B64)) + file_size = len(base64.b64decode(PNG_FILE_B64)) file_locations = [ FileLocation( file_path=f, - file_size=_file_size) + file_size=file_size) for f in sequence_files] instance.data["representations_with_traits"] = [ @@ -181,6 +181,7 @@ def mock_context( return context + def test_get_template_name(mock_context: pyblish.api.Context) -> None: """Test get_template_name. @@ -195,6 +196,7 @@ def test_get_template_name(mock_context: pyblish.api.Context) -> None: assert template_name == "default" + def test_filter_lifecycle() -> None: """Test filter_lifecycle.""" integrator = IntegrateTraits() @@ -241,6 +243,7 @@ def test_prepare_product( "id": project.product_entity["id"], } + def test_prepare_version( project: pytest_ayon.ProjectInfo, mock_context: pyblish.api.Context) -> None: @@ -249,7 +252,7 @@ def test_prepare_version( op_session = OperationsSession() product = integrator.prepare_product(mock_context[0], op_session) version = integrator.prepare_version( - mock_context[0], op_session , product) + mock_context[0], op_session, product) assert version == { "attrib": { From 71b69c4ded6707cd66118332133f30450fc89489 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 13 Feb 2025 11:56:36 +0100 Subject: [PATCH 113/781] :bug: fix padding --- client/ayon_core/pipeline/traits/temporal.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/traits/temporal.py b/client/ayon_core/pipeline/traits/temporal.py index 286336ea55..ad4b65d8ee 100644 --- a/client/ayon_core/pipeline/traits/temporal.py +++ b/client/ayon_core/pipeline/traits/temporal.py @@ -384,7 +384,13 @@ class Sequence(TraitBase): """ src_collection = Sequence._get_collection(file_locations) - return len(str(max(src_collection.indexes))) + padding = src_collection.padding + # sometimes Clique doens't get the padding right so + # we need to calculate it manually + if padding == 0: + padding = len(str(max(src_collection.indexes))) + + return padding @staticmethod def get_frame_list( From 575f9c6244fd85fdc4b80d9d5b8376fe76728899 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 14 Feb 2025 13:32:40 +0100 Subject: [PATCH 114/781] :sparkles: add tests for Bundle --- pyproject.toml | 4 +- .../plugins/publish/test_integrate_traits.py | 45 ++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 65132efcae..3e668ca3e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,8 @@ pre-commit = "^4.0.0" clique = "^2" pyblish-base = "^1.8" attrs = "^24.2.0" -mypy = "==1.15.0" pydantic-core = "==2.29.0" +speedcopy = "^2.0.0" [tool.poetry.dev-dependencies] @@ -31,7 +31,7 @@ ruff = "^0.9.3" pre-commit = "^4" codespell = "^2.2.6" semver = "^3.0.2" -mypy = "^1.14.0" +mypy = "==1.15.0" [tool.ruff] diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index 450cfb5f9f..252556fe29 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -14,6 +14,7 @@ from ayon_api.operations import ( ) from ayon_core.pipeline.anatomy import Anatomy from ayon_core.pipeline.traits import ( + Bundle, FileLocation, FileLocations, FrameRanged, @@ -177,6 +178,44 @@ def mock_context( pixel_aspect_ratio=1.0), MimeType(mime_type="image/png"), ]), + Representation(name="test_bundle", traits=[ + Persistent(), + Bundle( + items=[ + [ + FileLocation( + file_path=single_file, + file_size=len(base64.b64decode(PNG_FILE_B64))), + Image(), + MimeType(mime_type="image/png"), + ], + [ + Persistent(), + FrameRanged( + frame_start=1, + frame_end=SEQUENCE_LENGTH, + frame_in=0, + frame_out=SEQUENCE_LENGTH - 1, + frames_per_second="25", + ), + Sequence( + frame_padding=4, + frame_regex=re.compile( + r"img\.(?P(?P0*)\d{4})\.png$"), + ), + FileLocations( + file_paths=file_locations, + ), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + MimeType(mime_type="image/png"), + ], + ], + ), + ]), ] return context @@ -289,4 +328,8 @@ def test_get_transfers_from_representation( transfers = integrator.get_transfers_from_representations( instance, representations) - assert len(transfers) == 11 + assert len(representations) == 3 + assert len(transfers) == 22 + + for transfer in transfers: + ... From 0f0a987d91c52fb847ee51bf7fee7fd99bf6c7db Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Feb 2025 17:35:37 +0100 Subject: [PATCH 115/781] :bug: fix dot in extension --- .../plugins/publish/integrate_traits.py | 15 +++++-- .../plugins/publish/test_integrate_traits.py | 41 ++++++++++++++++++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index de1bd5fc16..72a7ddd479 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -918,8 +918,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template_item.template_data["frame"] = frame template_item.template_data["ext"] = ( - file_loc.file_path.suffix - ) + file_loc.file_path.suffix.lstrip(".")) template_filled = path_template_object.format_strict( template_item.template_data ) @@ -1026,7 +1025,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template_item.template_object["path"] ) template_item.template_data["ext"] = ( - representation.get_trait(FileLocation).file_path.suffix.rstrip(".") + representation.get_trait(FileLocation).file_path.suffix.lstrip(".") ) template_item.template_data.pop("frame", None) with contextlib.suppress(MissingTraitError): @@ -1110,10 +1109,17 @@ class IntegrateTraits(pyblish.api.InstancePlugin): path (Path): Destination url of published file. anatomy (Anatomy): Project anatomy part from instance. + Raises: + PublishError: If file does not exist. + Returns: dict[str, Any]: Representation file info dictionary. """ + if not path.exists(): + msg = f"File '{path}' does not exist." + raise PublishError(msg) + return { "id": create_entity_id(), "name": path.name, @@ -1131,6 +1137,9 @@ class IntegrateTraits(pyblish.api.InstancePlugin): ) -> list[dict[str, str]]: """Get legacy files for a given representation. + This expects the file to exist - it must run after the transfer + is done. + Returns: list: List of legacy files. diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index 252556fe29..98f02bdff6 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -12,6 +12,11 @@ import pytest from ayon_api.operations import ( OperationsSession, ) + +from ayon_core.lib.file_transaction import ( + FileTransaction, +) + from ayon_core.pipeline.anatomy import Anatomy from ayon_core.pipeline.traits import ( Bundle, @@ -30,9 +35,20 @@ from ayon_core.pipeline.version_start import get_versioning_start # Tagged, # TemplatePath, -from ayon_core.plugins.publish.integrate_traits import IntegrateTraits +from ayon_core.plugins.publish.integrate_traits import ( + IntegrateTraits, + TransferItem, +) + from ayon_core.settings import get_project_settings +from ayon_api.operations import ( + OperationsSession, + new_product_entity, + new_representation_entity, + new_version_entity, +) + if TYPE_CHECKING: import pytest_ayon @@ -315,6 +331,9 @@ def test_get_transfers_from_representation( mock_context: pyblish.api.Context) -> None: """Test get_transfers_from_representation. + This tests getting actual transfers from the representations and + also the legacy files. + Todo: This test will benefit massively from a proper mocking of the context. We need to parametrize the test with different representations and test the output of the function. @@ -332,4 +351,22 @@ def test_get_transfers_from_representation( assert len(transfers) == 22 for transfer in transfers: - ... + assert transfer.checksum == TransferItem.get_checksum( + transfer.source) + + file_transactions = FileTransaction( + # Enforce unique transfers + allow_queue_replacements=False) + + for transfer in transfers: + file_transactions.add( + transfer.source.as_posix(), + transfer.destination.as_posix(), + mode=FileTransaction.MODE_COPY, + ) + + file_transactions.process() + + for representation in representations: + files = integrator._get_legacy_files_for_representation( # noqa: SLF001 + transfers, representation, anatomy=instance.data["anatomy"]) From 534be2c64e0f2ac0f3355f4d413c3edc0fcde3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 3 Mar 2025 16:31:26 +0100 Subject: [PATCH 116/781] :recycle: change pydantic models to pure dataclasses --- client/ayon_core/pipeline/traits/README.md | 9 ++- client/ayon_core/pipeline/traits/color.py | 12 ++-- client/ayon_core/pipeline/traits/content.py | 47 ++++++++++------ .../ayon_core/pipeline/traits/cryptography.py | 21 +++---- client/ayon_core/pipeline/traits/lifecycle.py | 16 +++++- client/ayon_core/pipeline/traits/meta.py | 54 +++++++++++------- client/ayon_core/pipeline/traits/temporal.py | 55 ++++++++++--------- .../pipeline/traits/three_dimensional.py | 19 +++++-- client/ayon_core/pipeline/traits/trait.py | 23 +------- .../pipeline/traits/two_dimensional.py | 42 +++++++++----- client/pyproject.toml | 1 - pyproject.toml | 1 - 12 files changed, 174 insertions(+), 126 deletions(-) diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md index aa38d30a8e..1566b4f353 100644 --- a/client/ayon_core/pipeline/traits/README.md +++ b/client/ayon_core/pipeline/traits/README.md @@ -184,10 +184,13 @@ to different packages based on their use: | | Overscan | holds overscan/underscan information (added pixels to bottom/sides) | | UDIM | Representation is UDIM tile set -Traits are [Pydantic models](https://docs.pydantic.dev/latest/) with optional +Traits are Python data classes with optional validation and helper methods. If they implement `TraitBase.validate(Representation)` method, they can validate against all other traits -in the representation if needed. They can also implement pydantic form of -data validators. +in the representation if needed. + +> [!NOTE] +> They could be easily converted to [Pydantic models](https://docs.pydantic.dev/latest/) but since this must run in diverse Python environments inside DCC, we cannot +> easily resolve pydantic-core dependency (as it is binary written in Rust). > [!NOTE] > Every trait has id, name and some human readable description. Every trait diff --git a/client/ayon_core/pipeline/traits/color.py b/client/ayon_core/pipeline/traits/color.py index b816593624..491131c8bc 100644 --- a/client/ayon_core/pipeline/traits/color.py +++ b/client/ayon_core/pipeline/traits/color.py @@ -1,13 +1,13 @@ """Color management related traits.""" from __future__ import annotations +from dataclasses import dataclass from typing import ClassVar, Optional -from pydantic import Field - from .trait import TraitBase +@dataclass class ColorManaged(TraitBase): """Color managed trait. @@ -24,9 +24,7 @@ class ColorManaged(TraitBase): id: ClassVar[str] = "ayon.color.ColorManaged.v1" name: ClassVar[str] = "ColorManaged" + color_space: str description: ClassVar[str] = "Color Managed trait." - color_space: str = Field( - ..., - description="Color space." - ) - config: Optional[str] = Field(default=None, description="Color config.") + persistent: ClassVar[bool] = True + config: Optional[str] = None diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 4ee63b2b08..9bb43fcdb3 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -3,13 +3,12 @@ from __future__ import annotations import contextlib import re +from dataclasses import dataclass # TC003 is there because Path in TYPECHECKING will fail in tests from pathlib import Path # noqa: TC003 from typing import ClassVar, Generator, Optional -from pydantic import Field - from .representation import Representation from .temporal import FrameRanged, Handles, Sequence from .trait import ( @@ -21,6 +20,7 @@ from .two_dimensional import UDIM from .utils import get_sequence_from_files +@dataclass class MimeType(TraitBase): """MimeType trait model. @@ -40,9 +40,11 @@ class MimeType(TraitBase): name: ClassVar[str] = "MimeType" description: ClassVar[str] = "MimeType Trait Model" id: ClassVar[str] = "ayon.content.MimeType.v1" - mime_type: str = Field(..., title="Mime Type") + persistent: ClassVar[bool] = True + mime_type: str +@dataclass class LocatableContent(TraitBase): """LocatableContent trait model. @@ -57,15 +59,18 @@ class LocatableContent(TraitBase): description (str): Trait description. id (str): id should be namespaced trait name with version location (str): Location. + is_templated (Optional[bool]): Is the location templated? Default is None. """ name: ClassVar[str] = "LocatableContent" description: ClassVar[str] = "LocatableContent Trait Model" id: ClassVar[str] = "ayon.content.LocatableContent.v1" - location: str = Field(..., title="Location") - is_templated: Optional[bool] = Field(default=None, title="Is Templated") + persistent: ClassVar[bool] = True + location: str + is_templated: Optional[bool] = None +@dataclass class FileLocation(TraitBase): """FileLocation trait model. @@ -78,18 +83,20 @@ class FileLocation(TraitBase): description (str): Trait description. id (str): id should be namespaced trait name with version file_path (str): File path. - file_size (int): File size in bytes. - file_hash (str): File hash. + file_size (Optional[int]): File size in bytes. + file_hash (Optional[str]): File hash. """ name: ClassVar[str] = "FileLocation" description: ClassVar[str] = "FileLocation Trait Model" id: ClassVar[str] = "ayon.content.FileLocation.v1" - file_path: Path = Field(..., title="File Path") - file_size: Optional[int] = Field(default=None, title="File Size") - file_hash: Optional[str] = Field(default=None, title="File Hash") + persistent: ClassVar[bool] = True + file_path: Path + file_size: Optional[int] = None + file_hash: Optional[str] = None +@dataclass class FileLocations(TraitBase): """FileLocation trait model. @@ -108,7 +115,8 @@ class FileLocations(TraitBase): name: ClassVar[str] = "FileLocations" description: ClassVar[str] = "FileLocations Trait Model" id: ClassVar[str] = "ayon.content.FileLocations.v1" - file_paths: list[FileLocation] = Field(..., title="File Path") + persistent: ClassVar[bool] = True + file_paths: list[FileLocation] def get_files(self) -> Generator[Path, None, None]: """Get all file paths from the trait. @@ -340,6 +348,7 @@ class FileLocations(TraitBase): return frame_start_with_handles, frame_end_with_handles +@dataclass class RootlessLocation(TraitBase): """RootlessLocation trait model. @@ -363,9 +372,11 @@ class RootlessLocation(TraitBase): name: ClassVar[str] = "RootlessLocation" description: ClassVar[str] = "RootlessLocation Trait Model" id: ClassVar[str] = "ayon.content.RootlessLocation.v1" - rootless_path: str = Field(..., title="File Path") + persistent: ClassVar[bool] = True + rootless_path: str +@dataclass class Compressed(TraitBase): """Compressed trait model. @@ -386,9 +397,11 @@ class Compressed(TraitBase): name: ClassVar[str] = "Compressed" description: ClassVar[str] = "Compressed Trait" id: ClassVar[str] = "ayon.content.Compressed.v1" - compression_type: str = Field(..., title="Compression Type") + persistent: ClassVar[bool] = True + compression_type: str +@dataclass class Bundle(TraitBase): """Bundle trait model. @@ -424,8 +437,8 @@ class Bundle(TraitBase): name: ClassVar[str] = "Bundle" description: ClassVar[str] = "Bundle Trait" id: ClassVar[str] = "ayon.content.Bundle.v1" - items: list[list[TraitBase]] = Field( - ..., title="Bundles of traits") + persistent: ClassVar[bool] = True + items: list[list[TraitBase]] def to_representations(self) -> Generator[Representation]: """Convert bundle to representations. @@ -438,6 +451,7 @@ class Bundle(TraitBase): yield Representation(name=f"{self.name} {idx}", traits=item) +@dataclass class Fragment(TraitBase): """Fragment trait model. @@ -466,4 +480,5 @@ class Fragment(TraitBase): name: ClassVar[str] = "Fragment" description: ClassVar[str] = "Fragment Trait" id: ClassVar[str] = "ayon.content.Fragment.v1" - parent: str = Field(..., title="Parent Representation Id") + persistent: ClassVar[bool] = True + parent: str diff --git a/client/ayon_core/pipeline/traits/cryptography.py b/client/ayon_core/pipeline/traits/cryptography.py index 3719f3dbbf..d9445bd543 100644 --- a/client/ayon_core/pipeline/traits/cryptography.py +++ b/client/ayon_core/pipeline/traits/cryptography.py @@ -1,13 +1,13 @@ """Cryptography traits.""" from __future__ import annotations +from dataclasses import dataclass from typing import ClassVar, Optional -from pydantic import Field - from .trait import TraitBase +@dataclass class DigitallySigned(TraitBase): """Digitally signed trait. @@ -20,25 +20,22 @@ class DigitallySigned(TraitBase): id: ClassVar[str] = "ayon.cryptography.DigitallySigned.v1" name: ClassVar[str] = "DigitallySigned" description: ClassVar[str] = "Digitally signed trait." + persistent: ClassVar[bool] = True - +@dataclass class PGPSigned(DigitallySigned): """PGP signed trait. This trait holds PGP (RFC-4880) signed data. Attributes: - signature (str): PGP signature. + signed_data (str): Signed data. + clear_text (str): Clear text. """ id: ClassVar[str] = "ayon.cryptography.PGPSigned.v1" name: ClassVar[str] = "PGPSigned" description: ClassVar[str] = "PGP signed trait." - signed_data: str = Field( - ..., - description="Signed data." - ) - clear_text: Optional[str] = Field( - None, - description="Clear text." - ) + persistent: ClassVar[bool] = True + signed_data: str + clear_text: Optional[str] = None diff --git a/client/ayon_core/pipeline/traits/lifecycle.py b/client/ayon_core/pipeline/traits/lifecycle.py index be87a86cbc..6f38525c47 100644 --- a/client/ayon_core/pipeline/traits/lifecycle.py +++ b/client/ayon_core/pipeline/traits/lifecycle.py @@ -1,9 +1,11 @@ """Lifecycle traits.""" +from dataclasses import dataclass from typing import ClassVar from .trait import TraitBase, TraitValidationError +@dataclass class Transient(TraitBase): """Transient trait model. @@ -19,6 +21,7 @@ class Transient(TraitBase): name: ClassVar[str] = "Transient" description: ClassVar[str] = "Transient Trait Model" id: ClassVar[str] = "ayon.lifecycle.Transient.v1" + persistent: ClassVar[bool] = True # see note in Persistent def validate_trait(self, representation) -> None: # noqa: ANN001 """Validate representation is not Persistent. @@ -26,14 +29,16 @@ class Transient(TraitBase): Args: representation (Representation): Representation model. - Returns: - bool: True if representation is valid, False otherwise. + Raises: + TraitValidationError: If representation is marked as both + """ if representation.contains_trait(Persistent): msg = "Representation is marked as both Persistent and Transient." raise TraitValidationError(self.name, msg) +@dataclass class Persistent(TraitBase): """Persistent trait model. @@ -50,6 +55,10 @@ class Persistent(TraitBase): name: ClassVar[str] = "Persistent" description: ClassVar[str] = "Persistent Trait Model" id: ClassVar[str] = "ayon.lifecycle.Persistent.v1" + # note that this affects persistence of the trait itself, not + # the representation. This is a class variable, so it is shared + # among all instances of the class. + persistent: bool = True def validate_trait(self, representation) -> None: # noqa: ANN001 """Validate representation is not Transient. @@ -57,6 +66,9 @@ class Persistent(TraitBase): Args: representation (Representation): Representation model. + Raises: + TraitValidationError: If representation is marked as both + """ if representation.contains_trait(Transient): msg = "Representation is marked as both Persistent and Transient." diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py index 0f8c175af5..3bf4a87a0b 100644 --- a/client/ayon_core/pipeline/traits/meta.py +++ b/client/ayon_core/pipeline/traits/meta.py @@ -1,13 +1,13 @@ """Metadata traits.""" from __future__ import annotations +from dataclasses import dataclass from typing import ClassVar, List, Optional -from pydantic import Field - from .trait import TraitBase +@dataclass class Tagged(TraitBase): """Tagged trait model. @@ -27,9 +27,11 @@ class Tagged(TraitBase): name: ClassVar[str] = "Tagged" description: ClassVar[str] = "Tagged Trait Model" id: ClassVar[str] = "ayon.meta.Tagged.v1" - tags: List[str] = Field(..., title="Tags") + persistent: ClassVar[bool] = True + tags: List[str] +@dataclass class TemplatePath(TraitBase): """TemplatePath trait model. @@ -51,10 +53,12 @@ class TemplatePath(TraitBase): name: ClassVar[str] = "TemplatePath" description: ClassVar[str] = "Template Path Trait Model" id: ClassVar[str] = "ayon.meta.TemplatePath.v1" - template: str = Field(..., title="Template Path") - data: dict = Field(..., title="Formatting Data") + persistent: ClassVar[bool] = True + template: str + data: dict +@dataclass class Variant(TraitBase): """Variant trait model. @@ -75,9 +79,11 @@ class Variant(TraitBase): name: ClassVar[str] = "Variant" description: ClassVar[str] = "Variant Trait Model" id: ClassVar[str] = "ayon.meta.Variant.v1" - variant: str = Field(..., title="Variant") + persistent: ClassVar[bool] = True + variant: str +@dataclass class KeepOriginalLocation(TraitBase): """Keep files in its original location. @@ -88,9 +94,10 @@ class KeepOriginalLocation(TraitBase): name: ClassVar[str] = "KeepOriginalLocation" description: ClassVar[str] = "Keep Original Location Trait Model" id: ClassVar[str] = "ayon.meta.KeepOriginalLocation.v1" - persistent: bool = Field(default=False, title="Persistent") + persistent: ClassVar[bool] = False +@dataclass class KeepOriginalName(TraitBase): """Keep files in its original name. @@ -101,9 +108,10 @@ class KeepOriginalName(TraitBase): name: ClassVar[str] = "KeepOriginalName" description: ClassVar[str] = "Keep Original Name Trait Model" id: ClassVar[str] = "ayon.meta.KeepOriginalName.v1" - persistent: bool = Field(default=False, title="Persistent") + persistent: ClassVar[bool] = False +@dataclass class SourceApplication(TraitBase): """Metadata about the source (producing) application. @@ -115,22 +123,26 @@ class SourceApplication(TraitBase): Note that this is not really connected to any logic in ayon-applications addon. + Attributes: + application (str): Application name. + variant (str): Application variant. + version (str): Application version. + platform (str): Platform name (Windows, darwin, etc.). + host_name (str): AYON host name if applicable. """ name: ClassVar[str] = "SourceApplication" description: ClassVar[str] = "Source Application Trait Model" id: ClassVar[str] = "ayon.meta.SourceApplication.v1" - application: str = Field(..., title="Application Name") - variant: Optional[str] = Field( - None, title="Application Variant (e.g. Pro)") - version: Optional[str] = Field( - None, title="Application Version") - platform: Optional[str] = Field( - None, title="Platform Name (e.g. Windows)") - host_name: Optional[str] = Field( - None, title="AYON host Name if applicable") + persistent: ClassVar[bool] = True + application: str + variant: Optional[str] = None + version: Optional[str] = None + platform: Optional[str] = None + host_name: Optional[str] = None +@dataclass class IntendedUse(TraitBase): """Intended use of the representation. @@ -138,9 +150,13 @@ class IntendedUse(TraitBase): can be used in cases, where the other traits are not enough to describe the intended use. For example txt file with tracking points can be used as corner pin in After Effect but not in Nuke. - """ + Attributes: + use (str): Intended use description. + + """ name: ClassVar[str] = "IntendedUse" description: ClassVar[str] = "Intended Use Trait Model" id: ClassVar[str] = "ayon.meta.IntendedUse.v1" - use: str = Field(..., title="Intended Use") + persistent: ClassVar[bool] = True + use: str diff --git a/client/ayon_core/pipeline/traits/temporal.py b/client/ayon_core/pipeline/traits/temporal.py index 286336ea55..75025da02b 100644 --- a/client/ayon_core/pipeline/traits/temporal.py +++ b/client/ayon_core/pipeline/traits/temporal.py @@ -3,12 +3,12 @@ from __future__ import annotations import contextlib import re +from dataclasses import dataclass from enum import Enum, auto from re import Pattern from typing import TYPE_CHECKING, ClassVar, Optional import clique -from pydantic import Field, field_validator from .trait import MissingTraitError, TraitBase, TraitValidationError @@ -36,6 +36,7 @@ class GapPolicy(Enum): black = auto() +@dataclass class FrameRanged(TraitBase): """Frame ranged trait model. @@ -70,16 +71,16 @@ class FrameRanged(TraitBase): name: ClassVar[str] = "FrameRanged" description: ClassVar[str] = "Frame Ranged Trait" id: ClassVar[str] = "ayon.time.FrameRanged.v1" - frame_start: int = Field( - ..., title="Start Frame") - frame_end: int = Field( - ..., title="Frame Start") - frame_in: Optional[int] = Field(default=None, title="In Frame") - frame_out: Optional[int] = Field(default=None, title="Out Frame") - frames_per_second: str = Field(..., title="Frames Per Second") - step: Optional[int] = Field(default=1, title="Step") + persistent: ClassVar[bool] = True + frame_start: int + frame_end: int + frame_in: Optional[int] = None + frame_out: Optional[int] = None + frames_per_second: str = None + step: Optional[int] = None +@dataclass class Handles(TraitBase): """Handles trait model. @@ -98,14 +99,13 @@ class Handles(TraitBase): name: ClassVar[str] = "Handles" description: ClassVar[str] = "Handles Trait" id: ClassVar[str] = "ayon.time.Handles.v1" - inclusive: Optional[bool] = Field( - False, title="Handles are inclusive") # noqa: FBT003 - frame_start_handle: Optional[int] = Field( - 0, title="Frame Start Handle") - frame_end_handle: Optional[int] = Field( - 0, title="Frame End Handle") + persistent: ClassVar[bool] = True + inclusive: Optional[bool] = False + frame_start_handle: Optional[int] = None + frame_end_handle: Optional[int] = None +@dataclass class Sequence(TraitBase): """Sequence trait model. @@ -130,15 +130,12 @@ class Sequence(TraitBase): name: ClassVar[str] = "Sequence" description: ClassVar[str] = "Sequence Trait Model" id: ClassVar[str] = "ayon.time.Sequence.v1" - gaps_policy: Optional[GapPolicy] = Field( - default=GapPolicy.forbidden, title="Gaps Policy") - frame_padding: int = Field(..., title="Frame Padding") - frame_regex: Optional[Pattern] = Field( - default=None, title="Frame Regex") - frame_spec: Optional[str] = Field(default=None, - title="Frame Specification") + persistent: ClassVar[bool] = True + frame_padding: int + gaps_policy: Optional[GapPolicy] = GapPolicy.forbidden + frame_regex: Optional[Pattern] = None + frame_spec: Optional[str] = None - @field_validator("frame_regex") @classmethod def validate_frame_regex( cls, v: Optional[Pattern] @@ -426,15 +423,22 @@ class Sequence(TraitBase): # Do we need one for drop and non-drop frame? +@dataclass class SMPTETimecode(TraitBase): - """SMPTE Timecode trait model.""" + """SMPTE Timecode trait model. + + Attributes: + timecode (str): SMPTE Timecode HH:MM:SS:FF + """ name: ClassVar[str] = "Timecode" description: ClassVar[str] = "SMPTE Timecode Trait" id: ClassVar[str] = "ayon.time.SMPTETimecode.v1" - timecode: str = Field(..., title="SMPTE Timecode HH:MM:SS:FF") + persistent: ClassVar[bool] = True + timecode: str +@dataclass class Static(TraitBase): """Static time trait. @@ -444,3 +448,4 @@ class Static(TraitBase): name: ClassVar[str] = "Static" description: ClassVar[str] = "Static Time Trait" id: ClassVar[str] = "ayon.time.Static.v1" + persistent: ClassVar[bool] = True diff --git a/client/ayon_core/pipeline/traits/three_dimensional.py b/client/ayon_core/pipeline/traits/three_dimensional.py index 67f4415f73..d68fb99e61 100644 --- a/client/ayon_core/pipeline/traits/three_dimensional.py +++ b/client/ayon_core/pipeline/traits/three_dimensional.py @@ -1,11 +1,11 @@ """3D traits.""" +from dataclasses import dataclass from typing import ClassVar -from pydantic import Field - from .trait import TraitBase +@dataclass class Spatial(TraitBase): """Spatial trait model. @@ -29,11 +29,13 @@ class Spatial(TraitBase): id: ClassVar[str] = "ayon.3d.Spatial.v1" name: ClassVar[str] = "Spatial" description: ClassVar[str] = "Spatial trait model." - up_axis: str = Field(..., title="Up axis") - handedness: str = Field(..., title="Handedness") - meters_per_unit: float = Field(..., title="Meters per unit") + persistent: ClassVar[bool] = True + up_axis: str + handedness: str + meters_per_unit: float +@dataclass class Geometry(TraitBase): """Geometry type trait model. @@ -45,8 +47,10 @@ class Geometry(TraitBase): id: ClassVar[str] = "ayon.3d.Geometry.v1" name: ClassVar[str] = "Geometry" description: ClassVar[str] = "Geometry trait model." + persistent: ClassVar[bool] = True +@dataclass class Shader(TraitBase): """Shader trait model. @@ -58,8 +62,10 @@ class Shader(TraitBase): id: ClassVar[str] = "ayon.3d.Shader.v1" name: ClassVar[str] = "Shader" description: ClassVar[str] = "Shader trait model." + persistent: ClassVar[bool] = True +@dataclass class Lighting(TraitBase): """Lighting trait model. @@ -71,8 +77,10 @@ class Lighting(TraitBase): id: ClassVar[str] = "ayon.3d.Lighting.v1" name: ClassVar[str] = "Lighting" description: ClassVar[str] = "Lighting trait model." + persistent: ClassVar[bool] = True +@dataclass class IESProfile(TraitBase): """IES profile (IES-LM-64) type trait model. @@ -82,3 +90,4 @@ class IESProfile(TraitBase): id: ClassVar[str] = "ayon.3d.IESProfile.v1" name: ClassVar[str] = "IESProfile" description: ClassVar[str] = "IES profile trait model." + persistent: ClassVar[bool] = True diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index f15c525495..01a7641c59 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -3,16 +3,9 @@ from __future__ import annotations import re from abc import ABC, abstractmethod +from dataclasses import dataclass from typing import TYPE_CHECKING, Generic, Optional, TypeVar -import pydantic.alias_generators -from pydantic import ( - AliasGenerator, - BaseModel, - ConfigDict, - Field, -) - if TYPE_CHECKING: from .representation import Representation @@ -20,25 +13,15 @@ if TYPE_CHECKING: T = TypeVar("T", bound="TraitBase") -class TraitBase(ABC, BaseModel): +@dataclass +class TraitBase(ABC): """Base trait model. This model must be used as a base for all trait models. - It is using Pydantic BaseModel for serialization and validation. ``id``, ``name``, and ``description`` are abstract attributes that must be implemented in the derived classes. """ - model_config = ConfigDict( - alias_generator=AliasGenerator( - serialization_alias=pydantic.alias_generators.to_camel, - ) - ) - - persistent: bool = Field( - default=True, title="Persistent", - description="Whether the trait is persistent (integrated) or not.") - @property @abstractmethod def id(self) -> str: diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py index 77aa5767d6..93d7d8c86a 100644 --- a/client/ayon_core/pipeline/traits/two_dimensional.py +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -2,16 +2,16 @@ from __future__ import annotations import re +from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, Optional -from pydantic import Field, field_validator - from .trait import TraitBase if TYPE_CHECKING: from .content import FileLocation, FileLocations +@dataclass class Image(TraitBase): """Image trait model. @@ -26,8 +26,10 @@ class Image(TraitBase): name: ClassVar[str] = "Image" description: ClassVar[str] = "Image Trait" id: ClassVar[str] = "ayon.2d.Image.v1" + persistent: ClassVar[bool] = True +@dataclass class PixelBased(TraitBase): """PixelBased trait model. @@ -45,11 +47,13 @@ class PixelBased(TraitBase): name: ClassVar[str] = "PixelBased" description: ClassVar[str] = "PixelBased Trait Model" id: ClassVar[str] = "ayon.2d.PixelBased.v1" - display_window_width: int = Field(..., title="Display Window Width") - display_window_height: int = Field(..., title="Display Window Height") - pixel_aspect_ratio: float = Field(..., title="Pixel Aspect Ratio") + persistent: ClassVar[bool] = True + display_window_width: int + display_window_height: int + pixel_aspect_ratio: float +@dataclass class Planar(TraitBase): """Planar trait model. @@ -57,7 +61,7 @@ class Planar(TraitBase): Todo: * (antirotor): Is this really a planar configuration? As with - bitplanes and everything? If it serves as differentiator for + bit planes and everything? If it serves as differentiator for Deep images, should it be named differently? Like Raster? Attributes: @@ -70,9 +74,11 @@ class Planar(TraitBase): name: ClassVar[str] = "Planar" description: ClassVar[str] = "Planar Trait Model" id: ClassVar[str] = "ayon.2d.Planar.v1" - planar_configuration: str = Field(..., title="Planar-based Image") + persistent: ClassVar[bool] = True + planar_configuration: str +@dataclass class Deep(TraitBase): """Deep trait model. @@ -87,8 +93,10 @@ class Deep(TraitBase): name: ClassVar[str] = "Deep" description: ClassVar[str] = "Deep Trait Model" id: ClassVar[str] = "ayon.2d.Deep.v1" + persistent: ClassVar[bool] = True +@dataclass class Overscan(TraitBase): """Overscan trait model. @@ -108,12 +116,14 @@ class Overscan(TraitBase): name: ClassVar[str] = "Overscan" description: ClassVar[str] = "Overscan Trait" id: ClassVar[str] = "ayon.2d.Overscan.v1" - left: int = Field(..., title="Left Overscan") - right: int = Field(..., title="Right Overscan") - top: int = Field(..., title="Top Overscan") - bottom: int = Field(..., title="Bottom Overscan") + persistent: ClassVar[bool] = True + left: int + right: int + top: int + bottom: int +@dataclass class UDIM(TraitBase): """UDIM trait model. @@ -124,16 +134,18 @@ class UDIM(TraitBase): description (str): Trait description. id (str): id should be namespaced trait name with version udim (int): UDIM value. + udim_regex (str): UDIM regex. """ name: ClassVar[str] = "UDIM" description: ClassVar[str] = "UDIM Trait" id: ClassVar[str] = "ayon.2d.UDIM.v1" - udim: list[int] = Field(..., title="UDIM") - udim_regex: Optional[str] = Field( - default=r"(?:\.|_)(?P\d+)\.\D+\d?$", title="UDIM Regex") + persistent: ClassVar[bool] = True + udim: list[int] + udim_regex: Optional[str] = r"(?:\.|_)(?P\d+)\.\D+\d?$" - @field_validator("udim_regex") + # field validator for udim_regex - this works in pydantic model v2 but not + # with the pure data classes @classmethod def validate_frame_regex(cls, v: Optional[str]) -> Optional[str]: """Validate udim regex. diff --git a/client/pyproject.toml b/client/pyproject.toml index d61baee2c4..edf7f57317 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -10,7 +10,6 @@ pyblish-base = "^1.8.11" speedcopy = "^2.1" six = "^1.15" qtawesome = "0.7.3" -pydantic = "^2.9.2" [ayon.runtimeDependencies] aiohttp-middlewares = "^2.0.0" diff --git a/pyproject.toml b/pyproject.toml index ac291ab636..c8256adfca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ readme = "README.md" [tool.poetry.dependencies] python = ">=3.9.1,<3.10" -pydantic = "^2.9.2" pre-commit = "^4.0.0" clique = "^2" pyblish-base = "^1.8" From d5b2642e8d3f8b3bc6ba48b7dd3ab994efc2c32f Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 13 Mar 2025 14:55:40 +0200 Subject: [PATCH 117/781] implement `createprojectstructure` cli action. --- client/ayon_core/cli.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index d7cd3ba7f5..21354bec4a 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -22,7 +22,7 @@ from ayon_core.lib.env_tools import ( compute_env_variables_structure, merge_env_variables, ) - +from ayon_core.pipeline import project_folders @click.group(invoke_without_command=True) @@ -258,6 +258,28 @@ def _set_global_environments() -> None: os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" +@main_cli.command() +@click.option( + "--project", + type=str, + help="Define project (project must be set).", + required=True) +def createprojectstructure( + project, +): + """Create project folder structure as defined in setting + `ayon+settings://core/project_folder_structure` + + Args: + project (str): The name of the project for which you + want to create its additional folder structure. + + """ + + print(f">>> Creating project folder structure for project '{project}'.") + project_folders.create_project_folders(project) + + def _set_addons_environments(addons_manager): """Set global environments for AYON addons.""" From 4f6ea8709226fd8a3ddfb12bc2aeedb1ae45b100 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 13 Mar 2025 14:59:10 +0200 Subject: [PATCH 118/781] Implement `Create Project Folder Structure` web action --- server/__init__.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/server/__init__.py b/server/__init__.py index d60f50f471..1ae5284f4d 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -1,6 +1,11 @@ from typing import Any from ayon_server.addons import BaseServerAddon +from ayon_server.actions import ( + ActionExecutor, + ExecuteResponseModel, + SimpleActionManifest, +) from .settings import ( CoreSettings, @@ -9,6 +14,9 @@ from .settings import ( ) +IDENTIFIER_PREFIX = "core.launch" + + class CoreAddon(BaseServerAddon): settings_model = CoreSettings @@ -26,3 +34,54 @@ class CoreAddon(BaseServerAddon): return await super().convert_settings_overrides( source_version, overrides ) + + async def get_simple_actions( + self, + project_name: str | None = None, + variant: str = "production", + ) -> list[SimpleActionManifest]: + """Return a list of simple actions provided by the addon""" + output = [] + + # Add 'Create Project Folder Structure' action to folders. + output.append( + SimpleActionManifest( + identifier=f"{IDENTIFIER_PREFIX}.createprojectstructure", + label="Create Project Folder Structure", + icon={ + "type": "material-symbols", + "name": "create_new_folder", + }, + order=100, + entity_type="folder", + entity_subtypes=None, + allow_multiselection=False, + ) + ) + + return output + + async def execute_action( + self, + executor: "ActionExecutor", + ) -> "ExecuteResponseModel": + """Execute actions. + + Note: + Executes CLI actions defined in the + addon's client code or other addons. + + """ + + project_name = executor.context.project_name + + if executor.identifier == \ + f"{IDENTIFIER_PREFIX}.createprojectstructure": + return await executor.get_launcher_action_response( + args=[ + "createprojectstructure", + "--project", project_name, + ] + ) + + raise ValueError(f"Unknown action: {executor.identifier}") From 6c650748637691c999f95ba4b2b53637f7c2770c Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 13 Mar 2025 15:00:10 +0200 Subject: [PATCH 119/781] remove `CreateProjectFoldersAction` launcher action. --- .../plugins/actions/create_project_folders.py | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 client/ayon_core/plugins/actions/create_project_folders.py diff --git a/client/ayon_core/plugins/actions/create_project_folders.py b/client/ayon_core/plugins/actions/create_project_folders.py deleted file mode 100644 index 934a9169d7..0000000000 --- a/client/ayon_core/plugins/actions/create_project_folders.py +++ /dev/null @@ -1,29 +0,0 @@ -from ayon_core.pipeline import LauncherAction, project_folders - - -class CreateProjectFoldersAction(LauncherAction): - """Create project folders as defined in settings.""" - name = "create_project_folders" - label = "Create Project Folders" - icon = "sitemap" - color = "#e0e1e1" - order = 1000 - - def is_compatible(self, selection) -> bool: - - # Disable when the project folder structure setting is empty - # in settings - project_settings = selection.get_project_settings() - folder_structure = ( - project_settings["core"]["project_folder_structure"] - ).strip() - if not folder_structure or folder_structure == "{}": - return False - - return ( - selection.is_project_selected - and not selection.is_folder_selected - ) - - def process(self, selection, **kwargs): - project_folders.create_project_folders(selection.project_name) From 0c5d95d3d105e2d5dde69ec68967e09a85f358b4 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 18 Mar 2025 17:11:43 +0100 Subject: [PATCH 120/781] :bug: dataclasses don't have model_dump --- client/ayon_core/pipeline/traits/representation.py | 10 +++++----- client/ayon_core/pipeline/traits/trait.py | 11 ++++++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py index 43b0397597..c9604c4183 100644 --- a/client/ayon_core/pipeline/traits/representation.py +++ b/client/ayon_core/pipeline/traits/representation.py @@ -391,7 +391,7 @@ class Representation(Generic[T]): # noqa: PLR0904 """ return { - trait_id: trait.model_dump() + trait_id: trait.as_dict() for trait_id, trait in self._data.items() if trait and trait_id } @@ -593,10 +593,6 @@ class Representation(Generic[T]): # noqa: PLR0904 ) raise IncompatibleTraitVersionError(msg) from e - if requested_version is None: - trait_class = e.found_trait - requested_version = found_version - if found_version is None: msg = ( f"Trait {e.found_trait.id} found with no version, " @@ -604,6 +600,10 @@ class Representation(Generic[T]): # noqa: PLR0904 ) raise IncompatibleTraitVersionError(msg) from e + if requested_version is None: + trait_class = e.found_trait + requested_version = found_version + if requested_version > found_version: error_msg = ( f"Requested trait version {requested_version} is " diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index 01a7641c59..b618b9907b 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -3,7 +3,7 @@ from __future__ import annotations import re from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import asdict, dataclass from typing import TYPE_CHECKING, Generic, Optional, TypeVar if TYPE_CHECKING: @@ -79,6 +79,15 @@ class TraitBase(ABC): """ return re.sub(r"\.v\d+$", "", str(cls.id)) + def as_dict(self) -> dict: + """Return trait as dictionary. + + Returns: + dict: Trait as dictionary. + + """ + return asdict(self) + class IncompatibleTraitVersionError(Exception): """Incompatible trait version exception. From 8b501187a46218cce92f4bb0a55f778188a2b2da Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 19 Mar 2025 12:07:30 +0200 Subject: [PATCH 121/781] move the function above private functions --- client/ayon_core/cli.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index ad108f59fc..eef0b80d54 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -237,26 +237,6 @@ def version(build): print(os.environ["AYON_VERSION"]) -def _set_global_environments() -> None: - """Set global AYON environments.""" - # First resolve general environment - general_env = parse_env_variables_structure(get_general_environments()) - - # Merge environments with current environments and update values - merged_env = merge_env_variables( - compute_env_variables_structure(general_env), - dict(os.environ) - ) - env = compute_env_variables_structure(merged_env) - os.environ.clear() - os.environ.update(env) - - # Hardcoded default values - # Change scale factor only if is not set - if "QT_AUTO_SCREEN_SCALE_FACTOR" not in os.environ: - os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" - - @main_cli.command() @click.option( "--project", @@ -279,6 +259,26 @@ def createprojectstructure( project_folders.create_project_folders(project) +def _set_global_environments() -> None: + """Set global AYON environments.""" + # First resolve general environment + general_env = parse_env_variables_structure(get_general_environments()) + + # Merge environments with current environments and update values + merged_env = merge_env_variables( + compute_env_variables_structure(general_env), + dict(os.environ) + ) + env = compute_env_variables_structure(merged_env) + os.environ.clear() + os.environ.update(env) + + # Hardcoded default values + # Change scale factor only if is not set + if "QT_AUTO_SCREEN_SCALE_FACTOR" not in os.environ: + os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" + + def _set_addons_environments(addons_manager): """Set global environments for AYON addons.""" From 7cf02d97ae58340b26289e5af36da77524d6c634 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 19 Mar 2025 12:10:11 +0200 Subject: [PATCH 122/781] update help of project argument in create project structure. --- client/ayon_core/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index eef0b80d54..aaec9efba0 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -241,7 +241,7 @@ def version(build): @click.option( "--project", type=str, - help="Define project (project must be set).", + help="Project name", required=True) def createprojectstructure( project, From 71819f1ed6dbade2817ec6184f812d3809dd3100 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 19 Mar 2025 12:10:57 +0200 Subject: [PATCH 123/781] refactor dunction name to `create_project_structure` --- client/ayon_core/cli.py | 2 +- server/__init__.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index aaec9efba0..84e5428938 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -243,7 +243,7 @@ def version(build): type=str, help="Project name", required=True) -def createprojectstructure( +def create_project_structure( project, ): """Create project folder structure as defined in setting diff --git a/server/__init__.py b/server/__init__.py index 1ae5284f4d..2521061822 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -46,7 +46,7 @@ class CoreAddon(BaseServerAddon): # Add 'Create Project Folder Structure' action to folders. output.append( SimpleActionManifest( - identifier=f"{IDENTIFIER_PREFIX}.createprojectstructure", + identifier=f"{IDENTIFIER_PREFIX}.create_project_structure", label="Create Project Folder Structure", icon={ "type": "material-symbols", @@ -76,10 +76,10 @@ class CoreAddon(BaseServerAddon): project_name = executor.context.project_name if executor.identifier == \ - f"{IDENTIFIER_PREFIX}.createprojectstructure": + f"{IDENTIFIER_PREFIX}.create_project_structure": return await executor.get_launcher_action_response( args=[ - "createprojectstructure", + "create_project_structure", "--project", project_name, ] ) From 3636f5d9ccf10d410a6621fa7f349124e26d653f Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 19 Mar 2025 12:12:15 +0200 Subject: [PATCH 124/781] fix command argument --- server/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/__init__.py b/server/__init__.py index 2521061822..904d006e00 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -79,7 +79,7 @@ class CoreAddon(BaseServerAddon): f"{IDENTIFIER_PREFIX}.create_project_structure": return await executor.get_launcher_action_response( args=[ - "create_project_structure", + "create-project-structure", "--project", project_name, ] ) From 1331167e1390c696933a8d4d71ebf05eb2ecbd49 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 19 Mar 2025 12:13:00 +0200 Subject: [PATCH 125/781] update identifier prefix --- server/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/__init__.py b/server/__init__.py index 904d006e00..7a1d181b1b 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -14,7 +14,7 @@ from .settings import ( ) -IDENTIFIER_PREFIX = "core.launch" +IDENTIFIER_PREFIX = "core" class CoreAddon(BaseServerAddon): From 45109825af75492f4c693234cf96f162af1bfcf0 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 19 Mar 2025 12:14:56 +0200 Subject: [PATCH 126/781] update function type hints and docstring --- server/__init__.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/server/__init__.py b/server/__init__.py index 7a1d181b1b..1b9e7717c9 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -63,15 +63,9 @@ class CoreAddon(BaseServerAddon): async def execute_action( self, - executor: "ActionExecutor", - ) -> "ExecuteResponseModel": - """Execute actions. - - Note: - Executes CLI actions defined in the - addon's client code or other addons. - - """ + executor: ActionExecutor, + ) -> ExecuteResponseModel: + """Execute webactions.""" project_name = executor.context.project_name From ff7617850de783a5e2a61a6250fdd4657e5477d3 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 19 Mar 2025 12:22:27 +0200 Subject: [PATCH 127/781] only import the needed function --- client/ayon_core/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 84e5428938..186e11dfb5 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -22,7 +22,7 @@ from ayon_core.lib.env_tools import ( compute_env_variables_structure, merge_env_variables, ) -from ayon_core.pipeline import project_folders +from ayon_core.pipeline.project_folders import create_project_folders @click.group(invoke_without_command=True) @@ -256,7 +256,7 @@ def create_project_structure( """ print(f">>> Creating project folder structure for project '{project}'.") - project_folders.create_project_folders(project) + create_project_folders(project) def _set_global_environments() -> None: From 207f6d8a93387a5ab4c95d053a315b067ccffb65 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 19 Mar 2025 12:24:07 +0200 Subject: [PATCH 128/781] move import to the function. --- client/ayon_core/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 186e11dfb5..dc794c41c2 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -22,7 +22,6 @@ from ayon_core.lib.env_tools import ( compute_env_variables_structure, merge_env_variables, ) -from ayon_core.pipeline.project_folders import create_project_folders @click.group(invoke_without_command=True) @@ -255,6 +254,8 @@ def create_project_structure( """ + from ayon_core.pipeline.project_folders import create_project_folders + print(f">>> Creating project folder structure for project '{project}'.") create_project_folders(project) From 92cc5cd06b1cffe2308866607bc2b5756d3d127c Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 19 Mar 2025 18:47:09 +0100 Subject: [PATCH 129/781] :bug: replace published path in traits after copy and add some more tests --- .../plugins/publish/integrate_traits.py | 59 +++++++++++++-- .../plugins/publish/test_integrate_traits.py | 71 +++++++++++++++++++ 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 72a7ddd479..2a6755093e 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -4,9 +4,10 @@ from __future__ import annotations import contextlib import copy import hashlib +import json from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, List +from typing import TYPE_CHECKING, Any import pyblish.api from ayon_api import ( @@ -85,6 +86,7 @@ class TransferItem: template: str template_data: dict[str, Any] representation: Representation + related_trait: FileLocation @staticmethod def get_size(file_path: Path) -> int: @@ -146,7 +148,7 @@ class RepresentationEntity: status: str -def get_instance_families(instance: pyblish.api.Instance) -> List[str]: +def get_instance_families(instance: pyblish.api.Instance) -> list[str]: """Get all families of the instance. Todo: @@ -156,7 +158,7 @@ def get_instance_families(instance: pyblish.api.Instance) -> List[str]: instance (pyblish.api.Instance): Instance to get families from. Returns: - List[str]: List of families. + list[str]: List of families. """ family = instance.data.get("family") @@ -207,10 +209,39 @@ def get_changed_attributes( return changes +def prepare_for_json(data: dict[str, Any]) -> dict[str, Any]: + """Prepare data for JSON serialization. + + If there are values that json cannot serialize, this function will + convert them to strings. + + Args: + data (dict[str, Any]): Data to prepare. + + Returns: + dict[str, Any]: Prepared data. + + Raises: + TypeError: If the data cannot be converted to JSON. + + """ + prepared = {} + for key, value in data.items(): + if isinstance(value, dict): + value = prepare_for_json(value) + try: + json.dumps(value) + except TypeError: + value = value.as_posix() if issubclass( + value.__class__, Path) else str(value) + prepared[key] = value + return prepared + + class IntegrateTraits(pyblish.api.InstancePlugin): """Integrate representations with traits.""" - label = "Integrate Asset" + label = "Integrate Traits of an Asset" order = pyblish.api.IntegratorOrder log: logging.Logger @@ -226,7 +257,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): """ # 1) skip farm and integrate == False - if not instance.data.get("integrate"): + if instance.data.get("integrate", True) is False: self.log.debug("Instance is marked to skip integrating. Skipping") return @@ -246,7 +277,8 @@ class IntegrateTraits(pyblish.api.InstancePlugin): instance.data["representations_with_traits"] ) - representations: list[Representation] = instance.data["representations_with_traits"] # noqa: E501 + representations: list[Representation] = ( + instance.data["representations_with_traits"]) # noqa: E501 if not representations: self.log.debug( "Instance has no persistent representations. Skipping") @@ -284,6 +316,10 @@ class IntegrateTraits(pyblish.api.InstancePlugin): self.log.debug( "Transferred files %s", [file_transactions.transferred]) + # replace original paths with the destination in traits. + for transfer in transfers: + transfer.related_trait.file_path = transfer.destination + # 9) Create representation entities for representation in representations: representation_entity = new_representation_entity( @@ -300,8 +336,14 @@ class IntegrateTraits(pyblish.api.InstancePlugin): ) # add traits to representation entity representation_entity["traits"] = representation.traits_as_dict() + op_session.create_entity( + project_name=instance.context.data["projectName"], + entity_type="representation", + data=prepare_for_json(representation_entity), + ) # 10) Commit the session to AYON + self.log.debug("{}".format(op_session.to_data())) op_session.commit() def get_transfers_from_representations( @@ -805,7 +847,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template_data = copy.deepcopy(instance.data["anatomyData"]) template_data["representation"] = representation.name template_data["version"] = instance.data["version"] - template_data["hierarchy"] = instance.data["hierarchy"] + # template_data["hierarchy"] = instance.data["hierarchy"] # add colorspace data to template data if representation.contains_trait(ColorManaged): @@ -938,6 +980,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template=template_item.template, template_data=template_item.template_data, representation=representation, + related_trait=file_loc ) ) @@ -995,6 +1038,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template=template_item.template, template_data=template_item.template_data, representation=representation, + related_trait=file_loc ) ) # add template path and the data to resolve it @@ -1052,6 +1096,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template=template_item.template, template_data=template_item.template_data, representation=representation, + related_trait=file_loc ) ) # add template path and the data to resolve it diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index 98f02bdff6..1fa80d7b8a 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -237,6 +237,8 @@ def mock_context( return context + + def test_get_template_name(mock_context: pyblish.api.Context) -> None: """Test get_template_name. @@ -252,6 +254,75 @@ def test_get_template_name(mock_context: pyblish.api.Context) -> None: assert template_name == "default" +class TestGetSize: + @staticmethod + def get_size(file_path: Path) -> int: + """Get size of the file. + + Args: + file_path (Path): File path. + + Returns: + int: Size of the file. + + """ + return file_path.stat().st_size + + @pytest.mark.parametrize( + "file_path, expected_size", + [ + (Path("./test_file_1.txt"), 10), # id: happy_path_small_file + (Path("./test_file_2.txt"), 1024), # id: happy_path_medium_file + (Path("./test_file_3.txt"), 10485760) # id: happy_path_large_file + ], + ids=["happy_path_small_file", "happy_path_medium_file", "happy_path_large_file"] + ) + def test_get_size_happy_path(self, file_path: Path, expected_size: int, tmp_path: Path): + # Arrange + file_path = tmp_path / file_path + file_path.write_bytes(b"\0" * expected_size) + + # Act + size = self.get_size(file_path) + + # Assert + assert size == expected_size + + + @pytest.mark.parametrize( + "file_path, expected_size", + [ + (Path("./test_file_empty.txt"), 0) # id: edge_case_empty_file + ], + ids=["edge_case_empty_file"] + ) + def test_get_size_edge_cases(self, file_path: Path, expected_size: int, tmp_path: Path): + # Arrange + file_path = tmp_path / file_path + file_path.touch() # Create an empty file + + # Act + size = self.get_size(file_path) + + # Assert + assert size == expected_size + + @pytest.mark.parametrize( + "file_path, expected_exception", + [ + (Path("./non_existent_file.txt"), FileNotFoundError), # id: error_file_not_found + (123, TypeError) # id: error_invalid_input_type + ], + ids=["error_file_not_found", "error_invalid_input_type"] + ) + def test_get_size_error_cases(self, file_path, expected_exception, tmp_path): + + # Act & Assert + with pytest.raises(expected_exception): + file_path = tmp_path / file_path + self.get_size(file_path) + + def test_filter_lifecycle() -> None: """Test filter_lifecycle.""" integrator = IntegrateTraits() 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 130/781] 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 131/781] 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 132/781] 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 133/781] 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 134/781] 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 4152f9fc5e4cbcf2ef253781ee244ec1619c89fb Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Mar 2025 17:12:15 +0100 Subject: [PATCH 135/781] =?UTF-8?q?=F0=9F=A4=96obey=20our=20machine=20over?= =?UTF-8?q?lords,=20implement=20their=20kind=20suggestions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 4 +--- tests/client/ayon_core/pipeline/traits/test_content_traits.py | 2 +- ...wo_dimesional_traits.py => test_two_dimensional_traits.py} | 0 3 files changed, 2 insertions(+), 4 deletions(-) rename tests/client/ayon_core/pipeline/traits/{test_two_dimesional_traits.py => test_two_dimensional_traits.py} (100%) diff --git a/pyproject.toml b/pyproject.toml index 24d5300626..d3113041e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ pydocstyle.convention = "google" # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. select = ["ALL"] ignore = [ - "PTH", + "PTH", "ANN101", # must be set in older version of ruff "ANN204", "COM812", @@ -97,8 +97,6 @@ ignore = [ "S404", # subprocess module is possibly insecure "PLC0415", # import must be on top of the file "CPY001", # missing copyright header - "UP045" - ] # Allow fix for all enabled rules (when `--fix`) is provided. diff --git a/tests/client/ayon_core/pipeline/traits/test_content_traits.py b/tests/client/ayon_core/pipeline/traits/test_content_traits.py index 4aa149b8ee..3fcbd04ac0 100644 --- a/tests/client/ayon_core/pipeline/traits/test_content_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_content_traits.py @@ -122,7 +122,7 @@ def test_file_locations_validation() -> None: with pytest.raises(TraitValidationError): file_locations_trait.validate_trait(representation) - # invalid representation with mutliple file locations but + # invalid representation with multiple file locations but # unrelated to either Sequence or Bundle traits representation = Representation(name="test", traits=[ FileLocations(file_paths=[ diff --git a/tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py b/tests/client/ayon_core/pipeline/traits/test_two_dimensional_traits.py similarity index 100% rename from tests/client/ayon_core/pipeline/traits/test_two_dimesional_traits.py rename to tests/client/ayon_core/pipeline/traits/test_two_dimensional_traits.py From 45d0e05892aca00fa8a723c1f8564d259b033264 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Mar 2025 17:20:26 +0100 Subject: [PATCH 136/781] :alembic: fix tests --- tests/client/ayon_core/pipeline/traits/test_traits.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index b990c074d3..a1cd4792e9 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -21,18 +21,18 @@ REPRESENTATION_DATA: dict = { "file_path": Path("/path/to/file"), "file_size": 1024, "file_hash": None, - "persistent": True, + # "persistent": True, }, - Image.id: {"persistent": True}, + Image.id: {}, PixelBased.id: { "display_window_width": 1920, "display_window_height": 1080, "pixel_aspect_ratio": 1.0, - "persistent": True, + # "persistent": True, }, Planar.id: { "planar_configuration": "RGB", - "persistent": True, + # "persistent": True, }, } From a008145a94132c028e43e2c8f9d0f22ce8726b0e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Mar 2025 17:20:41 +0100 Subject: [PATCH 137/781] :dog: fix linter issues --- client/ayon_core/pipeline/traits/content.py | 3 ++- client/ayon_core/pipeline/traits/cryptography.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 9bb43fcdb3..5a19daedac 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -59,7 +59,8 @@ class LocatableContent(TraitBase): description (str): Trait description. id (str): id should be namespaced trait name with version location (str): Location. - is_templated (Optional[bool]): Is the location templated? Default is None. + is_templated (Optional[bool]): Is the location templated? + Default is None. """ name: ClassVar[str] = "LocatableContent" diff --git a/client/ayon_core/pipeline/traits/cryptography.py b/client/ayon_core/pipeline/traits/cryptography.py index d9445bd543..7fcbb1b387 100644 --- a/client/ayon_core/pipeline/traits/cryptography.py +++ b/client/ayon_core/pipeline/traits/cryptography.py @@ -22,6 +22,7 @@ class DigitallySigned(TraitBase): description: ClassVar[str] = "Digitally signed trait." persistent: ClassVar[bool] = True + @dataclass class PGPSigned(DigitallySigned): """PGP signed trait. From ed6e557afef30154cad4634a740247f57e9b14be Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Mar 2025 17:30:31 +0100 Subject: [PATCH 138/781] :bug: fix poetry.lock so GH test action can run --- poetry.lock | 74 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/poetry.lock b/poetry.lock index 7db190bdd2..d68f07f84b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "appdirs" @@ -6,6 +6,7 @@ version = "1.4.4" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, @@ -17,18 +18,19 @@ version = "25.1.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"}, {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"}, ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] [[package]] name = "ayon-python-api" @@ -36,6 +38,7 @@ version = "1.0.11" description = "AYON Python API" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "ayon-python-api-1.0.11.tar.gz", hash = "sha256:3497237c379979268c321304c7d19cb2844d699f6ac34c21293f5dac29c13531"}, {file = "ayon_python_api-1.0.11-py3-none-any.whl", hash = "sha256:f6134c29c8c8a36cdb2882f9404dd43817bd6636d663b3cd2344de9f1fc1e4b2"}, @@ -86,6 +89,7 @@ version = "2024.12.14" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, @@ -97,6 +101,7 @@ version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, @@ -108,6 +113,7 @@ version = "3.4.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" +groups = ["dev"] files = [ {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, @@ -237,6 +243,7 @@ version = "2.0.0" description = "Manage collections with common numerical component" optional = false python-versions = ">=3.0, <4.0" +groups = ["dev"] files = [ {file = "clique-2.0.0-py2.py3-none-any.whl", hash = "sha256:45e2a4c6078382e0b217e5e369494279cf03846d95ee601f93290bed5214c22e"}, {file = "clique-2.0.0.tar.gz", hash = "sha256:6e1115dbf21b1726f4b3db9e9567a662d6bdf72487c4a0a1f8cb7f10cf4f4754"}, @@ -253,6 +260,7 @@ version = "2.3.0" description = "Codespell" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "codespell-2.3.0-py3-none-any.whl", hash = "sha256:a9c7cef2501c9cfede2110fd6d4e5e62296920efe9abfb84648df866e47f58d1"}, {file = "codespell-2.3.0.tar.gz", hash = "sha256:360c7d10f75e65f67bad720af7007e1060a5d395670ec11a7ed1fed9dd17471f"}, @@ -261,7 +269,7 @@ files = [ [package.extras] dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] hard-encoding-detection = ["chardet"] -toml = ["tomli"] +toml = ["tomli ; python_version < \"3.11\""] types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] [[package]] @@ -270,6 +278,7 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -292,6 +301,7 @@ version = "0.3.9" description = "Distribution utilities" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, @@ -303,6 +313,7 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -317,6 +328,7 @@ version = "3.16.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, @@ -325,7 +337,7 @@ files = [ [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] -typing = ["typing-extensions (>=4.12.2)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] [[package]] name = "ghp-import" @@ -377,6 +389,7 @@ version = "2.6.3" description = "File identification library for Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd"}, {file = "identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02"}, @@ -391,6 +404,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -452,6 +466,7 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -862,6 +877,7 @@ version = "5.2.0" description = "Rolling backport of unittest.mock for all Pythons" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, @@ -878,6 +894,7 @@ version = "1.14.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "mypy-1.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e971c1c667007f9f2b397ffa80fa8e1e0adccff336e5e77e74cb5f22868bee87"}, {file = "mypy-1.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e86aaeaa3221a278c66d3d673b297232947d873773d61ca3ee0e28b2ff027179"}, @@ -931,6 +948,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -942,6 +960,7 @@ version = "1.9.1" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, @@ -953,6 +972,7 @@ version = "0.17.0" description = "Editorial interchange format and API" optional = false python-versions = "!=3.9.0,>=3.7" +groups = ["dev"] files = [ {file = "OpenTimelineIO-0.17.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:2dd31a570cabfd6227c1b1dd0cc038da10787492c26c55de058326e21fe8a313"}, {file = "OpenTimelineIO-0.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a1da5d4803d1ba5e846b181a9e0f4a392c76b9acc5e08947772bc086f2ebfc0"}, @@ -994,7 +1014,7 @@ files = [ [package.extras] dev = ["check-manifest", "coverage (>=4.5)", "flake8 (>=3.5)", "urllib3 (>=1.24.3)"] -view = ["PySide2 (>=5.11,<6.0)", "PySide6 (>=6.2,<7.0)"] +view = ["PySide2 (>=5.11,<6.0) ; platform_machine == \"x86_64\"", "PySide6 (>=6.2,<7.0) ; platform_machine == \"aarch64\""] [[package]] name = "packaging" @@ -1002,6 +1022,7 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -1041,6 +1062,7 @@ version = "4.3.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, @@ -1057,6 +1079,7 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -1072,6 +1095,7 @@ version = "4.0.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"}, {file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"}, @@ -1090,6 +1114,7 @@ version = "1.8.12" description = "Plug-in driven automation framework for content" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "pyblish-base-1.8.12.tar.gz", hash = "sha256:ebc184eb038864380555227a8b58055dd24ece7e6ef7f16d33416c718512871b"}, {file = "pyblish_base-1.8.12-py2.py3-none-any.whl", hash = "sha256:2cbe956bfbd4175a2d7d22b344cd345800f4d4437153434ab658fc12646a11e8"}, @@ -1150,6 +1175,7 @@ version = "8.3.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, @@ -1172,6 +1198,7 @@ version = "1.0.2" description = "pytest-print adds the printer fixture you can use to print messages to the user (directly to the pytest runner, not stdout)" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest_print-1.0.2-py3-none-any.whl", hash = "sha256:3ae7891085dddc3cd697bd6956787240107fe76d6b5cdcfcd782e33ca6543de9"}, {file = "pytest_print-1.0.2.tar.gz", hash = "sha256:2780350a7bbe7117f99c5d708dc7b0431beceda021b1fd3f11200670d7f33679"}, @@ -1204,6 +1231,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -1281,6 +1309,7 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -1302,6 +1331,7 @@ version = "0.9.9" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "ruff-0.9.9-py3-none-linux_armv6l.whl", hash = "sha256:628abb5ea10345e53dff55b167595a159d3e174d6720bf19761f5e467e68d367"}, {file = "ruff-0.9.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6cd1428e834b35d7493354723543b28cc11dc14d1ce19b685f6e68e07c05ec7"}, @@ -1329,6 +1359,7 @@ version = "3.0.2" description = "Python helper for Semantic Versioning (https://semver.org)" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "semver-3.0.2-py3-none-any.whl", hash = "sha256:b1ea4686fe70b981f85359eda33199d60c53964284e0cfb4977d243e37cf4bf4"}, {file = "semver-3.0.2.tar.gz", hash = "sha256:6253adb39c70f6e51afed2fa7152bcd414c411286088fb4b9effb133885ab4cc"}, @@ -1369,6 +1400,7 @@ version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -1404,12 +1436,25 @@ files = [ {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] +[[package]] +name = "tomlkit" +version = "0.13.2" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, + {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, +] + [[package]] name = "typing-extensions" version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -1421,6 +1466,7 @@ version = "1.3.8" description = "ASCII transliterations of Unicode text" optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39"}, {file = "Unidecode-1.3.8.tar.gz", hash = "sha256:cfdb349d46ed3873ece4586b96aa75258726e2fa8ec21d6f00a591d98806c2f4"}, @@ -1432,13 +1478,14 @@ version = "2.2.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1464,6 +1511,7 @@ version = "20.28.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0"}, {file = "virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa"}, @@ -1476,7 +1524,7 @@ platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [[package]] name = "watchdog" @@ -1542,6 +1590,6 @@ test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.funct type = ["pytest-mypy"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = ">=3.9.1,<3.10" -content-hash = "8e5b1f886eb608198752e1ce84f6bc89d1c8d53a5eeabff84a4272538f81403f" +content-hash = "f1ea28d3e849c446fd7659d39232703f4eee72f6e9250138234bc9e122ee256f" From 7d7fd313fa7d75d6eb92f2cd1120b192b1ca8b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 31 Mar 2025 15:42:06 +0200 Subject: [PATCH 139/781] :recycle: wrap trait based instance into functions --- client/ayon_core/pipeline/publish/__init__.py | 10 +++ client/ayon_core/pipeline/publish/lib.py | 71 ++++++++++++++++++- .../plugins/publish/integrate_traits.py | 14 ++-- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/publish/__init__.py b/client/ayon_core/pipeline/publish/__init__.py index 5363e0b378..ede7fc3a35 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -46,6 +46,11 @@ from .lib import ( get_publish_instance_families, main_cli_publish, + + add_trait_representations, + get_trait_representations, + has_trait_representations, + set_trait_representations, ) from .abstract_expected_files import ExpectedFiles @@ -104,4 +109,9 @@ __all__ = ( "RenderInstance", "AbstractCollectRender", + + "add_trait_representations", + "get_trait_representations", + "has_trait_representations", + "set_trait_representations", ) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 49ecab2221..f1dda288a6 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -6,7 +6,7 @@ import inspect import copy import warnings import xml.etree.ElementTree -from typing import Optional, Union, List +from typing import TYPE_CHECKING, Optional, Union, List import ayon_api import pyblish.util @@ -27,6 +27,11 @@ from .constants import ( DEFAULT_HERO_PUBLISH_TEMPLATE, ) +if TYPE_CHECKING: + from ayon_core.pipeline.traits import Representation + + +TRAIT_INSTANCE_KEY: str = "representations_with_traits" def get_template_name_profiles( project_name, project_settings=None, logger=None @@ -1062,3 +1067,67 @@ def main_cli_publish( sys.exit(1) log.info("Publish finished.") + + +def has_trait_representations( + instance: pyblish.api.Instance) -> bool: + """Check if instance has trait representation. + + Args: + instance (pyblish.api.Instance): Instance to check. + + Returns: + True: Instance has trait representation. + False: Instance does not have trait representation. + + """ + return bool(instance.data.get(TRAIT_INSTANCE_KEY)) + + +def add_trait_representations( + instance: pyblish.api.Instance, + representations: list[Representation] +): + """Add trait representations to instance. + + Args: + instance (pyblish.api.Instance): Instance to add trait + representations to. + representations (list[Representation]): List of representation + trait based representations to add. + + """ + if not has_trait_representations(instance): + instance.data[TRAIT_INSTANCE_KEY] = [] + instance.data[TRAIT_INSTANCE_KEY].extend(representations) + + +def set_trait_representations( + instance: pyblish.api.Instance, + representations: list[Representation] +): + """Set trait representations to instance. + + Args: + instance (pyblish.api.Instance): Instance to set trait + representations to. + representations (list[Representation]): List of trait + based representations. + + """ + instance.data[TRAIT_INSTANCE_KEY] = representations + + +def get_trait_representations( + instance: pyblish.api.Instance) -> list[Representation]: + """Get trait representations from instance. + + Args: + instance (pyblish.api.Instance): Instance to get trait + representations from. + + Returns: + list[Representation]: List of representation names. + + """ + return instance.data.get(TRAIT_INSTANCE_KEY, []) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 72a7ddd479..5587d64604 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -29,6 +29,9 @@ from ayon_core.lib.file_transaction import ( from ayon_core.pipeline.publish import ( PublishError, get_publish_template_name, + has_trait_representations, + get_trait_representations, + set_trait_representations, ) from ayon_core.pipeline.traits import ( UDIM, @@ -236,17 +239,20 @@ class IntegrateTraits(pyblish.api.InstancePlugin): return # TODO (antirotor): Find better name for the key - if not instance.data.get("representations_with_traits"): + if not has_trait_representations(instance): self.log.debug( "Instance has no representations with traits. Skipping") return # 2) filter representations based on LifeCycle traits - instance.data["representations_with_traits"] = self.filter_lifecycle( - instance.data["representations_with_traits"] + set_trait_representations( + instance, + self.filter_lifecycle(get_trait_representations(instance)) ) - representations: list[Representation] = instance.data["representations_with_traits"] # noqa: E501 + representations: list[Representation] = get_trait_representations( + instance + ) if not representations: self.log.debug( "Instance has no persistent representations. Skipping") From 04a2f08fea82ce4f546f673d4859f6a6116712ae Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 3 Apr 2025 18:05:54 +0200 Subject: [PATCH 140/781] Added new Pre and Post Loader Hooks Added functions to register/deregister them. --- client/ayon_core/pipeline/__init__.py | 24 ++++++++ client/ayon_core/pipeline/load/__init__.py | 24 ++++++++ client/ayon_core/pipeline/load/plugins.py | 66 ++++++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 41bcd0dbd1..c6903f1b8e 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -42,6 +42,18 @@ from .load import ( register_loader_plugin_path, deregister_loader_plugin, + discover_loader_pre_hook_plugin, + register_loader_pre_hook_plugin, + deregister_loader_pre_hook_plugin, + register_loader_pre_hook_plugin_path, + deregister_loader_pre_hook_plugin_path, + + discover_loader_post_hook_plugin, + register_loader_post_hook_plugin, + deregister_loader_post_hook_plugin, + register_loader_post_hook_plugin_path, + deregister_loader_post_hook_plugin_path, + load_container, remove_container, update_container, @@ -160,6 +172,18 @@ __all__ = ( "register_loader_plugin_path", "deregister_loader_plugin", + "discover_loader_pre_hook_plugin", + "register_loader_pre_hook_plugin", + "deregister_loader_pre_hook_plugin", + "register_loader_pre_hook_plugin_path", + "deregister_loader_pre_hook_plugin_path", + + "discover_loader_post_hook_plugin", + "register_loader_post_hook_plugin", + "deregister_loader_post_hook_plugin", + "register_loader_post_hook_plugin_path", + "deregister_loader_post_hook_plugin_path", + "load_container", "remove_container", "update_container", diff --git a/client/ayon_core/pipeline/load/__init__.py b/client/ayon_core/pipeline/load/__init__.py index bdc5ece620..fdbcaec1b9 100644 --- a/client/ayon_core/pipeline/load/__init__.py +++ b/client/ayon_core/pipeline/load/__init__.py @@ -49,6 +49,18 @@ from .plugins import ( deregister_loader_plugin_path, register_loader_plugin_path, deregister_loader_plugin, + + discover_loader_pre_hook_plugin, + register_loader_pre_hook_plugin, + deregister_loader_pre_hook_plugin, + register_loader_pre_hook_plugin_path, + deregister_loader_pre_hook_plugin_path, + + discover_loader_post_hook_plugin, + register_loader_post_hook_plugin, + deregister_loader_post_hook_plugin, + register_loader_post_hook_plugin_path, + deregister_loader_post_hook_plugin_path, ) @@ -103,4 +115,16 @@ __all__ = ( "deregister_loader_plugin_path", "register_loader_plugin_path", "deregister_loader_plugin", + + "discover_loader_pre_hook_plugin", + "register_loader_pre_hook_plugin", + "deregister_loader_pre_hook_plugin", + "register_loader_pre_hook_plugin_path", + "deregister_loader_pre_hook_plugin_path", + + "discover_loader_post_hook_plugin", + "register_loader_post_hook_plugin", + "deregister_loader_post_hook_plugin", + "register_loader_post_hook_plugin_path", + "deregister_loader_post_hook_plugin_path", ) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index b601914acd..553e88c2f7 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,5 +1,6 @@ import os import logging +from typing import ClassVar from ayon_core.settings import get_project_settings from ayon_core.pipeline.plugin_discover import ( @@ -264,6 +265,30 @@ class ProductLoaderPlugin(LoaderPlugin): """ +class PreLoadHookPlugin: + """Plugin that should be run before any Loaders in 'loaders' + + Should be used as non-invasive method to enrich core loading process. + Any external studio might want to modify loaded data before or afte + they are loaded without need to override existing core plugins. + """ + loaders: ClassVar[set[str]] + + def process(self, context, name=None, namespace=None, options=None): + pass + +class PostLoadHookPlugin: + """Plugin that should be run after any Loaders in 'loaders' + + Should be used as non-invasive method to enrich core loading process. + Any external studio might want to modify loaded data before or afte + they are loaded without need to override existing core plugins. + loaders: ClassVar[set[str]] + """ + def process(self, context, name=None, namespace=None, options=None): + pass + + def discover_loader_plugins(project_name=None): from ayon_core.lib import Logger from ayon_core.pipeline import get_current_project_name @@ -300,3 +325,44 @@ def deregister_loader_plugin_path(path): def register_loader_plugin_path(path): return register_plugin_path(LoaderPlugin, path) + + +def discover_loader_pre_hook_plugin(project_name=None): + plugins = discover(PreLoadHookPlugin) + return plugins + +def register_loader_pre_hook_plugin(plugin): + return register_plugin(PreLoadHookPlugin, plugin) + + +def deregister_loader_pre_hook_plugin(plugin): + deregister_plugin(PreLoadHookPlugin, plugin) + + +def register_loader_pre_hook_plugin_path(path): + return register_plugin_path(PreLoadHookPlugin, path) + + +def deregister_loader_pre_hook_plugin_path(path): + deregister_plugin_path(PreLoadHookPlugin, path) + + +def discover_loader_post_hook_plugin(): + plugins = discover(PostLoadHookPlugin) + return plugins + + +def register_loader_post_hook_plugin(plugin): + return register_plugin(PostLoadHookPlugin, plugin) + + +def deregister_loader_post_hook_plugin(plugin): + deregister_plugin(PostLoadHookPlugin, plugin) + + +def register_loader_post_hook_plugin_path(path): + return register_plugin_path(PostLoadHookPlugin, path) + + +def deregister_loader_post_hook_plugin_path(path): + deregister_plugin_path(PostLoadHookPlugin, path) From 7ec99b3715e800643a373eb20ccc9df7301ca2ec Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 3 Apr 2025 18:06:31 +0200 Subject: [PATCH 141/781] Initial implementation of pre/post loader hooks --- .../ayon_core/tools/loader/models/actions.py | 110 ++++++++++++++++-- 1 file changed, 101 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index cfe91cadab..c188bf1609 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -9,6 +9,7 @@ import ayon_api from ayon_core.lib import NestedCacheItem from ayon_core.pipeline.load import ( discover_loader_plugins, + discover_loader_pre_hook_plugin, ProductLoaderPlugin, filter_repre_contexts_by_loader, get_loader_identifier, @@ -17,6 +18,8 @@ from ayon_core.pipeline.load import ( load_with_product_contexts, LoadError, IncompatibleLoaderError, + get_loaders_by_name + ) from ayon_core.tools.loader.abstract import ActionItem @@ -50,6 +53,8 @@ class LoaderActionsModel: levels=1, lifetime=self.loaders_cache_lifetime) self._repre_loaders = NestedCacheItem( levels=1, lifetime=self.loaders_cache_lifetime) + self._hook_loaders_by_identifier = NestedCacheItem( + levels=1, lifetime=self.loaders_cache_lifetime) def reset(self): """Reset the model with all cached items.""" @@ -58,6 +63,7 @@ class LoaderActionsModel: self._loaders_by_identifier.reset() self._product_loaders.reset() self._repre_loaders.reset() + self._hook_loaders_by_identifier.reset() def get_versions_action_items(self, project_name, version_ids): """Get action items for given version ids. @@ -143,12 +149,14 @@ class LoaderActionsModel: ACTIONS_MODEL_SENDER, ) loader = self._get_loader_by_identifier(project_name, identifier) + hooks = self._get_hook_loaders_by_identifier(project_name, identifier) if representation_ids is not None: error_info = self._trigger_representation_loader( loader, options, project_name, representation_ids, + hooks ) elif version_ids is not None: error_info = self._trigger_version_loader( @@ -156,6 +164,7 @@ class LoaderActionsModel: options, project_name, version_ids, + hooks ) else: raise NotImplementedError( @@ -307,22 +316,40 @@ class LoaderActionsModel: we want to show loaders for? Returns: - tuple[list[ProductLoaderPlugin], list[LoaderPlugin]]: Discovered - loader plugins. + tuple( + list[ProductLoaderPlugin], + list[LoaderPlugin], + ): Discovered loader plugins. """ loaders_by_identifier_c = self._loaders_by_identifier[project_name] product_loaders_c = self._product_loaders[project_name] repre_loaders_c = self._repre_loaders[project_name] + hook_loaders_by_identifier_c = self._hook_loaders_by_identifier[project_name] if loaders_by_identifier_c.is_valid: - return product_loaders_c.get_data(), repre_loaders_c.get_data() + return ( + product_loaders_c.get_data(), + repre_loaders_c.get_data(), + hook_loaders_by_identifier_c.get_data() + ) # Get all representation->loader combinations available for the # index under the cursor, so we can list the user the options. available_loaders = self._filter_loaders_by_tool_name( project_name, discover_loader_plugins(project_name) ) - + hook_loaders_by_identifier = {} + pre_load_hook_plugins = discover_loader_pre_hook_plugin(project_name) + loaders_by_name = get_loaders_by_name() + for hook_plugin in pre_load_hook_plugins: + for load_plugin_name in hook_plugin.loaders: + load_plugin = loaders_by_name.get(load_plugin_name) + if not load_plugin: + continue + if not load_plugin.enabled: + continue + identifier = get_loader_identifier(load_plugin) + hook_loaders_by_identifier.setdefault(identifier, {}).setdefault("pre", []).append(hook_plugin) repre_loaders = [] product_loaders = [] loaders_by_identifier = {} @@ -340,6 +367,8 @@ class LoaderActionsModel: loaders_by_identifier_c.update_data(loaders_by_identifier) product_loaders_c.update_data(product_loaders) repre_loaders_c.update_data(repre_loaders) + hook_loaders_by_identifier_c.update_data(hook_loaders_by_identifier) + return product_loaders, repre_loaders def _get_loader_by_identifier(self, project_name, identifier): @@ -349,6 +378,13 @@ class LoaderActionsModel: loaders_by_identifier = loaders_by_identifier_c.get_data() return loaders_by_identifier.get(identifier) + def _get_hook_loaders_by_identifier(self, project_name, identifier): + if not self._hook_loaders_by_identifier[project_name].is_valid: + self._get_loaders(project_name) + hook_loaders_by_identifier_c = self._hook_loaders_by_identifier[project_name] + hook_loaders_by_identifier_c = hook_loaders_by_identifier_c.get_data() + return hook_loaders_by_identifier_c.get(identifier) + def _actions_sorter(self, action_item): """Sort the Loaders by their order and then their name. @@ -606,6 +642,7 @@ class LoaderActionsModel: options, project_name, version_ids, + hooks=None ): """Trigger version loader. @@ -655,7 +692,7 @@ class LoaderActionsModel: }) return self._load_products_by_loader( - loader, product_contexts, options + loader, product_contexts, options, hooks=hooks ) def _trigger_representation_loader( @@ -664,6 +701,7 @@ class LoaderActionsModel: options, project_name, representation_ids, + hooks ): """Trigger representation loader. @@ -716,10 +754,16 @@ class LoaderActionsModel: }) return self._load_representations_by_loader( - loader, repre_contexts, options + loader, repre_contexts, options, hooks ) - def _load_representations_by_loader(self, loader, repre_contexts, options): + def _load_representations_by_loader( + self, + loader, + repre_contexts, + options, + hooks=None + ): """Loops through list of repre_contexts and loads them with one loader Args: @@ -737,12 +781,26 @@ class LoaderActionsModel: if version < 0: version = "Hero" try: + for hook_plugin in hooks.get("pre", []): + hook_plugin.process( + loader, + repre_context, + options=options + ) + load_with_repre_context( loader, repre_context, options=options ) + for hook_plugin in hooks.get("post", []): + hook_plugin.process( + loader, + repre_context, + options=options + ) + except IncompatibleLoaderError as exc: print(exc) error_info.append(( @@ -770,7 +828,13 @@ class LoaderActionsModel: )) return error_info - def _load_products_by_loader(self, loader, version_contexts, options): + def _load_products_by_loader( + self, + loader, + version_contexts, + options, + hooks=None + ): """Triggers load with ProductLoader type of loaders. Warning: @@ -791,12 +855,26 @@ class LoaderActionsModel: product_name = context.get("product", {}).get("name") or "N/A" product_names.append(product_name) try: + for hook_plugin in hooks.get("pre", []): + hook_plugin.process( + loader, + version_contexts, + options=options + ) + load_with_product_contexts( loader, version_contexts, - options=options + options=options, ) + for hook_plugin in hooks.get("post", []): + hook_plugin.process( + loader, + version_contexts, + options=options + ) + except Exception as exc: formatted_traceback = None if not isinstance(exc, LoadError): @@ -817,12 +895,26 @@ class LoaderActionsModel: version_context.get("product", {}).get("name") or "N/A" ) try: + for hook_plugin in hooks.get("pre", []): + hook_plugin.process( + loader, + version_contexts, + options=options + ) + load_with_product_context( loader, version_context, options=options ) + for hook_plugin in hooks.get("post", []): + hook_plugin.process( + loader, + version_context, + options=options + ) + except Exception as exc: formatted_traceback = None if not isinstance(exc, LoadError): From e76691ea6e62473a923e9ae576d161a418f5e288 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Apr 2025 15:39:32 +0200 Subject: [PATCH 142/781] Moved usage of hooks out of UI actions to utils It is assumed that utils could be used in some kind 'load API', `actions` are tightly bound to UI loading. --- client/ayon_core/pipeline/load/utils.py | 74 +++++++++++++++++-- .../ayon_core/tools/loader/models/actions.py | 49 ++---------- 2 files changed, 72 insertions(+), 51 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index de8e1676e7..d280aa3407 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -288,7 +288,13 @@ def get_representation_context(project_name, representation): def load_with_repre_context( - Loader, repre_context, namespace=None, name=None, options=None, **kwargs + Loader, + repre_context, + namespace=None, + name=None, + options=None, + hooks=None, + **kwargs ): # Ensure the Loader is compatible for the representation @@ -322,11 +328,24 @@ def load_with_repre_context( # Deprecated - to be removed in OpenPype 3.16.6 or 3.17.0. loader._fname = get_representation_path_from_context(repre_context) - return loader.load(repre_context, name, namespace, options) + return _load_context( + Loader, + repre_context, + name, + namespace, + options, + hooks + ) def load_with_product_context( - Loader, product_context, namespace=None, name=None, options=None, **kwargs + Loader, + product_context, + namespace=None, + name=None, + options=None, + hooks=None, + **kwargs ): # Ensure options is a dictionary when no explicit options provided @@ -344,12 +363,24 @@ def load_with_product_context( Loader.__name__, product_context["folder"]["path"] ) ) - - return Loader().load(product_context, name, namespace, options) + return _load_context( + Loader, + product_context, + name, + namespace, + options, + hooks + ) def load_with_product_contexts( - Loader, product_contexts, namespace=None, name=None, options=None, **kwargs + Loader, + product_contexts, + namespace=None, + name=None, + options=None, + hooks=None, + **kwargs ): # Ensure options is a dictionary when no explicit options provided @@ -371,8 +402,37 @@ def load_with_product_contexts( Loader.__name__, joined_product_names ) ) + return _load_context( + Loader, + product_contexts, + name, + namespace, + options, + hooks + ) - return Loader().load(product_contexts, name, namespace, options) + +def _load_context(Loader, contexts, hooks, name, namespace, options): + """Helper function to wrap hooks around generic load function. + + Only dynamic part is different context(s) to be loaded. + """ + for hook_plugin in hooks.get("pre", []): + hook_plugin.process( + contexts, + name, + namespace, + options, + ) + load_return = Loader().load(contexts, name, namespace, options) + for hook_plugin in hooks.get("post", []): + hook_plugin.process( + contexts, + name, + namespace, + options, + ) + return load_return def load_container( diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index c188bf1609..7151dbdaec 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -781,26 +781,14 @@ class LoaderActionsModel: if version < 0: version = "Hero" try: - for hook_plugin in hooks.get("pre", []): - hook_plugin.process( - loader, - repre_context, - options=options - ) load_with_repre_context( loader, repre_context, - options=options + options=options, + hooks=hooks ) - for hook_plugin in hooks.get("post", []): - hook_plugin.process( - loader, - repre_context, - options=options - ) - except IncompatibleLoaderError as exc: print(exc) error_info.append(( @@ -855,26 +843,12 @@ class LoaderActionsModel: product_name = context.get("product", {}).get("name") or "N/A" product_names.append(product_name) try: - for hook_plugin in hooks.get("pre", []): - hook_plugin.process( - loader, - version_contexts, - options=options - ) - load_with_product_contexts( loader, version_contexts, options=options, + hooks=hooks ) - - for hook_plugin in hooks.get("post", []): - hook_plugin.process( - loader, - version_contexts, - options=options - ) - except Exception as exc: formatted_traceback = None if not isinstance(exc, LoadError): @@ -895,26 +869,13 @@ class LoaderActionsModel: version_context.get("product", {}).get("name") or "N/A" ) try: - for hook_plugin in hooks.get("pre", []): - hook_plugin.process( - loader, - version_contexts, - options=options - ) - load_with_product_context( loader, version_context, - options=options + options=options, + hooks=hooks ) - for hook_plugin in hooks.get("post", []): - hook_plugin.process( - loader, - version_context, - options=options - ) - except Exception as exc: formatted_traceback = None if not isinstance(exc, LoadError): From f10564ffb83dce99bd623aa10b5cb622f6d9ffa5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Apr 2025 16:25:59 +0200 Subject: [PATCH 143/781] Removed unnecessary functions --- client/ayon_core/pipeline/load/__init__.py | 4 ---- client/ayon_core/pipeline/load/plugins.py | 9 --------- 2 files changed, 13 deletions(-) diff --git a/client/ayon_core/pipeline/load/__init__.py b/client/ayon_core/pipeline/load/__init__.py index fdbcaec1b9..7f9734f94e 100644 --- a/client/ayon_core/pipeline/load/__init__.py +++ b/client/ayon_core/pipeline/load/__init__.py @@ -50,13 +50,11 @@ from .plugins import ( register_loader_plugin_path, deregister_loader_plugin, - discover_loader_pre_hook_plugin, register_loader_pre_hook_plugin, deregister_loader_pre_hook_plugin, register_loader_pre_hook_plugin_path, deregister_loader_pre_hook_plugin_path, - discover_loader_post_hook_plugin, register_loader_post_hook_plugin, deregister_loader_post_hook_plugin, register_loader_post_hook_plugin_path, @@ -116,13 +114,11 @@ __all__ = ( "register_loader_plugin_path", "deregister_loader_plugin", - "discover_loader_pre_hook_plugin", "register_loader_pre_hook_plugin", "deregister_loader_pre_hook_plugin", "register_loader_pre_hook_plugin_path", "deregister_loader_pre_hook_plugin_path", - "discover_loader_post_hook_plugin", "register_loader_post_hook_plugin", "deregister_loader_post_hook_plugin", "register_loader_post_hook_plugin_path", diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 553e88c2f7..6b1591221a 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -327,10 +327,6 @@ def register_loader_plugin_path(path): return register_plugin_path(LoaderPlugin, path) -def discover_loader_pre_hook_plugin(project_name=None): - plugins = discover(PreLoadHookPlugin) - return plugins - def register_loader_pre_hook_plugin(plugin): return register_plugin(PreLoadHookPlugin, plugin) @@ -347,11 +343,6 @@ def deregister_loader_pre_hook_plugin_path(path): deregister_plugin_path(PreLoadHookPlugin, path) -def discover_loader_post_hook_plugin(): - plugins = discover(PostLoadHookPlugin) - return plugins - - def register_loader_post_hook_plugin(plugin): return register_plugin(PostLoadHookPlugin, plugin) From 92600726b3e9e08a5f3f7f7d63d3919cf436b011 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Apr 2025 16:27:50 +0200 Subject: [PATCH 144/781] Extracted get_hook_loaders_by_identifier to utils --- client/ayon_core/pipeline/load/__init__.py | 2 ++ client/ayon_core/pipeline/load/utils.py | 32 +++++++++++++++++++ .../ayon_core/tools/loader/models/actions.py | 16 ++-------- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/pipeline/load/__init__.py b/client/ayon_core/pipeline/load/__init__.py index 7f9734f94e..eaba8cd78d 100644 --- a/client/ayon_core/pipeline/load/__init__.py +++ b/client/ayon_core/pipeline/load/__init__.py @@ -24,6 +24,7 @@ from .utils import ( get_loader_identifier, get_loaders_by_name, + get_hook_loaders_by_identifier, get_representation_path_from_context, get_representation_path, @@ -89,6 +90,7 @@ __all__ = ( "get_loader_identifier", "get_loaders_by_name", + "get_hook_loaders_by_identifier", "get_representation_path_from_context", "get_representation_path", diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index d280aa3407..9d3635a186 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -16,6 +16,7 @@ from ayon_core.lib import ( ) from ayon_core.pipeline import ( Anatomy, + discover ) log = logging.getLogger(__name__) @@ -1149,3 +1150,34 @@ def filter_containers(containers, project_name): uptodate_containers.append(container) return output + + +def get_hook_loaders_by_identifier(): + """Discovers pre/post hooks for loader plugins. + + Returns: + (dict) {"LoaderName": {"pre": ["PreLoader1"], "post":["PreLoader2]} + """ + # beware of circular imports! + from .plugins import PreLoadHookPlugin, PostLoadHookPlugin + hook_loaders_by_identifier = {} + _get_hook_loaders(hook_loaders_by_identifier, PreLoadHookPlugin, "pre") + _get_hook_loaders(hook_loaders_by_identifier, PostLoadHookPlugin, "post") + return hook_loaders_by_identifier + + +def _get_hook_loaders(hook_loaders_by_identifier, loader_plugin, loader_type): + load_hook_plugins = discover(loader_plugin) + loaders_by_name = get_loaders_by_name() + for hook_plugin in load_hook_plugins: + for load_plugin_name in hook_plugin.loaders: + load_plugin = loaders_by_name.get(load_plugin_name) + if not load_plugin: + continue + if not load_plugin.enabled: + continue + identifier = get_loader_identifier(load_plugin) + (hook_loaders_by_identifier.setdefault(identifier, {}) + .setdefault(loader_type, []).append( + hook_plugin) + ) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 7151dbdaec..71bd0c1289 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -18,7 +18,8 @@ from ayon_core.pipeline.load import ( load_with_product_contexts, LoadError, IncompatibleLoaderError, - get_loaders_by_name + get_loaders_by_name, + get_hook_loaders_by_identifier ) from ayon_core.tools.loader.abstract import ActionItem @@ -338,18 +339,7 @@ class LoaderActionsModel: available_loaders = self._filter_loaders_by_tool_name( project_name, discover_loader_plugins(project_name) ) - hook_loaders_by_identifier = {} - pre_load_hook_plugins = discover_loader_pre_hook_plugin(project_name) - loaders_by_name = get_loaders_by_name() - for hook_plugin in pre_load_hook_plugins: - for load_plugin_name in hook_plugin.loaders: - load_plugin = loaders_by_name.get(load_plugin_name) - if not load_plugin: - continue - if not load_plugin.enabled: - continue - identifier = get_loader_identifier(load_plugin) - hook_loaders_by_identifier.setdefault(identifier, {}).setdefault("pre", []).append(hook_plugin) + hook_loaders_by_identifier = get_hook_loaders_by_identifier() repre_loaders = [] product_loaders = [] loaders_by_identifier = {} From 969358d37a73abcbaf09df63137260b089a732df Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Apr 2025 16:30:38 +0200 Subject: [PATCH 145/781] Removed missed unnecessary functions --- client/ayon_core/pipeline/__init__.py | 4 ---- client/ayon_core/tools/loader/models/actions.py | 1 - 2 files changed, 5 deletions(-) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index c6903f1b8e..beb5fa5ac2 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -42,13 +42,11 @@ from .load import ( register_loader_plugin_path, deregister_loader_plugin, - discover_loader_pre_hook_plugin, register_loader_pre_hook_plugin, deregister_loader_pre_hook_plugin, register_loader_pre_hook_plugin_path, deregister_loader_pre_hook_plugin_path, - discover_loader_post_hook_plugin, register_loader_post_hook_plugin, deregister_loader_post_hook_plugin, register_loader_post_hook_plugin_path, @@ -172,13 +170,11 @@ __all__ = ( "register_loader_plugin_path", "deregister_loader_plugin", - "discover_loader_pre_hook_plugin", "register_loader_pre_hook_plugin", "deregister_loader_pre_hook_plugin", "register_loader_pre_hook_plugin_path", "deregister_loader_pre_hook_plugin_path", - "discover_loader_post_hook_plugin", "register_loader_post_hook_plugin", "deregister_loader_post_hook_plugin", "register_loader_post_hook_plugin_path", diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 71bd0c1289..07195f4b05 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -9,7 +9,6 @@ import ayon_api from ayon_core.lib import NestedCacheItem from ayon_core.pipeline.load import ( discover_loader_plugins, - discover_loader_pre_hook_plugin, ProductLoaderPlugin, filter_repre_contexts_by_loader, get_loader_identifier, From 42d869973c40ae8508d620fc904ce29ea5909e10 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Apr 2025 16:42:23 +0200 Subject: [PATCH 146/781] Fixed circular import --- client/ayon_core/pipeline/load/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 9d3635a186..910ffb2eac 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -16,7 +16,6 @@ from ayon_core.lib import ( ) from ayon_core.pipeline import ( Anatomy, - discover ) log = logging.getLogger(__name__) @@ -413,7 +412,7 @@ def load_with_product_contexts( ) -def _load_context(Loader, contexts, hooks, name, namespace, options): +def _load_context(Loader, contexts, name, namespace, options, hooks): """Helper function to wrap hooks around generic load function. Only dynamic part is different context(s) to be loaded. @@ -1160,6 +1159,7 @@ def get_hook_loaders_by_identifier(): """ # beware of circular imports! from .plugins import PreLoadHookPlugin, PostLoadHookPlugin + hook_loaders_by_identifier = {} _get_hook_loaders(hook_loaders_by_identifier, PreLoadHookPlugin, "pre") _get_hook_loaders(hook_loaders_by_identifier, PostLoadHookPlugin, "post") @@ -1167,6 +1167,8 @@ def get_hook_loaders_by_identifier(): def _get_hook_loaders(hook_loaders_by_identifier, loader_plugin, loader_type): + from ..plugin_discover import discover + load_hook_plugins = discover(loader_plugin) loaders_by_name = get_loaders_by_name() for hook_plugin in load_hook_plugins: From 3961f8a5e2282834d6852d49192527365d0e8b8c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Apr 2025 17:07:53 +0200 Subject: [PATCH 147/781] Added get_hook_loaders_by_identifier to pipeline API --- client/ayon_core/pipeline/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index beb5fa5ac2..0a805b16dc 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -61,6 +61,7 @@ from .load import ( get_representation_path, get_representation_context, get_repres_contexts, + get_hook_loaders_by_identifier ) from .publish import ( @@ -240,6 +241,8 @@ __all__ = ( "register_workfile_build_plugin_path", "deregister_workfile_build_plugin_path", + "get_hook_loaders_by_identifier", + # Backwards compatible function names "install", "uninstall", From a963f8c137896ec326695c01fdefa509cde9d510 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Apr 2025 17:08:13 +0200 Subject: [PATCH 148/781] Added get_hook_loaders_by_identifier SceneInventory controller --- client/ayon_core/tools/sceneinventory/control.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/tools/sceneinventory/control.py b/client/ayon_core/tools/sceneinventory/control.py index 60d9bc77a9..acbd67b14d 100644 --- a/client/ayon_core/tools/sceneinventory/control.py +++ b/client/ayon_core/tools/sceneinventory/control.py @@ -5,6 +5,7 @@ from ayon_core.host import HostBase from ayon_core.pipeline import ( registered_host, get_current_context, + get_hook_loaders_by_identifier ) from ayon_core.tools.common_models import HierarchyModel, ProjectsModel @@ -35,6 +36,8 @@ class SceneInventoryController: self._projects_model = ProjectsModel(self) self._event_system = self._create_event_system() + self._hooks_by_identifier = get_hook_loaders_by_identifier() + def get_host(self) -> HostBase: return self._host @@ -115,6 +118,10 @@ class SceneInventoryController: return self._containers_model.get_version_items( project_name, product_ids) + def get_hook_loaders_by_identifier(self): + """Returns lists of pre|post hooks per Loader identifier.""" + return self._hooks_by_identifier + # Site Sync methods def is_sitesync_enabled(self): return self._sitesync_model.is_sitesync_enabled() From 761ef4b4af343496c42a2236b373069210b0758f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Apr 2025 17:08:33 +0200 Subject: [PATCH 149/781] Added update methods for Loader Hooks --- client/ayon_core/pipeline/load/plugins.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 6b1591221a..af38cf4c45 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -277,6 +277,10 @@ class PreLoadHookPlugin: def process(self, context, name=None, namespace=None, options=None): pass + def update(self, container, context): + pass + + class PostLoadHookPlugin: """Plugin that should be run after any Loaders in 'loaders' @@ -288,6 +292,9 @@ class PostLoadHookPlugin: def process(self, context, name=None, namespace=None, options=None): pass + def update(self, container, context): + pass + def discover_loader_plugins(project_name=None): from ayon_core.lib import Logger From f44b81a7e58c645481b9653378ece6a72f09f154 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Apr 2025 17:09:06 +0200 Subject: [PATCH 150/781] Pass hook_loaders_by_id in SceneInventory view --- client/ayon_core/tools/sceneinventory/view.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index bb95e37d4e..75d3d9a680 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -1100,11 +1100,16 @@ class SceneInventoryView(QtWidgets.QTreeView): containers_by_id = self._controller.get_containers_by_item_ids( item_ids ) + hook_loaders_by_id = self._controller.get_hook_loaders_by_identifier() try: for item_id, item_version in zip(item_ids, versions): container = containers_by_id[item_id] try: - update_container(container, item_version) + update_container( + container, + item_version, + hook_loaders_by_id + ) except AssertionError: log.warning("Update failed", exc_info=True) self._show_version_error_dialog( From a5c10767fd3352aa2c7772d16b0e04d094b198fd Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Apr 2025 17:09:32 +0200 Subject: [PATCH 151/781] Added loader hooks to update_container --- client/ayon_core/pipeline/load/utils.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 910ffb2eac..abef1bc500 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -520,7 +520,7 @@ def remove_container(container): return Loader().remove(container) -def update_container(container, version=-1): +def update_container(container, version=-1, hooks_by_identifier=None): """Update a container""" from ayon_core.pipeline import get_current_project_name @@ -616,7 +616,20 @@ def update_container(container, version=-1): if not path or not os.path.exists(path): raise ValueError("Path {} doesn't exist".format(path)) - return Loader().update(container, context) + loader_identifier = get_loader_identifier(Loader) + hooks = hooks_by_identifier.get(loader_identifier, {}) + for hook_plugin in hooks.get("pre", []): + hook_plugin.update( + context, + container + ) + update_return = Loader().update(container, context) + for hook_plugin in hooks.get("post", []): + hook_plugin.update( + context, + container + ) + return update_return def switch_container(container, representation, loader_plugin=None): From 82ae29dc12ce92ee748b9c19feab40dc80c4b877 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Apr 2025 17:10:47 +0200 Subject: [PATCH 152/781] Lazy initialization of get_hook_loaders_by_identifier --- client/ayon_core/tools/sceneinventory/control.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/sceneinventory/control.py b/client/ayon_core/tools/sceneinventory/control.py index acbd67b14d..e6fb459129 100644 --- a/client/ayon_core/tools/sceneinventory/control.py +++ b/client/ayon_core/tools/sceneinventory/control.py @@ -36,7 +36,7 @@ class SceneInventoryController: self._projects_model = ProjectsModel(self) self._event_system = self._create_event_system() - self._hooks_by_identifier = get_hook_loaders_by_identifier() + self._hooks_by_identifier = None def get_host(self) -> HostBase: return self._host @@ -120,6 +120,8 @@ class SceneInventoryController: def get_hook_loaders_by_identifier(self): """Returns lists of pre|post hooks per Loader identifier.""" + if self._hooks_by_identifier is None: + self._hooks_by_identifier = get_hook_loaders_by_identifier() return self._hooks_by_identifier # Site Sync methods From f609d2f5c44e2caf18b7f512e207b2eb6aeab856 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Apr 2025 17:19:24 +0200 Subject: [PATCH 153/781] Added switch methods to Loader Hooks --- client/ayon_core/pipeline/load/plugins.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index af38cf4c45..e930f034ce 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -280,6 +280,9 @@ class PreLoadHookPlugin: def update(self, container, context): pass + def switch(self, container, context): + pass + class PostLoadHookPlugin: """Plugin that should be run after any Loaders in 'loaders' @@ -295,6 +298,9 @@ class PostLoadHookPlugin: def update(self, container, context): pass + def switch(self, container, context): + pass + def discover_loader_plugins(project_name=None): from ayon_core.lib import Logger From 3a2d831ca632fdbfc93bf2789bdc7501424d5262 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Apr 2025 17:20:09 +0200 Subject: [PATCH 154/781] Added Loader Hooks to switch_container --- client/ayon_core/pipeline/load/utils.py | 27 ++++++++++++++++--- .../sceneinventory/switch_dialog/dialog.py | 8 +++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index abef1bc500..94e7ad53a6 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -632,15 +632,22 @@ def update_container(container, version=-1, hooks_by_identifier=None): return update_return -def switch_container(container, representation, loader_plugin=None): +def switch_container( + container, + representation, + loader_plugin=None, + hooks_by_identifier=None +): """Switch a container to representation Args: container (dict): container information representation (dict): representation entity + loader_plugin (LoaderPlugin) + hooks_by_identifier (dict): {"pre": [PreHookPlugin1], "post":[]} Returns: - function call + return from function call """ from ayon_core.pipeline import get_current_project_name @@ -679,7 +686,21 @@ def switch_container(container, representation, loader_plugin=None): loader = loader_plugin(context) - return loader.switch(container, context) + loader_identifier = get_loader_identifier(loader) + hooks = hooks_by_identifier.get(loader_identifier, {}) + for hook_plugin in hooks.get("pre", []): + hook_plugin.switch( + context, + container + ) + switch_return = loader.switch(container, context) + for hook_plugin in hooks.get("post", []): + hook_plugin.switch( + context, + container + ) + + return switch_return def _fix_representation_context_compatibility(repre_context): diff --git a/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py b/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py index a6d88ed44a..c878cad079 100644 --- a/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py +++ b/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py @@ -1339,8 +1339,14 @@ class SwitchAssetDialog(QtWidgets.QDialog): repre_entity = repres_by_name[container_repre_name] error = None + hook_loaders_by_id = self._controller.get_hook_loaders_by_identifier() try: - switch_container(container, repre_entity, loader) + switch_container( + container, + repre_entity, + loader, + hook_loaders_by_id + ) except ( LoaderSwitchNotImplementedError, IncompatibleLoaderError, From 0070edb34b7ff68d79d3bb50593c478c1ab39774 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 7 Apr 2025 09:56:37 +0200 Subject: [PATCH 155/781] Updated docstrings --- client/ayon_core/tools/sceneinventory/control.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/sceneinventory/control.py b/client/ayon_core/tools/sceneinventory/control.py index e6fb459129..c4e59699c3 100644 --- a/client/ayon_core/tools/sceneinventory/control.py +++ b/client/ayon_core/tools/sceneinventory/control.py @@ -119,7 +119,11 @@ class SceneInventoryController: project_name, product_ids) def get_hook_loaders_by_identifier(self): - """Returns lists of pre|post hooks per Loader identifier.""" + """Returns lists of pre|post hooks per Loader identifier. + + Returns: + (dict) {"LoaderName": {"pre": ["PreLoader1"], "post":["PreLoader2]} + """ if self._hooks_by_identifier is None: self._hooks_by_identifier = get_hook_loaders_by_identifier() return self._hooks_by_identifier From 310174fd1a0fba4b0713475bc89e28385fe18e0e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 7 Apr 2025 10:18:19 +0200 Subject: [PATCH 156/781] Fix calling hook --- client/ayon_core/pipeline/load/utils.py | 30 ++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 94e7ad53a6..d251dff06f 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -417,16 +417,16 @@ def _load_context(Loader, contexts, name, namespace, options, hooks): Only dynamic part is different context(s) to be loaded. """ - for hook_plugin in hooks.get("pre", []): - hook_plugin.process( + for hook_plugin_cls in hooks.get("pre", []): + hook_plugin_cls().process( contexts, name, namespace, options, ) load_return = Loader().load(contexts, name, namespace, options) - for hook_plugin in hooks.get("post", []): - hook_plugin.process( + for hook_plugin_cls in hooks.get("post", []): + hook_plugin_cls().process( contexts, name, namespace, @@ -618,14 +618,14 @@ def update_container(container, version=-1, hooks_by_identifier=None): loader_identifier = get_loader_identifier(Loader) hooks = hooks_by_identifier.get(loader_identifier, {}) - for hook_plugin in hooks.get("pre", []): - hook_plugin.update( + for hook_plugin_cls in hooks.get("pre", []): + hook_plugin_cls().update( context, container ) update_return = Loader().update(container, context) - for hook_plugin in hooks.get("post", []): - hook_plugin.update( + for hook_plugin_cls in hooks.get("post", []): + hook_plugin_cls().update( context, container ) @@ -688,14 +688,14 @@ def switch_container( loader_identifier = get_loader_identifier(loader) hooks = hooks_by_identifier.get(loader_identifier, {}) - for hook_plugin in hooks.get("pre", []): - hook_plugin.switch( + for hook_plugin_cls in hooks.get("pre", []): + hook_plugin_cls().switch( context, container ) switch_return = loader.switch(container, context) - for hook_plugin in hooks.get("post", []): - hook_plugin.switch( + for hook_plugin_cls in hooks.get("post", []): + hook_plugin_cls().switch( context, container ) @@ -1205,8 +1205,8 @@ def _get_hook_loaders(hook_loaders_by_identifier, loader_plugin, loader_type): load_hook_plugins = discover(loader_plugin) loaders_by_name = get_loaders_by_name() - for hook_plugin in load_hook_plugins: - for load_plugin_name in hook_plugin.loaders: + for hook_plugin_cls in load_hook_plugins: + for load_plugin_name in hook_plugin_cls.loaders: load_plugin = loaders_by_name.get(load_plugin_name) if not load_plugin: continue @@ -1215,5 +1215,5 @@ def _get_hook_loaders(hook_loaders_by_identifier, loader_plugin, loader_type): identifier = get_loader_identifier(load_plugin) (hook_loaders_by_identifier.setdefault(identifier, {}) .setdefault(loader_type, []).append( - hook_plugin) + hook_plugin_cls) ) From a0002774301c859b5b6906dc630500e117fed32f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 7 Apr 2025 10:57:58 +0200 Subject: [PATCH 157/781] Update naming Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- 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 e930f034ce..9f9c6c223f 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -284,7 +284,7 @@ class PreLoadHookPlugin: pass -class PostLoadHookPlugin: +class PostLoaderHookPlugin: """Plugin that should be run after any Loaders in 'loaders' Should be used as non-invasive method to enrich core loading process. From d11a8d2731c2e22abf8da7c45103690b6b660535 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 7 Apr 2025 10:58:07 +0200 Subject: [PATCH 158/781] Update naming Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- 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 9f9c6c223f..40a5eeb1a8 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -265,7 +265,7 @@ class ProductLoaderPlugin(LoaderPlugin): """ -class PreLoadHookPlugin: +class PreLoaderHookPlugin: """Plugin that should be run before any Loaders in 'loaders' Should be used as non-invasive method to enrich core loading process. From 70cf7fe5d41d282a23c8e974479ef32112d0081a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 7 Apr 2025 10:58:25 +0200 Subject: [PATCH 159/781] Import annotations Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/load/plugins.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 40a5eeb1a8..4ac1b3076d 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import logging from typing import ClassVar From 0ec82bd34d83542949ab27ae209c012082714916 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Mon, 7 Apr 2025 18:48:12 +0200 Subject: [PATCH 160/781] check project name when adding or executing `create_project_structure` --- server/__init__.py | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/server/__init__.py b/server/__init__.py index 1b9e7717c9..1de7412d18 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -43,21 +43,22 @@ class CoreAddon(BaseServerAddon): """Return a list of simple actions provided by the addon""" output = [] - # Add 'Create Project Folder Structure' action to folders. - output.append( - SimpleActionManifest( - identifier=f"{IDENTIFIER_PREFIX}.create_project_structure", - label="Create Project Folder Structure", - icon={ - "type": "material-symbols", - "name": "create_new_folder", - }, - order=100, - entity_type="folder", - entity_subtypes=None, - allow_multiselection=False, + if project_name: + # Add 'Create Project Folder Structure' action to folders. + output.append( + SimpleActionManifest( + identifier=f"{IDENTIFIER_PREFIX}.createprojectstructure", + label="Create Project Folder Structure", + icon={ + "type": "material-symbols", + "name": "create_new_folder", + }, + order=100, + entity_type="folder", + entity_subtypes=None, + allow_multiselection=False, + ) ) - ) return output @@ -71,6 +72,14 @@ class CoreAddon(BaseServerAddon): if executor.identifier == \ f"{IDENTIFIER_PREFIX}.create_project_structure": + + if not project_name: + raise ValueError( + f"Can't execute {executor.identifier} because" + " of missing project name." + ) + return + return await executor.get_launcher_action_response( args=[ "create-project-structure", From 7a7f4b44a04af44d9d3fc8debaaefa52f297c81c Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Mon, 7 Apr 2025 18:51:02 +0200 Subject: [PATCH 161/781] use logger from backend with fallback to nxtools --- pyproject.toml | 1 + server/__init__.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 94badd2f1a..6b8997d66c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ attrs = "^25.0.0" pyblish-base = "^1.8.7" clique = "^2.0.0" opentimelineio = "^0.17.0" +nxtools = "^1.6" [tool.ruff] diff --git a/server/__init__.py b/server/__init__.py index 1de7412d18..aafd8d6fa5 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -6,6 +6,10 @@ from ayon_server.actions import ( ExecuteResponseModel, SimpleActionManifest, ) +try: + from ayon_server.logging import logger +except ImportError: + from nxtools import logging as logger from .settings import ( CoreSettings, @@ -74,7 +78,7 @@ class CoreAddon(BaseServerAddon): f"{IDENTIFIER_PREFIX}.create_project_structure": if not project_name: - raise ValueError( + logger.error( f"Can't execute {executor.identifier} because" " of missing project name." ) @@ -87,4 +91,4 @@ class CoreAddon(BaseServerAddon): ] ) - raise ValueError(f"Unknown action: {executor.identifier}") + logger.debug(f"Unknown action: {executor.identifier}") From 01069f62ac38d4f7b0668fbd21a2ee7ecdd3a60f Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Mon, 7 Apr 2025 19:24:59 +0200 Subject: [PATCH 162/781] update poetry lock --- poetry.lock | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 96e1dc0f4c..235bb6770d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "appdirs" @@ -887,6 +887,22 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +[[package]] +name = "nxtools" +version = "1.6" +description = "nxtools is a set of various tools and helpers used by Nebula broadcast automation system and other software by imm studios, z.s." +optional = false +python-versions = ">=3.6,<4.0" +groups = ["dev"] +files = [ + {file = "nxtools-1.6-py3-none-any.whl", hash = "sha256:cde2d2c19193a2bd9c8071eeb3f121474ec34ab5c014de9a90f0c434cdfee716"}, + {file = "nxtools-1.6.tar.gz", hash = "sha256:e91839eef643483a3165165a46a693ad976d40a393ee5476a341e4e8bb102b92"}, +] + +[package.dependencies] +colorama = ">=0.4.4,<0.5.0" +Unidecode = ">=1.2.0,<2.0.0" + [[package]] name = "opentimelineio" version = "0.17.0" @@ -1512,4 +1528,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9.1,<3.10" -content-hash = "24b6215b9c20a4f64f844d3deb121618aef510b1c5ee54242e50305db6c0c4f4" +content-hash = "c0d98dae3faeed5237f286362f9d280ab17c3d42f7cd41fdd69ee4546983c553" From 44fc6f75ab1352cc8106543d612cbea64db0d03d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Apr 2025 10:40:52 +0200 Subject: [PATCH 163/781] Typo --- client/ayon_core/pipeline/load/plugins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index e930f034ce..4a503d6f1e 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -269,7 +269,7 @@ class PreLoadHookPlugin: """Plugin that should be run before any Loaders in 'loaders' Should be used as non-invasive method to enrich core loading process. - Any external studio might want to modify loaded data before or afte + Any external studio might want to modify loaded data before or after they are loaded without need to override existing core plugins. """ loaders: ClassVar[set[str]] @@ -288,7 +288,7 @@ class PostLoadHookPlugin: """Plugin that should be run after any Loaders in 'loaders' Should be used as non-invasive method to enrich core loading process. - Any external studio might want to modify loaded data before or afte + Any external studio might want to modify loaded data before or after they are loaded without need to override existing core plugins. loaders: ClassVar[set[str]] """ From 2fe9381471c619c2bff46ea0dfb50d4d5f8dc9ce Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Apr 2025 10:44:21 +0200 Subject: [PATCH 164/781] Added loaded container to Post process arguments --- client/ayon_core/pipeline/load/plugins.py | 9 ++++++++- client/ayon_core/pipeline/load/utils.py | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 4a503d6f1e..1719ee8c07 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -292,7 +292,14 @@ class PostLoadHookPlugin: they are loaded without need to override existing core plugins. loaders: ClassVar[set[str]] """ - def process(self, context, name=None, namespace=None, options=None): + def process( + self, + container, + context, + name=None, + namespace=None, + options=None + ): pass def update(self, container, context): diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index d251dff06f..471d93bb63 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -427,6 +427,7 @@ def _load_context(Loader, contexts, name, namespace, options, hooks): load_return = Loader().load(contexts, name, namespace, options) for hook_plugin_cls in hooks.get("post", []): hook_plugin_cls().process( + load_return, contexts, name, namespace, From fb1879a645e6f16f0bb9dd81349b3cd052b7d91c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Apr 2025 10:46:11 +0200 Subject: [PATCH 165/781] Updated names --- client/ayon_core/pipeline/load/utils.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 471d93bb63..718316a4c2 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -424,16 +424,16 @@ def _load_context(Loader, contexts, name, namespace, options, hooks): namespace, options, ) - load_return = Loader().load(contexts, name, namespace, options) + loaded_container = Loader().load(contexts, name, namespace, options) for hook_plugin_cls in hooks.get("post", []): hook_plugin_cls().process( - load_return, + loaded_container, contexts, name, namespace, options, ) - return load_return + return loaded_container def load_container( @@ -624,13 +624,13 @@ def update_container(container, version=-1, hooks_by_identifier=None): context, container ) - update_return = Loader().update(container, context) + updated_container = Loader().update(container, context) for hook_plugin_cls in hooks.get("post", []): hook_plugin_cls().update( context, container ) - return update_return + return updated_container def switch_container( @@ -694,14 +694,14 @@ def switch_container( context, container ) - switch_return = loader.switch(container, context) + switched_container = loader.switch(container, context) for hook_plugin_cls in hooks.get("post", []): hook_plugin_cls().switch( context, container ) - return switch_return + return switched_container def _fix_representation_context_compatibility(repre_context): From 1e5f5446a77d5b0872a02498d31b4925a9c7cefa Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Apr 2025 10:49:04 +0200 Subject: [PATCH 166/781] Renamed loaders to more descriptive --- client/ayon_core/pipeline/load/plugins.py | 5 +++-- client/ayon_core/pipeline/load/utils.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 1719ee8c07..26363a40d9 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -272,7 +272,7 @@ class PreLoadHookPlugin: Any external studio might want to modify loaded data before or after they are loaded without need to override existing core plugins. """ - loaders: ClassVar[set[str]] + loader_identifiers: ClassVar[set[str]] def process(self, context, name=None, namespace=None, options=None): pass @@ -290,8 +290,9 @@ class PostLoadHookPlugin: Should be used as non-invasive method to enrich core loading process. Any external studio might want to modify loaded data before or after they are loaded without need to override existing core plugins. - loaders: ClassVar[set[str]] """ + loader_identifiers: ClassVar[set[str]] + def process( self, container, diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 718316a4c2..c61d743d05 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -1207,7 +1207,7 @@ def _get_hook_loaders(hook_loaders_by_identifier, loader_plugin, loader_type): load_hook_plugins = discover(loader_plugin) loaders_by_name = get_loaders_by_name() for hook_plugin_cls in load_hook_plugins: - for load_plugin_name in hook_plugin_cls.loaders: + for load_plugin_name in hook_plugin_cls.loader_identifiers: load_plugin = loaders_by_name.get(load_plugin_name) if not load_plugin: continue From 8c90fac2b5568cd1e5f38417216d062eab1254bc Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Apr 2025 12:36:21 +0200 Subject: [PATCH 167/781] Fix renamed class names --- client/ayon_core/pipeline/load/plugins.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 02efe5b43a..d069508bfa 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -350,32 +350,32 @@ def register_loader_plugin_path(path): def register_loader_pre_hook_plugin(plugin): - return register_plugin(PreLoadHookPlugin, plugin) + return register_plugin(PreLoaderHookPlugin, plugin) def deregister_loader_pre_hook_plugin(plugin): - deregister_plugin(PreLoadHookPlugin, plugin) + deregister_plugin(PreLoaderHookPlugin, plugin) def register_loader_pre_hook_plugin_path(path): - return register_plugin_path(PreLoadHookPlugin, path) + return register_plugin_path(PreLoaderHookPlugin, path) def deregister_loader_pre_hook_plugin_path(path): - deregister_plugin_path(PreLoadHookPlugin, path) + deregister_plugin_path(PreLoaderHookPlugin, path) def register_loader_post_hook_plugin(plugin): - return register_plugin(PostLoadHookPlugin, plugin) + return register_plugin(PostLoaderHookPlugin, plugin) def deregister_loader_post_hook_plugin(plugin): - deregister_plugin(PostLoadHookPlugin, plugin) + deregister_plugin(PostLoaderHookPlugin, plugin) def register_loader_post_hook_plugin_path(path): - return register_plugin_path(PostLoadHookPlugin, path) + return register_plugin_path(PostLoaderHookPlugin, path) def deregister_loader_post_hook_plugin_path(path): - deregister_plugin_path(PostLoadHookPlugin, path) + deregister_plugin_path(PostLoaderHookPlugin, path) From 2af6e95df2831bfe7e5e3e2ebea6ed196146e56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 9 Apr 2025 11:48:09 +0200 Subject: [PATCH 168/781] :recycle: update help and linter issues --- client/ayon_core/pipeline/publish/lib.py | 8 +- client/ayon_core/pipeline/traits/README.md | 114 +++++++++++--------- client/ayon_core/pipeline/traits/content.py | 4 +- pyproject.toml | 6 +- 4 files changed, 71 insertions(+), 61 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index f1dda288a6..0c1029d282 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -1075,11 +1075,11 @@ def has_trait_representations( Args: instance (pyblish.api.Instance): Instance to check. - + Returns: True: Instance has trait representation. False: Instance does not have trait representation. - + """ return bool(instance.data.get(TRAIT_INSTANCE_KEY)) @@ -1087,7 +1087,7 @@ def has_trait_representations( def add_trait_representations( instance: pyblish.api.Instance, representations: list[Representation] -): +) -> None: """Add trait representations to instance. Args: @@ -1105,7 +1105,7 @@ def add_trait_representations( def set_trait_representations( instance: pyblish.api.Instance, representations: list[Representation] -): +) -> None: """Set trait representations to instance. Args: diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md index 1566b4f353..3e8ff2449d 100644 --- a/client/ayon_core/pipeline/traits/README.md +++ b/client/ayon_core/pipeline/traits/README.md @@ -57,15 +57,15 @@ There are also some assumptions and limitations - like that if `files` in the representation are list they need to be sequence of files (it can't be a bunch of unrelated files). -This system is very flexible in one way, but it lack few very important things: +This system is very flexible in one way, but it lacks few very important things: - it is not clearly defined - you can add easily keys, values, tags but without unforeseeable consequences - it cannot handle "bundles" - multiple files that needs to be versioned together and belong together -- it cannot describe important information that you can't get from the file itself or -it is very pricy (like axis orientation and units from alembic files) +- it cannot describe important information that you can't get from the file itself, or +it is very expensive (like axis orientation and units from alembic files) ### New Representation model @@ -81,7 +81,7 @@ it is based on `TraitBase`. It shouldn't really duplicate information that is available in a moment of loading (or any usage) by other means. It should contain information that couldn't be determined by the file, or the AYON context. Some of those traits are aligned with [OpenAssetIO Media Creation](https://github.com/OpenAssetIO/OpenAssetIO-MediaCreation) with hopes of maintained compatibility (it -should be easy enough to convert between OpenAssetIO Traits and AOYN Traits). +should be easy enough to convert between OpenAssetIO Traits and AYON Traits). #### Details: Representation @@ -114,10 +114,10 @@ image = rep[Image.id] ``` > [!NOTE] -> Trait and their ids - every Trait has its id as s string with -> version appended - so **Image** has `ayon.2d.Image.v1`. This is is used on +> Trait and their ids - every Trait has its id as a string with +> version appended - so **Image** has `ayon.2d.Image.v1`. This is used on > several places (you see its use above for indexing traits). When querying, -> you can also omit the version at the end and it will try its best to find +> you can also omit the version at the end, and it will try its best to find > the latest possible version. More on that in [Traits]() You can construct the `Representation` from dictionary (for example @@ -145,44 +145,44 @@ all it's traits. As mentioned there are several traits defined directly in **ayon-core**. They are namespaced to different packages based on their use: -| namespace | trait | description -|---|---|--- -| color | ColorManaged | hold color management information -| content | MimeType | use MIME type (RFC 2046) to describe content (like image/jpeg) -| | LocatableContent | describe some location (file or URI) -| | FileLocation | path to file, with size and checksum -| | FileLocations | list of `FileLocation` -| | RootlessLocation | Path where root is replaced with AYON root token -| | Compressed | describes compression (of file or other) -| | Bundle | list of list of Traits - compound of inseparable "sub-representations" -| | Fragment | compound type marking the representation as a part of larger group of representations -| cryptography | DigitallySigned | Type traits marking data to be digitally signed -| | PGPSigned | Representation is signed by [PGP](https://www.openpgp.org/) -| lifecycle | Transient | Marks the representation to be temporary - not to be stored. -| | Persistent | Representation should be integrated (stored). Opposite of Transient. -| meta | Tagged | holds list of tag strings. -| | TemplatePath | Template consisted of tokens/keys and data to be used to resolve the template into string -| | Variant | Used to differentiate between data variants of the same output (mp4 as h.264 and h.265 for example) -| | KeepOriginalLocation | Marks the representation to keep the original location of the file -| | KeepOriginalName | Marks the representation to keep the original name of the file -| | SourceApplication | Holds information about producing application, about it's version, variant and platform. -| | IntendedUse | For specifying the intended use of the representation if it cannot be easily determined by other traits. -| three dimensional | Spatial | Spatial information like up-axis, units and handedness. -| | Geometry | Type trait to mark the representation as a geometry. -| | Shader | Type trait to mark the representation as a Shader. -| | Lighting | Type trait to mark the representation as Lighting. -| | IESProfile | States that the representation is IES Profile -| time | FrameRanged | Contains start and end frame information with in and out. -| | Handless | define additional frames at the end or beginning and if those frames are inclusive of the range or not. -| | Sequence | Describes sequence of frames and how the frames are defined in that sequence. -| | SMPTETimecode | Adds timecode information in SMPTE format. -| | Static | Marks the content as not time-variant. -| two dimensional | Image | Type traits of image. -| | PixelBased | Defines resolution and pixel aspect for the image data. -| | Planar | Whether pixel data is in planar configuration or packed. -| | Deep | Image encodes deep pixel data. -| | Overscan | holds overscan/underscan information (added pixels to bottom/sides) -| | UDIM | Representation is UDIM tile set +| namespace | trait | description | +|-------------------|----------------------|----------------------------------------------------------------------------------------------------------| +| color | ColorManaged | hold color management information | +| content | MimeType | use MIME type (RFC 2046) to describe content (like image/jpeg) | +| | LocatableContent | describe some location (file or URI) | +| | FileLocation | path to file, with size and checksum | +| | FileLocations | list of `FileLocation` | +| | RootlessLocation | Path where root is replaced with AYON root token | +| | Compressed | describes compression (of file or other) | +| | Bundle | list of list of Traits - compound of inseparable "sub-representations" | +| | Fragment | compound type marking the representation as a part of larger group of representations | +| cryptography | DigitallySigned | Type traits marking data to be digitally signed | +| | PGPSigned | Representation is signed by [PGP](https://www.openpgp.org/) | +| lifecycle | Transient | Marks the representation to be temporary - not to be stored. | +| | Persistent | Representation should be integrated (stored). Opposite of Transient. | +| meta | Tagged | holds list of tag strings. | +| | TemplatePath | Template consisted of tokens/keys and data to be used to resolve the template into string | +| | Variant | Used to differentiate between data variants of the same output (mp4 as h.264 and h.265 for example) | +| | KeepOriginalLocation | Marks the representation to keep the original location of the file | +| | KeepOriginalName | Marks the representation to keep the original name of the file | +| | SourceApplication | Holds information about producing application, about it's version, variant and platform. | +| | IntendedUse | For specifying the intended use of the representation if it cannot be easily determined by other traits. | +| three dimensional | Spatial | Spatial information like up-axis, units and handedness. | +| | Geometry | Type trait to mark the representation as a geometry. | +| | Shader | Type trait to mark the representation as a Shader. | +| | Lighting | Type trait to mark the representation as Lighting. | +| | IESProfile | States that the representation is IES Profile. | +| time | FrameRanged | Contains start and end frame information with in and out. | +| | Handless | define additional frames at the end or beginning and if those frames are inclusive of the range or not. | +| | Sequence | Describes sequence of frames and how the frames are defined in that sequence. | +| | SMPTETimecode | Adds timecode information in SMPTE format. | +| | Static | Marks the content as not time-variant. | +| two dimensional | Image | Type traits of image. | +| | PixelBased | Defines resolution and pixel aspect for the image data. | +| | Planar | Whether pixel data is in planar configuration or packed. | +| | Deep | Image encodes deep pixel data. | +| | Overscan | holds overscan/underscan information (added pixels to bottom/sides). | +| | UDIM | Representation is UDIM tile set. | Traits are Python data classes with optional validation and helper methods. If they implement `TraitBase.validate(Representation)` method, they can validate against all other traits @@ -193,7 +193,7 @@ in the representation if needed. > easily resolve pydantic-core dependency (as it is binary written in Rust). > [!NOTE] -> Every trait has id, name and some human readable description. Every trait +> Every trait has id, name and some human-readable description. Every trait > also has `persistent` property that is by default set to True. This > Controls whether this trait should be stored with the persistent representation > or not. Useful for traits to be used just to control the publishing process. @@ -211,6 +211,8 @@ from ayon_core.pipeline.traits import ( Persistent, Representation, Static, + + TraitValidationError, ) rep = Representation(name="reference image", traits=[ @@ -318,8 +320,18 @@ class MyAddon(AYONAddon, ITraits): ## Developer notes -### Pydantic models -If you want to use MyPy linter, you need to make sure that -optional fields typed as `Optional[Type]` needs to set default value -using `default` or `default_factory` parameter. Otherwise MyPy will -complain about missing named arguments. +Adding new trait based representations in to publish Instance and working with them is using +set of helper function defined in `ayon_core.pipeline.publish` module. These are: + +* add_trait_representations +* get_trait_representations +* has_trait_representations +* set_trait_representations + +And their main purpose is to handle the key under which the representation +is stored in the instance data. This is done to avoid name clashes with +other representations. The key is defined in the `AYON_PUBLISH_REPRESENTATION_KEY`. +It is strongly recommended to use those functions instead of +directly accessing the instance data. This is to ensure that the +code will work even if the key is changed in the future. + diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index 5a19daedac..bad90f5875 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -5,8 +5,8 @@ import contextlib import re from dataclasses import dataclass -# TC003 is there because Path in TYPECHECKING will fail in tests -from pathlib import Path # noqa: TC003 +# TCH003 is there because Path in TYPECHECKING will fail in tests +from pathlib import Path # noqa: TCH003 from typing import ClassVar, Generator, Optional from .representation import Representation diff --git a/pyproject.toml b/pyproject.toml index c5f2aff2d7..1ab501fbce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ pytest = "^8.0" pytest-print = "^1.0" ayon-python-api = "^1.0" # linting dependencies -ruff = "^0.9.3" +ruff = "^0.11.4" pre-commit = "^4" codespell = "^2.2.6" semver = "^3.0.2" @@ -80,7 +80,6 @@ exclude = [ line-length = 79 indent-width = 4 - # Assume Python 3.9 target-version = "py39" @@ -91,7 +90,7 @@ pydocstyle.convention = "google" select = ["ALL"] ignore = [ "PTH", - # "ANN101", # must be set in older version of ruff + # "ANN001", # must be set in older version of ruff "ANN204", "COM812", "S603", @@ -100,7 +99,6 @@ ignore = [ "UP006", # support for older python version (type vs. Type) "UP007", # ..^ "UP035", # .. - "UP045", # Use `X | None` for type annotations "ARG002", "INP001", # add `__init__.py` to namespaced package "FIX002", # FIX all TODOs From 46f72685b11e7a372dfe04ef11820a7a483032c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 9 Apr 2025 12:21:21 +0200 Subject: [PATCH 169/781] :memo: add loader and publisher info to docs --- client/ayon_core/pipeline/traits/README.md | 116 +++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md index 3e8ff2449d..235d8a367c 100644 --- a/client/ayon_core/pipeline/traits/README.md +++ b/client/ayon_core/pipeline/traits/README.md @@ -317,6 +317,122 @@ class MyAddon(AYONAddon, ITraits): MyTraitBar, ] ``` +## Usage in Loaders + +In loaders, you can implement `is_compatible_loader()` method to check if the +representation is compatible with the loader. You can use `Representation.from_dict()` to +create the representation from the context. You can also use `Representation.contains_traits()` +to check if the representation contains the required traits. You can even check for specific +values in the traits. + +You can use similar concepts directly in the `load()` method to get the traits. Here is +an example of how to use the traits in the hypothetical Maya loader: + +```python +"""Alembic loader using traits.""" +from __future__ import annotations +import json +from typing import Any, TypeVar, Type +from ayon_maya.api.plugin import MayaLoader +from ayon_core.pipeline.traits import ( + FileLocation, + Spatial, + + Representation, + TraitBase, +) + +T = TypeVar("T", bound=TraitBase) + + +class AlembicTraitLoader(MayaLoader): + """Alembic loader using traits.""" + label = "Alembic Trait Loader" + ... + + required_traits: list[T] = [ + FileLocation, + Spatial, + ] + + @staticmethod + def is_compatible_loader(context: dict[str, Any]) -> bool: + traits_raw = context["representation"].get("traits") + if not traits_raw: + return False + + # construct Representation object from the context + representation = Representation.from_dict( + name=context["representation"]["name"], + representation_id=context["representation"]["id"], + trait_data=json.loads(traits_raw), + ) + + # check if the representation is compatible with this loader + if representation.contains_traits(AlembicTraitLoader.required_traits): + # you can also check for specific values in traits here + return True + return False + + ... +``` + +## Usage Publishing plugins + +You can create the representations in the same way as mentioned in the examples above. +Straightforward way is to use `Representation` class and add the traits to it. Collect +traits in list and then pass them to the `Representation` constructor. You should add +the new Representation to the instance data using `add_trait_representations()` function. + +```python +class SomeExtractor(Extractor): + """Some extractor.""" + ... + + def extract(self, instance: Instance) -> None: + """Extract the data.""" + # get the path to the file + path = self.get_path(instance) + + # create the representation + traits: list[TraitBase] = [ + Geometry(), + MimeType(mime_type="application/abc"), + Persistent(), + Spatial( + up_axis=cmds.upAxis(q=True, axis=True), + meters_per_unit=maya_units_to_meters_per_unit( + instance.context.data["linearUnits"]), + handedness="right", + ), + ] + + if instance.data.get("frameStart"): + traits.append( + FrameRanged( + frame_start=instance.data["frameStart"], + frame_end=instance.data["frameEnd"], + frames_per_second=instance.context.data["fps"], + ) + ) + + representation = Representation( + name="alembic", + traits=[ + FileLocation( + file_path=Path(path), + file_size=os.path.getsize(path), + file_hash=get_file_hash(Path(path)) + ), + *traits], + ) + + add_trait_representations( + instance, + [representation], + ) + ... +``` ## Developer notes From e3c04bde8fa1cfbbc97d2f458afac7bd9314d5b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 14 Apr 2025 17:30:00 +0200 Subject: [PATCH 170/781] :recycle: revert my awesome ruff rules to please the lazy crowd --- pyproject.toml | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4844f65807..10e477ca9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,26 +87,8 @@ target-version = "py39" preview = true pydocstyle.convention = "google" # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -select = ["ALL"] -ignore = [ - "PTH", - # "ANN001", # must be set in older version of ruff - "ANN204", - "COM812", - "S603", - "ERA001", - "TRY003", - "UP006", # support for older python version (type vs. Type) - "UP007", # ..^ - "UP035", # .. - "ARG002", - "INP001", # add `__init__.py` to namespaced package - "FIX002", # FIX all TODOs - "TD003", # missing issue link - "S404", # subprocess module is possibly insecure - "PLC0415", # import must be on top of the file - "CPY001", # missing copyright header -] +select = ["E", "F", "W"] +ignore = [] # Allow fix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] From 0ada57c25bff29b80403255a0f37a91c73254654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 14 Apr 2025 17:40:22 +0200 Subject: [PATCH 171/781] :dog: fix linting issues in tests --- .../plugins/publish/test_integrate_traits.py | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index 1fa80d7b8a..c66dda1630 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -9,9 +9,6 @@ from typing import TYPE_CHECKING import pyblish.api import pytest -from ayon_api.operations import ( - OperationsSession, -) from ayon_core.lib.file_transaction import ( FileTransaction, @@ -44,9 +41,6 @@ from ayon_core.settings import get_project_settings from ayon_api.operations import ( OperationsSession, - new_product_entity, - new_representation_entity, - new_version_entity, ) if TYPE_CHECKING: @@ -237,8 +231,6 @@ def mock_context( return context - - def test_get_template_name(mock_context: pyblish.api.Context) -> None: """Test get_template_name. @@ -275,9 +267,12 @@ class TestGetSize: (Path("./test_file_2.txt"), 1024), # id: happy_path_medium_file (Path("./test_file_3.txt"), 10485760) # id: happy_path_large_file ], - ids=["happy_path_small_file", "happy_path_medium_file", "happy_path_large_file"] + ids=["happy_path_small_file", + "happy_path_medium_file", + "happy_path_large_file"] ) - def test_get_size_happy_path(self, file_path: Path, expected_size: int, tmp_path: Path): + def test_get_size_happy_path( + self, file_path: Path, expected_size: int, tmp_path: Path): # Arrange file_path = tmp_path / file_path file_path.write_bytes(b"\0" * expected_size) @@ -288,7 +283,6 @@ class TestGetSize: # Assert assert size == expected_size - @pytest.mark.parametrize( "file_path, expected_size", [ @@ -296,10 +290,11 @@ class TestGetSize: ], ids=["edge_case_empty_file"] ) - def test_get_size_edge_cases(self, file_path: Path, expected_size: int, tmp_path: Path): + def test_get_size_edge_cases( + self, file_path: Path, expected_size: int, tmp_path: Path): # Arrange file_path = tmp_path / file_path - file_path.touch() # Create an empty file + file_path.touch() # Create an empty file # Act size = self.get_size(file_path) @@ -310,12 +305,16 @@ class TestGetSize: @pytest.mark.parametrize( "file_path, expected_exception", [ - (Path("./non_existent_file.txt"), FileNotFoundError), # id: error_file_not_found - (123, TypeError) # id: error_invalid_input_type + ( + Path("./non_existent_file.txt"), + FileNotFoundError + ), # id: error_file_not_found + (123, TypeError) # id: error_invalid_input_type ], ids=["error_file_not_found", "error_invalid_input_type"] ) - def test_get_size_error_cases(self, file_path, expected_exception, tmp_path): + def test_get_size_error_cases( + self, file_path, expected_exception, tmp_path): # Act & Assert with pytest.raises(expected_exception): @@ -439,5 +438,5 @@ def test_get_transfers_from_representation( file_transactions.process() for representation in representations: - files = integrator._get_legacy_files_for_representation( # noqa: SLF001 + _ = integrator._get_legacy_files_for_representation( # noqa: SLF001 transfers, representation, anatomy=instance.data["anatomy"]) From 0fdff60147f61b0a31a94b9a7f19ce022f6e8380 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Tue, 15 Apr 2025 19:08:14 +0200 Subject: [PATCH 172/781] remove the poetry.lock - again --- poetry.lock | 1531 --------------------------------------------------- 1 file changed, 1531 deletions(-) delete mode 100644 poetry.lock diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 235bb6770d..0000000000 --- a/poetry.lock +++ /dev/null @@ -1,1531 +0,0 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. - -[[package]] -name = "appdirs" -version = "1.4.4" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, -] - -[[package]] -name = "attrs" -version = "25.1.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"}, - {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"}, -] - -[package.extras] -benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] - -[[package]] -name = "ayon-python-api" -version = "1.0.12" -description = "AYON Python API" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "ayon-python-api-1.0.12.tar.gz", hash = "sha256:8e4c03436df8afdda4c6ad4efce436068771995bb0153a90e003364afa0e7f55"}, - {file = "ayon_python_api-1.0.12-py3-none-any.whl", hash = "sha256:65f61c2595dd6deb26fed5e3fda7baef887f475fa4b21df12513646ddccf4a7d"}, -] - -[package.dependencies] -appdirs = ">=1,<2" -requests = ">=2.27.1" -Unidecode = ">=1.3.0" - -[[package]] -name = "babel" -version = "2.17.0" -description = "Internationalization utilities" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, - {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, -] - -[package.extras] -dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] - -[[package]] -name = "backrefs" -version = "5.8" -description = "A wrapper around re and regex that adds additional back references." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "backrefs-5.8-py310-none-any.whl", hash = "sha256:c67f6638a34a5b8730812f5101376f9d41dc38c43f1fdc35cb54700f6ed4465d"}, - {file = "backrefs-5.8-py311-none-any.whl", hash = "sha256:2e1c15e4af0e12e45c8701bd5da0902d326b2e200cafcd25e49d9f06d44bb61b"}, - {file = "backrefs-5.8-py312-none-any.whl", hash = "sha256:bbef7169a33811080d67cdf1538c8289f76f0942ff971222a16034da88a73486"}, - {file = "backrefs-5.8-py313-none-any.whl", hash = "sha256:e3a63b073867dbefd0536425f43db618578528e3896fb77be7141328642a1585"}, - {file = "backrefs-5.8-py39-none-any.whl", hash = "sha256:a66851e4533fb5b371aa0628e1fee1af05135616b86140c9d787a2ffdf4b8fdc"}, - {file = "backrefs-5.8.tar.gz", hash = "sha256:2cab642a205ce966af3dd4b38ee36009b31fa9502a35fd61d59ccc116e40a6bd"}, -] - -[package.extras] -extras = ["regex"] - -[[package]] -name = "certifi" -version = "2025.1.31" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, - {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, -] - -[[package]] -name = "cfgv" -version = "3.4.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.1" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, - {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, - {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, -] - -[[package]] -name = "click" -version = "8.1.8" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, - {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "clique" -version = "2.0.0" -description = "Manage collections with common numerical component" -optional = false -python-versions = ">=3.0, <4.0" -groups = ["dev"] -files = [ - {file = "clique-2.0.0-py2.py3-none-any.whl", hash = "sha256:45e2a4c6078382e0b217e5e369494279cf03846d95ee601f93290bed5214c22e"}, - {file = "clique-2.0.0.tar.gz", hash = "sha256:6e1115dbf21b1726f4b3db9e9567a662d6bdf72487c4a0a1f8cb7f10cf4f4754"}, -] - -[package.extras] -dev = ["lowdown (>=0.2.0,<1)", "pytest (>=2.3.5,<5)", "pytest-cov (>=2,<3)", "pytest-runner (>=2.7,<3)", "sphinx (>=2,<4)", "sphinx-rtd-theme (>=0.1.6,<1)"] -doc = ["lowdown (>=0.2.0,<1)", "sphinx (>=2,<4)", "sphinx-rtd-theme (>=0.1.6,<1)"] -test = ["pytest (>=2.3.5,<5)", "pytest-cov (>=2,<3)", "pytest-runner (>=2.7,<3)"] - -[[package]] -name = "codespell" -version = "2.4.1" -description = "Fix common misspellings in text files" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425"}, - {file = "codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5"}, -] - -[package.extras] -dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] -hard-encoding-detection = ["chardet"] -toml = ["tomli ; python_version < \"3.11\""] -types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "csscompressor" -version = "0.9.5" -description = "A python port of YUI CSS Compressor" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05"}, -] - -[[package]] -name = "distlib" -version = "0.3.9" -description = "Distribution utilities" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, - {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "filelock" -version = "3.17.0" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338"}, - {file = "filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] -typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] - -[[package]] -name = "ghp-import" -version = "2.1.0" -description = "Copy your docs directly to the gh-pages branch." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, - {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, -] - -[package.dependencies] -python-dateutil = ">=2.8.1" - -[package.extras] -dev = ["flake8", "markdown", "twine", "wheel"] - -[[package]] -name = "griffe" -version = "1.6.2" -description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "griffe-1.6.2-py3-none-any.whl", hash = "sha256:6399f7e663150e4278a312a8e8a14d2f3d7bd86e2ef2f8056a1058e38579c2ee"}, - {file = "griffe-1.6.2.tar.gz", hash = "sha256:3a46fa7bd83280909b63c12b9a975732a927dd97809efe5b7972290b606c5d91"}, -] - -[package.dependencies] -colorama = ">=0.4" - -[[package]] -name = "htmlmin2" -version = "0.1.13" -description = "An HTML Minifier" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "htmlmin2-0.1.13-py3-none-any.whl", hash = "sha256:75609f2a42e64f7ce57dbff28a39890363bde9e7e5885db633317efbdf8c79a2"}, -] - -[[package]] -name = "identify" -version = "2.6.7" -description = "File identification library for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "identify-2.6.7-py2.py3-none-any.whl", hash = "sha256:155931cb617a401807b09ecec6635d6c692d180090a1cedca8ef7d58ba5b6aa0"}, - {file = "identify-2.6.7.tar.gz", hash = "sha256:3fa266b42eba321ee0b2bb0936a6a6b9e36a1351cbb69055b3082f4193035684"}, -] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "importlib-metadata" -version = "8.6.1" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e"}, - {file = "importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] - -[[package]] -name = "importlib-resources" -version = "6.5.2" -description = "Read resources from Python packages" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec"}, - {file = "importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c"}, -] - -[package.dependencies] -zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] -type = ["pytest-mypy"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, - {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "jsmin" -version = "3.0.1" -description = "JavaScript minifier." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc"}, -] - -[[package]] -name = "markdown" -version = "3.7" -description = "Python implementation of John Gruber's Markdown." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, - {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - -[package.extras] -docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] -testing = ["coverage", "pyyaml"] - -[[package]] -name = "markdown-checklist" -version = "0.4.4" -description = "Python Markdown extension for task lists with checkboxes" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "markdown-checklist-0.4.4.tar.gz", hash = "sha256:69c93850798b1e01cdc6fcd4a80592d941f669f6451bbf69c71a4ffd1142f849"}, -] - -[package.dependencies] -markdown = "*" - -[package.extras] -coverage = ["coverage", "figleaf"] -testing = ["pytest"] - -[[package]] -name = "markupsafe" -version = "3.0.2" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, - {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, -] - -[[package]] -name = "mdx-gh-links" -version = "0.4" -description = "An extension to Python-Markdown which adds support for shorthand links to GitHub users, repositories, issues and commits." -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "mdx_gh_links-0.4-py3-none-any.whl", hash = "sha256:9057bca1fa5280bf1fcbf354381e46c9261cc32c2d5c0407801f8a910be5f099"}, - {file = "mdx_gh_links-0.4.tar.gz", hash = "sha256:41d5aac2ab201425aa0a19373c4095b79e5e015fdacfe83c398199fe55ca3686"}, -] - -[package.dependencies] -markdown = ">=3.0.0" - -[[package]] -name = "mergedeep" -version = "1.3.4" -description = "A deep merge function for 🐍." -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, - {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, -] - -[[package]] -name = "mike" -version = "2.1.3" -description = "Manage multiple versions of your MkDocs-powered documentation" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "mike-2.1.3-py3-none-any.whl", hash = "sha256:d90c64077e84f06272437b464735130d380703a76a5738b152932884c60c062a"}, - {file = "mike-2.1.3.tar.gz", hash = "sha256:abd79b8ea483fb0275b7972825d3082e5ae67a41820f8d8a0dc7a3f49944e810"}, -] - -[package.dependencies] -importlib-metadata = "*" -importlib-resources = "*" -jinja2 = ">=2.7" -mkdocs = ">=1.0" -pyparsing = ">=3.0" -pyyaml = ">=5.1" -pyyaml-env-tag = "*" -verspec = "*" - -[package.extras] -dev = ["coverage", "flake8 (>=3.0)", "flake8-quotes", "shtab"] -test = ["coverage", "flake8 (>=3.0)", "flake8-quotes", "shtab"] - -[[package]] -name = "mkdocs" -version = "1.6.1" -description = "Project documentation with Markdown." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, - {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, -] - -[package.dependencies] -click = ">=7.0" -colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} -ghp-import = ">=1.0" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} -jinja2 = ">=2.11.1" -markdown = ">=3.3.6" -markupsafe = ">=2.0.1" -mergedeep = ">=1.3.4" -mkdocs-get-deps = ">=0.2.0" -packaging = ">=20.5" -pathspec = ">=0.11.1" -pyyaml = ">=5.1" -pyyaml-env-tag = ">=0.1" -watchdog = ">=2.0" - -[package.extras] -i18n = ["babel (>=2.9.0)"] -min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4) ; platform_system == \"Windows\"", "ghp-import (==1.0)", "importlib-metadata (==4.4) ; python_version < \"3.10\"", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] - -[[package]] -name = "mkdocs-autoapi" -version = "0.4.0" -description = "MkDocs plugin providing automatic API reference generation" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mkdocs_autoapi-0.4.0-py3-none-any.whl", hash = "sha256:ce2b5e8b4d1df37bd1273a6bce1dded152107e2280cedbfa085cea10edf8531b"}, - {file = "mkdocs_autoapi-0.4.0.tar.gz", hash = "sha256:409c2da8eb297ef51381b066744ac1cdf846220bcc779bdba680fbc5a080df1e"}, -] - -[package.dependencies] -mkdocs = ">=1.4.0" -mkdocstrings = ">=0.19.0" - -[package.extras] -python = ["mkdocstrings[python] (>=0.19.0)"] -python-legacy = ["mkdocstrings[python-legacy] (>=0.19.0)"] -vba = ["mkdocstrings-vba (>=0.0.10)"] - -[[package]] -name = "mkdocs-autorefs" -version = "1.4.1" -description = "Automatically link across pages in MkDocs." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "mkdocs_autorefs-1.4.1-py3-none-any.whl", hash = "sha256:9793c5ac06a6ebbe52ec0f8439256e66187badf4b5334b5fde0b128ec134df4f"}, - {file = "mkdocs_autorefs-1.4.1.tar.gz", hash = "sha256:4b5b6235a4becb2b10425c2fa191737e415b37aa3418919db33e5d774c9db079"}, -] - -[package.dependencies] -Markdown = ">=3.3" -markupsafe = ">=2.0.1" -mkdocs = ">=1.1" - -[[package]] -name = "mkdocs-get-deps" -version = "0.2.0" -description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, - {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} -mergedeep = ">=1.3.4" -platformdirs = ">=2.2.0" -pyyaml = ">=5.1" - -[[package]] -name = "mkdocs-material" -version = "9.6.9" -description = "Documentation that simply works" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mkdocs_material-9.6.9-py3-none-any.whl", hash = "sha256:6e61b7fb623ce2aa4622056592b155a9eea56ff3487d0835075360be45a4c8d1"}, - {file = "mkdocs_material-9.6.9.tar.gz", hash = "sha256:a4872139715a1f27b2aa3f3dc31a9794b7bbf36333c0ba4607cf04786c94f89c"}, -] - -[package.dependencies] -babel = ">=2.10,<3.0" -backrefs = ">=5.7.post1,<6.0" -colorama = ">=0.4,<1.0" -jinja2 = ">=3.0,<4.0" -markdown = ">=3.2,<4.0" -mkdocs = ">=1.6,<2.0" -mkdocs-material-extensions = ">=1.3,<2.0" -paginate = ">=0.5,<1.0" -pygments = ">=2.16,<3.0" -pymdown-extensions = ">=10.2,<11.0" -requests = ">=2.26,<3.0" - -[package.extras] -git = ["mkdocs-git-committers-plugin-2 (>=1.1,<3)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"] -imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"] -recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -description = "Extension pack for Python Markdown and MkDocs Material." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, - {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, -] - -[[package]] -name = "mkdocs-minify-plugin" -version = "0.8.0" -description = "An MkDocs plugin to minify HTML, JS or CSS files prior to being written to disk" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mkdocs-minify-plugin-0.8.0.tar.gz", hash = "sha256:bc11b78b8120d79e817308e2b11539d790d21445eb63df831e393f76e52e753d"}, - {file = "mkdocs_minify_plugin-0.8.0-py3-none-any.whl", hash = "sha256:5fba1a3f7bd9a2142c9954a6559a57e946587b21f133165ece30ea145c66aee6"}, -] - -[package.dependencies] -csscompressor = ">=0.9.5" -htmlmin2 = ">=0.1.13" -jsmin = ">=3.0.1" -mkdocs = ">=1.4.1" - -[[package]] -name = "mkdocstrings" -version = "0.29.0" -description = "Automatic documentation from sources, for MkDocs." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "mkdocstrings-0.29.0-py3-none-any.whl", hash = "sha256:8ea98358d2006f60befa940fdebbbc88a26b37ecbcded10be726ba359284f73d"}, - {file = "mkdocstrings-0.29.0.tar.gz", hash = "sha256:3657be1384543ce0ee82112c3e521bbf48e41303aa0c229b9ffcccba057d922e"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} -Jinja2 = ">=2.11.1" -Markdown = ">=3.6" -MarkupSafe = ">=1.1" -mkdocs = ">=1.6" -mkdocs-autorefs = ">=1.4" -pymdown-extensions = ">=6.3" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.10\""} - -[package.extras] -crystal = ["mkdocstrings-crystal (>=0.3.4)"] -python = ["mkdocstrings-python (>=1.16.2)"] -python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] - -[[package]] -name = "mkdocstrings-python" -version = "1.16.8" -description = "A Python handler for mkdocstrings." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "mkdocstrings_python-1.16.8-py3-none-any.whl", hash = "sha256:211b7aaf776cd45578ecb531e5ad0d3a35a8be9101a6bfa10de38a69af9d8fd8"}, - {file = "mkdocstrings_python-1.16.8.tar.gz", hash = "sha256:9453ccae69be103810c1cf6435ce71c8f714ae37fef4d87d16aa92a7c800fe1d"}, -] - -[package.dependencies] -griffe = ">=1.6.2" -mkdocs-autorefs = ">=1.4" -mkdocstrings = ">=0.28.3" -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} - -[[package]] -name = "mkdocstrings-shell" -version = "1.0.3" -description = "A shell scripts/libraries handler for mkdocstrings." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "mkdocstrings_shell-1.0.3-py3-none-any.whl", hash = "sha256:b23ebe43d06c9c19a541548f34d42ee4e4324ae06423eba8a9136e295c67f345"}, - {file = "mkdocstrings_shell-1.0.3.tar.gz", hash = "sha256:3bdea6a1e794a5d0e15d461f33b92e0b9f3b9a1e2c33671d9a2b7d83c761096a"}, -] - -[package.dependencies] -mkdocstrings = ">=0.28.3" -shellman = ">=1.0.2" - -[[package]] -name = "mock" -version = "5.1.0" -description = "Rolling backport of unittest.mock for all Pythons" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"}, - {file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"}, -] - -[package.extras] -build = ["blurb", "twine", "wheel"] -docs = ["sphinx"] -test = ["pytest", "pytest-cov"] - -[[package]] -name = "nodeenv" -version = "1.9.1" -description = "Node.js virtual environment builder" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -files = [ - {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, - {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, -] - -[[package]] -name = "nxtools" -version = "1.6" -description = "nxtools is a set of various tools and helpers used by Nebula broadcast automation system and other software by imm studios, z.s." -optional = false -python-versions = ">=3.6,<4.0" -groups = ["dev"] -files = [ - {file = "nxtools-1.6-py3-none-any.whl", hash = "sha256:cde2d2c19193a2bd9c8071eeb3f121474ec34ab5c014de9a90f0c434cdfee716"}, - {file = "nxtools-1.6.tar.gz", hash = "sha256:e91839eef643483a3165165a46a693ad976d40a393ee5476a341e4e8bb102b92"}, -] - -[package.dependencies] -colorama = ">=0.4.4,<0.5.0" -Unidecode = ">=1.2.0,<2.0.0" - -[[package]] -name = "opentimelineio" -version = "0.17.0" -description = "Editorial interchange format and API" -optional = false -python-versions = "!=3.9.0,>=3.7" -groups = ["dev"] -files = [ - {file = "OpenTimelineIO-0.17.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:2dd31a570cabfd6227c1b1dd0cc038da10787492c26c55de058326e21fe8a313"}, - {file = "OpenTimelineIO-0.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a1da5d4803d1ba5e846b181a9e0f4a392c76b9acc5e08947772bc086f2ebfc0"}, - {file = "OpenTimelineIO-0.17.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3527977aec8202789a42d60e1e0dc11b4154f585ef72921760445f43e7967a00"}, - {file = "OpenTimelineIO-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b3aafb4c50455832ed2627c2cac654b896473a5c1f8348ddc07c10be5cfbd59"}, - {file = "OpenTimelineIO-0.17.0-cp310-cp310-win32.whl", hash = "sha256:fee45af9f6330773893cd0858e92f8256bb5bde4229b44a76f03e59a9fb1b1b6"}, - {file = "OpenTimelineIO-0.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:d51887619689c21d67cc4b11b1088f99ae44094513315e7a144be00f1393bfa8"}, - {file = "OpenTimelineIO-0.17.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:cbf05c3e8c0187969f79e91f7495d1f0dc3609557874d8e601ba2e072c70ddb1"}, - {file = "OpenTimelineIO-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d3430c3f4e88c5365d7b6afbee920b0815b62ecf141abe44cd739c9eedc04284"}, - {file = "OpenTimelineIO-0.17.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1912345227b0bd1654c7153863eadbcee60362aa46340678e576e5d2aa3106a"}, - {file = "OpenTimelineIO-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51e06eb11a868d970c1534e39faf916228d5163bf3598076d408d8f393ab0bd4"}, - {file = "OpenTimelineIO-0.17.0-cp311-cp311-win32.whl", hash = "sha256:5c3a3f4780b25a8c1a80d788becba691d12b629069ad8783d0db21027639276f"}, - {file = "OpenTimelineIO-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:43c8726b33af30ba42928972192311ea0f986edbbd5f74651bada182d4fe805c"}, - {file = "OpenTimelineIO-0.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:9a9af4105a088c0ab131780e49db268db7e37871aac33db842de6b2b16f14e39"}, - {file = "OpenTimelineIO-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e653ad1dd3b85f5c312a742dc24b61b330964aa391dc5bc072fe8b9c85adff1"}, - {file = "OpenTimelineIO-0.17.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a77823c27a1b93c6b87682372c3734ac5fddc10bfe53875e657d43c60fb885"}, - {file = "OpenTimelineIO-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4f4efcf3ddd81b62c4feb49a0bcc309b50ffeb6a8c48ab173d169a029006f4d"}, - {file = "OpenTimelineIO-0.17.0-cp312-cp312-win32.whl", hash = "sha256:9872ab74a20bb2bb3a50af04e80fe9238998d67d6be4e30e45aebe25d3eefac6"}, - {file = "OpenTimelineIO-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:c83b78be3312d3152d7e07ab32b0086fe220acc2a5b035b70ad69a787c0ece62"}, - {file = "OpenTimelineIO-0.17.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:0e671a6f2a1f772445bb326c7640dc977cfc3db589fe108a783a0311939cfac8"}, - {file = "OpenTimelineIO-0.17.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b931a3189b4ce064f06f15a89fe08ef4de01f7dcf0abc441fe2e02ef2a3311bb"}, - {file = "OpenTimelineIO-0.17.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923cb54d806c981cf1e91916c3e57fba5664c22f37763dd012bad5a5a7bd4db4"}, - {file = "OpenTimelineIO-0.17.0-cp37-cp37m-win32.whl", hash = "sha256:8e16598c5084dcb21df3d83978b0e5f72300af9edd4cdcb85e3b0ba5da0df4e8"}, - {file = "OpenTimelineIO-0.17.0-cp37-cp37m-win_amd64.whl", hash = "sha256:7eed5033494888fb3f802af50e60559e279b2f398802748872903c2f54efd2c9"}, - {file = "OpenTimelineIO-0.17.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:118baa22b9227da5003bee653601a68686ae2823682dcd7d13c88178c63081c3"}, - {file = "OpenTimelineIO-0.17.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:43389eacdee2169de454e1c79ecfea82f54a9e73b67151427a9b621349a22b7f"}, - {file = "OpenTimelineIO-0.17.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17659b1e6aa42ed617a942f7a2bfc6ecc375d0464ec127ce9edf896278ecaee9"}, - {file = "OpenTimelineIO-0.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36d5ea8cfbebf3c9013cc680eef5be48bffb515aafa9dc31e99bf66052a4ca3d"}, - {file = "OpenTimelineIO-0.17.0-cp38-cp38-win32.whl", hash = "sha256:cc67c74eb4b73bc0f7d135d3ff3dbbd86b2d451a9b142690a8d1631ad79c46f2"}, - {file = "OpenTimelineIO-0.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:69b39079bee6fa4aff34c6ad6544df394bc7388483fa5ce958ecd16e243a53ad"}, - {file = "OpenTimelineIO-0.17.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:a33554894dea17c22feec0201991e705c2c90a679ba2a012a0c558a7130df711"}, - {file = "OpenTimelineIO-0.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6b1ad3b3155370245b851b2f7b60006b2ebbb5bb76dd0fdc49bb4dce73fa7d96"}, - {file = "OpenTimelineIO-0.17.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:030454a9c0e9e82e5a153119f9afb8f3f4e64a3b27f80ac0dcde44b029fd3f3f"}, - {file = "OpenTimelineIO-0.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce64376a28919533bd4f744ff8885118abefa73f78fd408f95fa7a9489855b6"}, - {file = "OpenTimelineIO-0.17.0-cp39-cp39-win32.whl", hash = "sha256:fa8cdceb25f9003c3c0b5b32baef2c764949d88b867161ddc6f44f48f6bbfa4a"}, - {file = "OpenTimelineIO-0.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:fbcf8a000cd688633c8dc5d22e91912013c67c674329eba603358e3b54da32bf"}, - {file = "opentimelineio-0.17.0.tar.gz", hash = "sha256:10ef324e710457e9977387cd9ef91eb24a9837bfb370aec3330f9c0f146cea85"}, -] - -[package.extras] -dev = ["check-manifest", "coverage (>=4.5)", "flake8 (>=3.5)", "urllib3 (>=1.24.3)"] -view = ["PySide2 (>=5.11,<6.0) ; platform_machine == \"x86_64\"", "PySide6 (>=6.2,<7.0) ; platform_machine == \"aarch64\""] - -[[package]] -name = "packaging" -version = "24.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, -] - -[[package]] -name = "paginate" -version = "0.5.7" -description = "Divides large result sets into pages for easier browsing" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"}, - {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"}, -] - -[package.extras] -dev = ["pytest", "tox"] -lint = ["black"] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - -[[package]] -name = "platformdirs" -version = "4.3.6" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, - {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.11.2)"] - -[[package]] -name = "pluggy" -version = "1.5.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pre-commit" -version = "3.8.0" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, - {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - -[[package]] -name = "pyblish-base" -version = "1.8.12" -description = "Plug-in driven automation framework for content" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "pyblish-base-1.8.12.tar.gz", hash = "sha256:ebc184eb038864380555227a8b58055dd24ece7e6ef7f16d33416c718512871b"}, - {file = "pyblish_base-1.8.12-py2.py3-none-any.whl", hash = "sha256:2cbe956bfbd4175a2d7d22b344cd345800f4d4437153434ab658fc12646a11e8"}, -] - -[[package]] -name = "pygments" -version = "2.19.1" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, - {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pymdown-extensions" -version = "10.14.3" -description = "Extension pack for Python Markdown." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pymdown_extensions-10.14.3-py3-none-any.whl", hash = "sha256:05e0bee73d64b9c71a4ae17c72abc2f700e8bc8403755a00580b49a4e9f189e9"}, - {file = "pymdown_extensions-10.14.3.tar.gz", hash = "sha256:41e576ce3f5d650be59e900e4ceff231e0aed2a88cf30acaee41e02f063a061b"}, -] - -[package.dependencies] -markdown = ">=3.6" -pyyaml = "*" - -[package.extras] -extra = ["pygments (>=2.19.1)"] - -[[package]] -name = "pyparsing" -version = "3.2.3" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf"}, - {file = "pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "pytest" -version = "8.3.4" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, - {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.5,<2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-print" -version = "1.0.2" -description = "pytest-print adds the printer fixture you can use to print messages to the user (directly to the pytest runner, not stdout)" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pytest_print-1.0.2-py3-none-any.whl", hash = "sha256:3ae7891085dddc3cd697bd6956787240107fe76d6b5cdcfcd782e33ca6543de9"}, - {file = "pytest_print-1.0.2.tar.gz", hash = "sha256:2780350a7bbe7117f99c5d708dc7b0431beceda021b1fd3f11200670d7f33679"}, -] - -[package.dependencies] -pytest = ">=8.3.2" - -[package.extras] -test = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "pytest-mock (>=3.14)"] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["dev"] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "pyyaml" -version = "6.0.2" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, -] - -[[package]] -name = "pyyaml-env-tag" -version = "0.1" -description = "A custom YAML tag for referencing environment variables in YAML files. " -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, - {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, -] - -[package.dependencies] -pyyaml = "*" - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "ruff" -version = "0.3.7" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0e8377cccb2f07abd25e84fc5b2cbe48eeb0fea9f1719cad7caedb061d70e5ce"}, - {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:15a4d1cc1e64e556fa0d67bfd388fed416b7f3b26d5d1c3e7d192c897e39ba4b"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d28bdf3d7dc71dd46929fafeec98ba89b7c3550c3f0978e36389b5631b793663"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:379b67d4f49774ba679593b232dcd90d9e10f04d96e3c8ce4a28037ae473f7bb"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c060aea8ad5ef21cdfbbe05475ab5104ce7827b639a78dd55383a6e9895b7c51"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ebf8f615dde968272d70502c083ebf963b6781aacd3079081e03b32adfe4d58a"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d48098bd8f5c38897b03604f5428901b65e3c97d40b3952e38637b5404b739a2"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da8a4fda219bf9024692b1bc68c9cff4b80507879ada8769dc7e985755d662ea"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c44e0149f1d8b48c4d5c33d88c677a4aa22fd09b1683d6a7ff55b816b5d074f"}, - {file = "ruff-0.3.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3050ec0af72b709a62ecc2aca941b9cd479a7bf2b36cc4562f0033d688e44fa1"}, - {file = "ruff-0.3.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a29cc38e4c1ab00da18a3f6777f8b50099d73326981bb7d182e54a9a21bb4ff7"}, - {file = "ruff-0.3.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b15cc59c19edca917f51b1956637db47e200b0fc5e6e1878233d3a938384b0b"}, - {file = "ruff-0.3.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e491045781b1e38b72c91247cf4634f040f8d0cb3e6d3d64d38dcf43616650b4"}, - {file = "ruff-0.3.7-py3-none-win32.whl", hash = "sha256:bc931de87593d64fad3a22e201e55ad76271f1d5bfc44e1a1887edd0903c7d9f"}, - {file = "ruff-0.3.7-py3-none-win_amd64.whl", hash = "sha256:5ef0e501e1e39f35e03c2acb1d1238c595b8bb36cf7a170e7c1df1b73da00e74"}, - {file = "ruff-0.3.7-py3-none-win_arm64.whl", hash = "sha256:789e144f6dc7019d1f92a812891c645274ed08af6037d11fc65fcbc183b7d59f"}, - {file = "ruff-0.3.7.tar.gz", hash = "sha256:d5c1aebee5162c2226784800ae031f660c350e7a3402c4d1f8ea4e97e232e3ba"}, -] - -[[package]] -name = "semver" -version = "3.0.4" -description = "Python helper for Semantic Versioning (https://semver.org)" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, - {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, -] - -[[package]] -name = "shellman" -version = "1.0.2" -description = "Write documentation in comments and render it with templates." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "shellman-1.0.2-py3-none-any.whl", hash = "sha256:f8c960fd2d3785e195f86fcd8f110a8d51a950e759d82c14a5af0bd71b918b3c"}, - {file = "shellman-1.0.2.tar.gz", hash = "sha256:48cba79d6415c0d013ad4dfd2205ed81b0e468795d1886dcda943ac78eaffd38"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} -jinja2 = ">=3" -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["dev"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "tomli" -version = "2.2.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, -] - -[[package]] -name = "tomlkit" -version = "0.13.2" -description = "Style preserving TOML library" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, - {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, -] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[[package]] -name = "unidecode" -version = "1.3.8" -description = "ASCII transliterations of Unicode text" -optional = false -python-versions = ">=3.5" -groups = ["dev"] -files = [ - {file = "Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39"}, - {file = "Unidecode-1.3.8.tar.gz", hash = "sha256:cfdb349d46ed3873ece4586b96aa75258726e2fa8ec21d6f00a591d98806c2f4"}, -] - -[[package]] -name = "urllib3" -version = "2.3.0" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, - {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "verspec" -version = "0.1.0" -description = "Flexible version handling" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31"}, - {file = "verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e"}, -] - -[package.extras] -test = ["coverage", "flake8 (>=3.7)", "mypy", "pretend", "pytest"] - -[[package]] -name = "virtualenv" -version = "20.29.2" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "virtualenv-20.29.2-py3-none-any.whl", hash = "sha256:febddfc3d1ea571bdb1dc0f98d7b45d24def7428214d4fb73cc486c9568cce6a"}, - {file = "virtualenv-20.29.2.tar.gz", hash = "sha256:fdaabebf6d03b5ba83ae0a02cfe96f48a716f4fae556461d180825866f75b728"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<5" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] - -[[package]] -name = "watchdog" -version = "6.0.0" -description = "Filesystem events monitoring" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, - {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, - {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, - {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, - {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, - {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, - {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, - {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, - {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, - {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, - {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, - {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, - {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, - {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, - {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, - {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, - {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, - {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, - {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, - {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, - {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, - {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, - {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, -] - -[package.extras] -watchmedo = ["PyYAML (>=3.10)"] - -[[package]] -name = "zipp" -version = "3.21.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, - {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - -[metadata] -lock-version = "2.1" -python-versions = ">=3.9.1,<3.10" -content-hash = "c0d98dae3faeed5237f286362f9d280ab17c3d42f7cd41fdd69ee4546983c553" From 2cd692d1dafd00a207a02ff8c4c857ed7a15c652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 16 Apr 2025 11:51:06 +0200 Subject: [PATCH 173/781] :recycle: fix temp pytes-ayon dependency to pass tests --- pyproject.toml | 14 ++++++++++---- .../plugins/publish/test_integrate_traits.py | 6 ++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 10e477ca9b..32953bed45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,10 +26,6 @@ codespell = "^2.2.6" semver = "^3.0.2" mypy = "^1.14.0" mock = "^5.0.0" -attrs = "^25.0.0" -pyblish-base = "^1.8.7" -clique = "^2.0.0" -opentimelineio = "^0.17.0" tomlkit = "^0.13.2" requests = "^2.32.3" mkdocs-material = "^9.6.7" @@ -42,6 +38,16 @@ pymdown-extensions = "^10.14.3" mike = "^2.1.3" mkdocstrings-shell = "^1.0.2" +[tool.poetry.group.test.dependencies] +attrs = "^25.0.0" +pyblish-base = "^1.8.7" +clique = "^2.0.0" +opentimelineio = "^0.17.0" +speedcopy = "^2.1" +qtpy="^2.4.3" +pyside6 = "^6.5.2" +pytest-ayon = { git = "https://github.com/ynput/pytest-ayon.git", branch = "chore/align-dependencies" } + [tool.ruff] # Exclude a variety of commonly ignored directories. diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index c66dda1630..71fcb928c0 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -76,6 +76,7 @@ def sequence_files(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: def mock_context( project: pytest_ayon.ProjectInfo, single_file: Path, + folder_path: Path, sequence_files: list[Path]) -> pyblish.api.Context: """Return a mock instance. @@ -104,6 +105,10 @@ def mock_context( instance = context.create_instance("mock_instance") instance.data["source"] = "test_source" instance.data["families"] = ["render"] + + parents = project.folder_entity["path"].lstrip("/").split("/") + hierarchy = "/".join(parents) if parents else "" + instance.data["anatomyData"] = { "project": { "name": project.project_name, @@ -121,6 +126,7 @@ def mock_context( "name": project.product.name, "type": "test" # pytest-ayon doesn't return the product type yet }, + "hierarchy": hierarchy, } instance.data["folderEntity"] = project.folder_entity From 7b0b5a46ecc5ea3c270d1eb308e02d11f924bfa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 16 Apr 2025 12:23:56 +0200 Subject: [PATCH 174/781] :alembic: stop running tests requiring AYON server by default this is to ensure test will still run on GH. --- pyproject.toml | 8 ++++++++ .../ayon_core/plugins/publish/test_integrate_traits.py | 5 ++++- tools/manage.ps1 | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 16e7a53230..d940829a69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -152,3 +152,11 @@ addopts = "-ra -q" testpaths = [ "client/ayon_core/tests" ] +markers = [ + "unit: Unit tests", + "integration: Integration tests", + "api: API tests", + "cli: CLI tests", + "slow: Slow tests", + "server: Tests that require a running AYON server", +] diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py index 71fcb928c0..abb605a121 100644 --- a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -76,7 +76,6 @@ def sequence_files(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: def mock_context( project: pytest_ayon.ProjectInfo, single_file: Path, - folder_path: Path, sequence_files: list[Path]) -> pyblish.api.Context: """Return a mock instance. @@ -237,6 +236,7 @@ def mock_context( return context +@pytest.mark.server def test_get_template_name(mock_context: pyblish.api.Context) -> None: """Test get_template_name. @@ -355,6 +355,7 @@ def test_filter_lifecycle() -> None: assert filtered[0] == persistent_representation +@pytest.mark.server def test_prepare_product( project: pytest_ayon.ProjectInfo, mock_context: pyblish.api.Context) -> None: @@ -375,6 +376,7 @@ def test_prepare_product( } +@pytest.mark.server def test_prepare_version( project: pytest_ayon.ProjectInfo, mock_context: pyblish.api.Context) -> None: @@ -403,6 +405,7 @@ def test_prepare_version( } +@pytest.mark.server def test_get_transfers_from_representation( mock_context: pyblish.api.Context) -> None: """Test get_transfers_from_representation. diff --git a/tools/manage.ps1 b/tools/manage.ps1 index 8324277713..306a61e30d 100755 --- a/tools/manage.ps1 +++ b/tools/manage.ps1 @@ -242,7 +242,7 @@ function Run-From-Code { function Run-Tests { $Poetry = "$RepoRoot\.poetry\bin\poetry.exe" - $RunArgs = @( "run", "pytest", "$($RepoRoot)/tests") + $RunArgs = @( "run", "pytest", "$($RepoRoot)/tests", "-m", "not server") & $Poetry $RunArgs @arguments } From caf3eb58b8630c85a4d65cd049b773da0ba562ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 16 Apr 2025 12:32:32 +0200 Subject: [PATCH 175/781] :alembic: add server marks to shell script too --- tools/manage.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/manage.sh b/tools/manage.sh index 86ae7155c5..5362374045 100755 --- a/tools/manage.sh +++ b/tools/manage.sh @@ -186,7 +186,7 @@ run_command () { run_tests () { echo -e "${BIGreen}>>>${RST} Running tests..." shift; # will remove first arg ("run-tests") from the "$@" - "$POETRY_HOME/bin/poetry" run pytest ./tests + "$POETRY_HOME/bin/poetry" run pytest ./tests -m "not server" } main () { From 02827106cfe0bbbd9c2d23b07932cea3f0874512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 16 Apr 2025 12:32:58 +0200 Subject: [PATCH 176/781] :dog: remove `preview` flag this will be at the end added by another PR --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d940829a69..30cb5aab9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,7 +90,6 @@ indent-width = 4 target-version = "py39" [tool.ruff.lint] -preview = true pydocstyle.convention = "google" # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. select = ["E", "F", "W"] 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 177/781] 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 178/781] 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 179/781] 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 180/781] 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 181/781] 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 182/781] 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 183/781] 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 184/781] 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 185/781] 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 186/781] '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 187/781] 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 188/781] 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 189/781] 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 190/781] 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 191/781] 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 192/781] 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 193/781] 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 194/781] 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 195/781] 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 196/781] 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 197/781] 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 198/781] 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 199/781] 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 200/781] 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 201/781] 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 202/781] 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 203/781] 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 204/781] 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 205/781] 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 206/781] 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 207/781] 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 208/781] 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 209/781] 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 210/781] 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 211/781] 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 212/781] 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 213/781] 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 214/781] 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 215/781] 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 216/781] 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 217/781] 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 218/781] 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 219/781] 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 220/781] 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 221/781] 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 539be6c5270dbdfe739df9d2ccda8b59cc7b7340 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 9 May 2025 17:51:51 +0200 Subject: [PATCH 222/781] Handles OCIO shared view token The OCIO config can return a special token "" as the colorspace name for a display view. This commit implements handling for this token, replacing it with the display name if found. --- client/ayon_core/pipeline/colorspace.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 8c4f97ab1c..79aea391eb 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -1403,7 +1403,12 @@ def _get_display_view_colorspace_name(config_path, display, view): """ config = _get_ocio_config(config_path) - return config.getDisplayViewColorSpaceName(display, view) + colorspace = config.getDisplayViewColorSpaceName(display, view) + # Special token. See https://opencolorio.readthedocs.io/en/latest/guides/authoring/authoring.html#shared-views # noqa + if colorspace == "": + colorspace = display + + return colorspace def _get_ocio_config_colorspaces(config_path): From 30c08ad2c26b286e320fac90ac1d61e8e7e57cb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 12 May 2025 17:50:41 +0200 Subject: [PATCH 223/781] :pencil: fix some typos --- .../plugins/publish/integrate_traits.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index ec6bdfa283..38c9ecdeb4 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -66,7 +66,7 @@ if TYPE_CHECKING: @dataclass(frozen=True) class TransferItem: - """Represents single transfer item. + """Represents a single transfer item. Source file path, destination file path, template that was used to construct the destination path, template data that was used in the @@ -93,7 +93,7 @@ class TransferItem: @staticmethod def get_size(file_path: Path) -> int: - """Get size of the file. + """Get the size of the file. Args: file_path (Path): File path. @@ -759,12 +759,12 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def get_rootless_path(self, anatomy: Anatomy, path: str) -> str: r"""Get rootless variant of the path. - Returns, if possible, path without absolute portion from the root - (e.g. 'c:\' or '/opt/..'). This is basically wrapper for the + Returns, if possible, a path without an absolute portion from the root + (e.g. 'c:\' or '/opt/..'). This is basically a wrapper for the meth:`Anatomy.find_root_template_from_path` method that displays - warning if root path is not found. + a warning if the root path is not found. - This information is platform dependent and shouldn't be captured. + This information is platform-dependent and shouldn't be captured. For example:: 'c:/projects/MyProject1/Assets/publish...' @@ -955,9 +955,9 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template_padding = template_item.anatomy.templates_obj.frame_padding dst_padding = max(template_padding, dst_padding) - # go through all frames in the sequence - # find their corresponding file locations - # format their template and add them to transfers + # Go through all frames in the sequence and + # find their corresponding file locations, then + # format their template and add them to transfers. for frame in frames: file_loc: FileLocation = representation.get_trait( FileLocations).get_file_location_for_frame( From 52c03faf4b31594e67a4da1bd452f8f3fc16fbd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 12 May 2025 17:51:27 +0200 Subject: [PATCH 224/781] :sparkles: add function to get traits from addons --- client/ayon_core/pipeline/traits/utils.py | 46 +++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/traits/utils.py b/client/ayon_core/pipeline/traits/utils.py index ef22122124..7d579a76ce 100644 --- a/client/ayon_core/pipeline/traits/utils.py +++ b/client/ayon_core/pipeline/traits/utils.py @@ -1,18 +1,21 @@ """Utility functions for traits.""" from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional +import pyblish.api from clique import assemble +from ayon_core.addon import AddonsManager, ITraits from ayon_core.pipeline.traits.temporal import FrameRanged if TYPE_CHECKING: from pathlib import Path + from ayon_core.pipeline.traits.trait import TraitBase def get_sequence_from_files(paths: list[Path]) -> FrameRanged: - """Get original frame range from files. + """Get the original frame range from files. Note that this cannot guess frame rate, so it's set to 25. This will also fail on paths that cannot be assembled into @@ -42,10 +45,47 @@ def get_sequence_from_files(paths: list[Path]) -> FrameRanged: first_frame = sorted_frames[0] # Get last frame for padding last_frame = sorted_frames[-1] - # Use padding from collection of length of last frame as string + # Use padding from a collection of the last frame lengths as string # padding = max(col.padding, len(str(last_frame))) return FrameRanged( frame_start=first_frame, frame_end=last_frame, frames_per_second="25.0" ) + + +def get_available_traits( + addons_manager: Optional[AddonsManager] = None +) -> Optional[list[TraitBase]]: + """Get available traits from active addons. + + Args: + addons_manager (Optional[AddonsManager]): Addons manager instance. + If not provided, a new one will be created. Within pyblish + plugins, you can use an already collected instance of + AddonsManager from context `context.data["ayonAddonsManager"]`. + + Returns: + list[TraitBase]: List of available traits. + + """ + if addons_manager is None: + # Create a new instance of AddonsManager + addons_manager = AddonsManager() + + # Get active addons + enabled_addons = addons_manager.get_enabled_addons() + traits = [] + for addon in enabled_addons: + if not issubclass(type(addon), ITraits): + # Skip addons not providing traits + continue + # Get traits from addon + addon_traits = addon.get_addon_traits() + if addon_traits: + # Add traits to a list + for trait in addon_traits: + if trait not in traits: + traits.append(trait) + + return traits From 54c10cd6dad7e78a32b5e084ec0e58e6d111b932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Tue, 13 May 2025 12:16:12 +0200 Subject: [PATCH 225/781] Update client/ayon_core/pipeline/publish/lib.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- 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 0c1029d282..85b70f11be 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -1081,7 +1081,7 @@ def has_trait_representations( False: Instance does not have trait representation. """ - return bool(instance.data.get(TRAIT_INSTANCE_KEY)) + return TRAIT_INSTANCE_KEY in instance.data def add_trait_representations( From ae9c6d4218e1c67f1ebba63d6412ef0648bf8067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Tue, 13 May 2025 12:17:01 +0200 Subject: [PATCH 226/781] Update client/ayon_core/pipeline/publish/lib.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/publish/lib.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 85b70f11be..f5d5d8acdb 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -1097,9 +1097,8 @@ def add_trait_representations( trait based representations to add. """ - if not has_trait_representations(instance): - instance.data[TRAIT_INSTANCE_KEY] = [] - instance.data[TRAIT_INSTANCE_KEY].extend(representations) + repres = instance.data.setdefault(TRAIT_INSTANCE_KEY, []) + repres.extend(representations) def set_trait_representations( From 0508b841d000c98611e1a03428f4fb29f9d56be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 13 May 2025 12:20:43 +0200 Subject: [PATCH 227/781] :dog: fix ruff --- client/ayon_core/pipeline/publish/lib.py | 1 + client/ayon_core/pipeline/traits/utils.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index f5d5d8acdb..464b2b6d8f 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -33,6 +33,7 @@ if TYPE_CHECKING: TRAIT_INSTANCE_KEY: str = "representations_with_traits" + def get_template_name_profiles( project_name, project_settings=None, logger=None ): diff --git a/client/ayon_core/pipeline/traits/utils.py b/client/ayon_core/pipeline/traits/utils.py index 7d579a76ce..4cb9a643fa 100644 --- a/client/ayon_core/pipeline/traits/utils.py +++ b/client/ayon_core/pipeline/traits/utils.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional -import pyblish.api from clique import assemble from ayon_core.addon import AddonsManager, ITraits From 4d6f5be7f5c2788825ae5d4542dd20d63dec1a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 14 May 2025 18:50:39 +0200 Subject: [PATCH 228/781] :pencil: some fixes, mostly docstrings --- client/ayon_core/pipeline/traits/README.md | 38 ++++++++--------- client/ayon_core/pipeline/traits/color.py | 4 +- client/ayon_core/pipeline/traits/content.py | 28 ++++++------- client/ayon_core/pipeline/traits/lifecycle.py | 6 +-- client/ayon_core/pipeline/traits/meta.py | 20 ++++----- .../pipeline/traits/representation.py | 29 ++++++------- client/ayon_core/pipeline/traits/temporal.py | 42 +++++++++---------- client/ayon_core/pipeline/traits/trait.py | 12 +++--- .../pipeline/traits/two_dimensional.py | 20 +++++---- .../ayon_core/pipeline/traits/test_traits.py | 24 +++++++++++ 10 files changed, 126 insertions(+), 97 deletions(-) diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md index 235d8a367c..96ced3692c 100644 --- a/client/ayon_core/pipeline/traits/README.md +++ b/client/ayon_core/pipeline/traits/README.md @@ -3,12 +3,12 @@ ## Introduction The Representation is the lowest level entity, describing the concrete data chunk that -pipeline can act on. It can be specific file or just a set of metadata. Idea is that one +pipeline can act on. It can be a specific file or just a set of metadata. Idea is that one product version can have multiple representations - **Image** product can be jpeg or tiff, both formats are representation of the same source. ### Brief look into the past (and current state) -So far, representation was defined as dict-like structure: +So far, representation was defined as a dict-like structure: ```python { "name": "foo", @@ -18,9 +18,9 @@ So far, representation was defined as dict-like structure: } ``` -This is minimal form, but it can have additional keys like `frameStart`, `fps`, `resolutionWidth`, and more. Thare is also `tags` key that can hold `review`, `thumbnail`, `delete`, `toScanline` and other tag that are controlling the processing. +This is minimal form, but it can have additional keys like `frameStart`, `fps`, `resolutionWidth`, and more. Thare is also `tags` key that can hold `review`, `thumbnail`, `delete`, `toScanline` and other tags that are controlling the processing. -This will be *"translated"* to similar structure in database: +This will be *"translated"* to the similar structure in the database: ```python { @@ -57,12 +57,12 @@ There are also some assumptions and limitations - like that if `files` in the representation are list they need to be sequence of files (it can't be a bunch of unrelated files). -This system is very flexible in one way, but it lacks few very important things: +This system is very flexible in one way, but it lacks a few very important things: -- it is not clearly defined - you can add easily keys, values, tags but without +- it is not clearly defined — you can add easily keys, values, tags but without unforeseeable consequences -- it cannot handle "bundles" - multiple files that needs to be versioned together and +- it cannot handle "bundles" — multiple files that need to be versioned together and belong together - it cannot describe important information that you can't get from the file itself, or it is very expensive (like axis orientation and units from alembic files) @@ -70,7 +70,7 @@ it is very expensive (like axis orientation and units from alembic files) ### New Representation model -The idea about new representation model is obviously around solving points mentioned +The idea about a new representation model is about solving points mentioned above and also adding some benefits, like consistent IDE hints, typing, built-in validators and much more. @@ -78,7 +78,7 @@ above and also adding some benefits, like consistent IDE hints, typing, built-in The new representation is "just" a dictionary of traits. Trait can be anything provided it is based on `TraitBase`. It shouldn't really duplicate information that is -available in a moment of loading (or any usage) by other means. It should contain +available at the moment of loading (or any usage) by other means. It should contain information that couldn't be determined by the file, or the AYON context. Some of those traits are aligned with [OpenAssetIO Media Creation](https://github.com/OpenAssetIO/OpenAssetIO-MediaCreation) with hopes of maintained compatibility (it should be easy enough to convert between OpenAssetIO Traits and AYON Traits). @@ -114,18 +114,18 @@ image = rep[Image.id] ``` > [!NOTE] -> Trait and their ids - every Trait has its id as a string with +> Trait and their ids — every Trait has its id as a string with a > version appended - so **Image** has `ayon.2d.Image.v1`. This is used on > several places (you see its use above for indexing traits). When querying, > you can also omit the version at the end, and it will try its best to find > the latest possible version. More on that in [Traits]() -You can construct the `Representation` from dictionary (for example +You can construct the `Representation` from dictionary (for example, serialized as JSON) using `Representation.from_dict()`, or you can serialize `Representation` to dict to store with `Representation.traits_as_dict()`. -Every time representation is created, new id is generated. You can pass existing -id when creating new representation instance. +Every time representation is created, a new id is generated. You can pass existing +id when creating the new representation instance. ##### Equality @@ -200,7 +200,7 @@ in the representation if needed. ## Examples -Create simple image representation to be integrated by AYON: +Create a simple image representation to be integrated by AYON: ```python from pathlib import Path @@ -252,8 +252,8 @@ except MissingTraitError: print(f"resolution isn't set on {rep.name}") ``` -Accessing non-existent traits will result in exception. To test if -representation has some specific trait, you can use `.contains_trait()` method. +Accessing non-existent traits will result in an exception. To test if +the representation has some specific trait, you can use `.contains_trait()` method. You can also prepare the whole representation data as a dict and @@ -381,7 +381,7 @@ class AlembicTraitLoader(MayaLoader): You can create the representations in the same way as mentioned in the examples above. Straightforward way is to use `Representation` class and add the traits to it. Collect -traits in list and then pass them to the `Representation` constructor. You should add +traits in the list and then pass them to the `Representation` constructor. You should add the new Representation to the instance data using `add_trait_representations()` function. ```python @@ -436,8 +436,8 @@ class SomeExtractor(Extractor): ## Developer notes -Adding new trait based representations in to publish Instance and working with them is using -set of helper function defined in `ayon_core.pipeline.publish` module. These are: +Adding new trait-based representations in to the publishing Instance and working with them is using +a set of helper function defined in `ayon_core.pipeline.publish` module. These are: * add_trait_representations * get_trait_representations diff --git a/client/ayon_core/pipeline/traits/color.py b/client/ayon_core/pipeline/traits/color.py index 491131c8bc..6da7b86ae7 100644 --- a/client/ayon_core/pipeline/traits/color.py +++ b/client/ayon_core/pipeline/traits/color.py @@ -1,4 +1,4 @@ -"""Color management related traits.""" +"""Color-management-related traits.""" from __future__ import annotations from dataclasses import dataclass @@ -11,7 +11,7 @@ from .trait import TraitBase class ColorManaged(TraitBase): """Color managed trait. - Holds color management information. Can be used with Image related + Holds color management information. Can be used with Image-related traits to define color space and config. Sync with OpenAssetIO MediaCreation Traits. diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py index bad90f5875..42c162d28f 100644 --- a/client/ayon_core/pipeline/traits/content.py +++ b/client/ayon_core/pipeline/traits/content.py @@ -33,7 +33,7 @@ class MimeType(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with version mime_type (str): Mime type like image/jpeg. """ @@ -57,7 +57,7 @@ class LocatableContent(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with version location (str): Location. is_templated (Optional[bool]): Is the location templated? Default is None. @@ -82,7 +82,7 @@ class FileLocation(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with version file_path (str): File path. file_size (Optional[int]): File size in bytes. file_hash (Optional[str]): File hash. @@ -108,7 +108,7 @@ class FileLocations(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with version file_paths (list of FileLocation): File locations. """ @@ -136,7 +136,7 @@ class FileLocations(TraitBase): frame: int, sequence_trait: Optional[Sequence] = None, ) -> Optional[FileLocation]: - """Get file location for a frame. + """Get a file location for a frame. This method will return the file location for a given frame. If the frame is not found in the file paths, it will return None. @@ -166,7 +166,7 @@ class FileLocations(TraitBase): """Validate the trait. This method validates the trait against others in the representation. - In particular, it checks that the sequence trait is present and if + In particular, it checks that the sequence trait is present, and if so, it will compare the frame range to the file paths. Args: @@ -187,8 +187,8 @@ class FileLocations(TraitBase): if not representation.contains_trait(Sequence) \ and not representation.contains_trait(UDIM): # we have multiple files, but it is not a sequence - # or UDIM tile set what it it then? If the files are not related - # to each other then this representation is invalid. + # or UDIM tile set what is it then? If the files are not related + # to each other, then this representation is invalid. msg = ( "Multiple file locations defined, but no Sequence " "or UDIM trait defined. If the files are not related to " @@ -254,7 +254,7 @@ class FileLocations(TraitBase): f"({len(frames_from_spec)})" ) raise TraitValidationError(self.name, msg) - # if there is frame spec on the Sequence trait + # if there is a frame spec on the Sequence trait, # we should not validate the frame range from the files. # the rest is validated by Sequence validators. return @@ -354,7 +354,7 @@ class RootlessLocation(TraitBase): """RootlessLocation trait model. RootlessLocation trait is a trait that represents a file path that is - without specific root. To obtain absolute path, the root needs to be + without a specific root. To get the absolute path, the root needs to be resolved by AYON. Rootless path can be used on multiple platforms. Example:: @@ -366,7 +366,7 @@ class RootlessLocation(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with version rootless_path (str): Rootless path. """ @@ -391,7 +391,7 @@ class Compressed(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with version compression_type (str): Compression type. """ @@ -431,7 +431,7 @@ class Bundle(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with version items (list[list[TraitBase]]): List of representations. """ @@ -442,7 +442,7 @@ class Bundle(TraitBase): items: list[list[TraitBase]] def to_representations(self) -> Generator[Representation]: - """Convert bundle to representations. + """Convert a bundle to representations. Yields: Representation: Representation of the bundle. diff --git a/client/ayon_core/pipeline/traits/lifecycle.py b/client/ayon_core/pipeline/traits/lifecycle.py index b1b72f8fcb..4845f04779 100644 --- a/client/ayon_core/pipeline/traits/lifecycle.py +++ b/client/ayon_core/pipeline/traits/lifecycle.py @@ -15,7 +15,7 @@ class Transient(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with the version """ name: ClassVar[str] = "Transient" @@ -50,13 +50,13 @@ class Persistent(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with the version """ name: ClassVar[str] = "Persistent" description: ClassVar[str] = "Persistent Trait Model" id: ClassVar[str] = "ayon.lifecycle.Persistent.v1" - # note that this affects persistence of the trait itself, not + # Note that this affects the persistence of the trait itself, not # the representation. This is a class variable, so it is shared # among all instances of the class. persistent: bool = True diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py index 3bf4a87a0b..26edf3ffb6 100644 --- a/client/ayon_core/pipeline/traits/meta.py +++ b/client/ayon_core/pipeline/traits/meta.py @@ -11,7 +11,7 @@ from .trait import TraitBase class Tagged(TraitBase): """Tagged trait model. - This trait can hold list of tags. + This trait can hold a list of tags. Example:: @@ -20,7 +20,7 @@ class Tagged(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with version tags (List[str]): Tags. """ @@ -36,7 +36,7 @@ class TemplatePath(TraitBase): """TemplatePath trait model. This model represents a template path with formatting data. - Template path can be Anatomy template and data is used to format it. + Template path can be an Anatomy template and data is used to format it. Example:: @@ -45,7 +45,7 @@ class TemplatePath(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with version template (str): Template path. data (dict[str]): Formatting data. """ @@ -72,7 +72,7 @@ class Variant(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with version variant (str): Variant name. """ @@ -115,8 +115,8 @@ class KeepOriginalName(TraitBase): class SourceApplication(TraitBase): """Metadata about the source (producing) application. - This can be useful in cases, where this information is - needed but it cannot be determined from other means - like + This can be useful in cases where this information is + needed, but it cannot be determined from other means - like .txt files used for various motion tracking applications that must be interpreted by the loader. @@ -147,9 +147,9 @@ class IntendedUse(TraitBase): """Intended use of the representation. This trait describes the intended use of the representation. It - can be used in cases, where the other traits are not enough to - describe the intended use. For example txt file with tracking - points can be used as corner pin in After Effect but not in Nuke. + can be used in cases where the other traits are not enough to + describe the intended use. For example, a txt file with tracking + points can be used as a corner pin in After Effect but not in Nuke. Attributes: use (str): Intended use description. diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py index c9604c4183..f76d5df99f 100644 --- a/client/ayon_core/pipeline/traits/representation.py +++ b/client/ayon_core/pipeline/traits/representation.py @@ -31,7 +31,7 @@ T = TypeVar("T", bound="TraitBase") def _get_version_from_id(_id: str) -> Optional[int]: - """Get version from ID. + """Get the version from ID. Args: _id (str): ID. @@ -47,15 +47,16 @@ def _get_version_from_id(_id: str) -> Optional[int]: class Representation(Generic[T]): # noqa: PLR0904 """Representation of products. - Representation defines collection of individual properties that describe - the specific "form" of the product. Each property is represented by a - trait therefore the Representation is a collection of traits. + Representation defines a collection of individual properties that describe + the specific "form" of the product. A trait represents a set of + properties therefore, the Representation is a collection of traits. It holds methods to add, remove, get, and check for the existence of a - trait in the representation. It also provides a method to get all the + trait in the representation. Note: - `PLR0904` is rule for checking number of public methods in a class. + `PLR0904` is the rule for checking the number of public methods + in a class. Arguments: name (str): Representation name. Must be unique within instance. @@ -141,7 +142,7 @@ class Representation(Generic[T]): # noqa: PLR0904 trait already exists. Defaults to False. Raises: - ValueError: If the trait ID is not provided or the trait already + ValueError: If the trait ID is not provided, or the trait already exists. """ @@ -423,7 +424,7 @@ class Representation(Generic[T]): # noqa: PLR0904 @staticmethod def _get_version_from_id(trait_id: str) -> Union[int, None]: # sourcery skip: use-named-expression - """Check if the trait has version specified. + """Check if the trait has a version specified. Args: trait_id (str): Trait ID. @@ -498,11 +499,11 @@ class Representation(Generic[T]): # noqa: PLR0904 klass = getattr(module, attr_name) if not inspect.isclass(klass): continue - # this needs to be done because of the bug? in + # This needs to be done because of the bug? In # python ABCMeta, where ``issubclass`` is not working # if it hits the GenericAlias (that is in fact # tuple[int, int]). This is added to the scope by - # ``types`` module. + # the ``types`` module. if type(klass) is GenericAlias: continue if issubclass(klass, TraitBase) \ @@ -518,8 +519,8 @@ class Representation(Generic[T]): # noqa: PLR0904 """Get the trait class with corresponding to given ID. This method will search for the trait class in all the modules except - the blacklisted modules. There is some issue in Pydantic where - ``issubclass`` is not working properly so we are excluding explicitly + the blocklisted modules. There is some issue in Pydantic where + ``issubclass`` is not working properly, so we are excluding explicit modules with offending classes. This list can be updated as needed to speed up the search. @@ -540,7 +541,7 @@ class Representation(Generic[T]): # noqa: PLR0904 for trait_class in trait_candidates: if trait_class.id == trait_id: - # we found direct match + # we found a direct match return trait_class # if we didn't find direct match, we will search for the highest @@ -670,7 +671,7 @@ class Representation(Generic[T]): # noqa: PLR0904 try: trait_class = cls.get_trait_class_by_trait_id(trait_id) except UpgradableTraitError as e: - # we found newer version of trait, we will upgrade the data + # we found a newer version of trait, we will upgrade the data if hasattr(e.trait, "upgrade"): traits.append(e.trait.upgrade(value)) else: diff --git a/client/ayon_core/pipeline/traits/temporal.py b/client/ayon_core/pipeline/traits/temporal.py index dd11daa975..9ad5424eee 100644 --- a/client/ayon_core/pipeline/traits/temporal.py +++ b/client/ayon_core/pipeline/traits/temporal.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: class GapPolicy(Enum): """Gap policy enumeration. - This type defines how to handle gaps in sequence. + This type defines how to handle gaps in a sequence. Attributes: forbidden (int): Gaps are forbidden. @@ -40,7 +40,7 @@ class GapPolicy(Enum): class FrameRanged(TraitBase): """Frame ranged trait model. - Model representing a frame ranged trait. + Model representing a frame-ranged trait. Sync with OpenAssetIO MediaCreation Traits. For compatibility with OpenAssetIO, we'll need to handle different names of attributes: @@ -52,14 +52,14 @@ class FrameRanged(TraitBase): Note: frames_per_second is a string to allow various precision formats. FPS is a floating point number, but it can be also represented as a fraction (e.g. "30000/1001") or as a decimal - or even as irrational number. We need to support all these + or even as an irrational number. We need to support all these formats. To work with FPS, we'll need some helper function to convert FPS to Decimal from string. Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with a version frame_start (int): Frame start. frame_end (int): Frame end. frame_in (int): Frame in. @@ -90,7 +90,7 @@ class Handles(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with a version inclusive (bool): Handles are inclusive. frame_start_handle (int): Frame start handle. frame_end_handle (int): Frame end handle. @@ -116,7 +116,7 @@ class Sequence(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with a version gaps_policy (GapPolicy): Gaps policy - how to handle gaps in sequence. frame_padding (int): Frame padding. @@ -162,7 +162,7 @@ class Sequence(TraitBase): """Validate the trait.""" super().validate_trait(representation) - # if there is FileLocations trait, run validation + # if there is a FileLocations trait, run validation # on it as well with contextlib.suppress(MissingTraitError): @@ -182,9 +182,9 @@ class Sequence(TraitBase): from .content import FileLocations file_locs: FileLocations = representation.get_trait( FileLocations) - # validate if file locations on representation - # matches the frame list (if any) - # we need to extend the expected frames with Handles + # Validate if the file locations on representation + # match the frame list (if any). + # We need to extend the expected frames with Handles. frame_start = None frame_end = None handles_frame_start = None @@ -192,7 +192,7 @@ class Sequence(TraitBase): with contextlib.suppress(MissingTraitError): handles: Handles = representation.get_trait(Handles) # if handles are inclusive, they should be already - # accounted in the FrameRaged frame spec + # accounted for in the FrameRaged frame spec if not handles.inclusive: handles_frame_start = handles.frame_start_handle handles_frame_end = handles.frame_end_handle @@ -218,16 +218,16 @@ class Sequence(TraitBase): frame_end: Optional[int] = None, handles_frame_start: Optional[int] = None, handles_frame_end: Optional[int] = None) -> None: - """Validate frame list. + """Validate a frame list. This will take FileLocations trait and validate if the file locations match the frame list specification. - For example, if frame list is "1-10,20-30,40-50", then + For example, if the frame list is "1-10,20-30,40-50", then the frame numbers in the file locations should match these frames. - It will skip the validation if frame list is not provided. + It will skip the validation if the frame list is not provided. Args: file_locations (FileLocations): File locations trait. @@ -237,7 +237,7 @@ class Sequence(TraitBase): handles_frame_end (Optional[int]): Frame end handle. Raises: - TraitValidationError: If frame list does not match + TraitValidationError: If the frame list does not match the expected frames. """ @@ -341,7 +341,7 @@ class Sequence(TraitBase): def _get_collection( file_locations: FileLocations, regex: Optional[Pattern] = None) -> clique.Collection: - r"""Get collection from file locations. + r"""Get the collection from file locations. Args: file_locations (FileLocations): File locations trait. @@ -355,7 +355,7 @@ class Sequence(TraitBase): clique.Collection: Collection instance. Raises: - ValueError: If zero or multiple collections found. + ValueError: If zero or multiple of collections are found. """ patterns = [regex] if regex else None @@ -382,7 +382,7 @@ class Sequence(TraitBase): """ src_collection = Sequence._get_collection(file_locations) padding = src_collection.padding - # sometimes Clique doens't get the padding right so + # sometimes Clique doesn't get the padding right, so # we need to calculate it manually if padding == 0: padding = len(str(max(src_collection.indexes))) @@ -394,7 +394,7 @@ class Sequence(TraitBase): file_locations: FileLocations, regex: Optional[Pattern] = None, ) -> list[int]: - r"""Get frame list. + r"""Get the frame list. Args: file_locations (FileLocations): File locations trait. @@ -412,9 +412,9 @@ class Sequence(TraitBase): return list(src_collection.indexes) def get_frame_pattern(self) -> Pattern: - """Get frame regex as pattern. + """Get frame regex as a pattern. - If the regex is string, it will compile it to the pattern. + If the regex is a string, it will compile it to the pattern. Returns: Pattern: Compiled regex pattern. diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py index b618b9907b..85f8e07630 100644 --- a/client/ayon_core/pipeline/traits/trait.py +++ b/client/ayon_core/pipeline/traits/trait.py @@ -56,7 +56,7 @@ class TraitBase(ABC): @classmethod def get_version(cls) -> Optional[int]: # sourcery skip: use-named-expression - """Get trait version from ID. + """Get a trait version from ID. This assumes Trait ID ends with `.v{version}`. If not, it will return None. @@ -71,16 +71,16 @@ class TraitBase(ABC): @classmethod def get_versionless_id(cls) -> str: - """Get trait ID without version. + """Get a trait ID without a version. Returns: - str: Trait ID without version. + str: Trait ID without a version. """ return re.sub(r"\.v\d+$", "", str(cls.id)) def as_dict(self) -> dict: - """Return trait as dictionary. + """Return a trait as a dictionary. Returns: dict: Trait as dictionary. @@ -101,8 +101,8 @@ class UpgradableTraitError(Exception, Generic[T]): """Upgradable trait version exception. This exception is raised when the trait can upgrade existing data - meant for older versions of the trait. It must implement `upgrade` - method that will take old trait data as argument to handle the upgrade. + meant for older versions of the trait. It must implement an `upgrade` + method that will take old trait data as an argument to handle the upgrade. """ trait: T diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py index 93d7d8c86a..d94294bf74 100644 --- a/client/ayon_core/pipeline/traits/two_dimensional.py +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -20,7 +20,7 @@ class Image(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with version """ name: ClassVar[str] = "Image" @@ -33,12 +33,12 @@ class Image(TraitBase): class PixelBased(TraitBase): """PixelBased trait model. - Pixel related trait for image data. + The pixel-related trait for image data. Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with a version display_window_width (int): Width of the image display window. display_window_height (int): Height of the image display window. pixel_aspect_ratio (float): Pixel aspect ratio. @@ -87,7 +87,7 @@ class Deep(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with a version """ name: ClassVar[str] = "Deep" @@ -106,7 +106,7 @@ class Overscan(TraitBase): Attributes: name (str): Trait name. description (str): Trait description. - id (str): id should be namespaced trait name with version + id (str): id should be a namespaced trait name with a version left (int): Left overscan/underscan. right (int): Right overscan/underscan. top (int): Top overscan/underscan. @@ -144,8 +144,8 @@ class UDIM(TraitBase): udim: list[int] udim_regex: Optional[str] = r"(?:\.|_)(?P\d+)\.\D+\d?$" - # field validator for udim_regex - this works in pydantic model v2 but not - # with the pure data classes + # Field validator for udim_regex - this works in the pydantic model v2 + # but not with the pure data classes. @classmethod def validate_frame_regex(cls, v: Optional[str]) -> Optional[str]: """Validate udim regex. @@ -177,6 +177,8 @@ class UDIM(TraitBase): Optional[FileLocation]: File location. """ + if not self.udim_regex: + return None pattern = re.compile(self.udim_regex) for location in file_locations.file_paths: result = re.search(pattern, location.file_path.name) @@ -188,7 +190,7 @@ class UDIM(TraitBase): def get_udim_from_file_location( self, file_location: FileLocation) -> Optional[int]: - """Get UDIM from file location. + """Get UDIM from the file location. Args: file_location (FileLocation): File location. @@ -197,6 +199,8 @@ class UDIM(TraitBase): Optional[int]: UDIM value. """ + if not self.udim_regex: + return None pattern = re.compile(self.udim_regex) result = re.search(pattern, file_location.file_path.name) if result: diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index a1cd4792e9..a204c59cb7 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -378,3 +378,27 @@ def test_representation_equality() -> None: assert rep_d != rep_e # because of the trait difference assert rep_d != rep_f + +def test_get_repre_by_name(): + """Test getting representation by name.""" + rep_a = Representation(name="test_a", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + rep_b = Representation(name="test_b", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + + representations = [rep_a, rep_b] + repre = next(rep for rep in representations if rep.name == "test_a") 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 229/781] 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 230/781] 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 231/781] 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 232/781] 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 233/781] 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 234/781] 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 235/781] 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 236/781] 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 237/781] 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 238/781] 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 bc99edd74d5765968fb34aada785bf0eda72141b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 27 May 2025 10:47:46 +0200 Subject: [PATCH 239/781] remo identifier constance --- server/__init__.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/server/__init__.py b/server/__init__.py index aafd8d6fa5..c252f27762 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -18,9 +18,6 @@ from .settings import ( ) -IDENTIFIER_PREFIX = "core" - - class CoreAddon(BaseServerAddon): settings_model = CoreSettings @@ -51,7 +48,7 @@ class CoreAddon(BaseServerAddon): # Add 'Create Project Folder Structure' action to folders. output.append( SimpleActionManifest( - identifier=f"{IDENTIFIER_PREFIX}.createprojectstructure", + identifier="core.createprojectstructure", label="Create Project Folder Structure", icon={ "type": "material-symbols", @@ -74,9 +71,7 @@ class CoreAddon(BaseServerAddon): project_name = executor.context.project_name - if executor.identifier == \ - f"{IDENTIFIER_PREFIX}.create_project_structure": - + if executor.identifier == "core.createprojectstructure": if not project_name: logger.error( f"Can't execute {executor.identifier} because" From 6a7e1abf5da77d3d4884bed3e9db3f895cf325a5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 27 May 2025 10:48:01 +0200 Subject: [PATCH 240/781] use new executor methods --- server/__init__.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/server/__init__.py b/server/__init__.py index c252f27762..208268bbf7 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -77,13 +77,26 @@ class CoreAddon(BaseServerAddon): f"Can't execute {executor.identifier} because" " of missing project name." ) + # Works since AYON server 1.8.3 + if hasattr(executor, "get_simple_response"): + return await executor.get_simple_response( + "Missing project name", success=False + ) return - return await executor.get_launcher_action_response( - args=[ - "create-project-structure", - "--project", project_name, - ] - ) + args = [ + "create-project-structure", "--project", project_name, + ] + # Works since AYON server 1.8.3 + if hasattr(executor, "get_launcher_response"): + return await executor.get_launcher_response(args) + + return await executor.get_launcher_action_response(args) logger.debug(f"Unknown action: {executor.identifier}") + # Works since AYON server 1.8.3 + if hasattr(executor, "get_simple_response"): + return await executor.get_simple_response( + "Unknown action", success=False + ) + return From 6dbbe75099326609cecd47db2de493ddb0a9027e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 27 May 2025 10:50:58 +0200 Subject: [PATCH 241/781] remove unnecessary return --- server/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/server/__init__.py b/server/__init__.py index 208268bbf7..662c11679e 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -99,4 +99,3 @@ class CoreAddon(BaseServerAddon): return await executor.get_simple_response( "Unknown action", success=False ) - return From ef1c77bf4c3b225e2c023793865a7244bdafad84 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 27 May 2025 10:51:43 +0200 Subject: [PATCH 242/781] change entity type to project --- server/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/__init__.py b/server/__init__.py index 662c11679e..620cb3285c 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -55,7 +55,7 @@ class CoreAddon(BaseServerAddon): "name": "create_new_folder", }, order=100, - entity_type="folder", + entity_type="project", entity_subtypes=None, allow_multiselection=False, ) From 39b85970e25839fddd81d9ed77803e0c22d5b0f1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 27 May 2025 11:03:14 +0200 Subject: [PATCH 243/781] simplified description --- server/settings/main.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/server/settings/main.py b/server/settings/main.py index a5129e69af..67f92f0037 100644 --- a/server/settings/main.py +++ b/server/settings/main.py @@ -302,13 +302,8 @@ class CoreSettings(BaseSettingsModel): widget="textarea", title="Project folder structure", description=( - "Defines project folders to create on 'Create project folders'." - "\n\n" - "- In the launcher, this only creates the folders on disk and " - " when the setting is empty it will be hidden from users in the" - " launcher.\n" - "- In `ayon-ftrack` this will create the folders on disk **and**" - " will also create ftrack entities. It is never hidden there." + "Defines project folders to create on disk" + " for 'Create project folders' action." ), section="---" ) From e86450c48df1bc78c400cfa750605192e1c48125 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 27 May 2025 12:57:56 +0200 Subject: [PATCH 244/781] Allow Templated Workfile Build to build from an AYON Entity URI instead of filepath or templated filepath. --- .../workfile/workfile_template_builder.py | 184 ++++++++++++------ 1 file changed, 121 insertions(+), 63 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 27da278c5e..319ebb954e 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -17,6 +17,7 @@ import collections import copy from abc import ABC, abstractmethod +import ayon_api from ayon_api import ( get_folders, get_folder_by_path, @@ -60,6 +61,28 @@ from ayon_core.pipeline.create import ( _NOT_SET = object() +def resolve_entity_uri(entity_uri: str) -> str: + """Resolve AYON entity URI to a filesystem path for local system.""" + response = ayon_api.post( + "resolve", + resolveRoots=True, + uris=[entity_uri] + ) + if response.status_code != 200: + raise RuntimeError( + f"Unable to resolve AYON entity URI filepath for " + f"'{entity_uri}': {response.text}" + ) + + entities = response.data[0]["entities"] + if len(entities) != 1: + raise RuntimeError( + f"Unable to resolve AYON entity URI '{entity_uri}' to a " + f"single filepath. Received data: {response.data}" + ) + return entities[0]["filePath"] + + class TemplateNotFound(Exception): """Exception raised when template does not exist.""" pass @@ -823,7 +846,6 @@ class AbstractTemplateBuilder(ABC): """ host_name = self.host_name - project_name = self.project_name task_name = self.current_task_name task_type = self.current_task_type @@ -836,6 +858,8 @@ class AbstractTemplateBuilder(ABC): } ) + print("Build profiles", build_profiles) + if not profile: raise TemplateProfileNotFound(( "No matching profile found for task '{}' of type '{}' " @@ -843,6 +867,15 @@ class AbstractTemplateBuilder(ABC): ).format(task_name, task_type, host_name)) path = profile["path"] + if not path: + raise TemplateLoadFailed(( + "Template path is not set.\n" + "Path need to be set in {}\\Template Workfile Build " + "Settings\\Profiles" + ).format(host_name.title())) + + resolved_path = self.resolve_template_path(path) + self.log.info(f"Found template at: '{resolved_path}'") # switch to remove placeholders after they are used keep_placeholder = profile.get("keep_placeholder") @@ -852,86 +885,111 @@ class AbstractTemplateBuilder(ABC): if keep_placeholder is None: keep_placeholder = True - if not path: - raise TemplateLoadFailed(( - "Template path is not set.\n" - "Path need to be set in {}\\Template Workfile Build " - "Settings\\Profiles" - ).format(host_name.title())) - - # Try to fill path with environments and anatomy roots - anatomy = Anatomy(project_name) - fill_data = { - key: value - for key, value in os.environ.items() - } - - fill_data["root"] = anatomy.roots - fill_data["project"] = { - "name": project_name, - "code": anatomy.project_code, - } - - path = self.resolve_template_path(path, fill_data) - - if path and os.path.exists(path): - self.log.info("Found template at: '{}'".format(path)) - return { - "path": path, - "keep_placeholder": keep_placeholder, - "create_first_version": create_first_version - } - - solved_path = None - while True: - try: - solved_path = anatomy.path_remapper(path) - except KeyError as missing_key: - raise KeyError( - "Could not solve key '{}' in template path '{}'".format( - missing_key, path)) - - if solved_path is None: - solved_path = path - if solved_path == path: - break - path = solved_path - - solved_path = os.path.normpath(solved_path) - if not os.path.exists(solved_path): - raise TemplateNotFound( - "Template found in AYON settings for task '{}' with host " - "'{}' does not exists. (Not found : {})".format( - task_name, host_name, solved_path)) - - self.log.info("Found template at: '{}'".format(solved_path)) - return { - "path": solved_path, + "path": resolved_path, "keep_placeholder": keep_placeholder, "create_first_version": create_first_version } - def resolve_template_path(self, path, fill_data) -> str: + def resolve_template_path(self, path, fill_data=None) -> str: """Resolve the template path. - By default, this does nothing except returning the path directly. + By default, this: + - Resolves AYON entity URI to a filesystem path + - Returns path directly if it exists on disk. + - Resolves template keys through anatomy and environment variables. This can be overridden in host integrations to perform additional resolving over the template. Like, `hou.text.expandString` in Houdini. + It's recommended to still call the super().resolve_template_path() + to ensure the basic resolving is done across all integrations. Arguments: path (str): The input path. - fill_data (dict[str, str]): Data to use for template formatting. + fill_data (dict[str, str]): Deprecated. This is computed inside + the method using the current environment and project settings. + Used to be the data to use for template formatting. Returns: str: The resolved path. """ - result = StringTemplate.format_template(path, fill_data) - if result.solved: - path = result.normalized() - return path + + # If the path is an AYON entity URI, then resolve the filepath + # through the backend + if path.startswith("ayon+entity://") or path.startswith("ayon://"): + # This is a special case where the path is an AYON entity URI + # We need to resolve it to a filesystem path + resolved_path = resolve_entity_uri(path) + if not os.path.exists(resolved_path): + raise TemplateNotFound( + "Template found in AYON settings for task '{}' with host " + "'{}' does not resolve AYON entity URI '{}' " + "to an existing file on disk: '{}'".format( + self.current_task_name, + self.host_name, + path, + resolved_path, + ) + ) + return resolved_path + + # If the path is set and it's found on disk, return it directly + if path and os.path.exists(path): + return path + + # Otherwise assume a path with template keys, we do a very mundane + # check whether `{` or `<` is present in the path. + if "{" in path or "<" in path: + # Resolve keys through anatomy + project_name = self.project_name + task_name = self.current_task_name + host_name = self.host_name + + # Try to fill path with environments and anatomy roots + anatomy = Anatomy(project_name) + fill_data = { + key: value + for key, value in os.environ.items() + } + + fill_data["root"] = anatomy.roots + fill_data["project"] = { + "name": project_name, + "code": anatomy.project_code, + } + + # Recursively remap anatomy paths + while True: + try: + solved_path = anatomy.path_remapper(path) + except KeyError as missing_key: + raise KeyError( + f"Could not solve key '{missing_key}'" + f" in template path '{path}'" + ) + + if solved_path is None: + solved_path = path + if solved_path == path: + break + path = solved_path + + solved_path = os.path.normpath(solved_path) + if not os.path.exists(solved_path): + raise TemplateNotFound( + "Template found in AYON settings for task '{}' with host " + "'{}' does not exists. (Not found : {})".format( + task_name, host_name, solved_path)) + + result = StringTemplate.format_template(path, fill_data) + if result.solved: + path = result.normalized() + return path + + raise TemplateNotFound( + f"Unable to resolve template path: '{path}'" + ) def emit_event(self, topic, data=None, source=None) -> Event: return self._event_system.emit(topic, data, source) From 956121ba9649d1013ee36b7e1949c871e1795981 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 27 May 2025 13:01:43 +0200 Subject: [PATCH 245/781] Remove debug print --- .../ayon_core/pipeline/workfile/workfile_template_builder.py | 3 --- 1 file changed, 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 319ebb954e..f756190991 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -857,9 +857,6 @@ class AbstractTemplateBuilder(ABC): "task_names": task_name } ) - - print("Build profiles", build_profiles) - if not profile: raise TemplateProfileNotFound(( "No matching profile found for task '{}' of type '{}' " From 91df75f8eaaf0b6a74113c1d4facfd9be597d90f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 30 May 2025 16:10:20 +0200 Subject: [PATCH 246/781] :sparkles: add product base type support to loaders --- client/ayon_core/pipeline/load/plugins.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 4a11b929cc..7a92ed943d 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,3 +1,5 @@ +"""Plugins for loading representations and products into host applications.""" +from __future__ import annotations import os import logging @@ -15,7 +17,8 @@ from .utils import get_representation_path_from_context class LoaderPlugin(list): """Load representation into host application""" - product_types = set() + product_types: set[str] = set() + product_base_types: set[str] = set() representations = set() extensions = {"*"} order = 0 @@ -122,9 +125,11 @@ class LoaderPlugin(list): plugin_repre_names = cls.get_representations() plugin_product_types = cls.product_types + plugin_product_base_types = cls.product_base_types if ( not plugin_repre_names or not plugin_product_types + or not plugin_product_base_types or not cls.extensions ): return False @@ -147,10 +152,20 @@ class LoaderPlugin(list): if "*" in plugin_product_types: return True + plugin_product_base_types = set(plugin_product_base_types) + if "*" in plugin_product_base_types: + # If plugin supports all product base types, then it is compatible + # with any product type. + return True + product_entity = context["product"] product_type = product_entity["productType"] + product_base_type = product_entity.get("productBaseType") - return product_type in plugin_product_types + if product_type in plugin_product_types: + return True + + return product_base_type in plugin_product_base_types @classmethod def get_representations(cls): From fac933c16ab73d7f893e27ab6f0a022e2dd5dcf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Fri, 30 May 2025 16:46:18 +0200 Subject: [PATCH 247/781] :recycle: make the check backwards compatible Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- client/ayon_core/pipeline/load/plugins.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 7a92ed943d..32b96e3e7a 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -128,8 +128,7 @@ class LoaderPlugin(list): plugin_product_base_types = cls.product_base_types if ( not plugin_repre_names - or not plugin_product_types - or not plugin_product_base_types + or (not plugin_product_types and not plugin_product_base_types) or not cls.extensions ): return False 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 248/781] 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 249/781] 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 250/781] 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 251/781] 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 252/781] 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 253/781] 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 254/781] 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 255/781] 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 256/781] 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 257/781] 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 258/781] 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 259/781] 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 260/781] 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 261/781] 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 262/781] 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 263/781] 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 264/781] 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 265/781] 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 266/781] 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 267/781] 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 268/781] 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 269/781] 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 270/781] 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 6ea717bc3624cd17da53dd676772278704ac87d3 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 6 Jun 2025 10:01:32 +0200 Subject: [PATCH 271/781] :wrench: WIP on product base type support in loader tool --- client/ayon_core/tools/loader/abstract.py | 140 +++++++++++++----- .../ayon_core/tools/loader/models/products.py | 132 +++++++++++++++-- 2 files changed, 226 insertions(+), 46 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index d0d7cd430b..741eb59f81 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -1,5 +1,6 @@ +from __future__ import annotations from abc import ABC, abstractmethod -from typing import List +from typing import List, Optional, TypedDict from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, @@ -8,15 +9,62 @@ from ayon_core.lib.attribute_definitions import ( ) +IconData = TypedDict("IconData", { + "type": str, + "name": str, + "color": str +}) + +ProductBaseTypeItemData = TypedDict("ProductBaseTypeItemData", { + "name": str, + "icon": IconData +}) + + +VersionItemData = TypedDict("VersionItemData", { + "version_id": str, + "version": int, + "is_hero": bool, + "product_id": str, + "task_id": Optional[str], + "thumbnail_id": Optional[str], + "published_time": Optional[str], + "author": Optional[str], + "status": Optional[str], + "frame_range": Optional[str], + "duration": Optional[int], + "handles": Optional[str], + "step": Optional[int], + "comment": Optional[str], + "source": Optional[str] +}) + + +ProductItemData = TypedDict("ProductItemData", { + "product_id": str, + "product_type": str, + "product_base_type": str, + "product_name": str, + "product_icon": IconData, + "product_type_icon": IconData, + "product_base_type_icon": IconData, + "group_name": str, + "folder_id": str, + "folder_label": str, + "version_items": dict[str, VersionItemData], + "product_in_scene": bool +}) + + class ProductTypeItem: """Item representing product type. Args: name (str): Product type name. - icon (dict[str, Any]): Product type icon definition. + icon (IconData): Product type icon definition. """ - def __init__(self, name, icon): + def __init__(self, name: str, icon: IconData): self.name = name self.icon = icon @@ -31,6 +79,24 @@ class ProductTypeItem: return cls(**data) +class ProductBaseTypeItem: + """Item representing product base type.""" + + def __init__(self, name: str, icon: IconData): + self.name = name + self.icon = icon + + def to_data(self) -> ProductBaseTypeItemData: + return { + "name": self.name, + "icon": self.icon, + } + + @classmethod + def from_data(cls, data: ProductBaseTypeItemData): + return cls(**data) + + class ProductItem: """Product item with it versions. @@ -38,8 +104,8 @@ class ProductItem: product_id (str): Product id. product_type (str): Product type. product_name (str): Product name. - product_icon (dict[str, Any]): Product icon definition. - product_type_icon (dict[str, Any]): Product type icon definition. + product_icon (IconData): Product icon definition. + product_type_icon (IconData): Product type icon definition. product_in_scene (bool): Is product in scene (only when used in DCC). group_name (str): Group name. folder_id (str): Folder id. @@ -49,35 +115,42 @@ class ProductItem: def __init__( self, - product_id, - product_type, - product_name, - product_icon, - product_type_icon, - product_in_scene, - group_name, - folder_id, - folder_label, - version_items, + product_id: str, + product_type: str, + product_base_type: str, + product_name: str, + product_icon: IconData, + product_type_icon: IconData, + product_base_type_icon: IconData, + group_name: str, + folder_id: str, + folder_label: str, + version_items: dict[str, VersionItem], + *, + product_in_scene: bool, ): self.product_id = product_id self.product_type = product_type + self.product_base_type = product_base_type self.product_name = product_name self.product_icon = product_icon self.product_type_icon = product_type_icon + self.product_base_type_icon = product_base_type_icon self.product_in_scene = product_in_scene self.group_name = group_name self.folder_id = folder_id self.folder_label = folder_label self.version_items = version_items - def to_data(self): + def to_data(self) -> ProductItemData: return { "product_id": self.product_id, "product_type": self.product_type, + "product_base_type": self.product_base_type, "product_name": self.product_name, "product_icon": self.product_icon, "product_type_icon": self.product_type_icon, + "product_base_type_icon": self.product_base_type_icon, "product_in_scene": self.product_in_scene, "group_name": self.group_name, "folder_id": self.folder_id, @@ -124,21 +197,22 @@ class VersionItem: def __init__( self, - version_id, - version, - is_hero, - product_id, - task_id, - thumbnail_id, - published_time, - author, - status, - frame_range, - duration, - handles, - step, - comment, - source, + *, + version_id: str, + version: int, + is_hero: bool, + product_id: str, + task_id: Optional[str] = None, + thumbnail_id: Optional[str] = None, + published_time: Optional[str] = None, + author: Optional[str] = None, + status: Optional[str] = None, + frame_range: Optional[str] = None, + duration: Optional[int] = None, + handles: Optional[str] = None, + step: Optional[int] = None, + comment: Optional[str] = None, + source: Optional[str] = None, ): self.version_id = version_id self.product_id = product_id @@ -198,7 +272,7 @@ class VersionItem: def __le__(self, other): return self.__eq__(other) or self.__lt__(other) - def to_data(self): + def to_data(self) -> VersionItemData: return { "version_id": self.version_id, "product_id": self.product_id, @@ -218,7 +292,7 @@ class VersionItem: } @classmethod - def from_data(cls, data): + def from_data(cls, data: VersionItemData): return cls(**data) diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 34acc0550c..da2b049f50 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -1,19 +1,29 @@ +"""Products model for loader tools.""" +from __future__ import annotations import collections import contextlib +from typing import TYPE_CHECKING, Iterable, Optional import arrow import ayon_api from ayon_api.operations import OperationsSession + from ayon_core.lib import NestedCacheItem from ayon_core.style import get_default_entity_icon_color from ayon_core.tools.loader.abstract import ( + IconData, ProductTypeItem, + ProductBaseTypeItem, ProductItem, VersionItem, RepreItem, ) +if TYPE_CHECKING: + from ayon_api.typing import ProductBaseTypeDict, ProductDict, VersionDict + + PRODUCTS_MODEL_SENDER = "products.model" @@ -70,9 +80,10 @@ def version_item_from_entity(version): def product_item_from_entity( - product_entity, + product_entity: ProductDict, version_entities, - product_type_items_by_name, + product_type_items_by_name: dict[str, ProductTypeItem], + product_base_type_items_by_name: dict[str, ProductBaseTypeItem], folder_label, product_in_scene, ): @@ -88,9 +99,21 @@ def product_item_from_entity( # Cache the item for future use product_type_items_by_name[product_type] = product_type_item - product_type_icon = product_type_item.icon + product_base_type = product_entity.get("productBaseType") + product_base_type_item = product_base_type_items_by_name.get( + product_base_type) + # Same as for product type item above. Not sure if this is still needed + # though. + if product_base_type_item is None: + product_base_type_item = create_default_product_base_type_item( + product_base_type) + # Cache the item for future use + product_base_type_items_by_name[product_base_type] = ( + product_base_type_item) - product_icon = { + product_type_icon = product_type_item.icon + product_base_type_icon = product_base_type_item.icon + product_icon: IconData = { "type": "awesome-font", "name": "fa.file-o", "color": get_default_entity_icon_color(), @@ -103,9 +126,11 @@ def product_item_from_entity( return ProductItem( product_id=product_entity["id"], product_type=product_type, + product_base_type=product_base_type, product_name=product_entity["name"], product_icon=product_icon, product_type_icon=product_type_icon, + product_base_type_icon=product_base_type_icon, product_in_scene=product_in_scene, group_name=group, folder_id=product_entity["folderId"], @@ -114,11 +139,12 @@ def product_item_from_entity( ) -def product_type_item_from_data(product_type_data): +def product_type_item_from_data( + product_type_data: ProductDict) -> ProductTypeItem: # TODO implement icon implementation # icon = product_type_data["icon"] # color = product_type_data["color"] - icon = { + icon: IconData = { "type": "awesome-font", "name": "fa.folder", "color": "#0091B2", @@ -127,8 +153,30 @@ def product_type_item_from_data(product_type_data): return ProductTypeItem(product_type_data["name"], icon) -def create_default_product_type_item(product_type): - icon = { +def product_base_type_item_from_data( + product_base_type_data: ProductBaseTypeDict +) -> ProductBaseTypeItem: + """Create product base type item from data. + + Args: + product_base_type_data (ProductBaseTypeDict): Product base type data. + + Returns: + ProductBaseTypeDict: Product base type item. + + """ + icon: IconData = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + return ProductBaseTypeItem( + name=product_base_type_data["name"], + icon=icon) + + +def create_default_product_type_item(product_type: str) -> ProductTypeItem: + icon: IconData = { "type": "awesome-font", "name": "fa.folder", "color": "#0091B2", @@ -136,10 +184,28 @@ def create_default_product_type_item(product_type): return ProductTypeItem(product_type, icon) +def create_default_product_base_type_item( + product_base_type: str) -> ProductBaseTypeItem: + """Create default product base type item. + + Args: + product_base_type (str): Product base type name. + + Returns: + ProductBaseTypeItem: Default product base type item. + """ + icon: IconData = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + return ProductBaseTypeItem(product_base_type, icon) + + class ProductsModel: """Model for products, version and representation. - All of the entities are product based. This model prepares data for UI + All the entities are product based. This model prepares data for UI and caches it for faster access. Note: @@ -161,6 +227,8 @@ class ProductsModel: # Cache helpers self._product_type_items_cache = NestedCacheItem( levels=1, default_factory=list, lifetime=self.lifetime) + self._product_base_type_items_cache = NestedCacheItem( + levels=1, default_factory=list, lifetime=self.lifetime) self._product_items_cache = NestedCacheItem( levels=2, default_factory=dict, lifetime=self.lifetime) self._repre_items_cache = NestedCacheItem( @@ -199,6 +267,31 @@ class ProductsModel: ]) return cache.get_data() + def get_product_base_type_items( + self, + project_name: Optional[str]) -> list[ProductBaseTypeItem]: + """Product base type items for project. + + Args: + project_name (optional, str): Project name. + + Returns: + list[ProductBaseTypeDict]: Product base type items. + + """ + if not project_name: + return [] + + cache = self._product_base_type_items_cache[project_name] + if not cache.is_valid: + product_base_types = ayon_api.get_project_product_base_types( + project_name) + cache.update_data([ + product_base_type_item_from_data(product_base_type) + for product_base_type in product_base_types + ]) + return cache.get_data() + def get_product_items(self, project_name, folder_ids, sender): """Product items with versions for project and folder ids. @@ -449,11 +542,12 @@ class ProductsModel: def _create_product_items( self, - project_name, - products, - versions, + project_name: str, + products: Iterable[ProductDict], + versions: Iterable[VersionDict], folder_items=None, product_type_items=None, + product_base_type_items: Optional[Iterable[ProductBaseTypeItem]] = None ): if folder_items is None: folder_items = self._controller.get_folder_items(project_name) @@ -461,6 +555,11 @@ class ProductsModel: if product_type_items is None: product_type_items = self.get_product_type_items(project_name) + if product_base_type_items is None: + product_base_type_items = self.get_product_base_type_items( + project_name + ) + loaded_product_ids = self._controller.get_loaded_product_ids() versions_by_product_id = collections.defaultdict(list) @@ -470,7 +569,13 @@ class ProductsModel: product_type_item.name: product_type_item for product_type_item in product_type_items } - output = {} + + product_base_type_items_by_name: dict[str, ProductBaseTypeItem] = { + product_base_type_item.name: product_base_type_item + for product_base_type_item in product_base_type_items + } + + output: dict[str, ProductItem] = {} for product in products: product_id = product["id"] folder_id = product["folderId"] @@ -484,6 +589,7 @@ class ProductsModel: product, versions, product_type_items_by_name, + product_base_type_items_by_name, folder_item.label, product_id in loaded_product_ids, ) 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 272/781] 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 273/781] 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 274/781] 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 275/781] 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 276/781] 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 277/781] 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 278/781] 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 3a2f470dce3690c335466ecc01d1ff14588753be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 6 Jun 2025 14:03:31 +0200 Subject: [PATCH 279/781] :sparkles: show product base type in the loader --- client/ayon_core/tools/loader/abstract.py | 26 +++++++-- .../ayon_core/tools/loader/models/products.py | 2 +- .../tools/loader/ui/products_model.py | 55 +++++++++++-------- .../tools/loader/ui/products_widget.py | 1 + 4 files changed, 55 insertions(+), 29 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 741eb59f81..d6a4bf40cb 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -1,14 +1,15 @@ +"""Abstract base classes for loader tool.""" from __future__ import annotations + from abc import ABC, abstractmethod from typing import List, Optional, TypedDict from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, - serialize_attr_defs, deserialize_attr_defs, + serialize_attr_defs, ) - IconData = TypedDict("IconData", { "type": str, "name": str, @@ -80,20 +81,37 @@ class ProductTypeItem: class ProductBaseTypeItem: - """Item representing product base type.""" + """Item representing the product base type.""" def __init__(self, name: str, icon: IconData): + """Initialize product base type item.""" self.name = name self.icon = icon def to_data(self) -> ProductBaseTypeItemData: + """Convert item to data dictionary. + + Returns: + ProductBaseTypeItemData: Data representation of the item. + + """ return { "name": self.name, "icon": self.icon, } @classmethod - def from_data(cls, data: ProductBaseTypeItemData): + def from_data( + cls, data: ProductBaseTypeItemData) -> ProductBaseTypeItem: + """Create item from data dictionary. + + Args: + data (ProductBaseTypeItemData): Data to create item from. + + Returns: + ProductBaseTypeItem: Item created from the provided data. + + """ return cls(**data) diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index da2b049f50..41919461d0 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -270,7 +270,7 @@ class ProductsModel: def get_product_base_type_items( self, project_name: Optional[str]) -> list[ProductBaseTypeItem]: - """Product base type items for project. + """Product base type items for the project. Args: project_name (optional, str): Project name. diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index cebae9bca7..24050fc0c1 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -16,31 +16,32 @@ TASK_ID_ROLE = QtCore.Qt.UserRole + 5 PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6 PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7 PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8 -PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 9 -PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 10 -VERSION_ID_ROLE = QtCore.Qt.UserRole + 11 -VERSION_HERO_ROLE = QtCore.Qt.UserRole + 12 -VERSION_NAME_ROLE = QtCore.Qt.UserRole + 13 -VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 14 -VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 15 -VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 16 -VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 17 -VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 18 -VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 19 -VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 20 -VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 21 -VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 22 -VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 23 -VERSION_STEP_ROLE = QtCore.Qt.UserRole + 24 -VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 25 -VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 26 -ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27 -REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 28 -REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 29 -SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30 -SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 +PRODUCT_BASE_TYPE_ROLE = QtCore.Qt.UserRole + 9 +PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 10 +PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 11 +VERSION_ID_ROLE = QtCore.Qt.UserRole + 12 +VERSION_HERO_ROLE = QtCore.Qt.UserRole + 13 +VERSION_NAME_ROLE = QtCore.Qt.UserRole + 14 +VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 15 +VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 16 +VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 17 +VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 18 +VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 19 +VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 20 +VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 21 +VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 22 +VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 23 +VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 24 +VERSION_STEP_ROLE = QtCore.Qt.UserRole + 25 +VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 26 +VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 27 +ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 28 +REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 29 +REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 30 +SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 +SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 32 -STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 32 +STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 33 class ProductsModel(QtGui.QStandardItemModel): @@ -49,6 +50,7 @@ class ProductsModel(QtGui.QStandardItemModel): column_labels = [ "Product name", "Product type", + "Product base type", "Folder", "Version", "Status", @@ -79,6 +81,7 @@ class ProductsModel(QtGui.QStandardItemModel): product_name_col = column_labels.index("Product name") product_type_col = column_labels.index("Product type") + product_base_type_col = column_labels.index("Product base type") folders_label_col = column_labels.index("Folder") version_col = column_labels.index("Version") status_col = column_labels.index("Status") @@ -93,6 +96,7 @@ class ProductsModel(QtGui.QStandardItemModel): _display_role_mapping = { product_name_col: QtCore.Qt.DisplayRole, product_type_col: PRODUCT_TYPE_ROLE, + product_base_type_col: PRODUCT_BASE_TYPE_ROLE, folders_label_col: FOLDER_LABEL_ROLE, version_col: VERSION_NAME_ROLE, status_col: VERSION_STATUS_NAME_ROLE, @@ -432,6 +436,9 @@ class ProductsModel(QtGui.QStandardItemModel): model_item.setData(icon, QtCore.Qt.DecorationRole) model_item.setData(product_id, PRODUCT_ID_ROLE) model_item.setData(product_item.product_name, PRODUCT_NAME_ROLE) + model_item.setData( + product_item.product_base_type, PRODUCT_BASE_TYPE_ROLE + ) model_item.setData(product_item.product_type, PRODUCT_TYPE_ROLE) model_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE) model_item.setData(product_item.folder_id, FOLDER_ID_ROLE) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 94d95b9026..67116ad544 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -142,6 +142,7 @@ class ProductsWidget(QtWidgets.QWidget): default_widths = ( 200, # Product name 90, # Product type + 90, # Product base type 130, # Folder label 60, # Version 100, # Status From a3357a3ace0039a171c510a1e6a30ab19a231bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 6 Jun 2025 14:22:02 +0200 Subject: [PATCH 280/781] :dog: fix some linting issue --- client/ayon_core/tools/loader/ui/products_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 24050fc0c1..8b8d4a67bf 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -16,7 +16,7 @@ TASK_ID_ROLE = QtCore.Qt.UserRole + 5 PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6 PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7 PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8 -PRODUCT_BASE_TYPE_ROLE = QtCore.Qt.UserRole + 9 +PRODUCT_BASE_TYPE_ROLE = QtCore.Qt.UserRole + 9 PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 10 PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 11 VERSION_ID_ROLE = QtCore.Qt.UserRole + 12 From 98eb281adc2533548fa807d23862e93e675299a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 6 Jun 2025 14:22:25 +0200 Subject: [PATCH 281/781] :recycle: hide product base types if support is disabled --- client/ayon_core/tools/loader/ui/products_widget.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 67116ad544..511c346cb9 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -4,6 +4,7 @@ from typing import Optional from qtpy import QtWidgets, QtCore +from ayon_core.pipeline.compatibility import is_supporting_product_base_type from ayon_core.tools.utils import ( RecursiveSortFilterProxyModel, DeselectableTreeView, @@ -262,6 +263,12 @@ class ProductsWidget(QtWidgets.QWidget): self._controller.is_sitesync_enabled() ) + if not is_supporting_product_base_type(): + # Hide product base type column + products_view.setColumnHidden( + products_model.product_base_type_col, True + ) + def set_name_filter(self, name): """Set filter of product name. 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 282/781] 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 283/781] 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 284/781] 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 285/781] 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 286/781] 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 287/781] 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 288/781] 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 289/781] 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 290/781] 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 0f539ec6eaf5ae8cec07b12128f0b1d4aa773e2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 6 Jun 2025 18:12:53 +0200 Subject: [PATCH 291/781] :dog: fix linting issues --- tests/client/ayon_core/pipeline/traits/test_traits.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py index a204c59cb7..e4aef1ba18 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -362,7 +362,7 @@ def test_representation_equality() -> None: Planar(planar_configuration="RGBA"), ]) - # lets assume ids are the same (because ids are randomly generated) + # let's assume ids are the same (because ids are randomly generated) rep_b.representation_id = rep_d.representation_id = rep_a.representation_id rep_c.representation_id = rep_e.representation_id = rep_a.representation_id rep_f.representation_id = rep_a.representation_id @@ -379,6 +379,7 @@ def test_representation_equality() -> None: # because of the trait difference assert rep_d != rep_f + def test_get_repre_by_name(): """Test getting representation by name.""" rep_a = Representation(name="test_a", traits=[ @@ -401,4 +402,4 @@ def test_get_repre_by_name(): ]) representations = [rep_a, rep_b] - repre = next(rep for rep in representations if rep.name == "test_a") + _ = next(rep for rep in representations if rep.name == "test_a") From 74ed8bf2bb6b84499c48b395ab6ff70dacf1f2e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 9 Jun 2025 13:52:13 +0200 Subject: [PATCH 292/781] :recycle: refactor support check function name --- client/ayon_core/tools/loader/ui/products_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 511c346cb9..47046d5ec2 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -4,7 +4,7 @@ from typing import Optional from qtpy import QtWidgets, QtCore -from ayon_core.pipeline.compatibility import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.tools.utils import ( RecursiveSortFilterProxyModel, DeselectableTreeView, @@ -263,7 +263,7 @@ class ProductsWidget(QtWidgets.QWidget): self._controller.is_sitesync_enabled() ) - if not is_supporting_product_base_type(): + if not is_product_base_type_supported(): # Hide product base type column products_view.setColumnHidden( products_model.product_base_type_col, True From ceef6876e92691cb435031db8092cb6b14f8ee6c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Jun 2025 17:27:27 +0200 Subject: [PATCH 293/781] base implementation of search bar --- client/ayon_core/style/style.css | 40 ++ .../ayon_core/tools/loader/ui/search_bar.py | 637 ++++++++++++++++++ client/ayon_core/tools/loader/ui/window.py | 150 +++-- 3 files changed, 786 insertions(+), 41 deletions(-) create mode 100644 client/ayon_core/tools/loader/ui/search_bar.py diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 0e19702d53..9270ddee30 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -862,6 +862,46 @@ HintedLineEditButton { border-radius: 0.1em; } +/* Launcher specific stylesheets */ +FiltersBar { + background: {color:bg-inputs}; + border-radius: 5px; +} + +FiltersBar #SearchButton { + background: transparent; +} + +FiltersPopup #PopupWrapper, FilterValuePopup #PopupWrapper { + border-radius: 5px; + background: {color:bg-inputs}; +} + +FilterItemButton, FilterValueItemWidget { + border-radius: 5px; + background: transparent; +} +FilterItemButton:hover, FilterValueItemWidget:hover { + background: {color:bg-buttons-hover}; +} +FilterValueItemWidget[selected="1"] { + background: {color:bg-view-selection}; +} +FilterValueItemWidget[selected="1"]:hover { + background: {color:bg-view-selection-hover}; +} +SearchItemDisplayWidget { + border-radius: 5px; +} +SearchItemDisplayWidget #CloseButton { + background: transparent; + border-radius: 5px; +} +SearchItemDisplayWidget #ValueWidget { + border-radius: 3px; + background: {color:bg-buttons}; +} + /* Subset Manager */ #SubsetManagerDetailsText {} #SubsetManagerDetailsText[state="invalid"] { diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py new file mode 100644 index 0000000000..72c16e5566 --- /dev/null +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -0,0 +1,637 @@ +import copy +import uuid +from dataclasses import dataclass +from typing import Any, Optional + +from qtpy import QtCore, QtWidgets + +from ayon_core.style import load_stylesheet +from ayon_core.tools.utils import ( + get_qt_icon, + SquareButton, + BaseClickableFrame, + ClickableFrame, + PixmapLabel, +) + + +@dataclass +class FilterDefinition: + """Search bar definition. + + Attributes: + name (str): Name of the definition. + title (str): Title of the search bar. + icon (str): Icon name for the search bar. + placeholder (str): Placeholder text for the search bar. + + """ + name: str + title: str + filter_type: str + icon: Optional[dict[str, Any]] = None + placeholder: Optional[str] = None + items: Optional[list[dict[str, str]]] = None + + +class SearchItemDisplayWidget(QtWidgets.QFrame): + close_requested = QtCore.Signal(str) + edit_requested = QtCore.Signal(str) + + def __init__( + self, + filter_def: FilterDefinition, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + self._filter_def = filter_def + + close_icon = get_qt_icon({ + "type": "material-symbols", + "name": "close", + "color": "#FFFFFF", + }) + + title_widget = QtWidgets.QLabel(f"{filter_def.title}:", self) + + value_wrapper = QtWidgets.QWidget(self) + value_widget = QtWidgets.QLabel(value_wrapper) + value_widget.setObjectName("ValueWidget") + value_widget.setText(6 * " ") + value_layout = QtWidgets.QVBoxLayout(value_wrapper) + value_layout.setContentsMargins(2, 2, 2, 2) + value_layout.addWidget(value_widget) + + close_btn = SquareButton(self) + close_btn.setObjectName("CloseButton") + close_btn.setIcon(close_icon) + close_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(4, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(title_widget, 0) + main_layout.addWidget(value_wrapper, 0) + main_layout.addWidget(close_btn, 0) + + close_btn.clicked.connect(self._on_remove_clicked) + + self._value_wrapper = value_wrapper + self._value_widget = value_widget + self._value = None + + def set_value(self, value: "str | list[str]"): + text = "" + if isinstance(value, str): + text = value + elif len(value) == 1: + text = value[0] + elif len(value) > 1: + text = str(len(value)) + + if len(text) > 9: + text = text[:9] + "..." + + text = " " + text + " " + text_diff = 4 - len(text) + if text_diff > 0: + text = " " * text_diff + text + + self._value = copy.deepcopy(value) + self._value_widget.setText(text) + + def get_value(self): + return copy.deepcopy(self._value) + + def _on_remove_clicked(self): + self.close_requested.emit(self._filter_def.name) + + def _request_edit(self): + self.edit_requested.emit(self._filter_def.name) + + +class FilterItemButton(BaseClickableFrame): + filter_requested = QtCore.Signal(str) + + def __init__( + self, + filter_def: FilterDefinition, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + self._filter_def = filter_def + + title_widget = QtWidgets.QLabel(filter_def.title, self) + title_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addWidget(title_widget, 1) + + def _mouse_release_callback(self): + """Handle mouse release event to emit filter request.""" + self.filter_requested.emit(self._filter_def.name) + + +class FiltersPopup(QtWidgets.QWidget): + filter_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + wrapper = QtWidgets.QWidget(self) + wrapper.setObjectName("PopupWrapper") + + wraper_layout = QtWidgets.QVBoxLayout(wrapper) + wraper_layout.setContentsMargins(5, 5, 5, 5) + wraper_layout.setSpacing(0) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(wrapper) + + self._wrapper = wrapper + self._wrapper_layout = wraper_layout + self._preferred_width = None + + def set_preferred_width(self, width: int): + self._preferred_width = width + + def sizeHint(self): + sh = super().sizeHint() + if self._preferred_width is not None: + sh.setWidth(self._preferred_width) + return sh + + def set_filter_items(self, filter_items): + while self._wrapper_layout.count() > 0: + item = self._wrapper_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setVisible(False) + widget.deleteLater() + + for item in filter_items: + widget = FilterItemButton(item, self._wrapper) + widget.filter_requested.connect(self.filter_requested) + self._wrapper_layout.addWidget(widget) + + if self._wrapper_layout.count() == 0: + empty_label = QtWidgets.QLabel( + "No filters available...", self._wrapper + ) + self._wrapper_layout.addWidget(empty_label) + + +class FilterValueItemWidget(BaseClickableFrame): + selected = QtCore.Signal(str) + + def __init__(self, widget_id, value, icon, color, parent): + super().__init__(parent) + + label_widget = QtWidgets.QLabel(str(value), self) + if color: + label_widget.setStyleSheet(f"color: {color};") + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addWidget(label_widget, 1) + + self._icon_widget = None + self._label_widget = label_widget + self._main_layout = main_layout + self._selected = False + self._value = value + self._widget_id = widget_id + + if icon: + self.set_icon(icon) + + def set_icon(self, icon: dict[str, Any]): + """Set the icon for the widget.""" + icon = get_qt_icon(icon) + pixmap = icon.pixmap(64, 64) + if self._icon_widget is None: + self._icon_widget = PixmapLabel(pixmap, self) + self._main_layout.insertWidget(0, self._icon_widget, 0) + else: + self._icon_widget.setPixmap(pixmap) + + def get_value(self): + return self._value + + def set_selected(self, selected: bool): + """Set the selection state of the widget.""" + if self._selected == selected: + return + self._selected = selected + self.setProperty("selected", "1" if selected else "") + self.style().polish(self) + + def is_selected(self) -> bool: + return self._selected + + def _mouse_release_callback(self): + """Handle mouse release event to emit filter request.""" + self.selected.emit(self._widget_id) + + +class FilterValueItemsView(QtWidgets.QWidget): + value_changed = QtCore.Signal() + + def __init__(self, parent): + super().__init__(parent) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + + self._multiselection = False + self._main_layout = main_layout + self._last_selected_widget = None + self._widgets_by_id = {} + + def set_value(self, value): + current_value = self.get_value() + if self._multiselection: + if value is None: + value = [] + if not isinstance(value, list): + value = [value] + for widget in self._widgets_by_id.values(): + selected = widget.get_value() in value + if selected and self._last_selected_widget is None: + self._last_selected_widget = widget + widget.set_selected(selected) + + if value != current_value: + self.value_changed.emit() + return + + if isinstance(value, list): + if len(value) > 0: + value = value[0] + else: + value = None + + if value is None: + widget = next(iter(self._widgets_by_id.values())) + value = widget.get_value() + + self._last_selected_widget = None + for widget in self._widgets_by_id.values(): + selected = widget.get_value() in value + widget.set_selected(selected) + if selected: + self._last_selected_widget = widget + + if self._last_selected_widget is None: + widget = next(iter(self._widgets_by_id.values())) + self._last_selected_widget = widget + widget.set_selected(True) + + if value != current_value: + self.value_changed.emit() + + def set_multiselection(self, multiselection: bool): + self._multiselection = multiselection + if not self._widgets_by_id or self._multiselection: + return + + value_changed = False + if self._last_selected_widget is None: + value_changed = True + self._last_selected_widget = next( + iter(self._widgets_by_id.values()) + ) + for widget in self._widgets_by_id.values(): + widget.set_selected(widget is self._last_selected_widget) + + if value_changed: + self.value_changed.emit() + + def get_value(self): + """Get the value from the items view.""" + if self._multiselection: + return [ + widget.get_value() + for widget in self._widgets_by_id.values() + if widget.is_selected() + ] + if self._last_selected_widget is not None: + return self._last_selected_widget.get_value() + return None + + def set_items(self, items: list[dict[str, Any]]): + while self._main_layout.count() > 0: + item = self._main_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setVisible(False) + widget.deleteLater() + self._widgets_by_id = {} + self._last_selected_widget = None + + for item in items: + widget_id = uuid.uuid4().hex + widget = FilterValueItemWidget( + widget_id, + item["value"], + item.get("icon"), + item.get("color"), + self, + ) + widget.selected.connect(self._on_item_clicked) + self._widgets_by_id[widget_id] = widget + self._main_layout.addWidget(widget) + + def _on_item_clicked(self, widget_id): + widget = self._widgets_by_id.get(widget_id) + if widget is None: + return + + previous_widget = self._last_selected_widget + self._last_selected_widget = widget + if self._multiselection: + widget.set_selected(not widget.is_selected()) + else: + widget.set_selected(True) + if previous_widget is not None: + previous_widget.set_selected(False) + self.value_changed.emit() + + +class FilterValuePopup(QtWidgets.QWidget): + value_changed = QtCore.Signal(str) + closed = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + wrapper = QtWidgets.QWidget(self) + wrapper.setObjectName("PopupWrapper") + + text_input = QtWidgets.QLineEdit(wrapper) + text_input.setVisible(False) + + items_view = FilterValueItemsView(wrapper) + items_view.setVisible(False) + + wraper_layout = QtWidgets.QVBoxLayout(wrapper) + wraper_layout.setContentsMargins(5, 5, 5, 5) + wraper_layout.addWidget(text_input, 0) + wraper_layout.addWidget(items_view, 0) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(wrapper) + + text_input.textChanged.connect(self._text_changed) + text_input.returnPressed.connect(self._text_confirmed) + + items_view.value_changed.connect(self._selection_changed) + + self._wrapper = wrapper + self._wrapper_layout = wraper_layout + self._text_input = text_input + self._items_view = items_view + + self._active_widget = None + self._filter_name = None + self._preferred_width = None + + def set_preferred_width(self, width: int): + self._preferred_width = width + + def sizeHint(self): + sh = super().sizeHint() + if self._preferred_width is not None: + sh.setWidth(self._preferred_width) + return sh + + def set_filter_item( + self, + filter_def: FilterDefinition, + value, + ): + self._text_input.setVisible(False) + self._items_view.setVisible(False) + self._filter_name = filter_def.name + self._active_widget = None + if filter_def.filter_type == "text": + if filter_def.items: + if value is None: + value = filter_def.items[0]["value"] + self._active_widget = self._items_view + self._items_view.set_items(filter_def.items) + self._items_view.set_multiselection(False) + self._items_view.set_value(value) + else: + if value is None: + value = "" + self._text_input.setPlaceholderText( + filter_def.placeholder or "" + ) + self._text_input.setText(value) + self._active_widget = self._text_input + + elif filter_def.filter_type == "list": + if value is None: + value = [] + self._items_view.set_items(filter_def.items) + self._items_view.set_multiselection(True) + self._items_view.set_value(value) + self._active_widget = self._items_view + + if self._active_widget is not None: + self._active_widget.setVisible(True) + + def showEvent(self, event): + super().showEvent(event) + if self._active_widget is not None: + self._active_widget.setFocus() + + def closeEvent(self, event): + super().closeEvent(event) + self.closed.emit(self._filter_name) + + def hideEvent(self, event): + super().hideEvent(event) + self.closed.emit(self._filter_name) + + def get_value(self): + """Get the value from the active widget.""" + if self._active_widget is self._text_input: + return self._text_input.text() + elif self._active_widget is self._items_view: + return self._active_widget.get_value() + return None + + def _text_changed(self): + """Handle text change in the text input.""" + if self._active_widget == self._text_input: + # Emit value changed signal if text input is active + self.value_changed.emit(self._filter_name) + + def _text_confirmed(self): + self.close() + + def _selection_changed(self): + self.value_changed.emit(self._filter_name) + + +class FiltersBar(ClickableFrame): + filters_changed = QtCore.Signal() + + def __init__(self, parent): + super().__init__(parent) + + search_icon = get_qt_icon({ + "type": "material-symbols", + "name": "search", + "color": "#FFFFFF", + }) + search_btn = SquareButton(self) + search_btn.setIcon(search_icon) + search_btn.setFlat(True) + search_btn.setObjectName("SearchButton") + + filters_widget = QtWidgets.QWidget(self) + filters_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + filters_layout = QtWidgets.QHBoxLayout(filters_widget) + filters_layout.setContentsMargins(0, 0, 0, 0) + filters_layout.addStretch(1) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(4, 4, 4, 4) + main_layout.setSpacing(5) + main_layout.addWidget(search_btn, 0) + main_layout.addWidget(filters_widget, 1) + + search_btn.clicked.connect(self._on_filters_request) + self.clicked.connect(self._on_clicked) + + self._search_btn = search_btn + self._filters_widget = filters_widget + self._filters_layout = filters_layout + self._widgets_by_name = {} + self._filter_defs_by_name = {} + self._filters_popup = FiltersPopup(self) + self._filter_value_popup = FilterValuePopup(self) + + def set_search_items(self, filter_defs: list[FilterDefinition]): + self._filter_defs_by_name = { + filter_def.name: filter_def + for filter_def in filter_defs + } + + def add_item(self, name: str): + """Add a new item to the search bar. + + Args: + name (str): Search definition name. + + """ + filter_def = self._filter_defs_by_name.get(name) + if filter_def is None: + return + + item_widget = self._widgets_by_name.get(name) + if item_widget is not None: + return + + item_widget = SearchItemDisplayWidget( + filter_def, + parent=self._filters_widget, + ) + item_widget.close_requested.connect(self._on_item_close_requested) + self._widgets_by_name[name] = item_widget + idx = self._filters_layout.count() - 1 + self._filters_layout.insertWidget(idx, item_widget, 0) + + def _on_clicked(self): + self._show_filters_popup() + + def _show_filters_popup(self): + filter_defs = [ + filter_def + for filter_def in self._filter_defs_by_name.values() + if filter_def.name not in self._widgets_by_name + ] + filters_popup = FiltersPopup(self) + filters_popup.filter_requested.connect(self._on_filter_request) + filters_popup.set_filter_items(filter_defs) + filters_popup.set_preferred_width(self.width()) + + old_popup, self._filters_popup = self._filters_popup, filters_popup + + self._show_popup(filters_popup) + + old_popup.setVisible(False) + old_popup.deleteLater() + + def _on_filters_request(self): + self._show_filters_popup() + + def _on_filter_request(self, filter_name: str): + """Handle filter request from the popup.""" + self.add_item(filter_name) + self._filters_popup.hide() + filter_def = self._filter_defs_by_name.get(filter_name) + widget = self._widgets_by_name.get(filter_name) + value = None + if widget is not None: + value = widget.get_value() + + filter_value_popup = FilterValuePopup(self) + filter_value_popup.set_preferred_width(self.width()) + filter_value_popup.set_filter_item(filter_def, value) + filter_value_popup.value_changed.connect(self._on_filter_value_change) + filter_value_popup.closed.connect(self._on_filter_value_closed) + + old_popup, self._filter_value_popup = ( + self._filter_value_popup, filter_value_popup + ) + + self._show_popup(filter_value_popup) + self._on_filter_value_change(filter_def.name) + + old_popup.setVisible(False) + old_popup.deleteLater() + + def _show_popup(self, popup: QtWidgets.QWidget): + """Show a popup widget.""" + geo = self.geometry() + bl_pos_g = self.mapToGlobal(QtCore.QPoint(0, geo.height() + 5)) + popup.show() + popup.move(bl_pos_g.x(), bl_pos_g.y()) + popup.raise_() + + def _on_filter_value_change(self, name): + value = self._filter_value_popup.get_value() + item_widget = self._widgets_by_name.get(name) + item_widget.set_value(value) + + def _on_filter_value_closed(self, name): + widget = self._widgets_by_name.get(name) + if widget is None: + return + + value = widget.get_value() + if not value: + self._on_item_close_requested(name) + + def _on_item_close_requested(self, name): + widget = self._widgets_by_name.pop(name, None) + if widget is not None: + idx = self._filters_layout.indexOf(widget) + if idx > -1: + self._filters_layout.takeAt(idx) + widget.setVisible(False) + widget.deleteLater() diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index b70f5554c7..580934f2b2 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -11,6 +11,8 @@ from ayon_core.tools.utils import ( ) from ayon_core.tools.utils.lib import center_window from ayon_core.tools.utils import ProjectsCombobox +from ayon_core.tools.common_models import StatusItem +from ayon_core.tools.loader.abstract import ProductTypeItem from ayon_core.tools.loader.control import LoaderController from .folders_widget import LoaderFoldersWidget @@ -21,6 +23,7 @@ from .product_group_dialog import ProductGroupDialog from .info_widget import InfoWidget from .repres_widget import RepresentationsWidget from .statuses_combo import StatusesCombobox +from .search_bar import FiltersBar, FilterDefinition class LoadErrorMessageBox(ErrorMessageBox): @@ -182,32 +185,34 @@ class LoaderWindow(QtWidgets.QWidget): products_wrap_widget = QtWidgets.QWidget(main_splitter) products_inputs_widget = QtWidgets.QWidget(products_wrap_widget) - - products_filter_input = PlaceholderLineEdit(products_inputs_widget) - products_filter_input.setPlaceholderText("Product name filter...") - - product_types_filter_combo = ProductTypesCombobox( - controller, products_inputs_widget - ) - - product_status_filter_combo = StatusesCombobox(controller, self) + search_bar = FiltersBar(products_inputs_widget) + # + # products_filter_input = PlaceholderLineEdit(products_inputs_widget) + # products_filter_input.setPlaceholderText("Product name filter...") + # + # product_types_filter_combo = ProductTypesCombobox( + # controller, products_inputs_widget + # ) + # + # product_status_filter_combo = StatusesCombobox(controller, self) product_group_checkbox = QtWidgets.QCheckBox( "Enable grouping", products_inputs_widget) product_group_checkbox.setChecked(True) - products_widget = ProductsWidget(controller, products_wrap_widget) - products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget) products_inputs_layout.setContentsMargins(0, 0, 0, 0) - products_inputs_layout.addWidget(products_filter_input, 1) - products_inputs_layout.addWidget(product_types_filter_combo, 1) - products_inputs_layout.addWidget(product_status_filter_combo, 1) + # products_inputs_layout.addWidget(products_filter_input, 1) + # products_inputs_layout.addWidget(product_types_filter_combo, 1) + # products_inputs_layout.addWidget(product_status_filter_combo, 1) + products_inputs_layout.addWidget(search_bar, 1) products_inputs_layout.addWidget(product_group_checkbox, 0) + products_widget = ProductsWidget(controller, products_wrap_widget) + products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget) products_wrap_layout.setContentsMargins(0, 0, 0, 0) - products_wrap_layout.addWidget(products_inputs_widget, 0) + products_wrap_layout.addWidget(search_bar, 0) products_wrap_layout.addWidget(products_widget, 1) right_panel_splitter = QtWidgets.QSplitter(main_splitter) @@ -250,15 +255,16 @@ class LoaderWindow(QtWidgets.QWidget): folders_filter_input.textChanged.connect( self._on_folder_filter_change ) - products_filter_input.textChanged.connect( - self._on_product_filter_change - ) - product_types_filter_combo.value_changed.connect( - self._on_product_type_filter_change - ) - product_status_filter_combo.value_changed.connect( - self._on_status_filter_change - ) + search_bar.filters_changed.connect(self._on_filters_change) + # products_filter_input.textChanged.connect( + # self._on_product_filter_change + # ) + # product_types_filter_combo.value_changed.connect( + # self._on_product_type_filter_change + # ) + # product_status_filter_combo.value_changed.connect( + # self._on_status_filter_change + # ) product_group_checkbox.stateChanged.connect( self._on_product_group_change ) @@ -316,9 +322,10 @@ class LoaderWindow(QtWidgets.QWidget): self._tasks_widget = tasks_widget - self._products_filter_input = products_filter_input - self._product_types_filter_combo = product_types_filter_combo - self._product_status_filter_combo = product_status_filter_combo + self._search_bar = search_bar + # self._products_filter_input = products_filter_input + # self._product_types_filter_combo = product_types_filter_combo + # self._product_status_filter_combo = product_status_filter_combo self._product_group_checkbox = product_group_checkbox self._products_widget = products_widget @@ -344,6 +351,7 @@ class LoaderWindow(QtWidgets.QWidget): def refresh(self): self._reset_on_show = False self._controller.reset() + self._update_filters() def showEvent(self, event): super().showEvent(event) @@ -356,11 +364,11 @@ class LoaderWindow(QtWidgets.QWidget): def closeEvent(self, event): super().closeEvent(event) - ( - self - ._product_types_filter_combo - .reset_product_types_filter_on_refresh() - ) + # ( + # self + # ._product_types_filter_combo + # .reset_product_types_filter_on_refresh() + # ) self._reset_on_show = True @@ -435,19 +443,22 @@ class LoaderWindow(QtWidgets.QWidget): self._product_group_checkbox.isChecked() ) - def _on_product_filter_change(self, text): - self._products_widget.set_name_filter(text) + def _on_filters_change(self): + pass def _on_tasks_selection_change(self, event): self._products_widget.set_tasks_filter(event["task_ids"]) - def _on_status_filter_change(self): - status_names = self._product_status_filter_combo.get_value() - self._products_widget.set_statuses_filter(status_names) - - def _on_product_type_filter_change(self): - product_types = self._product_types_filter_combo.get_value() - self._products_widget.set_product_type_filter(product_types) + # def _on_product_filter_change(self, text): + # self._products_widget.set_name_filter(text) + # + # def _on_status_filter_change(self): + # status_names = self._product_status_filter_combo.get_value() + # self._products_widget.set_statuses_filter(status_names) + # + # def _on_product_type_filter_change(self): + # product_types = self._product_types_filter_combo.get_value() + # self._products_widget.set_product_type_filter(product_types) def _on_merged_products_selection_change(self): items = self._products_widget.get_selected_merged_products() @@ -491,6 +502,63 @@ class LoaderWindow(QtWidgets.QWidget): def _on_project_selection_changed(self, event): self._selected_project_name = event["project_name"] + self._update_filters() + + def _update_filters(self): + project_name = self._selected_project_name + product_type_items: list[ProductTypeItem] = [] + status_items: list[StatusItem] = [] + if project_name: + product_type_items = self._controller.get_product_type_items( + project_name + ) + status_items = self._controller.get_project_status_items( + project_name + ) + + filter_product_type_items = [ + { + "value": item.name, + "icon": item.icon, + } + for item in product_type_items + ] + filter_status_items = [ + { + "icon": { + "type": "material-symbols", + "name": status_item.icon, + "color": status_item.color + }, + "value": status_item.name, + } + for status_item in status_items + ] + + self._search_bar.set_search_items([ + FilterDefinition( + name="product_name", + title="Product name", + filter_type="text", + icon=None, + placeholder="Product name filter...", + items=None, + ), + FilterDefinition( + name="product_types", + title="Product type", + filter_type="list", + icon=None, + items=filter_product_type_items, + ), + FilterDefinition( + name="statuses", + title="Statuses", + filter_type="list", + icon=None, + items=filter_status_items, + ), + ]) def _on_folders_selection_changed(self, event): self._selected_folder_ids = set(event["folder_ids"]) From 98fbeb96ec5cdc661d26565c12dbab4569df05a1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Jun 2025 17:31:56 +0200 Subject: [PATCH 294/781] set status color to text --- client/ayon_core/tools/loader/ui/window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 580934f2b2..f236a1e3ae 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -530,6 +530,7 @@ class LoaderWindow(QtWidgets.QWidget): "name": status_item.icon, "color": status_item.color }, + "color": status_item.color, "value": status_item.name, } for status_item in status_items From 2c145ca30e52ea0884345598d581da144c679765 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 10:00:28 +0200 Subject: [PATCH 295/781] added scroll area to popups --- client/ayon_core/style/style.css | 18 ++++---- .../ayon_core/tools/loader/ui/search_bar.py | 43 +++++++++++++------ 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 9270ddee30..adf27a0e03 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -868,6 +868,9 @@ FiltersBar { border-radius: 5px; } +FiltersBar #ScrollArea { + background: {color:bg-inputs}; +} FiltersBar #SearchButton { background: transparent; } @@ -877,24 +880,23 @@ FiltersPopup #PopupWrapper, FilterValuePopup #PopupWrapper { background: {color:bg-inputs}; } -FilterItemButton, FilterValueItemWidget { +FilterItemButton, FilterValueItemButton { border-radius: 5px; background: transparent; } -FilterItemButton:hover, FilterValueItemWidget:hover { +FilterItemButton:hover, FilterValueItemButton:hover { background: {color:bg-buttons-hover}; } -FilterValueItemWidget[selected="1"] { +FilterValueItemButton[selected="1"] { background: {color:bg-view-selection}; } -FilterValueItemWidget[selected="1"]:hover { +FilterValueItemButton[selected="1"]:hover { background: {color:bg-view-selection-hover}; } -SearchItemDisplayWidget { - border-radius: 5px; +FilterValueItemsView #ContentWidget { + background: {color:bg-inputs}; } -SearchItemDisplayWidget #CloseButton { - background: transparent; +SearchItemDisplayWidget { border-radius: 5px; } SearchItemDisplayWidget #ValueWidget { diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 72c16e5566..d1946d4ab3 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -124,12 +124,13 @@ class FilterItemButton(BaseClickableFrame): self._filter_def = filter_def title_widget = QtWidgets.QLabel(filter_def.title, self) - title_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(5, 5, 5, 5) main_layout.addWidget(title_widget, 1) + self._title_widget = title_widget + def _mouse_release_callback(self): """Handle mouse release event to emit filter request.""" self.filter_requested.emit(self._filter_def.name) @@ -148,7 +149,7 @@ class FiltersPopup(QtWidgets.QWidget): wraper_layout = QtWidgets.QVBoxLayout(wrapper) wraper_layout.setContentsMargins(5, 5, 5, 5) - wraper_layout.setSpacing(0) + wraper_layout.setSpacing(5) main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) @@ -187,22 +188,22 @@ class FiltersPopup(QtWidgets.QWidget): self._wrapper_layout.addWidget(empty_label) -class FilterValueItemWidget(BaseClickableFrame): +class FilterValueItemButton(BaseClickableFrame): selected = QtCore.Signal(str) def __init__(self, widget_id, value, icon, color, parent): super().__init__(parent) - label_widget = QtWidgets.QLabel(str(value), self) + title_widget = QtWidgets.QLabel(str(value), self) if color: - label_widget.setStyleSheet(f"color: {color};") + title_widget.setStyleSheet(f"color: {color};") main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(5, 5, 5, 5) - main_layout.addWidget(label_widget, 1) + main_layout.addWidget(title_widget, 1) self._icon_widget = None - self._label_widget = label_widget + self._title_widget = title_widget self._main_layout = main_layout self._selected = False self._value = value @@ -245,13 +246,28 @@ class FilterValueItemsView(QtWidgets.QWidget): def __init__(self, parent): super().__init__(parent) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setObjectName("ScrollArea") + srcoll_viewport = scroll_area.viewport() + srcoll_viewport.setContentsMargins(0, 0, 0, 0) + scroll_area.setWidgetResizable(True) + scroll_area.setMinimumHeight(20) + scroll_area.setMaximumHeight(400) + + content_widget = QtWidgets.QWidget(scroll_area) + content_widget.setObjectName("ContentWidget") + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + + scroll_area.setWidget(content_widget) main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(scroll_area) self._multiselection = False - self._main_layout = main_layout + self._content_layout = content_layout self._last_selected_widget = None self._widgets_by_id = {} @@ -327,8 +343,8 @@ class FilterValueItemsView(QtWidgets.QWidget): return None def set_items(self, items: list[dict[str, Any]]): - while self._main_layout.count() > 0: - item = self._main_layout.takeAt(0) + while self._content_layout.count() > 0: + item = self._content_layout.takeAt(0) widget = item.widget() if widget is not None: widget.setVisible(False) @@ -338,7 +354,7 @@ class FilterValueItemsView(QtWidgets.QWidget): for item in items: widget_id = uuid.uuid4().hex - widget = FilterValueItemWidget( + widget = FilterValueItemButton( widget_id, item["value"], item.get("icon"), @@ -347,7 +363,7 @@ class FilterValueItemsView(QtWidgets.QWidget): ) widget.selected.connect(self._on_item_clicked) self._widgets_by_id[widget_id] = widget - self._main_layout.addWidget(widget) + self._content_layout.addWidget(widget) def _on_item_clicked(self, widget_id): widget = self._widgets_by_id.get(widget_id) @@ -385,6 +401,7 @@ class FilterValuePopup(QtWidgets.QWidget): wraper_layout = QtWidgets.QVBoxLayout(wrapper) wraper_layout.setContentsMargins(5, 5, 5, 5) + wraper_layout.setSpacing(5) wraper_layout.addWidget(text_input, 0) wraper_layout.addWidget(items_view, 0) From 2845ac39b47f17bf5e544be81c090ed3cdd0ec5d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 10:00:49 +0200 Subject: [PATCH 296/781] enhanced close button --- .../ayon_core/tools/loader/ui/search_bar.py | 57 +++++++++++++++---- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index d1946d4ab3..a9dea848bd 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -3,9 +3,9 @@ import uuid from dataclasses import dataclass from typing import Any, Optional -from qtpy import QtCore, QtWidgets +from qtpy import QtCore, QtWidgets, QtGui -from ayon_core.style import load_stylesheet +from ayon_core.style import load_stylesheet, get_objected_colors from ayon_core.tools.utils import ( get_qt_icon, SquareButton, @@ -34,7 +34,49 @@ class FilterDefinition: items: Optional[list[dict[str, str]]] = None +class CloseButton(SquareButton): + """Close button for search item display widget.""" + _icon = None + _hover_color = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.__class__._icon is None: + self.__class__._icon = get_qt_icon({ + "type": "material-symbols", + "name": "close", + "color": "#FFFFFF", + }) + if self.__class__._hover_color is None: + color = get_objected_colors("bg-view-selection-hover") + self.__class__._hover_color = color.get_qcolor() + + self.setIcon(self.__class__._icon) + + def paintEvent(self, event): + """Override paint event to draw a close button.""" + painter = QtWidgets.QStylePainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + option = QtWidgets.QStyleOptionButton() + self.initStyleOption(option) + icon = self.icon() + size = min(self.width(), self.height()) + rect = QtCore.QRect(0, 0, size, size) + rect.adjust(2, 2, -2, -2) + painter.setPen(QtCore.Qt.NoPen) + bg_color = QtCore.Qt.transparent + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color = self._hover_color + + painter.setBrush(bg_color) + painter.setClipRect(event.rect()) + painter.drawEllipse(rect) + rect.adjust(2, 2, -2, -2) + icon.paint(painter, rect) + + class SearchItemDisplayWidget(QtWidgets.QFrame): + """Widget displaying a set filter in the bar.""" close_requested = QtCore.Signal(str) edit_requested = QtCore.Signal(str) @@ -47,12 +89,6 @@ class SearchItemDisplayWidget(QtWidgets.QFrame): self._filter_def = filter_def - close_icon = get_qt_icon({ - "type": "material-symbols", - "name": "close", - "color": "#FFFFFF", - }) - title_widget = QtWidgets.QLabel(f"{filter_def.title}:", self) value_wrapper = QtWidgets.QWidget(self) @@ -63,10 +99,7 @@ class SearchItemDisplayWidget(QtWidgets.QFrame): value_layout.setContentsMargins(2, 2, 2, 2) value_layout.addWidget(value_widget) - close_btn = SquareButton(self) - close_btn.setObjectName("CloseButton") - close_btn.setIcon(close_icon) - close_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + close_btn = CloseButton(self) main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(4, 0, 0, 0) From 02d26f9d2d7b3551ec125276d792874214eb2693 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 10:01:00 +0200 Subject: [PATCH 297/781] avoid fake spacing --- client/ayon_core/tools/loader/ui/search_bar.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index a9dea848bd..27d65bee30 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -94,7 +94,7 @@ class SearchItemDisplayWidget(QtWidgets.QFrame): value_wrapper = QtWidgets.QWidget(self) value_widget = QtWidgets.QLabel(value_wrapper) value_widget.setObjectName("ValueWidget") - value_widget.setText(6 * " ") + value_widget.setText("") value_layout = QtWidgets.QVBoxLayout(value_wrapper) value_layout.setContentsMargins(2, 2, 2, 2) value_layout.addWidget(value_widget) @@ -116,20 +116,19 @@ class SearchItemDisplayWidget(QtWidgets.QFrame): def set_value(self, value: "str | list[str]"): text = "" + ellide = True if isinstance(value, str): text = value elif len(value) == 1: text = value[0] elif len(value) > 1: - text = str(len(value)) + ellide = False + text = f"Items: {len(value)}" - if len(text) > 9: + if ellide and len(text) > 9: text = text[:9] + "..." text = " " + text + " " - text_diff = 4 - len(text) - if text_diff > 0: - text = " " * text_diff + text self._value = copy.deepcopy(value) self._value_widget.setText(text) From 2da85dd1f4ac5c07182d12f79e2d9243f5547d6a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:08:22 +0200 Subject: [PATCH 298/781] added shadow --- client/ayon_core/style/style.css | 5 +++ .../ayon_core/tools/loader/ui/search_bar.py | 43 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index adf27a0e03..525cf28633 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -880,6 +880,11 @@ FiltersPopup #PopupWrapper, FilterValuePopup #PopupWrapper { background: {color:bg-inputs}; } +FiltersPopup #ShadowFrame, FilterValuePopup #ShadowFrame { + border-radius: 5px; + background: rgba(0, 0, 0, 0.5); +} + FilterItemButton, FilterValueItemButton { border-radius: 5px; background: transparent; diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 27d65bee30..ac0718f988 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -176,6 +176,9 @@ class FiltersPopup(QtWidgets.QWidget): self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + shadow_frame = QtWidgets.QFrame(self) + shadow_frame.setObjectName("ShadowFrame") + wrapper = QtWidgets.QWidget(self) wrapper.setObjectName("PopupWrapper") @@ -184,9 +187,12 @@ class FiltersPopup(QtWidgets.QWidget): wraper_layout.setSpacing(5) main_layout = QtWidgets.QHBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setContentsMargins(2, 2, 2, 2) main_layout.addWidget(wrapper) + shadow_frame.stackUnder(wrapper) + + self._shadow_frame = shadow_frame self._wrapper = wrapper self._wrapper_layout = wraper_layout self._preferred_width = None @@ -211,13 +217,26 @@ class FiltersPopup(QtWidgets.QWidget): for item in filter_items: widget = FilterItemButton(item, self._wrapper) widget.filter_requested.connect(self.filter_requested) - self._wrapper_layout.addWidget(widget) + self._wrapper_layout.addWidget(widget, 0) if self._wrapper_layout.count() == 0: empty_label = QtWidgets.QLabel( "No filters available...", self._wrapper ) - self._wrapper_layout.addWidget(empty_label) + self._wrapper_layout.addWidget(empty_label, 0) + + def showEvent(self, event): + super().showEvent(event) + self._update_shadow() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_shadow() + + def _update_shadow(self): + geo = self.geometry() + geo.moveTopLeft(QtCore.QPoint(0, 0)) + self._shadow_frame.setGeometry(geo) class FilterValueItemButton(BaseClickableFrame): @@ -422,6 +441,9 @@ class FilterValuePopup(QtWidgets.QWidget): self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + shadow_frame = QtWidgets.QFrame(self) + shadow_frame.setObjectName("ShadowFrame") + wrapper = QtWidgets.QWidget(self) wrapper.setObjectName("PopupWrapper") @@ -438,7 +460,7 @@ class FilterValuePopup(QtWidgets.QWidget): wraper_layout.addWidget(items_view, 0) main_layout = QtWidgets.QHBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setContentsMargins(2, 2, 2, 2) main_layout.addWidget(wrapper) text_input.textChanged.connect(self._text_changed) @@ -446,6 +468,9 @@ class FilterValuePopup(QtWidgets.QWidget): items_view.value_changed.connect(self._selection_changed) + shadow_frame.stackUnder(wrapper) + + self._shadow_frame = shadow_frame self._wrapper = wrapper self._wrapper_layout = wraper_layout self._text_input = text_input @@ -505,6 +530,7 @@ class FilterValuePopup(QtWidgets.QWidget): super().showEvent(event) if self._active_widget is not None: self._active_widget.setFocus() + self._update_shadow() def closeEvent(self, event): super().closeEvent(event) @@ -514,6 +540,15 @@ class FilterValuePopup(QtWidgets.QWidget): super().hideEvent(event) self.closed.emit(self._filter_name) + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_shadow() + + def _update_shadow(self): + geo = self.geometry() + geo.moveTopLeft(QtCore.QPoint(0, 0)) + self._shadow_frame.setGeometry(geo) + def get_value(self): """Get the value from the active widget.""" if self._active_widget is self._text_input: From fc9945959043f2aeba7116635afc5693ab5f9b13 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:08:34 +0200 Subject: [PATCH 299/781] make filters empty --- client/ayon_core/tools/loader/ui/window.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index f236a1e3ae..7a3d198ab6 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -506,6 +506,10 @@ class LoaderWindow(QtWidgets.QWidget): def _update_filters(self): project_name = self._selected_project_name + if not project_name: + self._search_bar.set_search_items([]) + return + product_type_items: list[ProductTypeItem] = [] status_items: list[StatusItem] = [] if project_name: From 7ae9fa9cf5b2dff79bd582b4aeaa4b51c77b741a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:14:10 +0200 Subject: [PATCH 300/781] add no items to select from item and stretch --- client/ayon_core/tools/loader/ui/search_bar.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index ac0718f988..c19a3a1e18 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -414,7 +414,14 @@ class FilterValueItemsView(QtWidgets.QWidget): ) widget.selected.connect(self._on_item_clicked) self._widgets_by_id[widget_id] = widget - self._content_layout.addWidget(widget) + self._content_layout.addWidget(widget, 0) + + if self._content_layout.count() == 0: + empty_label = QtWidgets.QLabel( + "No items to select from...", self + ) + self._content_layout.addWidget(empty_label, 0) + self._content_layout.addStretch(1) def _on_item_clicked(self, widget_id): widget = self._widgets_by_id.get(widget_id) From 9961f203c50b440b8d8a1146e9e7fccc72eb0949 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:31:00 +0200 Subject: [PATCH 301/781] handle edit requests --- client/ayon_core/tools/loader/ui/search_bar.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index c19a3a1e18..eba7d8f7cb 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -641,6 +641,7 @@ class FiltersBar(ClickableFrame): filter_def, parent=self._filters_widget, ) + item_widget.edit_requested.connect(self._on_filter_request) item_widget.close_requested.connect(self._on_item_close_requested) self._widgets_by_name[name] = item_widget idx = self._filters_layout.count() - 1 @@ -662,11 +663,12 @@ class FiltersBar(ClickableFrame): old_popup, self._filters_popup = self._filters_popup, filters_popup - self._show_popup(filters_popup) - + self._filter_value_popup.setVisible(False) old_popup.setVisible(False) old_popup.deleteLater() + self._show_popup(filters_popup) + def _on_filters_request(self): self._show_filters_popup() @@ -690,12 +692,14 @@ class FiltersBar(ClickableFrame): self._filter_value_popup, filter_value_popup ) - self._show_popup(filter_value_popup) - self._on_filter_value_change(filter_def.name) - old_popup.setVisible(False) old_popup.deleteLater() + self._filters_popup.setVisible(False) + + self._show_popup(filter_value_popup) + self._on_filter_value_change(filter_def.name) + def _show_popup(self, popup: QtWidgets.QWidget): """Show a popup widget.""" geo = self.geometry() From bbeaad95a0bd15d09b4b5e79e4a49d9f552e6f88 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:40:16 +0200 Subject: [PATCH 302/781] wrap filters widget --- .../ayon_core/tools/loader/ui/search_bar.py | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index eba7d8f7cb..ffd08e71a1 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -593,7 +593,13 @@ class FiltersBar(ClickableFrame): search_btn.setFlat(True) search_btn.setObjectName("SearchButton") - filters_widget = QtWidgets.QWidget(self) + # Wrapper is used to avoid squashing filters + # - the filters are positioned manually without layout + filters_wrap = QtWidgets.QWidget(self) + filters_wrap.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + # Widget where set filters are displayed + filters_widget = QtWidgets.QWidget(filters_wrap) filters_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) filters_layout = QtWidgets.QHBoxLayout(filters_widget) filters_layout.setContentsMargins(0, 0, 0, 0) @@ -603,12 +609,13 @@ class FiltersBar(ClickableFrame): main_layout.setContentsMargins(4, 4, 4, 4) main_layout.setSpacing(5) main_layout.addWidget(search_btn, 0) - main_layout.addWidget(filters_widget, 1) + main_layout.addWidget(filters_wrap, 1) search_btn.clicked.connect(self._on_filters_request) self.clicked.connect(self._on_clicked) self._search_btn = search_btn + self._filters_wrap = filters_wrap self._filters_widget = filters_widget self._filters_layout = filters_layout self._widgets_by_name = {} @@ -616,6 +623,14 @@ class FiltersBar(ClickableFrame): self._filters_popup = FiltersPopup(self) self._filter_value_popup = FilterValuePopup(self) + def showEvent(self, event): + super().showEvent(event) + self._update_filters_geo() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_filters_geo() + def set_search_items(self, filter_defs: list[FilterDefinition]): self._filter_defs_by_name = { filter_def.name: filter_def @@ -647,6 +662,13 @@ class FiltersBar(ClickableFrame): idx = self._filters_layout.count() - 1 self._filters_layout.insertWidget(idx, item_widget, 0) + def _update_filters_geo(self): + geo = self._filters_wrap.geometry() + geo.moveTopLeft(QtCore.QPoint(0, 0)) + geo.setWidth(geo.width() * 10) + + self._filters_widget.setGeometry(geo) + def _on_clicked(self): self._show_filters_popup() From d0889e33f65d6f1fb77e3e9b4df9093ff0d962fe Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:41:41 +0200 Subject: [PATCH 303/781] set arbitrary width --- client/ayon_core/tools/loader/ui/search_bar.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index ffd08e71a1..a48324bc53 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -665,7 +665,8 @@ class FiltersBar(ClickableFrame): def _update_filters_geo(self): geo = self._filters_wrap.geometry() geo.moveTopLeft(QtCore.QPoint(0, 0)) - geo.setWidth(geo.width() * 10) + # Arbitrary width + geo.setWidth(1000) self._filters_widget.setGeometry(geo) From 75154db1858395d9ccce5cd108c56a1e3148dc5d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:03:17 +0200 Subject: [PATCH 304/781] fix events propagation --- client/ayon_core/style/style.css | 3 +++ .../ayon_core/tools/loader/ui/search_bar.py | 13 ++++++------ client/ayon_core/tools/utils/widgets.py | 20 ++++++++++++------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 525cf28633..c123751ac4 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -904,6 +904,9 @@ FilterValueItemsView #ContentWidget { SearchItemDisplayWidget { border-radius: 5px; } +SearchItemDisplayWidget:hover { + background: {color:bg-buttons}; +} SearchItemDisplayWidget #ValueWidget { border-radius: 3px; background: {color:bg-buttons}; diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index a48324bc53..c234d7d47b 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -10,7 +10,6 @@ from ayon_core.tools.utils import ( get_qt_icon, SquareButton, BaseClickableFrame, - ClickableFrame, PixmapLabel, ) @@ -75,7 +74,7 @@ class CloseButton(SquareButton): icon.paint(painter, rect) -class SearchItemDisplayWidget(QtWidgets.QFrame): +class SearchItemDisplayWidget(BaseClickableFrame): """Widget displaying a set filter in the bar.""" close_requested = QtCore.Signal(str) edit_requested = QtCore.Signal(str) @@ -92,6 +91,7 @@ class SearchItemDisplayWidget(QtWidgets.QFrame): title_widget = QtWidgets.QLabel(f"{filter_def.title}:", self) value_wrapper = QtWidgets.QWidget(self) + value_wrapper.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) value_widget = QtWidgets.QLabel(value_wrapper) value_widget.setObjectName("ValueWidget") value_widget.setText("") @@ -139,7 +139,7 @@ class SearchItemDisplayWidget(QtWidgets.QFrame): def _on_remove_clicked(self): self.close_requested.emit(self._filter_def.name) - def _request_edit(self): + def _mouse_release_callback(self): self.edit_requested.emit(self._filter_def.name) @@ -174,7 +174,7 @@ class FiltersPopup(QtWidgets.QWidget): def __init__(self, parent): super().__init__(parent) self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) shadow_frame = QtWidgets.QFrame(self) shadow_frame.setObjectName("ShadowFrame") @@ -577,7 +577,7 @@ class FilterValuePopup(QtWidgets.QWidget): self.value_changed.emit(self._filter_name) -class FiltersBar(ClickableFrame): +class FiltersBar(BaseClickableFrame): filters_changed = QtCore.Signal() def __init__(self, parent): @@ -612,7 +612,6 @@ class FiltersBar(ClickableFrame): main_layout.addWidget(filters_wrap, 1) search_btn.clicked.connect(self._on_filters_request) - self.clicked.connect(self._on_clicked) self._search_btn = search_btn self._filters_wrap = filters_wrap @@ -670,7 +669,7 @@ class FiltersBar(ClickableFrame): self._filters_widget.setGeometry(geo) - def _on_clicked(self): + def _mouse_release_callback(self): self._show_filters_popup() def _show_filters_popup(self): diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 0cd6d68ab3..70e5e3a0e3 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -426,7 +426,7 @@ class BaseClickableFrame(QtWidgets.QFrame): Callback is defined by overriding `_mouse_release_callback`. """ def __init__(self, parent): - super(BaseClickableFrame, self).__init__(parent) + super().__init__(parent) self._mouse_pressed = False @@ -434,17 +434,23 @@ class BaseClickableFrame(QtWidgets.QFrame): pass def mousePressEvent(self, event): + super().mousePressEvent(event) + if event.isAccepted(): + return if event.button() == QtCore.Qt.LeftButton: self._mouse_pressed = True - super(BaseClickableFrame, self).mousePressEvent(event) + event.accept() def mouseReleaseEvent(self, event): - if self._mouse_pressed: - self._mouse_pressed = False - if self.rect().contains(event.pos()): - self._mouse_release_callback() + pressed, self._mouse_pressed = self._mouse_pressed, False + super().mouseReleaseEvent(event) + if event.isAccepted(): + return - super(BaseClickableFrame, self).mouseReleaseEvent(event) + accepted = pressed and self.rect().contains(event.pos()) + if accepted: + event.accept() + self._mouse_release_callback() class ClickableFrame(BaseClickableFrame): From e9981081aabf7974276342a58b4a2f02f612ad23 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:03:30 +0200 Subject: [PATCH 305/781] extend the size to 3000 --- client/ayon_core/tools/loader/ui/search_bar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index c234d7d47b..629212876a 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -665,7 +665,7 @@ class FiltersBar(BaseClickableFrame): geo = self._filters_wrap.geometry() geo.moveTopLeft(QtCore.QPoint(0, 0)) # Arbitrary width - geo.setWidth(1000) + geo.setWidth(3000) self._filters_widget.setGeometry(geo) From de15350fe67c1cb0e9fd5ac4ec3ea5b7204a9d94 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:38:13 +0200 Subject: [PATCH 306/781] emit filter changed signal --- client/ayon_core/tools/loader/ui/search_bar.py | 13 ++++++++++++- client/ayon_core/tools/loader/ui/window.py | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 629212876a..6852500f9f 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -578,7 +578,7 @@ class FilterValuePopup(QtWidgets.QWidget): class FiltersBar(BaseClickableFrame): - filters_changed = QtCore.Signal() + filter_changed = QtCore.Signal(str) def __init__(self, parent): super().__init__(parent) @@ -636,6 +636,16 @@ class FiltersBar(BaseClickableFrame): for filter_def in filter_defs } + def get_filter_value(self, name: str) -> Optional[Any]: + """Get the value of a filter by its name.""" + item_widget = self._widgets_by_name.get(name) + if item_widget is not None: + value = item_widget.get_value() + if isinstance(value, list) and len(value) == 0: + return None + return value + return None + def add_item(self, name: str): """Add a new item to the search bar. @@ -734,6 +744,7 @@ class FiltersBar(BaseClickableFrame): value = self._filter_value_popup.get_value() item_widget = self._widgets_by_name.get(name) item_widget.set_value(value) + self.filter_changed.emit(name) def _on_filter_value_closed(self, name): widget = self._widgets_by_name.get(name) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 7a3d198ab6..1e2904b667 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -255,7 +255,7 @@ class LoaderWindow(QtWidgets.QWidget): folders_filter_input.textChanged.connect( self._on_folder_filter_change ) - search_bar.filters_changed.connect(self._on_filters_change) + search_bar.filter_changed.connect(self._on_filter_change) # products_filter_input.textChanged.connect( # self._on_product_filter_change # ) @@ -443,8 +443,17 @@ class LoaderWindow(QtWidgets.QWidget): self._product_group_checkbox.isChecked() ) - def _on_filters_change(self): - pass + def _on_filter_change(self, filter_name): + if filter_name == "product_name": + self._products_widget.set_name_filter( + self._search_bar.get_filter_value("product_name") + ) + elif filter_name == "product_types": + product_types = self._search_bar.get_filter_value("product_types") + self._products_widget.set_product_type_filter(product_types) + elif filter_name == "statuses": + status_names = self._search_bar.get_filter_value("statuses") + self._products_widget.set_statuses_filter(status_names) def _on_tasks_selection_change(self, event): self._products_widget.set_tasks_filter(event["task_ids"]) From de6c4c434218320bf4530d2f54d394d95371d20a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:38:26 +0200 Subject: [PATCH 307/781] fix status names filter --- client/ayon_core/tools/loader/ui/products_delegates.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 8cece4687f..7a7ffe1b90 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -185,7 +185,9 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): widget.set_tasks_filter(task_ids) def set_statuses_filter(self, status_names): - self._statuses_filter = set(status_names) + if status_names is not None: + status_names = set(status_names) + self._statuses_filter = status_names for widget in self._editor_by_id.values(): widget.set_statuses_filter(status_names) From 7a03ba98f9cdabbbdea77f9bd3cdf6290a62acf5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:39:55 +0200 Subject: [PATCH 308/781] emi signal on fitler removement --- client/ayon_core/tools/loader/ui/search_bar.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 6852500f9f..cbecccb594 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -763,3 +763,4 @@ class FiltersBar(BaseClickableFrame): self._filters_layout.takeAt(idx) widget.setVisible(False) widget.deleteLater() + self.filter_changed.emit(name) From badfcbfaa5acf0028eeea5b537700ea183d9e33f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:41:49 +0200 Subject: [PATCH 309/781] get rid of super --- client/ayon_core/tools/utils/widgets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 70e5e3a0e3..c4862304f1 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -1159,7 +1159,7 @@ class SquareButton(QtWidgets.QPushButton): """ def __init__(self, *args, **kwargs): - super(SquareButton, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) sp = self.sizePolicy() sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) @@ -1168,17 +1168,17 @@ class SquareButton(QtWidgets.QPushButton): self._ideal_width = None def showEvent(self, event): - super(SquareButton, self).showEvent(event) + super().showEvent(event) self._ideal_width = self.height() self.updateGeometry() def resizeEvent(self, event): - super(SquareButton, self).resizeEvent(event) + super().resizeEvent(event) self._ideal_width = self.height() self.updateGeometry() def sizeHint(self): - sh = super(SquareButton, self).sizeHint() + sh = super().sizeHint() ideal_width = self._ideal_width if ideal_width is None: ideal_width = sh.height() From 05f2230f18f1e64a51569c07b0972648b4c75709 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:53:36 +0200 Subject: [PATCH 310/781] added border to bar --- client/ayon_core/style/style.css | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index c123751ac4..17f852b6e3 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -865,6 +865,7 @@ HintedLineEditButton { /* Launcher specific stylesheets */ FiltersBar { background: {color:bg-inputs}; + border: 1px solid {color:border}; border-radius: 5px; } From 0f56f06b2c26fcfc1045abe87177fd4f3ae06ed5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 14:35:25 +0200 Subject: [PATCH 311/781] fix None value handling --- client/ayon_core/tools/loader/ui/search_bar.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index cbecccb594..b2c0e73bb5 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -117,7 +117,9 @@ class SearchItemDisplayWidget(BaseClickableFrame): def set_value(self, value: "str | list[str]"): text = "" ellide = True - if isinstance(value, str): + if value is None: + pass + elif isinstance(value, str): text = value elif len(value) == 1: text = value[0] From 2bc62fc2a2de51474382ecbb65855503b4fb1c0b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 14:35:56 +0200 Subject: [PATCH 312/781] added helper buttons --- .../ayon_core/tools/loader/ui/search_bar.py | 61 +++++++++++++++++++ client/ayon_core/tools/loader/ui/window.py | 1 + 2 files changed, 62 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index b2c0e73bb5..bf04fec926 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -315,10 +315,30 @@ class FilterValueItemsView(QtWidgets.QWidget): scroll_area.setWidget(content_widget) + btns_widget = QtWidgets.QWidget(self) + btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + select_all_btn = QtWidgets.QPushButton("Select all", btns_widget) + clear_btn = QtWidgets.QPushButton("Clear", btns_widget) + swap_btn = QtWidgets.QPushButton("Swap", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(select_all_btn, 0) + btns_layout.addWidget(clear_btn, 0) + btns_layout.addWidget(swap_btn, 0) + main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(scroll_area) + main_layout.addWidget(btns_widget, 0) + select_all_btn.clicked.connect(self._on_select_all) + clear_btn.clicked.connect(self._on_clear_selection) + swap_btn.clicked.connect(self._on_swap_selection) + + self._btns_widget = btns_widget self._multiselection = False self._content_layout = content_layout self._last_selected_widget = None @@ -368,6 +388,11 @@ class FilterValueItemsView(QtWidgets.QWidget): def set_multiselection(self, multiselection: bool): self._multiselection = multiselection + if not self._widgets_by_id or not self._multiselection: + self._btns_widget.setVisible(False) + else: + self._btns_widget.setVisible(True) + if not self._widgets_by_id or self._multiselection: return @@ -422,9 +447,45 @@ class FilterValueItemsView(QtWidgets.QWidget): empty_label = QtWidgets.QLabel( "No items to select from...", self ) + self._btns_widget.setVisible(False) self._content_layout.addWidget(empty_label, 0) + else: + self._btns_widget.setVisible(self._multiselection) self._content_layout.addStretch(1) + def _on_select_all(self): + changed = False + for widget in self._widgets_by_id.values(): + if not widget.is_selected(): + changed = True + widget.set_selected(True) + if self._last_selected_widget is None: + self._last_selected_widget = widget + + if changed: + self.value_changed.emit() + + def _on_swap_selection(self): + self._last_selected_widget = None + for widget in self._widgets_by_id.values(): + selected = not widget.is_selected() + widget.set_selected(selected) + if selected and self._last_selected_widget is None: + self._last_selected_widget = widget + + self.value_changed.emit() + + def _on_clear_selection(self): + self._last_selected_widget = None + changed = False + for widget in self._widgets_by_id.values(): + if widget.is_selected(): + changed = True + widget.set_selected(False) + + if changed: + self.value_changed.emit() + def _on_item_clicked(self, widget_id): widget = self._widgets_by_id.get(widget_id) if widget is None: diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 1e2904b667..6f87e95375 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -451,6 +451,7 @@ class LoaderWindow(QtWidgets.QWidget): elif filter_name == "product_types": product_types = self._search_bar.get_filter_value("product_types") self._products_widget.set_product_type_filter(product_types) + elif filter_name == "statuses": status_names = self._search_bar.get_filter_value("statuses") self._products_widget.set_statuses_filter(status_names) From ff2645e335af1e9b1b91fe45d7c22c178c7ee64b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:27:21 +0200 Subject: [PATCH 313/781] remove commented code --- client/ayon_core/tools/loader/ui/window.py | 58 +++------------------- 1 file changed, 6 insertions(+), 52 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 6f87e95375..a8361caeab 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -18,11 +18,9 @@ from ayon_core.tools.loader.control import LoaderController from .folders_widget import LoaderFoldersWidget from .tasks_widget import LoaderTasksWidget from .products_widget import ProductsWidget -from .product_types_combo import ProductTypesCombobox from .product_group_dialog import ProductGroupDialog from .info_widget import InfoWidget from .repres_widget import RepresentationsWidget -from .statuses_combo import StatusesCombobox from .search_bar import FiltersBar, FilterDefinition @@ -186,15 +184,6 @@ class LoaderWindow(QtWidgets.QWidget): products_inputs_widget = QtWidgets.QWidget(products_wrap_widget) search_bar = FiltersBar(products_inputs_widget) - # - # products_filter_input = PlaceholderLineEdit(products_inputs_widget) - # products_filter_input.setPlaceholderText("Product name filter...") - # - # product_types_filter_combo = ProductTypesCombobox( - # controller, products_inputs_widget - # ) - # - # product_status_filter_combo = StatusesCombobox(controller, self) product_group_checkbox = QtWidgets.QCheckBox( "Enable grouping", products_inputs_widget) @@ -202,9 +191,6 @@ class LoaderWindow(QtWidgets.QWidget): products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget) products_inputs_layout.setContentsMargins(0, 0, 0, 0) - # products_inputs_layout.addWidget(products_filter_input, 1) - # products_inputs_layout.addWidget(product_types_filter_combo, 1) - # products_inputs_layout.addWidget(product_status_filter_combo, 1) products_inputs_layout.addWidget(search_bar, 1) products_inputs_layout.addWidget(product_group_checkbox, 0) @@ -256,15 +242,6 @@ class LoaderWindow(QtWidgets.QWidget): self._on_folder_filter_change ) search_bar.filter_changed.connect(self._on_filter_change) - # products_filter_input.textChanged.connect( - # self._on_product_filter_change - # ) - # product_types_filter_combo.value_changed.connect( - # self._on_product_type_filter_change - # ) - # product_status_filter_combo.value_changed.connect( - # self._on_status_filter_change - # ) product_group_checkbox.stateChanged.connect( self._on_product_group_change ) @@ -323,9 +300,6 @@ class LoaderWindow(QtWidgets.QWidget): self._tasks_widget = tasks_widget self._search_bar = search_bar - # self._products_filter_input = products_filter_input - # self._product_types_filter_combo = product_types_filter_combo - # self._product_status_filter_combo = product_status_filter_combo self._product_group_checkbox = product_group_checkbox self._products_widget = products_widget @@ -364,12 +338,6 @@ class LoaderWindow(QtWidgets.QWidget): def closeEvent(self, event): super().closeEvent(event) - # ( - # self - # ._product_types_filter_combo - # .reset_product_types_filter_on_refresh() - # ) - self._reset_on_show = True def keyPressEvent(self, event): @@ -459,17 +427,6 @@ class LoaderWindow(QtWidgets.QWidget): def _on_tasks_selection_change(self, event): self._products_widget.set_tasks_filter(event["task_ids"]) - # def _on_product_filter_change(self, text): - # self._products_widget.set_name_filter(text) - # - # def _on_status_filter_change(self): - # status_names = self._product_status_filter_combo.get_value() - # self._products_widget.set_statuses_filter(status_names) - # - # def _on_product_type_filter_change(self): - # product_types = self._product_types_filter_combo.get_value() - # self._products_widget.set_product_type_filter(product_types) - def _on_merged_products_selection_change(self): items = self._products_widget.get_selected_merged_products() self._folders_widget.set_merged_products_selection(items) @@ -520,15 +477,12 @@ class LoaderWindow(QtWidgets.QWidget): self._search_bar.set_search_items([]) return - product_type_items: list[ProductTypeItem] = [] - status_items: list[StatusItem] = [] - if project_name: - product_type_items = self._controller.get_product_type_items( - project_name - ) - status_items = self._controller.get_project_status_items( - project_name - ) + product_type_items: list[ProductTypeItem] = ( + self._controller.get_product_type_items(project_name) + ) + status_items: list[StatusItem] = ( + self._controller.get_project_status_items(project_name) + ) filter_product_type_items = [ { From 93b5ea5c31fe1bb56ee1049dbc885f0b5546a7d1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:28:01 +0200 Subject: [PATCH 314/781] implemented helper functions to get tags from project --- .../ayon_core/tools/common_models/__init__.py | 2 ++ .../tools/common_models/hierarchy.py | 28 ++++++++++++++++ .../ayon_core/tools/common_models/projects.py | 27 ++++++++++++++++ client/ayon_core/tools/loader/abstract.py | 32 ++++++++++++++++++- client/ayon_core/tools/loader/control.py | 11 +++++++ 5 files changed, 99 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/common_models/__init__.py b/client/ayon_core/tools/common_models/__init__.py index ece189fdc6..ec69e20b64 100644 --- a/client/ayon_core/tools/common_models/__init__.py +++ b/client/ayon_core/tools/common_models/__init__.py @@ -2,6 +2,7 @@ from .cache import CacheItem, NestedCacheItem from .projects import ( + TagItem, StatusItem, StatusStates, ProjectItem, @@ -25,6 +26,7 @@ __all__ = ( "CacheItem", "NestedCacheItem", + "TagItem", "StatusItem", "StatusStates", "ProjectItem", diff --git a/client/ayon_core/tools/common_models/hierarchy.py b/client/ayon_core/tools/common_models/hierarchy.py index 891eb80960..6b861d8fa5 100644 --- a/client/ayon_core/tools/common_models/hierarchy.py +++ b/client/ayon_core/tools/common_models/hierarchy.py @@ -217,6 +217,8 @@ class HierarchyModel(object): lifetime = 60 # A minute def __init__(self, controller): + self._tags_by_entity_type = NestedCacheItem( + levels=1, default_factory=dict, lifetime=self.lifetime) self._folders_items = NestedCacheItem( levels=1, default_factory=dict, lifetime=self.lifetime) self._folders_by_id = NestedCacheItem( @@ -235,6 +237,7 @@ class HierarchyModel(object): self._controller = controller def reset(self): + self._tags_by_entity_type.reset() self._folders_items.reset() self._folders_by_id.reset() @@ -514,6 +517,31 @@ class HierarchyModel(object): return output + def get_available_tags_by_entity_type( + self, project_name: str + ) -> dict[str, list[str]]: + """Get available tags for all entity types in a project.""" + cache = self._tags_by_entity_type.get(project_name) + if not cache.is_valid: + tags = None + if project_name: + response = ayon_api.get(f"projects/{project_name}/tags") + if response.status_code == 200: + tags = response.data + + # Fake empty tags + if tags is None: + tags = { + "folders": [], + "tasks": [], + "products": [], + "versions": [], + "representations": [], + "workfiles": [] + } + cache.update_data(tags) + return cache.get_data() + @contextlib.contextmanager def _folder_refresh_event_manager(self, project_name, sender): self._folders_refreshing.add(project_name) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 7ec941e6bd..8f3135b2d5 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import contextlib from abc import ABC, abstractmethod from typing import Dict, Any +from dataclasses import dataclass import ayon_api @@ -72,6 +75,14 @@ class StatusItem: ) +@dataclass +class TagItem: + """Tag definition set on project anatomy.""" + name: str + color: str + + + class FolderTypeItem: """Item representing folder type of project. @@ -288,6 +299,22 @@ class ProjectsModel(object): project_cache.update_data(entity) return project_cache.get_data() + def get_project_anatomy_tags(self, project_name: str) -> list[TagItem]: + """Get project anatomy tags. + + Args: + project_name (str): Project name. + + Returns: + list[TagItem]: Tag definitions. + + """ + project_entity = self.get_project_entity(project_name) + return [ + TagItem(tag["name"], tag["color"]) + for tag in project_entity["tags"] + ] + def get_project_status_items(self, project_name, sender): """Get project status items. diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index d0d7cd430b..8ae82c7e02 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -1,3 +1,4 @@ +from __future__ import annotations from abc import ABC, abstractmethod from typing import List @@ -6,6 +7,7 @@ from ayon_core.lib.attribute_definitions import ( serialize_attr_defs, deserialize_attr_defs, ) +from ayon_core.tools.common_models import TagItem class ProductTypeItem: @@ -517,8 +519,21 @@ class FrontendLoaderController(_BaseLoaderController): Returns: list[ProjectItem]: List of project items. - """ + """ + pass + + @abstractmethod + def get_project_anatomy_tags(self, project_name: str) -> list[TagItem]: + """Tag items defined on project anatomy. + + Args: + project_name (str): Project name. + + Returns: + list[TagItem]: Tag definition items. + + """ pass @abstractmethod @@ -590,6 +605,21 @@ class FrontendLoaderController(_BaseLoaderController): """ pass + @abstractmethod + def get_available_tags_by_entity_type( + self, project_name: str + ) -> dict[str, list[str]]: + """Get available tags by entity type. + + Args: + project_name (str): Project name. + + Returns: + dict[str, list[str]]: Available tags by entity type. + + """ + pass + @abstractmethod def get_project_status_items(self, project_name, sender=None): """Items for all projects available on server. diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index b3a80b34d4..95f48b3519 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -13,6 +13,7 @@ from ayon_core.tools.common_models import ( ProjectsModel, HierarchyModel, ThumbnailsModel, + TagItem, ) from .abstract import ( @@ -223,6 +224,16 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): output[folder_id] = label return output + def get_available_tags_by_entity_type( + self, project_name: str + ) -> dict[str, list[str]]: + return self._hierarchy_model.get_available_tags_by_entity_type( + project_name + ) + + def get_project_anatomy_tags(self, project_name: str) -> list[TagItem]: + return self._projects_model.get_project_anatomy_tags(project_name) + def get_product_items(self, project_name, folder_ids, sender=None): return self._products_model.get_product_items( project_name, folder_ids, sender) From fb6386cb8f13078247350800f423b6d73471a207 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:53:51 +0200 Subject: [PATCH 315/781] added tags to version item --- client/ayon_core/tools/loader/abstract.py | 4 ++++ client/ayon_core/tools/loader/models/products.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 8ae82c7e02..09d900074c 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -115,6 +115,7 @@ class VersionItem: published_time (Union[str, None]): Published time in format '%Y%m%dT%H%M%SZ'. status (Union[str, None]): Status name. + tags (Union[list[str], None]): Tags. author (Union[str, None]): Author. frame_range (Union[str, None]): Frame range. duration (Union[int, None]): Duration. @@ -133,6 +134,7 @@ class VersionItem: task_id, thumbnail_id, published_time, + tags, author, status, frame_range, @@ -150,6 +152,7 @@ class VersionItem: self.is_hero = is_hero self.published_time = published_time self.author = author + self.tags = tags self.status = status self.frame_range = frame_range self.duration = duration @@ -210,6 +213,7 @@ class VersionItem: "is_hero": self.is_hero, "published_time": self.published_time, "author": self.author, + "tags": self.tags, "status": self.status, "frame_range": self.frame_range, "duration": self.duration, diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 34acc0550c..a8dd269bc3 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -19,6 +19,7 @@ PRODUCTS_MODEL_SENDER = "products.model" def version_item_from_entity(version): version_attribs = version["attrib"] + tags = version["tags"] frame_start = version_attribs.get("frameStart") frame_end = version_attribs.get("frameEnd") handle_start = version_attribs.get("handleStart") @@ -59,6 +60,7 @@ def version_item_from_entity(version): thumbnail_id=version["thumbnailId"], published_time=published_time, author=author, + tags=tags, status=version["status"], frame_range=frame_range, duration=duration, From 715031a9b55e6ceb97b74ebc86435d8d8dd37ca9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:55:02 +0200 Subject: [PATCH 316/781] added version tags filter --- .../tools/loader/ui/products_delegates.py | 60 +++++++++++++++++-- .../tools/loader/ui/products_model.py | 6 ++ .../tools/loader/ui/products_widget.py | 23 ++++++- client/ayon_core/tools/loader/ui/window.py | 27 +++++++++ 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 7a7ffe1b90..2e98d14253 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import numbers import uuid from typing import Dict @@ -18,14 +20,22 @@ from .products_model import ( SYNC_REMOTE_SITE_AVAILABILITY, ) -STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 +VERSION_ID_ROLE = QtCore.Qt.UserRole + 1 TASK_ID_ROLE = QtCore.Qt.UserRole + 2 +STATUS_NAME_ROLE = QtCore.Qt.UserRole + 3 class VersionsModel(QtGui.QStandardItemModel): def __init__(self): super().__init__() self._items_by_id = {} + self._tags_by_version_id = {} + + def get_version_tags(self, version_id: str) -> set[str]: + tags = self._tags_by_version_id.get(version_id) + if tags is None: + tags = set() + return tags def update_versions(self, version_items): version_ids = { @@ -39,6 +49,7 @@ class VersionsModel(QtGui.QStandardItemModel): item = self._items_by_id.pop(item_id) root_item.removeRow(item.row()) + tags_by_version_id = {} for idx, version_item in enumerate(version_items): version_id = version_item.version_id @@ -48,11 +59,14 @@ class VersionsModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem(label) item.setData(version_id, QtCore.Qt.UserRole) self._items_by_id[version_id] = item + item.setData(version_id, VERSION_ID_ROLE) item.setData(version_item.status, STATUS_NAME_ROLE) item.setData(version_item.task_id, TASK_ID_ROLE) + tags_by_version_id[version_id] = set(version_item.tags) if item.row() != idx: root_item.insertRow(idx, item) + self._tags_by_version_id = tags_by_version_id class VersionsFilterModel(QtCore.QSortFilterProxyModel): @@ -60,22 +74,39 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): super().__init__() self._status_filter = None self._task_ids_filter = None + self._tags_filter = None def filterAcceptsRow(self, row, parent): + index = None if self._status_filter is not None: if not self._status_filter: return False - - index = self.sourceModel().index(row, 0, parent) + if index is None: + index = self.sourceModel().index(row, 0, parent) status = index.data(STATUS_NAME_ROLE) if status not in self._status_filter: return False if self._task_ids_filter: - index = self.sourceModel().index(row, 0, parent) + if index is None: + index = self.sourceModel().index(row, 0, parent) task_id = index.data(TASK_ID_ROLE) if task_id not in self._task_ids_filter: return False + + if self._tags_filter is not None: + if not self._tags_filter: + return False + + if index is None: + index = self.sourceModel().index(row, 0, parent) + version_id = index.data(VERSION_ID_ROLE) + + model = self.sourceModel() + tags = model.get_version_tags(version_id) + if not tags & self._tags_filter: + return False + return True def set_tasks_filter(self, task_ids): @@ -90,6 +121,12 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): self._status_filter = status_names self.invalidateFilter() + def set_tags_filter(self, tags): + if self._tags_filter == tags: + return + self._tags_filter = tags + self.invalidateFilter() + class VersionComboBox(QtWidgets.QComboBox): value_changed = QtCore.Signal(str, str) @@ -130,6 +167,13 @@ class VersionComboBox(QtWidgets.QComboBox): if self.currentIndex() != 0: self.setCurrentIndex(0) + def set_tags_filter(self, tags): + self._proxy_model.set_tags_filter(tags) + if self.count() == 0: + return + if self.currentIndex() != 0: + self.setCurrentIndex(0) + def all_versions_filtered_out(self): if self._items_by_id: return self.count() == 0 @@ -173,6 +217,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): self._editor_by_id: Dict[str, VersionComboBox] = {} self._task_ids_filter = None self._statuses_filter = None + self._tags_filter = None def displayText(self, value, locale): if not isinstance(value, numbers.Integral): @@ -191,6 +236,13 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): for widget in self._editor_by_id.values(): widget.set_statuses_filter(status_names) + def set_tags_filter(self, tags): + if tags is not None: + tags = set(tags) + self._tags_filter = tags + for widget in self._editor_by_id.values(): + widget.set_tags_filter(tags) + def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) if fg_color: diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index cebae9bca7..06af731f8f 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -41,6 +41,7 @@ SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30 SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 32 +VERSION_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 33 class ProductsModel(QtGui.QStandardItemModel): @@ -422,6 +423,10 @@ class ProductsModel(QtGui.QStandardItemModel): version_item.status for version_item in product_item.version_items.values() } + tags = set() + for version_item in product_item.version_items.values(): + tags |= set(version_item.tags) + if model_item is None: product_id = product_item.product_id model_item = QtGui.QStandardItem(product_item.product_name) @@ -440,6 +445,7 @@ class ProductsModel(QtGui.QStandardItemModel): self._items_by_id[product_id] = model_item model_item.setData("|".join(statuses), STATUS_NAME_FILTER_ROLE) + model_item.setData("|".join(tags), VERSION_TAGS_FILTER_ROLE) model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE) in_scene = 1 if product_item.product_in_scene else 0 model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 94d95b9026..6c18cdc1f9 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -26,6 +26,7 @@ from .products_model import ( VERSION_STATUS_ICON_ROLE, VERSION_THUMBNAIL_ID_ROLE, STATUS_NAME_FILTER_ROLE, + VERSION_TAGS_FILTER_ROLE, ) from .products_delegates import ( VersionDelegate, @@ -41,6 +42,7 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._product_type_filters = None self._statuses_filter = None + self._tags_filter = None self._task_ids_filter = None self._ascending_sort = True @@ -67,6 +69,12 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._statuses_filter = statuses_filter self.invalidateFilter() + def set_version_tags_filter(self, tags): + if self._tags_filter == tags: + return + self._tags_filter = tags + self.invalidateFilter() + def filterAcceptsRow(self, source_row, source_parent): source_model = self.sourceModel() index = source_model.index(source_row, 0, source_parent) @@ -83,6 +91,11 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): ): return False + if not self._accept_row_by_role_value( + index, self._tags_filter, VERSION_TAGS_FILTER_ROLE + ): + return False + return super().filterAcceptsRow(source_row, source_parent) def _accept_task_ids_filter(self, index): @@ -102,9 +115,9 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): if not filter_value: return False - status_s = index.data(role) - for status in status_s.split("|"): - if status in filter_value: + value_s = index.data(role) + for value in value_s.split("|"): + if value in filter_value: return True return False @@ -290,6 +303,10 @@ class ProductsWidget(QtWidgets.QWidget): self._version_delegate.set_statuses_filter(status_names) self._products_proxy_model.set_statuses_filter(status_names) + def set_version_tags_filter(self, tags): + self._version_delegate.set_tags_filter(tags) + self._products_proxy_model.set_version_tags_filter(tags) + def set_product_type_filter(self, product_type_filters): """ diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index a8361caeab..a5f74c2c6f 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -424,6 +424,10 @@ class LoaderWindow(QtWidgets.QWidget): status_names = self._search_bar.get_filter_value("statuses") self._products_widget.set_statuses_filter(status_names) + elif filter_name == "version_tags": + version_tags = self._search_bar.get_filter_value("version_tags") + self._products_widget.set_version_tags_filter(version_tags) + def _on_tasks_selection_change(self, event): self._products_widget.set_tasks_filter(event["task_ids"]) @@ -483,6 +487,14 @@ class LoaderWindow(QtWidgets.QWidget): status_items: list[StatusItem] = ( self._controller.get_project_status_items(project_name) ) + tags_by_entity_type = ( + self._controller.get_available_tags_by_entity_type(project_name) + ) + tag_items = self._controller.get_project_anatomy_tags(project_name) + tag_color_by_name = { + tag_item.name: tag_item.color + for tag_item in tag_items + } filter_product_type_items = [ { @@ -503,6 +515,14 @@ class LoaderWindow(QtWidgets.QWidget): } for status_item in status_items ] + version_tags = [ + { + "value": tag_name, + "color": tag_color_by_name.get(tag_name), + } + for tag_name in tags_by_entity_type.get("versions") or [] + ] + self._search_bar.set_search_items([ FilterDefinition( @@ -527,6 +547,13 @@ class LoaderWindow(QtWidgets.QWidget): icon=None, items=filter_status_items, ), + FilterDefinition( + name="version_tags", + title="Version tags", + filter_type="list", + icon=None, + items=version_tags, + ), ]) def _on_folders_selection_changed(self, event): From aec2ee828cdd3686d476b53d761f3cd152b39133 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 Jun 2025 19:23:02 +0200 Subject: [PATCH 317/781] Raise dedicated `EntityResolutionError` --- .../pipeline/workfile/workfile_template_builder.py | 6 +++++- 1 file changed, 5 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 f756190991..152421f283 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -61,6 +61,10 @@ from ayon_core.pipeline.create import ( _NOT_SET = object() +class EntityResolutionError(Exception): + """Exception raised when entity URI resolution fails.""" + + def resolve_entity_uri(entity_uri: str) -> str: """Resolve AYON entity URI to a filesystem path for local system.""" response = ayon_api.post( @@ -76,7 +80,7 @@ def resolve_entity_uri(entity_uri: str) -> str: entities = response.data[0]["entities"] if len(entities) != 1: - raise RuntimeError( + raise EntityResolutionError( f"Unable to resolve AYON entity URI '{entity_uri}' to a " f"single filepath. Received data: {response.data}" ) From 7b5ff1394c1684a9a1740ece36e5d343adf853b9 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 Jun 2025 19:30:48 +0200 Subject: [PATCH 318/781] Do not raise error on non-existing path from `resolve_template_path` to match backwards compatible behavior - feedback by @iLLiCiTiT --- .../workfile/workfile_template_builder.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 152421f283..74dc503cac 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -876,6 +876,12 @@ class AbstractTemplateBuilder(ABC): ).format(host_name.title())) resolved_path = self.resolve_template_path(path) + if not resolved_path: + raise TemplateNotFound( + f"Template path '{path}' does not resolve to a valid existing " + "template file on disk." + ) + self.log.info(f"Found template at: '{resolved_path}'") # switch to remove placeholders after they are used @@ -923,7 +929,7 @@ class AbstractTemplateBuilder(ABC): # We need to resolve it to a filesystem path resolved_path = resolve_entity_uri(path) if not os.path.exists(resolved_path): - raise TemplateNotFound( + self.log.warning( "Template found in AYON settings for task '{}' with host " "'{}' does not resolve AYON entity URI '{}' " "to an existing file on disk: '{}'".format( @@ -978,19 +984,22 @@ class AbstractTemplateBuilder(ABC): solved_path = os.path.normpath(solved_path) if not os.path.exists(solved_path): - raise TemplateNotFound( + self.log.warning( "Template found in AYON settings for task '{}' with host " "'{}' does not exists. (Not found : {})".format( - task_name, host_name, solved_path)) + task_name, host_name, solved_path) + ) + return path result = StringTemplate.format_template(path, fill_data) if result.solved: path = result.normalized() return path - raise TemplateNotFound( + self.log.warning( f"Unable to resolve template path: '{path}'" ) + return path def emit_event(self, topic, data=None, source=None) -> Event: return self._event_system.emit(topic, data, source) From 706d72f045e179e9c633a4015719d8346a722ba0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 Jun 2025 19:33:36 +0200 Subject: [PATCH 319/781] Check file existence for error check --- 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 74dc503cac..193fbeca2a 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -876,7 +876,7 @@ class AbstractTemplateBuilder(ABC): ).format(host_name.title())) resolved_path = self.resolve_template_path(path) - if not resolved_path: + if not resolved_path or not os.path.exists(resolved_path): raise TemplateNotFound( f"Template path '{path}' does not resolve to a valid existing " "template file on disk." From b523aa6b9f8286068268a88314377faca628d235 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 Jun 2025 19:36:37 +0200 Subject: [PATCH 320/781] Move warning out of `resolve_template_path` function so that inherited function,e.g. like in ayon-houdini can continue without having tons of logs that are irrelevant if houdini specific logic could still resolve it. --- .../workfile/workfile_template_builder.py | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 193fbeca2a..ef909c9503 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -878,8 +878,9 @@ class AbstractTemplateBuilder(ABC): resolved_path = self.resolve_template_path(path) if not resolved_path or not os.path.exists(resolved_path): raise TemplateNotFound( - f"Template path '{path}' does not resolve to a valid existing " - "template file on disk." + "Template file found in AYON settings for task '{}' with host " + "'{}' does not exists. (Not found : {})".format( + task_name, host_name, resolved_path) ) self.log.info(f"Found template at: '{resolved_path}'") @@ -928,17 +929,6 @@ class AbstractTemplateBuilder(ABC): # This is a special case where the path is an AYON entity URI # We need to resolve it to a filesystem path resolved_path = resolve_entity_uri(path) - if not os.path.exists(resolved_path): - self.log.warning( - "Template found in AYON settings for task '{}' with host " - "'{}' does not resolve AYON entity URI '{}' " - "to an existing file on disk: '{}'".format( - self.current_task_name, - self.host_name, - path, - resolved_path, - ) - ) return resolved_path # If the path is set and it's found on disk, return it directly @@ -984,11 +974,6 @@ class AbstractTemplateBuilder(ABC): solved_path = os.path.normpath(solved_path) if not os.path.exists(solved_path): - self.log.warning( - "Template found in AYON settings for task '{}' with host " - "'{}' does not exists. (Not found : {})".format( - task_name, host_name, solved_path) - ) return path result = StringTemplate.format_template(path, fill_data) @@ -996,9 +981,6 @@ class AbstractTemplateBuilder(ABC): path = result.normalized() return path - self.log.warning( - f"Unable to resolve template path: '{path}'" - ) return path def emit_event(self, topic, data=None, source=None) -> Event: From 16b56f7579b0b22f57db27e723f5d38761ee8404 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 Jun 2025 19:37:16 +0200 Subject: [PATCH 321/781] Remove unused variables --- client/ayon_core/pipeline/workfile/workfile_template_builder.py | 2 -- 1 file changed, 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 ef909c9503..de2eb9ce5c 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -940,8 +940,6 @@ class AbstractTemplateBuilder(ABC): if "{" in path or "<" in path: # Resolve keys through anatomy project_name = self.project_name - task_name = self.current_task_name - host_name = self.host_name # Try to fill path with environments and anatomy roots anatomy = Anatomy(project_name) From 40d5efc9a5bf2e1ce14ff07b20fcf1b3b2f42ecf Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 Jun 2025 20:08:29 +0200 Subject: [PATCH 322/781] Fix typo --- 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 de2eb9ce5c..d62bb49227 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -8,7 +8,7 @@ targeted by task types and names. Placeholders are created using placeholder plugins which should care about logic and data of placeholder items. 'PlaceholderItem' is used to keep track -about it's progress. +about its progress. """ import os 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 323/781] 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 324/781] 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 d681c87aadbc14404511fd39996d9ac5ce2a3c06 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 12 Jun 2025 13:49:46 +0200 Subject: [PATCH 325/781] Restructure `resolve_template_path` --- .../workfile/workfile_template_builder.py | 64 ++++++++++--------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index d62bb49227..8cea7de86b 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -935,51 +935,53 @@ class AbstractTemplateBuilder(ABC): if path and os.path.exists(path): return path - # Otherwise assume a path with template keys, we do a very mundane - # check whether `{` or `<` is present in the path. - if "{" in path or "<" in path: - # Resolve keys through anatomy - project_name = self.project_name + # We may have path for another platform, like C:/path/to/file + # or a path with template keys, like {project[code]} or both. + # Try to fill path with environments and anatomy roots + project_name = self.project_name + anatomy = Anatomy(project_name) - # Try to fill path with environments and anatomy roots - anatomy = Anatomy(project_name) + # Simple check whether the path contains any template keys + if "{" in path: fill_data = { key: value for key, value in os.environ.items() } - fill_data["root"] = anatomy.roots fill_data["project"] = { "name": project_name, "code": anatomy.project_code, } - # Recursively remap anatomy paths - while True: - try: - solved_path = anatomy.path_remapper(path) - except KeyError as missing_key: - raise KeyError( - f"Could not solve key '{missing_key}'" - f" in template path '{path}'" - ) - - if solved_path is None: - solved_path = path - if solved_path == path: - break - path = solved_path - - solved_path = os.path.normpath(solved_path) - if not os.path.exists(solved_path): + # Format the template using local fill data + result = StringTemplate.format_template(path, fill_data) + if not result.solved: return path - result = StringTemplate.format_template(path, fill_data) - if result.solved: - path = result.normalized() - return path + path = result.normalized() + if os.path.exists(path): + return path - return path + # If the path were set in settings using a Windows path and we + # are now on a Linux system, we try to convert the solved path to + # the current platform. + while True: + try: + solved_path = anatomy.path_remapper(path) + except KeyError as missing_key: + raise KeyError( + f"Could not solve key '{missing_key}'" + f" in template path '{path}'" + ) + + if solved_path is None: + solved_path = path + if solved_path == path: + break + path = solved_path + + solved_path = os.path.normpath(solved_path) + return solved_path def emit_event(self, topic, data=None, source=None) -> Event: return self._event_system.emit(topic, data, source) From 9b7be088907faccdee3faa3ed632e3e0794902c4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 17:59:28 +0200 Subject: [PATCH 326/781] more descriptiove naming 'version_tags' --- .../tools/loader/ui/products_delegates.py | 36 +++++++++---------- .../tools/loader/ui/products_model.py | 6 ++-- .../tools/loader/ui/products_widget.py | 14 ++++---- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 2e98d14253..8190fce337 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -29,10 +29,10 @@ class VersionsModel(QtGui.QStandardItemModel): def __init__(self): super().__init__() self._items_by_id = {} - self._tags_by_version_id = {} + self._version_tags_by_version_id = {} def get_version_tags(self, version_id: str) -> set[str]: - tags = self._tags_by_version_id.get(version_id) + tags = self._version_tags_by_version_id.get(version_id) if tags is None: tags = set() return tags @@ -49,7 +49,7 @@ class VersionsModel(QtGui.QStandardItemModel): item = self._items_by_id.pop(item_id) root_item.removeRow(item.row()) - tags_by_version_id = {} + version_tags_by_version_id = {} for idx, version_item in enumerate(version_items): version_id = version_item.version_id @@ -62,11 +62,11 @@ class VersionsModel(QtGui.QStandardItemModel): item.setData(version_id, VERSION_ID_ROLE) item.setData(version_item.status, STATUS_NAME_ROLE) item.setData(version_item.task_id, TASK_ID_ROLE) - tags_by_version_id[version_id] = set(version_item.tags) + version_tags_by_version_id[version_id] = set(version_item.tags) if item.row() != idx: root_item.insertRow(idx, item) - self._tags_by_version_id = tags_by_version_id + self._version_tags_by_version_id = version_tags_by_version_id class VersionsFilterModel(QtCore.QSortFilterProxyModel): @@ -74,7 +74,7 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): super().__init__() self._status_filter = None self._task_ids_filter = None - self._tags_filter = None + self._version_tags_filter = None def filterAcceptsRow(self, row, parent): index = None @@ -94,8 +94,8 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): if task_id not in self._task_ids_filter: return False - if self._tags_filter is not None: - if not self._tags_filter: + if self._version_tags_filter is not None: + if not self._version_tags_filter: return False if index is None: @@ -104,7 +104,7 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): model = self.sourceModel() tags = model.get_version_tags(version_id) - if not tags & self._tags_filter: + if not tags & self._version_tags_filter: return False return True @@ -121,10 +121,10 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): self._status_filter = status_names self.invalidateFilter() - def set_tags_filter(self, tags): - if self._tags_filter == tags: + def set_version_tags_filter(self, tags): + if self._version_tags_filter == tags: return - self._tags_filter = tags + self._version_tags_filter = tags self.invalidateFilter() @@ -167,8 +167,8 @@ class VersionComboBox(QtWidgets.QComboBox): if self.currentIndex() != 0: self.setCurrentIndex(0) - def set_tags_filter(self, tags): - self._proxy_model.set_tags_filter(tags) + def set_version_tags_filter(self, tags): + self._proxy_model.set_version_tags_filter(tags) if self.count() == 0: return if self.currentIndex() != 0: @@ -217,7 +217,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): self._editor_by_id: Dict[str, VersionComboBox] = {} self._task_ids_filter = None self._statuses_filter = None - self._tags_filter = None + self._version_tags_filter = None def displayText(self, value, locale): if not isinstance(value, numbers.Integral): @@ -236,12 +236,12 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): for widget in self._editor_by_id.values(): widget.set_statuses_filter(status_names) - def set_tags_filter(self, tags): + def set_version_tags_filter(self, tags): if tags is not None: tags = set(tags) - self._tags_filter = tags + self._version_tags_filter = tags for widget in self._editor_by_id.values(): - widget.set_tags_filter(tags) + widget.set_version_tags_filter(tags) def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 06af731f8f..d3bf6b2e38 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -423,9 +423,9 @@ class ProductsModel(QtGui.QStandardItemModel): version_item.status for version_item in product_item.version_items.values() } - tags = set() + version_tags = set() for version_item in product_item.version_items.values(): - tags |= set(version_item.tags) + version_tags |= set(version_item.tags) if model_item is None: product_id = product_item.product_id @@ -445,7 +445,7 @@ class ProductsModel(QtGui.QStandardItemModel): self._items_by_id[product_id] = model_item model_item.setData("|".join(statuses), STATUS_NAME_FILTER_ROLE) - model_item.setData("|".join(tags), VERSION_TAGS_FILTER_ROLE) + model_item.setData("|".join(version_tags), VERSION_TAGS_FILTER_ROLE) model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE) in_scene = 1 if product_item.product_in_scene else 0 model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 6c18cdc1f9..0126102d71 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -42,7 +42,7 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._product_type_filters = None self._statuses_filter = None - self._tags_filter = None + self._version_tags_filter = None self._task_ids_filter = None self._ascending_sort = True @@ -70,9 +70,9 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self.invalidateFilter() def set_version_tags_filter(self, tags): - if self._tags_filter == tags: + if self._version_tags_filter == tags: return - self._tags_filter = tags + self._version_tags_filter = tags self.invalidateFilter() def filterAcceptsRow(self, source_row, source_parent): @@ -92,7 +92,7 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): return False if not self._accept_row_by_role_value( - index, self._tags_filter, VERSION_TAGS_FILTER_ROLE + index, self._version_tags_filter, VERSION_TAGS_FILTER_ROLE ): return False @@ -303,9 +303,9 @@ class ProductsWidget(QtWidgets.QWidget): self._version_delegate.set_statuses_filter(status_names) self._products_proxy_model.set_statuses_filter(status_names) - def set_version_tags_filter(self, tags): - self._version_delegate.set_tags_filter(tags) - self._products_proxy_model.set_version_tags_filter(tags) + def set_version_tags_filter(self, version_tags): + self._version_delegate.set_version_tags_filter(version_tags) + self._products_proxy_model.set_version_tags_filter(version_tags) def set_product_type_filter(self, product_type_filters): """ From a404db80451bbbd3c7ba0328f96aecaf07edc4c9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:00:25 +0200 Subject: [PATCH 327/781] update flters after refresh --- client/ayon_core/tools/loader/ui/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index a5f74c2c6f..ddc7ef7329 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -325,7 +325,6 @@ class LoaderWindow(QtWidgets.QWidget): def refresh(self): self._reset_on_show = False self._controller.reset() - self._update_filters() def showEvent(self, event): super().showEvent(event) @@ -462,6 +461,7 @@ class LoaderWindow(QtWidgets.QWidget): self._projects_combobox.set_current_context_project(project_name) if not self._refresh_handler.project_refreshed: self._projects_combobox.refresh() + self._update_filters() def _on_load_finished(self, event): error_info = event["error_info"] From 9374e19ec3fa13b6fdc3df8b4c0ccb49c31a02ef Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:00:48 +0200 Subject: [PATCH 328/781] task items now have tags --- client/ayon_core/tools/common_models/hierarchy.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/common_models/hierarchy.py b/client/ayon_core/tools/common_models/hierarchy.py index 6b861d8fa5..37d97af625 100644 --- a/client/ayon_core/tools/common_models/hierarchy.py +++ b/client/ayon_core/tools/common_models/hierarchy.py @@ -100,12 +100,14 @@ class TaskItem: label: Union[str, None], task_type: str, parent_id: str, + tags: list[str], ): self.task_id = task_id self.name = name self.label = label self.task_type = task_type self.parent_id = parent_id + self.tags = tags self._full_label = None @@ -145,6 +147,7 @@ class TaskItem: "label": self.label, "parent_id": self.parent_id, "task_type": self.task_type, + "tags": self.tags, } @classmethod @@ -176,7 +179,8 @@ def _get_task_items_from_tasks(tasks): task["name"], task["label"], task["type"], - folder_id + folder_id, + task["tags"], )) return output @@ -645,6 +649,6 @@ class HierarchyModel(object): tasks = list(ayon_api.get_tasks( project_name, folder_ids=[folder_id], - fields={"id", "name", "label", "folderId", "type"} + fields={"id", "name", "label", "folderId", "type", "tags"} )) return _get_task_items_from_tasks(tasks) From c2f9e55dd1f9f44f06aaeb427721a3ed3eecf255 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:02:10 +0200 Subject: [PATCH 329/781] added task tags filter base --- .../tools/loader/ui/products_widget.py | 3 +++ client/ayon_core/tools/loader/ui/window.py | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 0126102d71..1e391895f8 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -307,6 +307,9 @@ class ProductsWidget(QtWidgets.QWidget): self._version_delegate.set_version_tags_filter(version_tags) self._products_proxy_model.set_version_tags_filter(version_tags) + def set_task_tags_filter(self, task_tags): + pass + def set_product_type_filter(self, product_type_filters): """ diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index ddc7ef7329..c39f92234e 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -427,6 +427,10 @@ class LoaderWindow(QtWidgets.QWidget): version_tags = self._search_bar.get_filter_value("version_tags") self._products_widget.set_version_tags_filter(version_tags) + elif filter_name == "task_tags": + task_tags = self._search_bar.get_filter_value("task_tags") + self._products_widget.set_task_tags_filter(task_tags) + def _on_tasks_selection_change(self, event): self._products_widget.set_tasks_filter(event["task_ids"]) @@ -522,7 +526,13 @@ class LoaderWindow(QtWidgets.QWidget): } for tag_name in tags_by_entity_type.get("versions") or [] ] - + task_tags = [ + { + "value": tag_name, + "color": tag_color_by_name.get(tag_name), + } + for tag_name in tags_by_entity_type.get("tasks") or [] + ] self._search_bar.set_search_items([ FilterDefinition( @@ -554,6 +564,13 @@ class LoaderWindow(QtWidgets.QWidget): icon=None, items=version_tags, ), + FilterDefinition( + name="task_tags", + title="Task tags", + filter_type="list", + icon=None, + items=task_tags, + ), ]) def _on_folders_selection_changed(self, event): From 84bc798458b5b7fddc39196a0790bfec1fa8e54b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:02:49 +0200 Subject: [PATCH 330/781] remove task refresh logic --- client/ayon_core/tools/loader/ui/tasks_widget.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index 5779fc2a01..cc7e2e9c95 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -332,10 +332,6 @@ class LoaderTasksWidget(QtWidgets.QWidget): "selection.folders.changed", self._on_folders_selection_changed, ) - controller.register_event_callback( - "tasks.refresh.finished", - self._on_tasks_refresh_finished - ) selection_model = tasks_view.selectionModel() selection_model.selectionChanged.connect(self._on_selection_change) @@ -373,10 +369,6 @@ class LoaderTasksWidget(QtWidgets.QWidget): def _clear(self): self._tasks_model.clear() - def _on_tasks_refresh_finished(self, event): - if event["sender"] != TASKS_MODEL_SENDER_NAME: - self._set_project_name(event["project_name"]) - def _on_folders_selection_changed(self, event): project_name = event["project_name"] folder_ids = event["folder_ids"] From da6bc0b72838864665b3524bfa61d2ac2912a996 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:05:01 +0200 Subject: [PATCH 331/781] added task tags data to product --- .../tools/loader/ui/products_model.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index d3bf6b2e38..2e257073cf 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -41,7 +41,8 @@ SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30 SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 32 -VERSION_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 33 +TASK_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 33 +VERSION_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 34 class ProductsModel(QtGui.QStandardItemModel): @@ -131,6 +132,7 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_folder_ids = [] self._last_project_statuses = {} self._last_status_icons_by_name = {} + self._last_task_tags_by_task_id = {} def get_product_item_indexes(self): return [ @@ -424,8 +426,14 @@ class ProductsModel(QtGui.QStandardItemModel): for version_item in product_item.version_items.values() } version_tags = set() + task_tags = set() for version_item in product_item.version_items.values(): version_tags |= set(version_item.tags) + _task_tags = self._last_task_tags_by_task_id.get( + version_item.task_id + ) + if _task_tags: + task_tags |= set(_task_tags) if model_item is None: product_id = product_item.product_id @@ -446,6 +454,7 @@ class ProductsModel(QtGui.QStandardItemModel): model_item.setData("|".join(statuses), STATUS_NAME_FILTER_ROLE) model_item.setData("|".join(version_tags), VERSION_TAGS_FILTER_ROLE) + model_item.setData("|".join(task_tags), TASK_TAGS_FILTER_ROLE) model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE) in_scene = 1 if product_item.product_in_scene else 0 model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE) @@ -476,6 +485,14 @@ class ProductsModel(QtGui.QStandardItemModel): } self._last_status_icons_by_name = {} + task_items = self._controller.get_task_items( + project_name, folder_ids, sender=PRODUCTS_MODEL_SENDER_NAME + ) + self._last_task_tags_by_task_id = { + task_item.task_id: task_item.tags + for task_item in task_items + } + active_site_icon_def = self._controller.get_active_site_icon_def( project_name ) @@ -490,6 +507,7 @@ class ProductsModel(QtGui.QStandardItemModel): folder_ids, sender=PRODUCTS_MODEL_SENDER_NAME ) + product_items_by_id = { product_item.product_id: product_item for product_item in product_items From b2c583d2589fa95db84d1e1a243b0fc5aa123a5a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:05:13 +0200 Subject: [PATCH 332/781] fixed variable names --- client/ayon_core/tools/loader/ui/products_model.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 2e257073cf..59199a48e8 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -227,9 +227,9 @@ class ProductsModel(QtGui.QStandardItemModel): product_item = self._product_items_by_id.get(product_id) if product_item is None: return None - product_items = list(product_item.version_items.values()) - product_items.sort(reverse=True) - return product_items + version_items = list(product_item.version_items.values()) + version_items.sort(reverse=True) + return version_items if role == QtCore.Qt.EditRole: return None From a471b48b04e84b6b0b2887c38770764ac219e8c3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:05:58 +0200 Subject: [PATCH 333/781] implemented tags filter for product items --- .../ayon_core/tools/loader/ui/products_widget.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 1e391895f8..775656f13c 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -27,6 +27,7 @@ from .products_model import ( VERSION_THUMBNAIL_ID_ROLE, STATUS_NAME_FILTER_ROLE, VERSION_TAGS_FILTER_ROLE, + TASK_TAGS_FILTER_ROLE, ) from .products_delegates import ( VersionDelegate, @@ -43,6 +44,7 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._product_type_filters = None self._statuses_filter = None self._version_tags_filter = None + self._task_tags_filter = None self._task_ids_filter = None self._ascending_sort = True @@ -75,6 +77,12 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._version_tags_filter = tags self.invalidateFilter() + def set_task_tags_filter(self, tags): + if self._task_tags_filter == tags: + return + self._task_tags_filter = tags + self.invalidateFilter() + def filterAcceptsRow(self, source_row, source_parent): source_model = self.sourceModel() index = source_model.index(source_row, 0, source_parent) @@ -96,6 +104,11 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): ): return False + if not self._accept_row_by_role_value( + index, self._task_tags_filter, TASK_TAGS_FILTER_ROLE + ): + return False + return super().filterAcceptsRow(source_row, source_parent) def _accept_task_ids_filter(self, index): @@ -308,7 +321,7 @@ class ProductsWidget(QtWidgets.QWidget): self._products_proxy_model.set_version_tags_filter(version_tags) def set_task_tags_filter(self, task_tags): - pass + self._products_proxy_model.set_task_tags_filter(task_tags) def set_product_type_filter(self, product_type_filters): """ From e02118b93a43a90821a4e05f8b5c39b48b62fe33 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:07:34 +0200 Subject: [PATCH 334/781] cleanup naming conflicts --- .../tools/loader/ui/products_delegates.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 8190fce337..4283b66e61 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -20,12 +20,12 @@ from .products_model import ( SYNC_REMOTE_SITE_AVAILABILITY, ) -VERSION_ID_ROLE = QtCore.Qt.UserRole + 1 -TASK_ID_ROLE = QtCore.Qt.UserRole + 2 -STATUS_NAME_ROLE = QtCore.Qt.UserRole + 3 +COMBO_VERSION_ID_ROLE = QtCore.Qt.UserRole + 1 +COMBO_TASK_ID_ROLE = QtCore.Qt.UserRole + 2 +COMBO_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 3 -class VersionsModel(QtGui.QStandardItemModel): +class ComboVersionsModel(QtGui.QStandardItemModel): def __init__(self): super().__init__() self._items_by_id = {} @@ -59,9 +59,9 @@ class VersionsModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem(label) item.setData(version_id, QtCore.Qt.UserRole) self._items_by_id[version_id] = item - item.setData(version_id, VERSION_ID_ROLE) - item.setData(version_item.status, STATUS_NAME_ROLE) - item.setData(version_item.task_id, TASK_ID_ROLE) + item.setData(version_id, COMBO_VERSION_ID_ROLE) + item.setData(version_item.status, COMBO_STATUS_NAME_ROLE) + item.setData(version_item.task_id, COMBO_TASK_ID_ROLE) version_tags_by_version_id[version_id] = set(version_item.tags) if item.row() != idx: @@ -69,7 +69,7 @@ class VersionsModel(QtGui.QStandardItemModel): self._version_tags_by_version_id = version_tags_by_version_id -class VersionsFilterModel(QtCore.QSortFilterProxyModel): +class ComboVersionsFilterModel(QtCore.QSortFilterProxyModel): def __init__(self): super().__init__() self._status_filter = None @@ -83,14 +83,14 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): return False if index is None: index = self.sourceModel().index(row, 0, parent) - status = index.data(STATUS_NAME_ROLE) + status = index.data(COMBO_STATUS_NAME_ROLE) if status not in self._status_filter: return False if self._task_ids_filter: if index is None: index = self.sourceModel().index(row, 0, parent) - task_id = index.data(TASK_ID_ROLE) + task_id = index.data(COMBO_TASK_ID_ROLE) if task_id not in self._task_ids_filter: return False @@ -100,7 +100,7 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): if index is None: index = self.sourceModel().index(row, 0, parent) - version_id = index.data(VERSION_ID_ROLE) + version_id = index.data(COMBO_VERSION_ID_ROLE) model = self.sourceModel() tags = model.get_version_tags(version_id) @@ -134,8 +134,8 @@ class VersionComboBox(QtWidgets.QComboBox): def __init__(self, product_id, parent): super().__init__(parent) - versions_model = VersionsModel() - proxy_model = VersionsFilterModel() + versions_model = ComboVersionsModel() + proxy_model = ComboVersionsFilterModel() proxy_model.setSourceModel(versions_model) self.setModel(proxy_model) From 9a72f2bafc5da6e3206b18c127036efb084310f5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:16:22 +0200 Subject: [PATCH 335/781] better way how to get versions --- client/ayon_core/tools/loader/ui/products_delegates.py | 6 +++++- client/ayon_core/tools/loader/ui/products_model.py | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 4283b66e61..c22a99ab55 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -317,8 +317,12 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): editor.clear() # Current value of the index - versions = index.data(VERSION_NAME_EDIT_ROLE) or [] + product_id = index.data(PRODUCT_ID_ROLE) version_id = index.data(VERSION_ID_ROLE) + model = index.model() + while hasattr(model, "sourceModel"): + model = model.sourceModel() + versions = model.get_version_items_by_product_id(product_id) editor.update_versions(versions, version_id) editor.set_tasks_filter(self._task_ids_filter) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 59199a48e8..2015c86f92 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -173,6 +173,14 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_folder_ids ) + def get_version_items_by_product_id(self, product_id: str): + product_item = self._product_items_by_id.get(product_id) + if product_item is None: + return None + version_items = list(product_item.version_items.values()) + version_items.sort(reverse=True) + return version_items + def flags(self, index): # Make the version column editable if index.column() == self.version_col and index.data(PRODUCT_ID_ROLE): From 5a4a888c222aff03ad568f664a1c23a4d8969840 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:24:03 +0200 Subject: [PATCH 336/781] faster filtering --- client/ayon_core/tools/loader/ui/products_model.py | 3 +++ client/ayon_core/tools/loader/ui/products_widget.py | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 2015c86f92..b3042629ff 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -173,6 +173,9 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_folder_ids ) + def get_task_tags_by_id(self, task_id): + return self._last_task_tags_by_task_id.get(task_id, set()) + def get_version_items_by_product_id(self, product_id: str): product_item = self._product_items_by_id.get(product_id) if product_item is None: diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 775656f13c..935b9a147e 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -129,9 +129,10 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): return False value_s = index.data(role) - for value in value_s.split("|"): - if value in filter_value: - return True + if value_s: + for value in value_s.split("|"): + if value in filter_value: + return True return False def lessThan(self, left, right): From 810b9cc573a0ad07604734153f12083a244bddb2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:39:07 +0200 Subject: [PATCH 337/781] implemented version filtering by task tags --- .../tools/loader/ui/products_delegates.py | 86 +++++++++++++++---- .../tools/loader/ui/products_widget.py | 1 + 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index c22a99ab55..e78b32ceb1 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -23,21 +23,16 @@ from .products_model import ( COMBO_VERSION_ID_ROLE = QtCore.Qt.UserRole + 1 COMBO_TASK_ID_ROLE = QtCore.Qt.UserRole + 2 COMBO_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 3 +COMBO_VERSION_TAGS_ROLE = QtCore.Qt.UserRole + 4 +COMBO_TASK_TAGS_ROLE = QtCore.Qt.UserRole + 5 class ComboVersionsModel(QtGui.QStandardItemModel): def __init__(self): super().__init__() self._items_by_id = {} - self._version_tags_by_version_id = {} - def get_version_tags(self, version_id: str) -> set[str]: - tags = self._version_tags_by_version_id.get(version_id) - if tags is None: - tags = set() - return tags - - def update_versions(self, version_items): + def update_versions(self, version_items, task_tags_by_version_id): version_ids = { version_item.version_id for version_item in version_items @@ -59,14 +54,17 @@ class ComboVersionsModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem(label) item.setData(version_id, QtCore.Qt.UserRole) self._items_by_id[version_id] = item + version_tags = set(version_item.tags) + task_tags = task_tags_by_version_id[version_id] item.setData(version_id, COMBO_VERSION_ID_ROLE) item.setData(version_item.status, COMBO_STATUS_NAME_ROLE) item.setData(version_item.task_id, COMBO_TASK_ID_ROLE) + item.setData("|".join(version_tags), COMBO_VERSION_TAGS_ROLE) + item.setData("|".join(task_tags), COMBO_TASK_TAGS_ROLE) version_tags_by_version_id[version_id] = set(version_item.tags) if item.row() != idx: root_item.insertRow(idx, item) - self._version_tags_by_version_id = version_tags_by_version_id class ComboVersionsFilterModel(QtCore.QSortFilterProxyModel): @@ -75,6 +73,7 @@ class ComboVersionsFilterModel(QtCore.QSortFilterProxyModel): self._status_filter = None self._task_ids_filter = None self._version_tags_filter = None + self._task_tags_filter = None def filterAcceptsRow(self, row, parent): index = None @@ -99,12 +98,28 @@ class ComboVersionsFilterModel(QtCore.QSortFilterProxyModel): return False if index is None: - index = self.sourceModel().index(row, 0, parent) - version_id = index.data(COMBO_VERSION_ID_ROLE) + model = self.sourceModel() + index = model.index(row, 0, parent) + version_tags_s = index.data(COMBO_TASK_TAGS_ROLE) + version_tags = set() + if version_tags_s: + version_tags = set(version_tags_s.split("|")) - model = self.sourceModel() - tags = model.get_version_tags(version_id) - if not tags & self._version_tags_filter: + if not version_tags & self._version_tags_filter: + return False + + if self._task_tags_filter is not None: + if not self._task_tags_filter: + return False + + if index is None: + model = self.sourceModel() + index = model.index(row, 0, parent) + task_tags_s = index.data(COMBO_TASK_TAGS_ROLE) + task_tags = set() + if task_tags_s: + task_tags = set(task_tags_s.split("|")) + if not (task_tags & self._task_tags_filter): return False return True @@ -115,6 +130,12 @@ class ComboVersionsFilterModel(QtCore.QSortFilterProxyModel): self._task_ids_filter = task_ids self.invalidateFilter() + def set_task_tags_filter(self, tags): + if self._task_tags_filter == tags: + return + self._task_tags_filter = tags + self.invalidateFilter() + def set_statuses_filter(self, status_names): if self._status_filter == status_names: return @@ -160,6 +181,13 @@ class VersionComboBox(QtWidgets.QComboBox): if self.currentIndex() != 0: self.setCurrentIndex(0) + def set_task_tags_filter(self, tags): + self._proxy_model.set_task_tags_filter(tags) + if self.count() == 0: + return + if self.currentIndex() != 0: + self.setCurrentIndex(0) + def set_statuses_filter(self, status_names): self._proxy_model.set_statuses_filter(status_names) if self.count() == 0: @@ -179,7 +207,12 @@ class VersionComboBox(QtWidgets.QComboBox): return self.count() == 0 return False - def update_versions(self, version_items, current_version_id): + def update_versions( + self, + version_items, + current_version_id, + task_tags_by_version_id, + ): self.blockSignals(True) version_items = list(version_items) version_ids = [ @@ -190,7 +223,9 @@ class VersionComboBox(QtWidgets.QComboBox): current_version_id = version_ids[0] self._current_id = current_version_id - self._versions_model.update_versions(version_items) + self._versions_model.update_versions( + version_items, task_tags_by_version_id + ) index = version_ids.index(current_version_id) if self.currentIndex() != index: @@ -218,6 +253,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): self._task_ids_filter = None self._statuses_filter = None self._version_tags_filter = None + self._task_tags_filter = None def displayText(self, value, locale): if not isinstance(value, numbers.Integral): @@ -243,6 +279,13 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): for widget in self._editor_by_id.values(): widget.set_version_tags_filter(tags) + def set_task_tags_filter(self, tags): + if tags is not None: + tags = set(tags) + self._task_tags_filter = tags + for widget in self._editor_by_id.values(): + widget.set_task_tags_filter(tags) + def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) if fg_color: @@ -254,7 +297,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): fg_color = None if not fg_color: - return super(VersionDelegate, self).paint(painter, option, index) + return super().paint(painter, option, index) if option.widget: style = option.widget.style() @@ -323,9 +366,16 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): while hasattr(model, "sourceModel"): model = model.sourceModel() versions = model.get_version_items_by_product_id(product_id) + task_tags_by_version_id = { + version_item.version_id: model.get_task_tags_by_id( + version_item.task_id + ) + for version_item in versions + } - editor.update_versions(versions, version_id) + editor.update_versions(versions, version_id, task_tags_by_version_id) editor.set_tasks_filter(self._task_ids_filter) + editor.set_task_tags_filter(self._task_tags_filter) editor.set_statuses_filter(self._statuses_filter) def setModelData(self, editor, model, index): diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 935b9a147e..8cb1d48acb 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -322,6 +322,7 @@ class ProductsWidget(QtWidgets.QWidget): self._products_proxy_model.set_version_tags_filter(version_tags) def set_task_tags_filter(self, task_tags): + self._version_delegate.set_task_tags_filter(task_tags) self._products_proxy_model.set_task_tags_filter(task_tags) def set_product_type_filter(self, product_type_filters): From aa252af0a4aca29b16fd8364d0f7f1771b697da7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 09:38:09 +0200 Subject: [PATCH 338/781] add typehints --- client/ayon_core/tools/loader/abstract.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 09d900074c..8d5e631852 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -1,13 +1,13 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import List +from typing import Iterable, Optional from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, serialize_attr_defs, deserialize_attr_defs, ) -from ayon_core.tools.common_models import TagItem +from ayon_core.tools.common_models import TaskItem, TagItem class ProductTypeItem: @@ -360,8 +360,8 @@ class ProductTypesFilter: Defines the filtering for product types. """ - def __init__(self, product_types: List[str], is_allow_list: bool): - self.product_types: List[str] = product_types + def __init__(self, product_types: list[str], is_allow_list: bool): + self.product_types: list[str] = product_types self.is_allow_list: bool = is_allow_list @@ -561,7 +561,12 @@ class FrontendLoaderController(_BaseLoaderController): pass @abstractmethod - def get_task_items(self, project_name, folder_ids, sender=None): + def get_task_items( + self, + project_name: str, + folder_ids: Iterable[str], + sender: Optional[str] = None, + ) -> list[TaskItem]: """Task items for folder ids. Args: From 61af049a8c3db7410e76e0e934cd318893a6cc61 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 10:44:38 +0200 Subject: [PATCH 339/781] ruff fixes --- client/ayon_core/tools/loader/ui/search_bar.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index bf04fec926..840013c97c 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -5,7 +5,7 @@ from typing import Any, Optional from qtpy import QtCore, QtWidgets, QtGui -from ayon_core.style import load_stylesheet, get_objected_colors +from ayon_core.style import get_objected_colors from ayon_core.tools.utils import ( get_qt_icon, SquareButton, @@ -682,7 +682,7 @@ class FiltersBar(BaseClickableFrame): self._filters_layout = filters_layout self._widgets_by_name = {} self._filter_defs_by_name = {} - self._filters_popup = FiltersPopup(self) + self._filters_popup = FiltersPopup(self) self._filter_value_popup = FilterValuePopup(self) def showEvent(self, event): From 60caf47cfffd6ad5507829073f29c085c0fa6324 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 10:48:07 +0200 Subject: [PATCH 340/781] remove unncessary line --- client/ayon_core/tools/common_models/projects.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 8f3135b2d5..69ac4e34a8 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -82,7 +82,6 @@ class TagItem: color: str - class FolderTypeItem: """Item representing folder type of project. From 6e095b8c188b09e3f067538b40652aeaa1089821 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 14:35:23 +0200 Subject: [PATCH 341/781] Updated docstring --- 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 62d376c6d1..ed26e5fe74 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -257,7 +257,7 @@ class PreLoaderHookPlugin: """Plugin that should be run before any Loaders in 'loaders' Should be used as non-invasive method to enrich core loading process. - Any external studio might want to modify loaded data before or after + Any studio might want to modify loaded data before or after they are loaded without need to override existing core plugins. """ loader_identifiers: ClassVar[set[str]] From 0ac277404c953e26770e3a1aaf6d645ba85af2ed Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 14:37:03 +0200 Subject: [PATCH 342/781] Updated docstring --- client/ayon_core/tools/loader/models/actions.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 07195f4b05..c2444da456 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -316,10 +316,8 @@ class LoaderActionsModel: we want to show loaders for? Returns: - tuple( - list[ProductLoaderPlugin], - list[LoaderPlugin], - ): Discovered loader plugins. + tuple[list[ProductLoaderPlugin], list[LoaderPlugin]]: Discovered + loader plugins. """ loaders_by_identifier_c = self._loaders_by_identifier[project_name] From 4f0c2a51c87ed1f3234c63bf894eafbcaf11a785 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 14:40:50 +0200 Subject: [PATCH 343/781] fix missing group checkbox --- client/ayon_core/tools/loader/ui/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index c39f92234e..40802d3d88 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -198,7 +198,7 @@ class LoaderWindow(QtWidgets.QWidget): products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget) products_wrap_layout.setContentsMargins(0, 0, 0, 0) - products_wrap_layout.addWidget(search_bar, 0) + products_wrap_layout.addWidget(products_inputs_widget, 0) products_wrap_layout.addWidget(products_widget, 1) right_panel_splitter = QtWidgets.QSplitter(main_splitter) From ee96cdc2c38f1e04b9ba2ccffe4b18ecbe83c6e8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 14:44:14 +0200 Subject: [PATCH 344/781] Remove methods for pre/post loader hooks from higher api This "hides" a bit methods that are not completely relevant from high level API. --- client/ayon_core/pipeline/__init__.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 0a805b16dc..41bcd0dbd1 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -42,16 +42,6 @@ from .load import ( register_loader_plugin_path, deregister_loader_plugin, - register_loader_pre_hook_plugin, - deregister_loader_pre_hook_plugin, - register_loader_pre_hook_plugin_path, - deregister_loader_pre_hook_plugin_path, - - register_loader_post_hook_plugin, - deregister_loader_post_hook_plugin, - register_loader_post_hook_plugin_path, - deregister_loader_post_hook_plugin_path, - load_container, remove_container, update_container, @@ -61,7 +51,6 @@ from .load import ( get_representation_path, get_representation_context, get_repres_contexts, - get_hook_loaders_by_identifier ) from .publish import ( @@ -171,16 +160,6 @@ __all__ = ( "register_loader_plugin_path", "deregister_loader_plugin", - "register_loader_pre_hook_plugin", - "deregister_loader_pre_hook_plugin", - "register_loader_pre_hook_plugin_path", - "deregister_loader_pre_hook_plugin_path", - - "register_loader_post_hook_plugin", - "deregister_loader_post_hook_plugin", - "register_loader_post_hook_plugin_path", - "deregister_loader_post_hook_plugin_path", - "load_container", "remove_container", "update_container", @@ -241,8 +220,6 @@ __all__ = ( "register_workfile_build_plugin_path", "deregister_workfile_build_plugin_path", - "get_hook_loaders_by_identifier", - # Backwards compatible function names "install", "uninstall", From 9033640efa2cf9e7225c5d7ee226a7cd8f2aa218 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 14:48:50 +0200 Subject: [PATCH 345/781] remove unused widgets --- .../tools/loader/ui/product_types_combo.py | 170 ------------------ .../tools/loader/ui/statuses_combo.py | 157 ---------------- 2 files changed, 327 deletions(-) delete mode 100644 client/ayon_core/tools/loader/ui/product_types_combo.py delete mode 100644 client/ayon_core/tools/loader/ui/statuses_combo.py diff --git a/client/ayon_core/tools/loader/ui/product_types_combo.py b/client/ayon_core/tools/loader/ui/product_types_combo.py deleted file mode 100644 index 525f1cae1b..0000000000 --- a/client/ayon_core/tools/loader/ui/product_types_combo.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations -from qtpy import QtGui, QtCore - -from ._multicombobox import ( - CustomPaintMultiselectComboBox, - BaseQtModel, -) - -STATUS_ITEM_TYPE = 0 -SELECT_ALL_TYPE = 1 -DESELECT_ALL_TYPE = 2 -SWAP_STATE_TYPE = 3 - -PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1 -ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 2 -ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 3 - - -class ProductTypesQtModel(BaseQtModel): - refreshed = QtCore.Signal() - - def __init__(self, controller): - self._reset_filters_on_refresh = True - self._refreshing = False - self._bulk_change = False - self._items_by_name = {} - - super().__init__( - item_type_role=ITEM_TYPE_ROLE, - item_subtype_role=ITEM_SUBTYPE_ROLE, - empty_values_label="No product types...", - controller=controller, - ) - - def is_refreshing(self): - return self._refreshing - - def refresh(self, project_name): - self._refreshing = True - super().refresh(project_name) - - self._reset_filters_on_refresh = False - self._refreshing = False - self.refreshed.emit() - - def reset_product_types_filter_on_refresh(self): - self._reset_filters_on_refresh = True - - def _get_standard_items(self) -> list[QtGui.QStandardItem]: - return list(self._items_by_name.values()) - - def _clear_standard_items(self): - self._items_by_name.clear() - - def _prepare_new_value_items(self, project_name: str, _: bool) -> tuple[ - list[QtGui.QStandardItem], list[QtGui.QStandardItem] - ]: - product_type_items = self._controller.get_product_type_items( - project_name) - self._last_project = project_name - - names_to_remove = set(self._items_by_name.keys()) - items = [] - items_filter_required = {} - for product_type_item in product_type_items: - name = product_type_item.name - names_to_remove.discard(name) - item = self._items_by_name.get(name) - # Apply filter to new items or if filters reset is requested - filter_required = self._reset_filters_on_refresh - if item is None: - filter_required = True - item = QtGui.QStandardItem(name) - item.setData(name, PRODUCT_TYPE_ROLE) - item.setEditable(False) - item.setCheckable(True) - self._items_by_name[name] = item - - items.append(item) - - if filter_required: - items_filter_required[name] = item - - if items_filter_required: - product_types_filter = self._controller.get_product_types_filter() - for product_type, item in items_filter_required.items(): - matching = ( - int(product_type in product_types_filter.product_types) - + int(product_types_filter.is_allow_list) - ) - item.setCheckState( - QtCore.Qt.Checked - if matching % 2 == 0 - else QtCore.Qt.Unchecked - ) - - items_to_remove = [] - for name in names_to_remove: - items_to_remove.append( - self._items_by_name.pop(name) - ) - - # Uncheck all if all are checked (same result) - if all( - item.checkState() == QtCore.Qt.Checked - for item in items - ): - for item in items: - item.setCheckState(QtCore.Qt.Unchecked) - - return items, items_to_remove - - -class ProductTypesCombobox(CustomPaintMultiselectComboBox): - def __init__(self, controller, parent): - self._controller = controller - model = ProductTypesQtModel(controller) - super().__init__( - PRODUCT_TYPE_ROLE, - PRODUCT_TYPE_ROLE, - QtCore.Qt.ForegroundRole, - QtCore.Qt.DecorationRole, - item_type_role=ITEM_TYPE_ROLE, - model=model, - parent=parent - ) - - model.refreshed.connect(self._on_model_refresh) - - self.set_placeholder_text("Product types filter...") - self._model = model - self._last_project_name = None - self._fully_disabled_filter = False - - controller.register_event_callback( - "selection.project.changed", - self._on_project_change - ) - controller.register_event_callback( - "projects.refresh.finished", - self._on_projects_refresh - ) - self.setToolTip("Product types filter") - self.value_changed.connect( - self._on_product_type_filter_change - ) - - def reset_product_types_filter_on_refresh(self): - self._model.reset_product_types_filter_on_refresh() - - def _on_model_refresh(self): - self.value_changed.emit() - - def _on_product_type_filter_change(self): - lines = ["Product types filter"] - for item in self.get_value_info(): - status_name, enabled = item - lines.append(f"{'✔' if enabled else '☐'} {status_name}") - - self.setToolTip("\n".join(lines)) - - def _on_project_change(self, event): - project_name = event["project_name"] - self._last_project_name = project_name - self._model.refresh(project_name) - - def _on_projects_refresh(self): - if self._last_project_name: - self._model.refresh(self._last_project_name) - self._on_product_type_filter_change() diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py deleted file mode 100644 index 2f034d00de..0000000000 --- a/client/ayon_core/tools/loader/ui/statuses_combo.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -from qtpy import QtCore, QtGui - -from ayon_core.tools.utils import get_qt_icon -from ayon_core.tools.common_models import StatusItem - -from ._multicombobox import ( - CustomPaintMultiselectComboBox, - BaseQtModel, -) - -STATUS_ITEM_TYPE = 0 -SELECT_ALL_TYPE = 1 -DESELECT_ALL_TYPE = 2 -SWAP_STATE_TYPE = 3 - -STATUSES_FILTER_SENDER = "loader.statuses_filter" -STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 -STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 2 -STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 3 -STATUS_ICON_ROLE = QtCore.Qt.UserRole + 4 -ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 -ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 6 - - -class StatusesQtModel(BaseQtModel): - def __init__(self, controller): - self._items_by_name: dict[str, QtGui.QStandardItem] = {} - self._icons_by_name_n_color: dict[str, QtGui.QIcon] = {} - super().__init__( - ITEM_TYPE_ROLE, - ITEM_SUBTYPE_ROLE, - "No statuses...", - controller, - ) - - def _get_standard_items(self) -> list[QtGui.QStandardItem]: - return list(self._items_by_name.values()) - - def _clear_standard_items(self): - self._items_by_name.clear() - - def _prepare_new_value_items( - self, project_name: str, project_changed: bool - ): - status_items: list[StatusItem] = ( - self._controller.get_project_status_items( - project_name, sender=STATUSES_FILTER_SENDER - ) - ) - items = [] - items_to_remove = [] - if not status_items: - return items, items_to_remove - - names_to_remove = set(self._items_by_name) - for row_idx, status_item in enumerate(status_items): - name = status_item.name - if name in self._items_by_name: - item = self._items_by_name[name] - names_to_remove.discard(name) - else: - item = QtGui.QStandardItem() - item.setData(ITEM_SUBTYPE_ROLE, STATUS_ITEM_TYPE) - item.setCheckState(QtCore.Qt.Unchecked) - item.setFlags( - QtCore.Qt.ItemIsEnabled - | QtCore.Qt.ItemIsSelectable - | QtCore.Qt.ItemIsUserCheckable - ) - self._items_by_name[name] = item - - icon = self._get_icon(status_item) - for role, value in ( - (STATUS_NAME_ROLE, status_item.name), - (STATUS_SHORT_ROLE, status_item.short), - (STATUS_COLOR_ROLE, status_item.color), - (STATUS_ICON_ROLE, icon), - ): - if item.data(role) != value: - item.setData(value, role) - - if project_changed: - item.setCheckState(QtCore.Qt.Unchecked) - items.append(item) - - for name in names_to_remove: - items_to_remove.append(self._items_by_name.pop(name)) - - return items, items_to_remove - - def _get_icon(self, status_item: StatusItem) -> QtGui.QIcon: - name = status_item.name - color = status_item.color - unique_id = "|".join([name or "", color or ""]) - icon = self._icons_by_name_n_color.get(unique_id) - if icon is not None: - return icon - - icon: QtGui.QIcon = get_qt_icon({ - "type": "material-symbols", - "name": status_item.icon, - "color": status_item.color - }) - self._icons_by_name_n_color[unique_id] = icon - return icon - - -class StatusesCombobox(CustomPaintMultiselectComboBox): - def __init__(self, controller, parent): - self._controller = controller - model = StatusesQtModel(controller) - super().__init__( - STATUS_NAME_ROLE, - STATUS_SHORT_ROLE, - STATUS_COLOR_ROLE, - STATUS_ICON_ROLE, - item_type_role=ITEM_TYPE_ROLE, - model=model, - parent=parent - ) - self.set_placeholder_text("Version status filter...") - self._model = model - self._last_project_name = None - self._fully_disabled_filter = False - - controller.register_event_callback( - "selection.project.changed", - self._on_project_change - ) - controller.register_event_callback( - "projects.refresh.finished", - self._on_projects_refresh - ) - self.setToolTip("Statuses filter") - self.value_changed.connect( - self._on_status_filter_change - ) - - def _on_status_filter_change(self): - lines = ["Statuses filter"] - for item in self.get_value_info(): - status_name, enabled = item - lines.append(f"{'✔' if enabled else '☐'} {status_name}") - - self.setToolTip("\n".join(lines)) - - def _on_project_change(self, event): - project_name = event["project_name"] - self._last_project_name = project_name - self._model.refresh(project_name) - - def _on_projects_refresh(self): - if self._last_project_name: - self._model.refresh(self._last_project_name) - self._on_status_filter_change() From c34e3bb15ed6d5c4cb50b993680921856cf567db Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:22:22 +0200 Subject: [PATCH 346/781] auto set product type filters --- .../ayon_core/tools/loader/ui/search_bar.py | 13 ++++++++++ client/ayon_core/tools/loader/ui/window.py | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 840013c97c..5e9e409ee1 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -709,6 +709,19 @@ class FiltersBar(BaseClickableFrame): return value return None + def set_filter_value(self, name: str, value: Any): + """Set the value of a filter by its name.""" + if name not in self._filter_defs_by_name: + return + + item_widget = self._widgets_by_name.get(name) + if item_widget is None: + self.add_item(name) + item_widget = self._widgets_by_name.get(name) + + item_widget.set_value(value) + self.filter_changed.emit(name) + def add_item(self, name: str): """Add a new item to the search bar. diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 40802d3d88..a3a476b330 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -318,6 +318,8 @@ class LoaderWindow(QtWidgets.QWidget): self._selected_folder_ids = set() self._selected_version_ids = set() + self._set_product_type_filters = True + self._products_widget.set_enable_grouping( self._product_group_checkbox.isChecked() ) @@ -573,6 +575,29 @@ class LoaderWindow(QtWidgets.QWidget): ), ]) + # Set product types filter from settings + if self._set_product_type_filters: + self._set_product_type_filters = False + product_types_filter = self._controller.get_product_types_filter() + product_types = [] + for item in filter_product_type_items: + product_type = item["value"] + matching = ( + int(product_type in product_types_filter.product_types) + + int(product_types_filter.is_allow_list) + ) + if matching % 2 == 0: + product_types.append(product_type) + + if ( + product_types + and len(product_types) < len(filter_product_type_items) + ): + self._search_bar.set_filter_value( + "product_types", + product_types + ) + def _on_folders_selection_changed(self, event): self._selected_folder_ids = set(event["folder_ids"]) self._update_thumbnails() From 93c2e1ad900701147b96c38886791fdbb7be55e5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:48:40 +0200 Subject: [PATCH 347/781] added quick filter input for items --- .../ayon_core/tools/loader/ui/search_bar.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 5e9e409ee1..df77f5405e 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -300,6 +300,12 @@ class FilterValueItemsView(QtWidgets.QWidget): def __init__(self, parent): super().__init__(parent) + filter_input = QtWidgets.QLineEdit(self) + + filter_timeout = QtCore.QTimer(self) + filter_timeout.setSingleShot(True) + filter_timeout.setInterval(20) + scroll_area = QtWidgets.QScrollArea(self) scroll_area.setObjectName("ScrollArea") srcoll_viewport = scroll_area.viewport() @@ -310,6 +316,7 @@ class FilterValueItemsView(QtWidgets.QWidget): content_widget = QtWidgets.QWidget(scroll_area) content_widget.setObjectName("ContentWidget") + content_layout = QtWidgets.QVBoxLayout(content_widget) content_layout.setContentsMargins(0, 0, 0, 0) @@ -331,19 +338,31 @@ class FilterValueItemsView(QtWidgets.QWidget): main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(filter_input, 0) main_layout.addWidget(scroll_area) main_layout.addWidget(btns_widget, 0) + filter_timeout.timeout.connect(self._on_filter_timeout) + filter_input.textChanged.connect(self._on_filter_change) select_all_btn.clicked.connect(self._on_select_all) clear_btn.clicked.connect(self._on_clear_selection) swap_btn.clicked.connect(self._on_swap_selection) + self._filter_timeout = filter_timeout + self._filter_input = filter_input self._btns_widget = btns_widget self._multiselection = False self._content_layout = content_layout self._last_selected_widget = None self._widgets_by_id = {} + def showEvent(self, event): + super().showEvent(event) + self._filter_timeout.start() + + def _on_filter_timeout(self): + self._filter_input.setFocus() + def set_value(self, value): current_value = self.get_value() if self._multiselection: @@ -453,6 +472,12 @@ class FilterValueItemsView(QtWidgets.QWidget): self._btns_widget.setVisible(self._multiselection) self._content_layout.addStretch(1) + def _on_filter_change(self, text): + text = text.lower() + for widget in self._widgets_by_id.values(): + visible = not text or text in widget.get_value().lower() + widget.setVisible(visible) + def _on_select_all(self): changed = False for widget in self._widgets_by_id.values(): From 5ebdd74bf728621a2d0fb605223c40855408b623 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:49:23 +0200 Subject: [PATCH 348/781] reset filter text on fitlers change --- client/ayon_core/tools/loader/ui/search_bar.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index df77f5405e..7108ea8b11 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -446,8 +446,11 @@ class FilterValueItemsView(QtWidgets.QWidget): if widget is not None: widget.setVisible(False) widget.deleteLater() + self._widgets_by_id = {} self._last_selected_widget = None + # Change filter + self._filter_input.setText("") for item in items: widget_id = uuid.uuid4().hex From f1e93e980755387a77a62943e8b5329d6ecf82cf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:59:40 +0200 Subject: [PATCH 349/781] close popups on enter press --- .../ayon_core/tools/loader/ui/search_bar.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 7108ea8b11..31069904b0 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -199,6 +199,13 @@ class FiltersPopup(QtWidgets.QWidget): self._wrapper_layout = wraper_layout self._preferred_width = None + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): + event.accept() + self.close() + return + super().keyPressEvent(event) + def set_preferred_width(self, width: int): self._preferred_width = width @@ -296,12 +303,15 @@ class FilterValueItemButton(BaseClickableFrame): class FilterValueItemsView(QtWidgets.QWidget): value_changed = QtCore.Signal() + close_requested = QtCore.Signal() def __init__(self, parent): super().__init__(parent) filter_input = QtWidgets.QLineEdit(self) + # Timeout is used to delay the filter focus change on 'showEvent' + # - the focus is changed to something else if is not delayed filter_timeout = QtCore.QTimer(self) filter_timeout.setSingleShot(True) filter_timeout.setInterval(20) @@ -344,6 +354,7 @@ class FilterValueItemsView(QtWidgets.QWidget): filter_timeout.timeout.connect(self._on_filter_timeout) filter_input.textChanged.connect(self._on_filter_change) + filter_input.returnPressed.connect(self.close_requested) select_all_btn.clicked.connect(self._on_select_all) clear_btn.clicked.connect(self._on_clear_selection) swap_btn.clicked.connect(self._on_swap_selection) @@ -360,8 +371,12 @@ class FilterValueItemsView(QtWidgets.QWidget): super().showEvent(event) self._filter_timeout.start() - def _on_filter_timeout(self): - self._filter_input.setFocus() + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): + event.accept() + self.close_requested.emit() + return + super().keyPressEvent(event) def set_value(self, value): current_value = self.get_value() @@ -470,11 +485,16 @@ class FilterValueItemsView(QtWidgets.QWidget): "No items to select from...", self ) self._btns_widget.setVisible(False) + self._filter_input.setVisible(False) self._content_layout.addWidget(empty_label, 0) else: + self._filter_input.setVisible(True) self._btns_widget.setVisible(self._multiselection) self._content_layout.addStretch(1) + def _on_filter_timeout(self): + self._filter_input.setFocus() + def _on_filter_change(self, text): text = text.lower() for widget in self._widgets_by_id.values(): @@ -565,6 +585,7 @@ class FilterValuePopup(QtWidgets.QWidget): text_input.returnPressed.connect(self._text_confirmed) items_view.value_changed.connect(self._selection_changed) + items_view.close_requested.connect(self._close_requested) shadow_frame.stackUnder(wrapper) @@ -667,6 +688,9 @@ class FilterValuePopup(QtWidgets.QWidget): def _selection_changed(self): self.value_changed.emit(self._filter_name) + def _close_requested(self): + self.close() + class FiltersBar(BaseClickableFrame): filter_changed = QtCore.Signal(str) From 4180fcfdb4f6a12636eb812ed31c98bcd166a23e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:07:34 +0200 Subject: [PATCH 350/781] added ctrl + F shortcut to show filter popup --- client/ayon_core/tools/loader/ui/search_bar.py | 6 +++--- client/ayon_core/tools/loader/ui/window.py | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 31069904b0..8ab455a158 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -808,9 +808,9 @@ class FiltersBar(BaseClickableFrame): self._filters_widget.setGeometry(geo) def _mouse_release_callback(self): - self._show_filters_popup() + self.show_filters_popup() - def _show_filters_popup(self): + def show_filters_popup(self): filter_defs = [ filter_def for filter_def in self._filter_defs_by_name.values() @@ -830,7 +830,7 @@ class FiltersBar(BaseClickableFrame): self._show_popup(filters_popup) def _on_filters_request(self): - self._show_filters_popup() + self.show_filters_popup() def _on_filter_request(self, filter_name: str): """Handle filter request from the popup.""" diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index a3a476b330..314a4f749e 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -346,6 +346,15 @@ class LoaderWindow(QtWidgets.QWidget): ctrl_pressed = QtCore.Qt.ControlModifier & modifiers # Grouping products on pressing Ctrl + G + if ( + ctrl_pressed + and event.key() == QtCore.Qt.Key_F + and not event.isAutoRepeat() + ): + self._search_bar.show_filters_popup() + event.setAccepted(True) + return + if ( ctrl_pressed and event.key() == QtCore.Qt.Key_G From 3f0e96cb6253cabdd4c379cd4d4222eb8366cdb5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:08:47 +0200 Subject: [PATCH 351/781] better method position --- .../ayon_core/tools/loader/ui/search_bar.py | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 8ab455a158..9b74f63557 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -745,6 +745,25 @@ class FiltersBar(BaseClickableFrame): super().resizeEvent(event) self._update_filters_geo() + def show_filters_popup(self): + filter_defs = [ + filter_def + for filter_def in self._filter_defs_by_name.values() + if filter_def.name not in self._widgets_by_name + ] + filters_popup = FiltersPopup(self) + filters_popup.filter_requested.connect(self._on_filter_request) + filters_popup.set_filter_items(filter_defs) + filters_popup.set_preferred_width(self.width()) + + old_popup, self._filters_popup = self._filters_popup, filters_popup + + self._filter_value_popup.setVisible(False) + old_popup.setVisible(False) + old_popup.deleteLater() + + self._show_popup(filters_popup) + def set_search_items(self, filter_defs: list[FilterDefinition]): self._filter_defs_by_name = { filter_def.name: filter_def @@ -810,25 +829,6 @@ class FiltersBar(BaseClickableFrame): def _mouse_release_callback(self): self.show_filters_popup() - def show_filters_popup(self): - filter_defs = [ - filter_def - for filter_def in self._filter_defs_by_name.values() - if filter_def.name not in self._widgets_by_name - ] - filters_popup = FiltersPopup(self) - filters_popup.filter_requested.connect(self._on_filter_request) - filters_popup.set_filter_items(filter_defs) - filters_popup.set_preferred_width(self.width()) - - old_popup, self._filters_popup = self._filters_popup, filters_popup - - self._filter_value_popup.setVisible(False) - old_popup.setVisible(False) - old_popup.deleteLater() - - self._show_popup(filters_popup) - def _on_filters_request(self): self.show_filters_popup() From 4457a432cb382e7a21fa8202e3ab1b2f524b7239 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:19:19 +0200 Subject: [PATCH 352/781] Merged pre/post hooks into single class --- client/ayon_core/pipeline/load/__init__.py | 27 +++++----------- client/ayon_core/pipeline/load/plugins.py | 36 ++++++---------------- 2 files changed, 18 insertions(+), 45 deletions(-) diff --git a/client/ayon_core/pipeline/load/__init__.py b/client/ayon_core/pipeline/load/__init__.py index eaba8cd78d..2a33fa119b 100644 --- a/client/ayon_core/pipeline/load/__init__.py +++ b/client/ayon_core/pipeline/load/__init__.py @@ -24,7 +24,6 @@ from .utils import ( get_loader_identifier, get_loaders_by_name, - get_hook_loaders_by_identifier, get_representation_path_from_context, get_representation_path, @@ -51,15 +50,10 @@ from .plugins import ( register_loader_plugin_path, deregister_loader_plugin, - register_loader_pre_hook_plugin, - deregister_loader_pre_hook_plugin, - register_loader_pre_hook_plugin_path, - deregister_loader_pre_hook_plugin_path, - - register_loader_post_hook_plugin, - deregister_loader_post_hook_plugin, - register_loader_post_hook_plugin_path, - deregister_loader_post_hook_plugin_path, + register_loader_hook_plugin, + deregister_loader_hook_plugin, + register_loader_hook_plugin_path, + deregister_loader_hook_plugin_path, ) @@ -90,7 +84,6 @@ __all__ = ( "get_loader_identifier", "get_loaders_by_name", - "get_hook_loaders_by_identifier", "get_representation_path_from_context", "get_representation_path", @@ -116,13 +109,9 @@ __all__ = ( "register_loader_plugin_path", "deregister_loader_plugin", - "register_loader_pre_hook_plugin", - "deregister_loader_pre_hook_plugin", - "register_loader_pre_hook_plugin_path", - "deregister_loader_pre_hook_plugin_path", + "register_loader_hook_plugin", + "deregister_loader_hook_plugin", + "register_loader_hook_plugin_path", + "deregister_loader_hook_plugin_path", - "register_loader_post_hook_plugin", - "deregister_loader_post_hook_plugin", - "register_loader_post_hook_plugin_path", - "deregister_loader_post_hook_plugin_path", ) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index ed26e5fe74..39133bc342 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -253,8 +253,8 @@ class ProductLoaderPlugin(LoaderPlugin): """ -class PreLoaderHookPlugin: - """Plugin that should be run before any Loaders in 'loaders' +class PrePostLoaderHookPlugin: + """Plugin that should be run before or post specific Loader in 'loaders' Should be used as non-invasive method to enrich core loading process. Any studio might want to modify loaded data before or after @@ -336,33 +336,17 @@ def register_loader_plugin_path(path): return register_plugin_path(LoaderPlugin, path) -def register_loader_pre_hook_plugin(plugin): - return register_plugin(PreLoaderHookPlugin, plugin) +def register_loader_hook_plugin(plugin): + return register_plugin(PrePostLoaderHookPlugin, plugin) -def deregister_loader_pre_hook_plugin(plugin): - deregister_plugin(PreLoaderHookPlugin, plugin) +def deregister_loader_hook_plugin(plugin): + deregister_plugin(PrePostLoaderHookPlugin, plugin) -def register_loader_pre_hook_plugin_path(path): - return register_plugin_path(PreLoaderHookPlugin, path) +def register_loader_hook_plugin_path(path): + return register_plugin_path(PrePostLoaderHookPlugin, path) -def deregister_loader_pre_hook_plugin_path(path): - deregister_plugin_path(PreLoaderHookPlugin, path) - - -def register_loader_post_hook_plugin(plugin): - return register_plugin(PostLoaderHookPlugin, plugin) - - -def deregister_loader_post_hook_plugin(plugin): - deregister_plugin(PostLoaderHookPlugin, plugin) - - -def register_loader_post_hook_plugin_path(path): - return register_plugin_path(PostLoaderHookPlugin, path) - - -def deregister_loader_post_hook_plugin_path(path): - deregister_plugin_path(PostLoaderHookPlugin, path) +def deregister_loader_hook_plugin_path(path): + deregister_plugin_path(PrePostLoaderHookPlugin, path) From ca768aeddf81a0cb7e1e76fd7326ca10ea1e2c5d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:21:56 +0200 Subject: [PATCH 353/781] Removed get_hook_loaders_by_identifier Replaced by monkeypatch load method --- client/ayon_core/pipeline/load/utils.py | 37 ------------------- .../ayon_core/tools/loader/models/actions.py | 25 +------------ .../ayon_core/tools/sceneinventory/control.py | 13 ------- .../sceneinventory/switch_dialog/dialog.py | 2 - client/ayon_core/tools/sceneinventory/view.py | 2 - 5 files changed, 2 insertions(+), 77 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 186cc8f0d7..fba4a8d2a8 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -293,7 +293,6 @@ def load_with_repre_context( namespace=None, name=None, options=None, - hooks=None, **kwargs ): @@ -338,7 +337,6 @@ def load_with_product_context( namespace=None, name=None, options=None, - hooks=None, **kwargs ): @@ -373,7 +371,6 @@ def load_with_product_contexts( namespace=None, name=None, options=None, - hooks=None, **kwargs ): @@ -1178,37 +1175,3 @@ def filter_containers(containers, project_name): uptodate_containers.append(container) return output - - -def get_hook_loaders_by_identifier(): - """Discovers pre/post hooks for loader plugins. - - Returns: - (dict) {"LoaderName": {"pre": ["PreLoader1"], "post":["PreLoader2]} - """ - # beware of circular imports! - from .plugins import PreLoadHookPlugin, PostLoadHookPlugin - - hook_loaders_by_identifier = {} - _get_hook_loaders(hook_loaders_by_identifier, PreLoadHookPlugin, "pre") - _get_hook_loaders(hook_loaders_by_identifier, PostLoadHookPlugin, "post") - return hook_loaders_by_identifier - - -def _get_hook_loaders(hook_loaders_by_identifier, loader_plugin, loader_type): - from ..plugin_discover import discover - - load_hook_plugins = discover(loader_plugin) - loaders_by_name = get_loaders_by_name() - for hook_plugin_cls in load_hook_plugins: - for load_plugin_name in hook_plugin_cls.loader_identifiers: - load_plugin = loaders_by_name.get(load_plugin_name) - if not load_plugin: - continue - if not load_plugin.enabled: - continue - identifier = get_loader_identifier(load_plugin) - (hook_loaders_by_identifier.setdefault(identifier, {}) - .setdefault(loader_type, []).append( - hook_plugin_cls) - ) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index c2444da456..6ce9b0836d 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -17,8 +17,6 @@ from ayon_core.pipeline.load import ( load_with_product_contexts, LoadError, IncompatibleLoaderError, - get_loaders_by_name, - get_hook_loaders_by_identifier ) from ayon_core.tools.loader.abstract import ActionItem @@ -149,14 +147,12 @@ class LoaderActionsModel: ACTIONS_MODEL_SENDER, ) loader = self._get_loader_by_identifier(project_name, identifier) - hooks = self._get_hook_loaders_by_identifier(project_name, identifier) if representation_ids is not None: error_info = self._trigger_representation_loader( loader, options, project_name, representation_ids, - hooks ) elif version_ids is not None: error_info = self._trigger_version_loader( @@ -164,7 +160,6 @@ class LoaderActionsModel: options, project_name, version_ids, - hooks ) else: raise NotImplementedError( @@ -336,7 +331,6 @@ class LoaderActionsModel: available_loaders = self._filter_loaders_by_tool_name( project_name, discover_loader_plugins(project_name) ) - hook_loaders_by_identifier = get_hook_loaders_by_identifier() repre_loaders = [] product_loaders = [] loaders_by_identifier = {} @@ -354,7 +348,6 @@ class LoaderActionsModel: loaders_by_identifier_c.update_data(loaders_by_identifier) product_loaders_c.update_data(product_loaders) repre_loaders_c.update_data(repre_loaders) - hook_loaders_by_identifier_c.update_data(hook_loaders_by_identifier) return product_loaders, repre_loaders @@ -365,13 +358,6 @@ class LoaderActionsModel: loaders_by_identifier = loaders_by_identifier_c.get_data() return loaders_by_identifier.get(identifier) - def _get_hook_loaders_by_identifier(self, project_name, identifier): - if not self._hook_loaders_by_identifier[project_name].is_valid: - self._get_loaders(project_name) - hook_loaders_by_identifier_c = self._hook_loaders_by_identifier[project_name] - hook_loaders_by_identifier_c = hook_loaders_by_identifier_c.get_data() - return hook_loaders_by_identifier_c.get(identifier) - def _actions_sorter(self, action_item): """Sort the Loaders by their order and then their name. @@ -629,7 +615,6 @@ class LoaderActionsModel: options, project_name, version_ids, - hooks=None ): """Trigger version loader. @@ -679,7 +664,7 @@ class LoaderActionsModel: }) return self._load_products_by_loader( - loader, product_contexts, options, hooks=hooks + loader, product_contexts, options ) def _trigger_representation_loader( @@ -688,7 +673,6 @@ class LoaderActionsModel: options, project_name, representation_ids, - hooks ): """Trigger representation loader. @@ -741,7 +725,7 @@ class LoaderActionsModel: }) return self._load_representations_by_loader( - loader, repre_contexts, options, hooks + loader, repre_contexts, options ) def _load_representations_by_loader( @@ -749,7 +733,6 @@ class LoaderActionsModel: loader, repre_contexts, options, - hooks=None ): """Loops through list of repre_contexts and loads them with one loader @@ -773,7 +756,6 @@ class LoaderActionsModel: loader, repre_context, options=options, - hooks=hooks ) except IncompatibleLoaderError as exc: @@ -808,7 +790,6 @@ class LoaderActionsModel: loader, version_contexts, options, - hooks=None ): """Triggers load with ProductLoader type of loaders. @@ -834,7 +815,6 @@ class LoaderActionsModel: loader, version_contexts, options=options, - hooks=hooks ) except Exception as exc: formatted_traceback = None @@ -860,7 +840,6 @@ class LoaderActionsModel: loader, version_context, options=options, - hooks=hooks ) except Exception as exc: diff --git a/client/ayon_core/tools/sceneinventory/control.py b/client/ayon_core/tools/sceneinventory/control.py index c4e59699c3..60d9bc77a9 100644 --- a/client/ayon_core/tools/sceneinventory/control.py +++ b/client/ayon_core/tools/sceneinventory/control.py @@ -5,7 +5,6 @@ from ayon_core.host import HostBase from ayon_core.pipeline import ( registered_host, get_current_context, - get_hook_loaders_by_identifier ) from ayon_core.tools.common_models import HierarchyModel, ProjectsModel @@ -36,8 +35,6 @@ class SceneInventoryController: self._projects_model = ProjectsModel(self) self._event_system = self._create_event_system() - self._hooks_by_identifier = None - def get_host(self) -> HostBase: return self._host @@ -118,16 +115,6 @@ class SceneInventoryController: return self._containers_model.get_version_items( project_name, product_ids) - def get_hook_loaders_by_identifier(self): - """Returns lists of pre|post hooks per Loader identifier. - - Returns: - (dict) {"LoaderName": {"pre": ["PreLoader1"], "post":["PreLoader2]} - """ - if self._hooks_by_identifier is None: - self._hooks_by_identifier = get_hook_loaders_by_identifier() - return self._hooks_by_identifier - # Site Sync methods def is_sitesync_enabled(self): return self._sitesync_model.is_sitesync_enabled() diff --git a/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py b/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py index c878cad079..f1b144f2aa 100644 --- a/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py +++ b/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py @@ -1339,13 +1339,11 @@ class SwitchAssetDialog(QtWidgets.QDialog): repre_entity = repres_by_name[container_repre_name] error = None - hook_loaders_by_id = self._controller.get_hook_loaders_by_identifier() try: switch_container( container, repre_entity, loader, - hook_loaders_by_id ) except ( LoaderSwitchNotImplementedError, diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index 75d3d9a680..42338fe33b 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -1100,7 +1100,6 @@ class SceneInventoryView(QtWidgets.QTreeView): containers_by_id = self._controller.get_containers_by_item_ids( item_ids ) - hook_loaders_by_id = self._controller.get_hook_loaders_by_identifier() try: for item_id, item_version in zip(item_ids, versions): container = containers_by_id[item_id] @@ -1108,7 +1107,6 @@ class SceneInventoryView(QtWidgets.QTreeView): update_container( container, item_version, - hook_loaders_by_id ) except AssertionError: log.warning("Update failed", exc_info=True) From 37f5f5583250c122e15898f5b0b33374b3f89b57 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:22:24 +0200 Subject: [PATCH 354/781] Added typing --- client/ayon_core/pipeline/load/plugins.py | 36 ++++++++--------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 39133bc342..56b6a84706 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -265,33 +265,23 @@ class PrePostLoaderHookPlugin: def process(self, context, name=None, namespace=None, options=None): pass - def update(self, container, context): - pass - - def switch(self, container, context): - pass - - -class PostLoaderHookPlugin: - """Plugin that should be run after any Loaders in 'loaders' - - Should be used as non-invasive method to enrich core loading process. - Any external studio might want to modify loaded data before or after - they are loaded without need to override existing core plugins. - """ - loader_identifiers: ClassVar[set[str]] - - def process( + def pre_process( self, - container, - context, - name=None, - namespace=None, - options=None + context: dict, + name: str | None = None, + namespace: str | None = None, + options: dict | None = None, ): pass - def update(self, container, context): + def post_process( + self, + container: dict, # (ayon:container-3.0) + context: dict, + name: str | None = None, + namespace: str | None = None, + options: dict | None = None + ): pass def switch(self, container, context): From b742dfc381cfca3a8584a126aae67529557b5f43 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:22:44 +0200 Subject: [PATCH 355/781] Changed from loader_identifiers to is_compatible method --- client/ayon_core/pipeline/load/plugins.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 56b6a84706..c72f697e8b 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -260,9 +260,8 @@ class PrePostLoaderHookPlugin: Any studio might want to modify loaded data before or after they are loaded without need to override existing core plugins. """ - loader_identifiers: ClassVar[set[str]] - - def process(self, context, name=None, namespace=None, options=None): + @classmethod + def is_compatible(cls, Loader: LoaderPlugin) -> bool: pass def pre_process( From 976ef5fb2b8064efecfa4824e3d516b18bab197a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:23:22 +0200 Subject: [PATCH 356/781] Added monkey patched load method if hooks are found for loader --- client/ayon_core/pipeline/load/plugins.py | 32 +++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index c72f697e8b..695e4634f8 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -292,10 +292,11 @@ def discover_loader_plugins(project_name=None): from ayon_core.pipeline import get_current_project_name log = Logger.get_logger("LoaderDiscover") - plugins = discover(LoaderPlugin) if not project_name: project_name = get_current_project_name() project_settings = get_project_settings(project_name) + plugins = discover(LoaderPlugin) + hooks = discover(PrePostLoaderHookPlugin) for plugin in plugins: try: plugin.apply_settings(project_settings) @@ -304,11 +305,38 @@ def discover_loader_plugins(project_name=None): "Failed to apply settings to loader {}".format( plugin.__name__ ), - exc_info=True + exc_info=True, ) + for Hook in hooks: + if Hook.is_compatible(plugin): + hook_loader_load(plugin, Hook()) return plugins +def hook_loader_load(loader_class, hook_instance): + # If this is the first hook being added, wrap the original load method + if not hasattr(loader_class, '_load_hooks'): + loader_class._load_hooks = [] + + original_load = loader_class.load + + def wrapped_load(self, *args, **kwargs): + # Call pre_load on all hooks + for hook in loader_class._load_hooks: + hook.pre_load(*args, **kwargs) + # Call original load + result = original_load(self, *args, **kwargs) + # Call post_load on all hooks + for hook in loader_class._load_hooks: + hook.post_load(*args, **kwargs) + return result + + loader_class.load = wrapped_load + + # Add the new hook instance to the list + loader_class._load_hooks.append(hook_instance) + + def register_loader_plugin(plugin): return register_plugin(LoaderPlugin, plugin) From d936c2f4d091bbe4a7e9887ec4deb927ad59ad04 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:27:07 +0200 Subject: [PATCH 357/781] define key sequences --- client/ayon_core/tools/loader/ui/window.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 314a4f749e..01d96410ed 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -23,6 +23,13 @@ from .info_widget import InfoWidget from .repres_widget import RepresentationsWidget from .search_bar import FiltersBar, FilterDefinition +FIND_KEY_SEQUENCE = QtGui.QKeySequence( + QtCore.Qt.Modifier.CTRL | QtCore.Qt.Key_F +) +GROUP_KEY_SEQUENCE = QtGui.QKeySequence( + QtCore.Qt.Modifier.CTRL | QtCore.Qt.Key_G +) + class LoadErrorMessageBox(ErrorMessageBox): def __init__(self, messages, parent=None): @@ -342,22 +349,18 @@ class LoaderWindow(QtWidgets.QWidget): self._reset_on_show = True def keyPressEvent(self, event): - modifiers = event.modifiers() - ctrl_pressed = QtCore.Qt.ControlModifier & modifiers - - # Grouping products on pressing Ctrl + G + combination = event.keyCombination() if ( - ctrl_pressed - and event.key() == QtCore.Qt.Key_F + FIND_KEY_SEQUENCE.matches(combination) and not event.isAutoRepeat() ): self._search_bar.show_filters_popup() event.setAccepted(True) return + # Grouping products on pressing Ctrl + G if ( - ctrl_pressed - and event.key() == QtCore.Qt.Key_G + GROUP_KEY_SEQUENCE.matches(combination) and not event.isAutoRepeat() ): self._show_group_dialog() From 4c113ca5b50e5afbdcfd1291bd637d0c3b665026 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:28:02 +0200 Subject: [PATCH 358/781] Removed unneeded _load_context --- client/ayon_core/pipeline/load/utils.py | 51 ++----------------------- 1 file changed, 3 insertions(+), 48 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index fba4a8d2a8..02a48dc6b6 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -321,14 +321,7 @@ def load_with_repre_context( ) loader = Loader() - return _load_context( - Loader, - repre_context, - name, - namespace, - options, - hooks - ) + loader.load(repre_context, name, namespace, options) def load_with_product_context( @@ -355,14 +348,7 @@ def load_with_product_context( Loader.__name__, product_context["folder"]["path"] ) ) - return _load_context( - Loader, - product_context, - name, - namespace, - options, - hooks - ) + return Loader().load(product_context, name, namespace, options) def load_with_product_contexts( @@ -393,38 +379,7 @@ def load_with_product_contexts( Loader.__name__, joined_product_names ) ) - return _load_context( - Loader, - product_contexts, - name, - namespace, - options, - hooks - ) - - -def _load_context(Loader, contexts, name, namespace, options, hooks): - """Helper function to wrap hooks around generic load function. - - Only dynamic part is different context(s) to be loaded. - """ - for hook_plugin_cls in hooks.get("pre", []): - hook_plugin_cls().process( - contexts, - name, - namespace, - options, - ) - loaded_container = Loader().load(contexts, name, namespace, options) - for hook_plugin_cls in hooks.get("post", []): - hook_plugin_cls().process( - loaded_container, - contexts, - name, - namespace, - options, - ) - return loaded_container + return Loader().load(product_contexts, name, namespace, options) def load_container( From 55583e68f8c388a15514db16859225288426f1df Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:29:14 +0200 Subject: [PATCH 359/781] Fix missing return --- client/ayon_core/pipeline/load/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 02a48dc6b6..75d9450003 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -321,7 +321,7 @@ def load_with_repre_context( ) loader = Loader() - loader.load(repre_context, name, namespace, options) + return loader.load(repre_context, name, namespace, options) def load_with_product_context( From 7e88916fcd8567da2b466a27bdd1b7096e1bc106 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:29:54 +0200 Subject: [PATCH 360/781] Reverted missing newline --- client/ayon_core/pipeline/load/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 75d9450003..45000b023b 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -348,6 +348,7 @@ def load_with_product_context( Loader.__name__, product_context["folder"]["path"] ) ) + return Loader().load(product_context, name, namespace, options) @@ -379,6 +380,7 @@ def load_with_product_contexts( Loader.__name__, joined_product_names ) ) + return Loader().load(product_contexts, name, namespace, options) From f21f4a5e016c0f02c187d92d7d4051714096a9d0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:33:09 +0200 Subject: [PATCH 361/781] Removed usage of hooks in update, switch --- client/ayon_core/pipeline/load/utils.py | 33 +++---------------------- 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 45000b023b..a61ffee95a 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -469,7 +469,7 @@ def remove_container(container): return Loader().remove(container) -def update_container(container, version=-1, hooks_by_identifier=None): +def update_container(container, version=-1): """Update a container""" from ayon_core.pipeline import get_current_project_name @@ -565,20 +565,7 @@ def update_container(container, version=-1, hooks_by_identifier=None): if not path or not os.path.exists(path): raise ValueError("Path {} doesn't exist".format(path)) - loader_identifier = get_loader_identifier(Loader) - hooks = hooks_by_identifier.get(loader_identifier, {}) - for hook_plugin_cls in hooks.get("pre", []): - hook_plugin_cls().update( - context, - container - ) - updated_container = Loader().update(container, context) - for hook_plugin_cls in hooks.get("post", []): - hook_plugin_cls().update( - context, - container - ) - return updated_container + return Loader().update(container, context) def switch_container( @@ -635,21 +622,7 @@ def switch_container( loader = loader_plugin(context) - loader_identifier = get_loader_identifier(loader) - hooks = hooks_by_identifier.get(loader_identifier, {}) - for hook_plugin_cls in hooks.get("pre", []): - hook_plugin_cls().switch( - context, - container - ) - switched_container = loader.switch(container, context) - for hook_plugin_cls in hooks.get("post", []): - hook_plugin_cls().switch( - context, - container - ) - - return switched_container + return loader.switch(container, context) def _fix_representation_context_compatibility(repre_context): From 07809b56ddcadf764182fbeb923d72bd59ec734c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:35:22 +0200 Subject: [PATCH 362/781] Removed _hook_loaders_by_identifier in actions --- client/ayon_core/tools/loader/models/actions.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 6ce9b0836d..f55f303f21 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -51,8 +51,6 @@ class LoaderActionsModel: levels=1, lifetime=self.loaders_cache_lifetime) self._repre_loaders = NestedCacheItem( levels=1, lifetime=self.loaders_cache_lifetime) - self._hook_loaders_by_identifier = NestedCacheItem( - levels=1, lifetime=self.loaders_cache_lifetime) def reset(self): """Reset the model with all cached items.""" @@ -61,7 +59,6 @@ class LoaderActionsModel: self._loaders_by_identifier.reset() self._product_loaders.reset() self._repre_loaders.reset() - self._hook_loaders_by_identifier.reset() def get_versions_action_items(self, project_name, version_ids): """Get action items for given version ids. @@ -318,13 +315,8 @@ class LoaderActionsModel: loaders_by_identifier_c = self._loaders_by_identifier[project_name] product_loaders_c = self._product_loaders[project_name] repre_loaders_c = self._repre_loaders[project_name] - hook_loaders_by_identifier_c = self._hook_loaders_by_identifier[project_name] if loaders_by_identifier_c.is_valid: - return ( - product_loaders_c.get_data(), - repre_loaders_c.get_data(), - hook_loaders_by_identifier_c.get_data() - ) + return product_loaders_c.get_data(), repre_loaders_c.get_data() # Get all representation->loader combinations available for the # index under the cursor, so we can list the user the options. From 9a050d92006c268ac4a99ddce83ae31c87006832 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:35:32 +0200 Subject: [PATCH 363/781] Formatting change --- client/ayon_core/tools/loader/models/actions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index f55f303f21..9a1a19c682 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -724,7 +724,7 @@ class LoaderActionsModel: self, loader, repre_contexts, - options, + options ): """Loops through list of repre_contexts and loads them with one loader @@ -747,7 +747,7 @@ class LoaderActionsModel: load_with_repre_context( loader, repre_context, - options=options, + options=options ) except IncompatibleLoaderError as exc: @@ -781,7 +781,7 @@ class LoaderActionsModel: self, loader, version_contexts, - options, + options ): """Triggers load with ProductLoader type of loaders. @@ -806,7 +806,7 @@ class LoaderActionsModel: load_with_product_contexts( loader, version_contexts, - options=options, + options=options ) except Exception as exc: formatted_traceback = None @@ -831,7 +831,7 @@ class LoaderActionsModel: load_with_product_context( loader, version_context, - options=options, + options=options ) except Exception as exc: From c8cca23e48fe6cd52a7632b376bd3946e38e3d57 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:37:43 +0200 Subject: [PATCH 364/781] Revert unneeded change --- client/ayon_core/tools/sceneinventory/view.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index 42338fe33b..bb95e37d4e 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -1104,10 +1104,7 @@ class SceneInventoryView(QtWidgets.QTreeView): for item_id, item_version in zip(item_ids, versions): container = containers_by_id[item_id] try: - update_container( - container, - item_version, - ) + update_container(container, item_version) except AssertionError: log.warning("Update failed", exc_info=True) self._show_version_error_dialog( From a4babae5f5432d6d01f68e44d2b5b6675e08365d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:38:29 +0200 Subject: [PATCH 365/781] Revert unneeded change --- .../ayon_core/tools/sceneinventory/switch_dialog/dialog.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py b/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py index f1b144f2aa..a6d88ed44a 100644 --- a/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py +++ b/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py @@ -1340,11 +1340,7 @@ class SwitchAssetDialog(QtWidgets.QDialog): error = None try: - switch_container( - container, - repre_entity, - loader, - ) + switch_container(container, repre_entity, loader) except ( LoaderSwitchNotImplementedError, IncompatibleLoaderError, From 26e2b45c9c4f00425a261a5c46de9dfd2451d709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:41:12 +0200 Subject: [PATCH 366/781] Update client/ayon_core/tools/loader/abstract.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/loader/abstract.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index d6a4bf40cb..1c3e30a109 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -215,22 +215,21 @@ class VersionItem: def __init__( self, - *, version_id: str, version: int, is_hero: bool, product_id: str, - task_id: Optional[str] = None, - thumbnail_id: Optional[str] = None, - published_time: Optional[str] = None, - author: Optional[str] = None, - status: Optional[str] = None, - frame_range: Optional[str] = None, - duration: Optional[int] = None, - handles: Optional[str] = None, - step: Optional[int] = None, - comment: Optional[str] = None, - source: Optional[str] = None, + task_id: Optional[str], + thumbnail_id: Optional[str], + published_time: Optional[str], + author: Optional[str], + status: Optional[str], + frame_range: Optional[str], + duration: Optional[int], + handles: Optional[str], + step: Optional[int], + comment: Optional[str], + source: Optional[str], ): self.version_id = version_id self.product_id = product_id From 1d66a86d799863aa51e2161fd0dbed5e337a6426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:41:22 +0200 Subject: [PATCH 367/781] Update client/ayon_core/tools/loader/abstract.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/loader/abstract.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 1c3e30a109..804956f875 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -144,7 +144,6 @@ class ProductItem: folder_id: str, folder_label: str, version_items: dict[str, VersionItem], - *, product_in_scene: bool, ): self.product_id = product_id From 00f948e9ea08ed3706b9033b630c0372c8749c31 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:45:56 +0200 Subject: [PATCH 368/781] fix qkeysequence match comparison --- client/ayon_core/tools/loader/ui/window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 01d96410ed..047c6fb159 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -351,7 +351,7 @@ class LoaderWindow(QtWidgets.QWidget): def keyPressEvent(self, event): combination = event.keyCombination() if ( - FIND_KEY_SEQUENCE.matches(combination) + FIND_KEY_SEQUENCE == combination and not event.isAutoRepeat() ): self._search_bar.show_filters_popup() @@ -360,7 +360,7 @@ class LoaderWindow(QtWidgets.QWidget): # Grouping products on pressing Ctrl + G if ( - GROUP_KEY_SEQUENCE.matches(combination) + GROUP_KEY_SEQUENCE == combination and not event.isAutoRepeat() ): self._show_group_dialog() From 83398c6a3e92b93181bd17224b9cd9c46531195a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:52:29 +0200 Subject: [PATCH 369/781] auto-fill product name filter on typing in filters popup --- .../ayon_core/tools/loader/ui/search_bar.py | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 9b74f63557..d3e702c031 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -172,6 +172,7 @@ class FilterItemButton(BaseClickableFrame): class FiltersPopup(QtWidgets.QWidget): filter_requested = QtCore.Signal(str) + text_filter_requested = QtCore.Signal(str) def __init__(self, parent): super().__init__(parent) @@ -204,6 +205,19 @@ class FiltersPopup(QtWidgets.QWidget): event.accept() self.close() return + + if event.key() not in ( + QtCore.Qt.Key_Escape, + QtCore.Qt.Key_Tab, + QtCore.Qt.Key_Backtab, + QtCore.Qt.Key_Backspace, + QtCore.Qt.Key_Return, + ): + text = event.text() + if text: + event.accept() + self.text_filter_requested.emit(text) + return super().keyPressEvent(event) def set_preferred_width(self, width: int): @@ -608,6 +622,17 @@ class FilterValuePopup(QtWidgets.QWidget): sh.setWidth(self._preferred_width) return sh + def set_text_filter(self, text: str): + if self._active_widget is None: + return + + if isinstance(self._active_widget, QtWidgets.QLineEdit): + full_text = self._active_widget.text() + text + self._active_widget.setText(full_text) + self._active_widget.setFocus() + self._active_widget.setCursorPosition(len(full_text)) + return + def set_filter_item( self, filter_def: FilterDefinition, @@ -726,7 +751,13 @@ class FiltersBar(BaseClickableFrame): main_layout.addWidget(search_btn, 0) main_layout.addWidget(filters_wrap, 1) + filters_popup = FiltersPopup(self) + filter_value_popup = FilterValuePopup(self) + search_btn.clicked.connect(self._on_filters_request) + filters_popup.text_filter_requested.connect( + self._on_text_filter_request + ) self._search_btn = search_btn self._filters_wrap = filters_wrap @@ -734,8 +765,8 @@ class FiltersBar(BaseClickableFrame): self._filters_layout = filters_layout self._widgets_by_name = {} self._filter_defs_by_name = {} - self._filters_popup = FiltersPopup(self) - self._filter_value_popup = FilterValuePopup(self) + self._filters_popup = filters_popup + self._filter_value_popup = filter_value_popup def showEvent(self, event): super().showEvent(event) @@ -753,6 +784,9 @@ class FiltersBar(BaseClickableFrame): ] filters_popup = FiltersPopup(self) filters_popup.filter_requested.connect(self._on_filter_request) + filters_popup.text_filter_requested.connect( + self._on_text_filter_request + ) filters_popup.set_filter_items(filter_defs) filters_popup.set_preferred_width(self.width()) @@ -832,6 +866,10 @@ class FiltersBar(BaseClickableFrame): def _on_filters_request(self): self.show_filters_popup() + def _on_text_filter_request(self, text: str): + self._on_filter_request("product_name") + self._filter_value_popup.set_text_filter(text) + def _on_filter_request(self, filter_name: str): """Handle filter request from the popup.""" self.add_item(filter_name) From 0e292eb3560d9771ded3b4c9ab0198a59952ef25 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 17:43:47 +0200 Subject: [PATCH 370/781] Renamed --- client/ayon_core/pipeline/load/plugins.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 695e4634f8..02479655eb 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -307,9 +307,9 @@ def discover_loader_plugins(project_name=None): ), exc_info=True, ) - for Hook in hooks: - if Hook.is_compatible(plugin): - hook_loader_load(plugin, Hook()) + for hookCls in hooks: + if hookCls.is_compatible(plugin): + hook_loader_load(plugin, hookCls()) return plugins From 276eff0097a2846cd7fd3497e22ff2f67ce88781 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 17:45:44 +0200 Subject: [PATCH 371/781] Added docstring --- client/ayon_core/pipeline/load/plugins.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 02479655eb..190b3d3a30 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -313,7 +313,11 @@ def discover_loader_plugins(project_name=None): return plugins -def hook_loader_load(loader_class, hook_instance): +def hook_loader_load( + loader_class: LoaderPlugin, + hook_instance: PrePostLoaderHookPlugin +) -> None: + """Monkey patch method replacing Loader.load method with wrapped hooks.""" # If this is the first hook being added, wrap the original load method if not hasattr(loader_class, '_load_hooks'): loader_class._load_hooks = [] From 118796a32523fedf60709437502f1ade8304dd7b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 17:46:41 +0200 Subject: [PATCH 372/781] Passed returned container from load as keyword Could be appended on position 0 on args, but this feels safer. --- client/ayon_core/pipeline/load/plugins.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 190b3d3a30..9040b122e3 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -264,7 +264,7 @@ class PrePostLoaderHookPlugin: def is_compatible(cls, Loader: LoaderPlugin) -> bool: pass - def pre_process( + def pre_load( self, context: dict, name: str | None = None, @@ -273,13 +273,13 @@ class PrePostLoaderHookPlugin: ): pass - def post_process( + def post_load( self, - container: dict, # (ayon:container-3.0) context: dict, name: str | None = None, namespace: str | None = None, - options: dict | None = None + options: dict | None = None, + container: dict | None = None, # (ayon:container-3.0) ): pass @@ -329,11 +329,12 @@ def hook_loader_load( for hook in loader_class._load_hooks: hook.pre_load(*args, **kwargs) # Call original load - result = original_load(self, *args, **kwargs) + container = original_load(self, *args, **kwargs) + kwargs["container"] = container # Call post_load on all hooks for hook in loader_class._load_hooks: hook.post_load(*args, **kwargs) - return result + return container loader_class.load = wrapped_load From 4ae7ab28ab0ff89ae930d5900307bfb2048d755e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 17:55:12 +0200 Subject: [PATCH 373/781] Removed unnecessary import --- client/ayon_core/pipeline/load/plugins.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 9040b122e3..808eaf9340 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,7 +1,6 @@ from __future__ import annotations import os import logging -from typing import ClassVar from ayon_core.settings import get_project_settings from ayon_core.pipeline.plugin_discover import ( 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 374/781] 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 cf4f9cfea61b2cbb95aa6caef3fcda10723c36b9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 16 Jun 2025 14:04:47 +0200 Subject: [PATCH 375/781] Removed unused argument --- client/ayon_core/pipeline/load/utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index a61ffee95a..3c50d76fb5 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -572,7 +572,6 @@ def switch_container( container, representation, loader_plugin=None, - hooks_by_identifier=None ): """Switch a container to representation @@ -580,7 +579,6 @@ def switch_container( container (dict): container information representation (dict): representation entity loader_plugin (LoaderPlugin) - hooks_by_identifier (dict): {"pre": [PreHookPlugin1], "post":[]} Returns: return from function call From 234e38869773507c0b9e8eb96a8d1f182e71a8e8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 16 Jun 2025 14:07:52 +0200 Subject: [PATCH 376/781] Added abstractmethod decorator --- client/ayon_core/pipeline/load/plugins.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 808eaf9340..50423af051 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,6 +1,7 @@ from __future__ import annotations import os import logging +from abc import abstractmethod from ayon_core.settings import get_project_settings from ayon_core.pipeline.plugin_discover import ( @@ -260,9 +261,11 @@ class PrePostLoaderHookPlugin: they are loaded without need to override existing core plugins. """ @classmethod + @abstractmethod def is_compatible(cls, Loader: LoaderPlugin) -> bool: pass + @abstractmethod def pre_load( self, context: dict, @@ -272,6 +275,7 @@ class PrePostLoaderHookPlugin: ): pass + @abstractmethod def post_load( self, context: dict, From 0e582c7d5fbdf54e90b54f5398257495e7e7735e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 16 Jun 2025 14:08:26 +0200 Subject: [PATCH 377/781] Update docstring --- 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 50423af051..2268935099 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -254,7 +254,7 @@ class ProductLoaderPlugin(LoaderPlugin): class PrePostLoaderHookPlugin: - """Plugin that should be run before or post specific Loader in 'loaders' + """Plugin that runs before and post specific Loader in 'loaders' Should be used as non-invasive method to enrich core loading process. Any studio might want to modify loaded data before or after From 03e3b29597c7c7ccab83e1777f7be13d4d419b3e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 16 Jun 2025 14:22:08 +0200 Subject: [PATCH 378/781] Renamed variable --- client/ayon_core/pipeline/load/plugins.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 2268935099..4cf497836c 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -310,9 +310,9 @@ def discover_loader_plugins(project_name=None): ), exc_info=True, ) - for hookCls in hooks: - if hookCls.is_compatible(plugin): - hook_loader_load(plugin, hookCls()) + for hook_cls in hooks: + if hook_cls.is_compatible(plugin): + hook_loader_load(plugin, hook_cls()) return plugins 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 379/781] 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 380/781] 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 d07dcb259021ebb22d8a295375a6bbcb0603f8ee Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:26:57 +0200 Subject: [PATCH 381/781] fix keycombination --- client/ayon_core/tools/loader/ui/window.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 047c6fb159..d056b62b13 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -349,7 +349,10 @@ class LoaderWindow(QtWidgets.QWidget): self._reset_on_show = True def keyPressEvent(self, event): - combination = event.keyCombination() + if hasattr(event, "keyCombination"): + combination = event.keyCombination() + else: + combination = QtGui.QKeySequence(event.modifiers() | event.key()) if ( FIND_KEY_SEQUENCE == combination and not event.isAutoRepeat() From 6006fcbb713e4724740775d3e59f1f50ed23973c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:29:18 +0200 Subject: [PATCH 382/781] go to name filtering on backspace --- client/ayon_core/tools/loader/ui/search_bar.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index d3e702c031..4620eb815f 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -206,11 +206,17 @@ class FiltersPopup(QtWidgets.QWidget): self.close() return + if event.key() in ( + QtCore.Qt.Key_Backtab, + QtCore.Qt.Key_Backspace, + ): + self.text_filter_requested.emit("") + event.accept() + return + if event.key() not in ( QtCore.Qt.Key_Escape, QtCore.Qt.Key_Tab, - QtCore.Qt.Key_Backtab, - QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Return, ): text = event.text() From 7c6c054cd7638f40cf6bf5d68d41cc6f4fc32e5f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:43:02 +0200 Subject: [PATCH 383/781] handle backspace --- .../ayon_core/tools/loader/ui/search_bar.py | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 4620eb815f..cb9dada37c 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -14,6 +14,23 @@ from ayon_core.tools.utils import ( ) +def set_line_edit_focus( + widget: QtWidgets.QLineEdit, + *, + append_text: Optional[str] = None, + backspace: bool = False, +): + full_text = widget.text() + if backspace and full_text: + full_text = full_text[:-1] + + if append_text: + full_text += append_text + widget.setText(full_text) + widget.setFocus() + widget.setCursorPosition(len(full_text)) + + @dataclass class FilterDefinition: """Search bar definition. @@ -633,11 +650,13 @@ class FilterValuePopup(QtWidgets.QWidget): return if isinstance(self._active_widget, QtWidgets.QLineEdit): - full_text = self._active_widget.text() + text - self._active_widget.setText(full_text) - self._active_widget.setFocus() - self._active_widget.setCursorPosition(len(full_text)) - return + kwargs = {} + if text: + kwargs["append_text"] = text + else: + kwargs["backspace"] = True + + set_line_edit_focus(self._active_widget, **kwargs) def set_filter_item( self, From a3a14f08e4e337236c5dcfca30135dcd27ab3034 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:43:15 +0200 Subject: [PATCH 384/781] don't force to add filter by name if is not defined --- client/ayon_core/tools/loader/ui/search_bar.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index cb9dada37c..1687b17703 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -892,6 +892,9 @@ class FiltersBar(BaseClickableFrame): self.show_filters_popup() def _on_text_filter_request(self, text: str): + if "product_name" not in self._filter_defs_by_name: + return + self._on_filter_request("product_name") self._filter_value_popup.set_text_filter(text) From bedd605d2e8186c8ada394743d3a27db3e8fbda5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:43:28 +0200 Subject: [PATCH 385/781] change filter of list items on writing --- .../ayon_core/tools/loader/ui/search_bar.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 1687b17703..1a625cca6d 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -413,6 +413,26 @@ class FilterValueItemsView(QtWidgets.QWidget): event.accept() self.close_requested.emit() return + + if event.key() in ( + QtCore.Qt.Key_Backtab, + QtCore.Qt.Key_Backspace, + ): + event.accept() + set_line_edit_focus(self._filter_input, backspace=True) + return + + if event.key() not in ( + QtCore.Qt.Key_Escape, + QtCore.Qt.Key_Tab, + QtCore.Qt.Key_Return, + ): + text = event.text() + if text: + event.accept() + set_line_edit_focus(self._filter_input, append_text=text) + return + super().keyPressEvent(event) def set_value(self, value): From cd7351aa5250b1772e21999a740cf49fab04e71e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:47:33 +0200 Subject: [PATCH 386/781] added placeholder --- client/ayon_core/tools/loader/ui/search_bar.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 1a625cca6d..645d4644d6 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -346,6 +346,7 @@ class FilterValueItemsView(QtWidgets.QWidget): super().__init__(parent) filter_input = QtWidgets.QLineEdit(self) + filter_input.setPlaceholderText("Filter items...") # Timeout is used to delay the filter focus change on 'showEvent' # - the focus is changed to something else if is not delayed From f4556ac697ea9b127451b661f8c0e6c9f546bdd2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 16 Jun 2025 15:58:03 +0200 Subject: [PATCH 387/781] Formatting change --- client/ayon_core/tools/loader/models/actions.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 9a1a19c682..40331d73a4 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -17,7 +17,6 @@ from ayon_core.pipeline.load import ( load_with_product_contexts, LoadError, IncompatibleLoaderError, - ) from ayon_core.tools.loader.abstract import ActionItem @@ -743,7 +742,6 @@ class LoaderActionsModel: if version < 0: version = "Hero" try: - load_with_repre_context( loader, repre_context, From 77d5d8d162fcccf322f635e56d5498dac7d6164a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 16 Jun 2025 17:33:30 +0200 Subject: [PATCH 388/781] Implemented order attribute for sorting --- client/ayon_core/pipeline/load/plugins.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 4cf497836c..9f3d9531c7 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -260,6 +260,8 @@ class PrePostLoaderHookPlugin: Any studio might want to modify loaded data before or after they are loaded without need to override existing core plugins. """ + order = 0 + @classmethod @abstractmethod def is_compatible(cls, Loader: LoaderPlugin) -> bool: @@ -300,6 +302,7 @@ def discover_loader_plugins(project_name=None): project_settings = get_project_settings(project_name) plugins = discover(LoaderPlugin) hooks = discover(PrePostLoaderHookPlugin) + sorted_hooks = sorted(hooks, key=lambda hook: hook.order) for plugin in plugins: try: plugin.apply_settings(project_settings) @@ -310,7 +313,7 @@ def discover_loader_plugins(project_name=None): ), exc_info=True, ) - for hook_cls in hooks: + for hook_cls in sorted_hooks: if hook_cls.is_compatible(plugin): hook_loader_load(plugin, hook_cls()) return plugins From 802b8b2567ccd13067d1a14b86138781c02ae114 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 16 Jun 2025 17:38:15 +0200 Subject: [PATCH 389/781] Refactored monkey patched method --- client/ayon_core/pipeline/load/plugins.py | 41 +++++++++++------------ 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 9f3d9531c7..30e8856cf8 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -313,39 +313,36 @@ def discover_loader_plugins(project_name=None): ), exc_info=True, ) + compatible_hooks = [] for hook_cls in sorted_hooks: if hook_cls.is_compatible(plugin): - hook_loader_load(plugin, hook_cls()) + compatible_hooks.append(hook_cls) + add_hooks_to_loader(plugin, compatible_hooks) return plugins -def hook_loader_load( +def add_hooks_to_loader( loader_class: LoaderPlugin, - hook_instance: PrePostLoaderHookPlugin + compatible_hooks: list[PrePostLoaderHookPlugin] ) -> None: """Monkey patch method replacing Loader.load method with wrapped hooks.""" - # If this is the first hook being added, wrap the original load method - if not hasattr(loader_class, '_load_hooks'): - loader_class._load_hooks = [] + loader_class._load_hooks = compatible_hooks - original_load = loader_class.load + original_load = loader_class.load - def wrapped_load(self, *args, **kwargs): - # Call pre_load on all hooks - for hook in loader_class._load_hooks: - hook.pre_load(*args, **kwargs) - # Call original load - container = original_load(self, *args, **kwargs) - kwargs["container"] = container - # Call post_load on all hooks - for hook in loader_class._load_hooks: - hook.post_load(*args, **kwargs) - return container + def wrapped_load(self, *args, **kwargs): + # Call pre_load on all hooks + for hook in loader_class._load_hooks: + hook.pre_load(*args, **kwargs) + # Call original load + container = original_load(self, *args, **kwargs) + kwargs["container"] = container + # Call post_load on all hooks + for hook in loader_class._load_hooks: + hook.post_load(*args, **kwargs) + return container - loader_class.load = wrapped_load - - # Add the new hook instance to the list - loader_class._load_hooks.append(hook_instance) + loader_class.load = wrapped_load def register_loader_plugin(plugin): 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 390/781] 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 391/781] 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 392/781] 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 393/781] 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 3db2cd046a1f8755b51a2473bd8af2d138385083 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 17 Jun 2025 14:44:24 +0200 Subject: [PATCH 394/781] Implemented pre/post for update and remove --- client/ayon_core/pipeline/load/plugins.py | 80 ++++++++++++++++++----- 1 file changed, 63 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 30e8856cf8..76cc8bd19b 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -288,7 +288,33 @@ class PrePostLoaderHookPlugin: ): pass - def switch(self, container, context): + @abstractmethod + def pre_update( + self, + container: dict, # (ayon:container-3.0) + context: dict, + ): + pass + + @abstractmethod + def post_update( + container: dict, # (ayon:container-3.0) + context: dict, + ): + pass + + @abstractmethod + def pre_remove( + self, + container: dict, # (ayon:container-3.0) + ): + pass + + @abstractmethod + def post_remove( + container: dict, # (ayon:container-3.0) + context: dict, + ): pass @@ -322,27 +348,47 @@ def discover_loader_plugins(project_name=None): def add_hooks_to_loader( - loader_class: LoaderPlugin, - compatible_hooks: list[PrePostLoaderHookPlugin] + loader_class: LoaderPlugin, compatible_hooks: list[PrePostLoaderHookPlugin] ) -> None: - """Monkey patch method replacing Loader.load method with wrapped hooks.""" + """Monkey patch method replacing Loader.load|update|remove methods + + It wraps applicable loaders with pre/post hooks. Discovery is called only + once per loaders discovery. + """ loader_class._load_hooks = compatible_hooks - original_load = loader_class.load + def wrap_method(method_name: str): + original_method = getattr(loader_class, method_name) - def wrapped_load(self, *args, **kwargs): - # Call pre_load on all hooks - for hook in loader_class._load_hooks: - hook.pre_load(*args, **kwargs) - # Call original load - container = original_load(self, *args, **kwargs) - kwargs["container"] = container - # Call post_load on all hooks - for hook in loader_class._load_hooks: - hook.post_load(*args, **kwargs) - return container + def wrapped_method(self, *args, **kwargs): + # Call pre_ on all hooks + pre_hook_name = f"pre_{method_name}" + for hook in loader_class._load_hooks: + pre_hook = getattr(hook, pre_hook_name, None) + if callable(pre_hook): + pre_hook(self, *args, **kwargs) - loader_class.load = wrapped_load + # Call original method + result = original_method(self, *args, **kwargs) + + # Add result to kwargs if needed + # Assuming container-like result for load, update, remove + kwargs["container"] = result + + # Call post_ on all hooks + post_hook_name = f"post_{method_name}" + for hook in loader_class._load_hooks: + post_hook = getattr(hook, post_hook_name, None) + if callable(post_hook): + post_hook(self, *args, **kwargs) + + return result + + setattr(loader_class, method_name, wrapped_method) + + for method in ("load", "update", "remove"): + if hasattr(loader_class, method): + wrap_method(method) def register_loader_plugin(plugin): From 3018dd35b68825f0cff49744713511ca583bd217 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Jun 2025 21:58:16 +0200 Subject: [PATCH 395/781] Refactor `PrePostLoaderHookPlugin` to `LoaderHookPlugin` --- client/ayon_core/pipeline/load/plugins.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 76cc8bd19b..ca3cdce5e3 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -253,7 +253,7 @@ class ProductLoaderPlugin(LoaderPlugin): """ -class PrePostLoaderHookPlugin: +class LoaderHookPlugin: """Plugin that runs before and post specific Loader in 'loaders' Should be used as non-invasive method to enrich core loading process. @@ -327,7 +327,7 @@ def discover_loader_plugins(project_name=None): project_name = get_current_project_name() project_settings = get_project_settings(project_name) plugins = discover(LoaderPlugin) - hooks = discover(PrePostLoaderHookPlugin) + hooks = discover(LoaderHookPlugin) sorted_hooks = sorted(hooks, key=lambda hook: hook.order) for plugin in plugins: try: @@ -348,7 +348,7 @@ def discover_loader_plugins(project_name=None): def add_hooks_to_loader( - loader_class: LoaderPlugin, compatible_hooks: list[PrePostLoaderHookPlugin] + loader_class: LoaderPlugin, compatible_hooks: list[LoaderHookPlugin] ) -> None: """Monkey patch method replacing Loader.load|update|remove methods @@ -408,16 +408,16 @@ def register_loader_plugin_path(path): def register_loader_hook_plugin(plugin): - return register_plugin(PrePostLoaderHookPlugin, plugin) + return register_plugin(LoaderHookPlugin, plugin) def deregister_loader_hook_plugin(plugin): - deregister_plugin(PrePostLoaderHookPlugin, plugin) + deregister_plugin(LoaderHookPlugin, plugin) def register_loader_hook_plugin_path(path): - return register_plugin_path(PrePostLoaderHookPlugin, path) + return register_plugin_path(LoaderHookPlugin, path) def deregister_loader_hook_plugin_path(path): - deregister_plugin_path(PrePostLoaderHookPlugin, path) + deregister_plugin_path(LoaderHookPlugin, path) From 85ef0fefa41cfbdbe9dfea21cfc390fab69258b1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Jun 2025 21:59:06 +0200 Subject: [PATCH 396/781] Fix `post_update` and `post_remove` --- client/ayon_core/pipeline/load/plugins.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index ca3cdce5e3..7176bd82ac 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -298,6 +298,7 @@ class LoaderHookPlugin: @abstractmethod def post_update( + self, container: dict, # (ayon:container-3.0) context: dict, ): @@ -312,8 +313,8 @@ class LoaderHookPlugin: @abstractmethod def post_remove( + self, container: dict, # (ayon:container-3.0) - context: dict, ): pass From bcca8ca8c1d847dc5064c37ef8a790c415a451b4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Jun 2025 22:41:31 +0200 Subject: [PATCH 397/781] Rename `post_*` method kwarg `container` to `result` to not clash with `container` argument on `update` and `remove` and make it clearer that it's the "result" of something --- client/ayon_core/pipeline/load/plugins.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 7176bd82ac..0fc2718190 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,6 +1,7 @@ from __future__ import annotations import os import logging +from typing import Any from abc import abstractmethod from ayon_core.settings import get_project_settings @@ -259,6 +260,9 @@ class LoaderHookPlugin: Should be used as non-invasive method to enrich core loading process. Any studio might want to modify loaded data before or after they are loaded without need to override existing core plugins. + + The post methods are called after the loader's methods and receive the + return value of the loader's method as `result` argument. """ order = 0 @@ -284,7 +288,7 @@ class LoaderHookPlugin: name: str | None = None, namespace: str | None = None, options: dict | None = None, - container: dict | None = None, # (ayon:container-3.0) + result: Any = None, ): pass @@ -301,6 +305,7 @@ class LoaderHookPlugin: self, container: dict, # (ayon:container-3.0) context: dict, + result: Any = None, ): pass @@ -315,6 +320,7 @@ class LoaderHookPlugin: def post_remove( self, container: dict, # (ayon:container-3.0) + result: Any = None, ): pass @@ -374,7 +380,7 @@ def add_hooks_to_loader( # Add result to kwargs if needed # Assuming container-like result for load, update, remove - kwargs["container"] = result + kwargs["result"] = result # Call post_ on all hooks post_hook_name = f"post_{method_name}" From d583279c6dad91ecb5e72218455560d66afe6697 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Jun 2025 23:26:30 +0200 Subject: [PATCH 398/781] Fix type hint --- client/ayon_core/pipeline/load/plugins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 0fc2718190..4582c2b329 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,7 +1,7 @@ from __future__ import annotations import os import logging -from typing import Any +from typing import Any, Type from abc import abstractmethod from ayon_core.settings import get_project_settings @@ -268,7 +268,7 @@ class LoaderHookPlugin: @classmethod @abstractmethod - def is_compatible(cls, Loader: LoaderPlugin) -> bool: + def is_compatible(cls, Loader: Type[LoaderPlugin]) -> bool: pass @abstractmethod From a593516f29394b28f03cb14e8d2d1818ebbc9ca0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 17 Jun 2025 23:39:25 +0200 Subject: [PATCH 399/781] Fix Hook logic to actually run on a `LoaderHookPlugin` instance - Now `self` on the hook method actually refers to an instantiated `LoaderHookPlugin` - Add `plugin` kwargs to the hook methods to still provide access to the `LoaderPlugin` instance for potential advanced behavior - Fix type hint on `compatible_hooks` arguments to `add_hooks_to_loader` function --- client/ayon_core/pipeline/load/plugins.py | 30 +++++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 4582c2b329..4f0071b4d3 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -278,6 +278,7 @@ class LoaderHookPlugin: name: str | None = None, namespace: str | None = None, options: dict | None = None, + plugin: LoaderPlugin | None = None, ): pass @@ -288,6 +289,7 @@ class LoaderHookPlugin: name: str | None = None, namespace: str | None = None, options: dict | None = None, + plugin: LoaderPlugin | None = None, result: Any = None, ): pass @@ -297,6 +299,8 @@ class LoaderHookPlugin: self, container: dict, # (ayon:container-3.0) context: dict, + plugin: LoaderPlugin | None = None, + ): pass @@ -305,6 +309,7 @@ class LoaderHookPlugin: self, container: dict, # (ayon:container-3.0) context: dict, + plugin: LoaderPlugin | None = None, result: Any = None, ): pass @@ -313,6 +318,7 @@ class LoaderHookPlugin: def pre_remove( self, container: dict, # (ayon:container-3.0) + plugin: LoaderPlugin | None = None, ): pass @@ -320,6 +326,7 @@ class LoaderHookPlugin: def post_remove( self, container: dict, # (ayon:container-3.0) + plugin: LoaderPlugin | None = None, result: Any = None, ): pass @@ -355,7 +362,7 @@ def discover_loader_plugins(project_name=None): def add_hooks_to_loader( - loader_class: LoaderPlugin, compatible_hooks: list[LoaderHookPlugin] + loader_class: LoaderPlugin, compatible_hooks: list[Type[LoaderHookPlugin]] ) -> None: """Monkey patch method replacing Loader.load|update|remove methods @@ -370,24 +377,31 @@ def add_hooks_to_loader( def wrapped_method(self, *args, **kwargs): # Call pre_ on all hooks pre_hook_name = f"pre_{method_name}" - for hook in loader_class._load_hooks: + + # Pass the LoaderPlugin instance to the hooks + hook_kwargs = kwargs.copy() + hook_kwargs["plugin"] = self + + hooks: list[LoaderHookPlugin] = [] + for Hook in loader_class._load_hooks: + hook = Hook() # Instantiate the hook + hooks.append(hook) pre_hook = getattr(hook, pre_hook_name, None) if callable(pre_hook): - pre_hook(self, *args, **kwargs) + pre_hook(*args, **hook_kwargs) # Call original method result = original_method(self, *args, **kwargs) - # Add result to kwargs if needed - # Assuming container-like result for load, update, remove - kwargs["result"] = result + # Add result to kwargs for post hooks from the original method + hook_kwargs["result"] = result # Call post_ on all hooks post_hook_name = f"post_{method_name}" - for hook in loader_class._load_hooks: + for hook in hooks: post_hook = getattr(hook, post_hook_name, None) if callable(post_hook): - post_hook(self, *args, **kwargs) + post_hook(*args, **hook_kwargs) return result From 7ce23aff0776c067e840263ed72bc22073922abc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 08:56:37 +0200 Subject: [PATCH 400/781] added pinned projects to project item --- .../ayon_core/tools/common_models/projects.py | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 7ec941e6bd..9bbdc8a75c 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -1,6 +1,8 @@ +from __future__ import annotations import contextlib from abc import ABC, abstractmethod from typing import Dict, Any +from dataclasses import dataclass import ayon_api @@ -140,6 +142,7 @@ class TaskTypeItem: ) +@dataclass class ProjectItem: """Item representing folder entity on a server. @@ -150,21 +153,14 @@ class ProjectItem: active (Union[str, None]): Parent folder id. If 'None' then project is parent. """ - - def __init__(self, name, active, is_library, icon=None): - self.name = name - self.active = active - self.is_library = is_library - if icon is None: - icon = { - "type": "awesome-font", - "name": "fa.book" if is_library else "fa.map", - "color": get_default_entity_icon_color(), - } - self.icon = icon + name: str + active: bool + is_library: bool + icon: dict[str, Any] + is_pinned: bool = False @classmethod - def from_entity(cls, project_entity): + def from_entity(cls, project_entity: dict[str, Any]) -> "ProjectItem": """Creates folder item from entity. Args: @@ -174,10 +170,16 @@ class ProjectItem: ProjectItem: Project item. """ + icon = { + "type": "awesome-font", + "name": "fa.book" if project_entity["library"] else "fa.map", + "color": get_default_entity_icon_color(), + } return cls( project_entity["name"], project_entity["active"], project_entity["library"], + icon ) def to_data(self): @@ -208,16 +210,18 @@ class ProjectItem: return cls(**data) -def _get_project_items_from_entitiy(projects): +def _get_project_items_from_entitiy( + projects: list[dict[str, Any]] +) -> list[ProjectItem]: """ Args: projects (list[dict[str, Any]]): List of projects. Returns: - ProjectItem: Project item. - """ + list[ProjectItem]: Project item. + """ return [ ProjectItem.from_entity(project) for project in projects @@ -428,9 +432,19 @@ class ProjectsModel(object): self._projects_cache.update_data(project_items) return self._projects_cache.get_data() - def _query_projects(self): + def _query_projects(self) -> list[ProjectItem]: projects = ayon_api.get_projects(fields=["name", "active", "library"]) - return _get_project_items_from_entitiy(projects) + user = ayon_api.get_user() + pinned_projects = ( + user + .get("data", {}) + .get("frontendPreferences", {}) + .get("pinnedProjects") + ) or [] + project_items = _get_project_items_from_entitiy(list(projects)) + for project in project_items: + project.is_pinned = project.name in pinned_projects + return project_items def _status_items_getter(self, project_entity): if not project_entity: From 51116bb9bc444f4ff87799dd8385db4dcdb4dbfa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 08:57:32 +0200 Subject: [PATCH 401/781] sort and show the pinned project --- .../ayon_core/tools/utils/projects_widget.py | 244 ++++++++++++++++-- 1 file changed, 224 insertions(+), 20 deletions(-) diff --git a/client/ayon_core/tools/utils/projects_widget.py b/client/ayon_core/tools/utils/projects_widget.py index c340be2f83..c3d0e4160a 100644 --- a/client/ayon_core/tools/utils/projects_widget.py +++ b/client/ayon_core/tools/utils/projects_widget.py @@ -1,21 +1,69 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from collections.abc import Callable +import typing +from typing import Optional + from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER +from ayon_core.tools.common_models import ( + ProjectItem, + PROJECTS_MODEL_SENDER, +) from .lib import RefreshThread, get_qt_icon +if typing.TYPE_CHECKING: + from typing import TypedDict + + class ExpectedProjectSelectionData(TypedDict): + name: Optional[str] + current: Optional[str] + selected: Optional[str] + + + class ExpectedSelectionData(TypedDict): + project: ExpectedProjectSelectionData + + PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3 PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4 -LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5 +PROJECT_IS_PINNED_ROLE = QtCore.Qt.UserRole + 5 +LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 6 + + +class AbstractProjectController(ABC): + @abstractmethod + def register_event_callback(self, topic: str, callback: Callable): + pass + + @abstractmethod + def get_project_items( + self, sender: Optional[str] = None + ) -> list[str]: + pass + + @abstractmethod + def set_selected_project(self, project_name: str): + pass + + # These are required only if widget should handle expected selection + @abstractmethod + def expected_project_selected(self, project_name: str): + pass + + @abstractmethod + def get_expected_selection_data(self) -> "ExpectedSelectionData": + pass class ProjectsQtModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() - def __init__(self, controller): - super(ProjectsQtModel, self).__init__() + def __init__(self, controller: AbstractProjectController): + super().__init__() self._controller = controller self._project_items = {} @@ -213,7 +261,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel): else: self.refreshed.emit() - def _fill_items(self, project_items): + def _fill_items(self, project_items: list[ProjectItem]): new_project_names = { project_item.name for project_item in project_items @@ -252,6 +300,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel): item.setData(project_name, PROJECT_NAME_ROLE) item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE) item.setData(project_item.is_library, PROJECT_IS_LIBRARY_ROLE) + item.setData(project_item.is_pinned, PROJECT_IS_PINNED_ROLE) is_current = project_name == self._current_context_project item.setData(is_current, PROJECT_IS_CURRENT_ROLE) self._project_items[project_name] = item @@ -323,26 +372,52 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): return False # Library separator should be before library projects - result = self._type_sort(left_index, right_index) - if result is not None: - return result + l_is_library = left_index.data(PROJECT_IS_LIBRARY_ROLE) + r_is_library = right_index.data(PROJECT_IS_LIBRARY_ROLE) + l_is_sep = left_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE) + r_is_sep = right_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE) + if l_is_sep: + return bool(r_is_library) - if left_index.data(PROJECT_NAME_ROLE) is None: + if r_is_sep: + return not l_is_library + + # Non project items should be on top + l_project_name = left_index.data(PROJECT_NAME_ROLE) + r_project_name = right_index.data(PROJECT_NAME_ROLE) + if l_project_name is None: return True - - if right_index.data(PROJECT_NAME_ROLE) is None: + if r_project_name is None: return False left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE) right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE) - if right_is_active == left_is_active: - return super(ProjectSortFilterProxy, self).lessThan( - left_index, right_index - ) + if right_is_active != left_is_active: + return left_is_active - if left_is_active: + l_is_pinned = left_index.data(PROJECT_IS_PINNED_ROLE) + r_is_pinned = right_index.data(PROJECT_IS_PINNED_ROLE) + if l_is_pinned is True and not r_is_pinned: return True - return False + + if r_is_pinned is True and not l_is_pinned: + return False + + # Move inactive projects to the end + left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE) + right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE) + if right_is_active != left_is_active: + return left_is_active + + # Move library projects after standard projects + if ( + l_is_library is not None + and r_is_library is not None + and l_is_library != r_is_library + ): + return r_is_library + return super().lessThan(left_index, right_index) + def filterAcceptsRow(self, source_row, source_parent): index = self.sourceModel().index(source_row, 0, source_parent) @@ -415,15 +490,144 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): self.invalidate() +class ProjectsDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._pin_icon = None + + def paint(self, painter, option, index): + is_pinned = index.data(PROJECT_IS_PINNED_ROLE) + if not is_pinned: + super().paint(painter, option, index) + return + opt = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + widget = option.widget + if widget is None: + style = QtWidgets.QApplication.style() + else: + style = widget.style() + # CE_ItemViewItem + proxy = style.proxy() + painter.save() + painter.setClipRect(option.rect) + decor_rect = proxy.subElementRect( + QtWidgets.QStyle.SE_ItemViewItemDecoration, opt, widget + ) + text_rect = proxy.subElementRect( + QtWidgets.QStyle.SE_ItemViewItemText, opt, widget + ) + proxy.drawPrimitive( + QtWidgets.QStyle.PE_PanelItemViewItem, opt, painter, widget + ) + mode = QtGui.QIcon.Normal + if not opt.state & QtWidgets.QStyle.State_Enabled: + mode = QtGui.QIcon.Disabled + elif opt.state & QtWidgets.QStyle.State_Selected: + mode = QtGui.QIcon.Selected + state = QtGui.QIcon.Off + if opt.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + + # Draw project icon + opt.icon.paint( + painter, decor_rect, opt.decorationAlignment, mode, state + ) + + # Draw pin icon + if index.data(PROJECT_IS_PINNED_ROLE): + pin_icon = self._get_pin_icon() + pin_rect = QtCore.QRect(decor_rect) + diff = option.rect.width() - pin_rect.width() + pin_rect.moveLeft(diff) + pin_icon.paint( + painter, pin_rect, opt.decorationAlignment, mode, state + ) + + # Draw text + if opt.text: + if not opt.state & QtWidgets.QStyle.State_Enabled: + cg = QtGui.QPalette.Disabled + elif not (opt.state & QtWidgets.QStyle.State_Active): + cg = QtGui.QPalette.Inactive + else: + cg = QtGui.QPalette.Normal + + if opt.state & QtWidgets.QStyle.State_Selected: + painter.setPen(opt.palette.color(cg, QtGui.QPalette.HighlightedText)) + else: + painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text)) + + if opt.state & QtWidgets.QStyle.State_Editing: + painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text)) + painter.drawRect(text_rect.adjusted(0, 0, -1, -1)) + + painter.drawText( + text_rect, + opt.displayAlignment, + opt.text + ) + + # Draw focus rect + if opt.state & QtWidgets.QStyle.State_HasFocus: + focus_opt = QtWidgets.QStyleOptionFocusRect() + focus_opt.state = option.state + focus_opt.direction = option.direction + focus_opt.rect = option.rect + focus_opt.fontMetrics = option.fontMetrics + focus_opt.palette = option.palette + + focus_opt.rect = style.subElementRect( + QtWidgets.QCommonStyle.SE_ItemViewItemFocusRect, + option, + option.widget + ) + focus_opt.state |= ( + QtWidgets.QStyle.State_KeyboardFocusChange + | QtWidgets.QStyle.State_Item + ) + focus_opt.backgroundColor = option.palette.color( + ( + QtGui.QPalette.Normal + if option.state & QtWidgets.QStyle.State_Enabled + else QtGui.QPalette.Disabled + ), + ( + QtGui.QPalette.Highlight + if option.state & QtWidgets.QStyle.State_Selected + else QtGui.QPalette.Window + ) + ) + style.drawPrimitive( + QtWidgets.QCommonStyle.PE_FrameFocusRect, + focus_opt, + painter, + option.widget + ) + painter.restore() + + def _get_pin_icon(self): + if self._pin_icon is None: + self._pin_icon = get_qt_icon({ + "type": "material-symbols", + "name": "keep", + }) + return self._pin_icon + class ProjectsCombobox(QtWidgets.QWidget): refreshed = QtCore.Signal() selection_changed = QtCore.Signal() - def __init__(self, controller, parent, handle_expected_selection=False): - super(ProjectsCombobox, self).__init__(parent) + def __init__( + self, + controller: AbstractProjectController, + parent: QtWidgets.QWidget, + handle_expected_selection: bool = False, + ): + super().__init__(parent) projects_combobox = QtWidgets.QComboBox(self) - combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox) + combobox_delegate = ProjectsDelegate(projects_combobox) projects_combobox.setItemDelegate(combobox_delegate) projects_model = ProjectsQtModel(controller) projects_proxy_model = ProjectSortFilterProxy() From b9b801f53d128637551002dc22127b62f04b07d1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 09:12:37 +0200 Subject: [PATCH 402/781] support only shift modifier for auto-text filter --- client/ayon_core/tools/loader/ui/search_bar.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 645d4644d6..d9b4fc65c7 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -231,7 +231,11 @@ class FiltersPopup(QtWidgets.QWidget): event.accept() return - if event.key() not in ( + valid_modifiers = event.modifiers() in ( + QtCore.Qt.NoModifier, + QtCore.Qt.ShiftModifier, + ) + if valid_modifiers and event.key() not in ( QtCore.Qt.Key_Escape, QtCore.Qt.Key_Tab, QtCore.Qt.Key_Return, @@ -423,7 +427,11 @@ class FilterValueItemsView(QtWidgets.QWidget): set_line_edit_focus(self._filter_input, backspace=True) return - if event.key() not in ( + valid_modifiers = event.modifiers() in ( + QtCore.Qt.NoModifier, + QtCore.Qt.ShiftModifier, + ) + if valid_modifiers and event.key() not in ( QtCore.Qt.Key_Escape, QtCore.Qt.Key_Tab, QtCore.Qt.Key_Return, From 48e29d57b846b48b64f39eaf5410e58644e0a0a4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 09:54:04 +0200 Subject: [PATCH 403/781] added back and confirm buttons --- client/ayon_core/style/style.css | 13 ++ .../ayon_core/tools/loader/ui/search_bar.py | 138 +++++++++++++++--- 2 files changed, 132 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 400fde3077..82b958f812 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -907,6 +907,19 @@ FiltersBar #SearchButton { background: transparent; } +FiltersBar #BackButton { + background: transparent; +} + +FiltersBar #BackButton:hover { + background: {color:bg-buttons-hover}; +} + +FiltersBar #ConfirmButton { + background: #91CDFB; + color: #03344D; +} + FiltersPopup #PopupWrapper, FilterValuePopup #PopupWrapper { border-radius: 5px; background: {color:bg-inputs}; diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index d9b4fc65c7..1e74426949 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -11,6 +11,7 @@ from ayon_core.tools.utils import ( SquareButton, BaseClickableFrame, PixmapLabel, + SeparatorWidget, ) @@ -342,9 +343,88 @@ class FilterValueItemButton(BaseClickableFrame): self.selected.emit(self._widget_id) +class FilterValueTextInput(QtWidgets.QWidget): + back_requested = QtCore.Signal() + value_changed = QtCore.Signal(str) + close_requested = QtCore.Signal() + + def __init__(self, parent): + super().__init__(parent) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + # Timeout is used to delay the filter focus change on 'showEvent' + # - the focus is changed to something else if is not delayed + filter_timeout = QtCore.QTimer(self) + filter_timeout.setSingleShot(True) + filter_timeout.setInterval(20) + + btns_sep = SeparatorWidget(size=1, parent=self) + btns_widget = QtWidgets.QWidget(self) + btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + text_input = QtWidgets.QLineEdit(self) + + back_btn = QtWidgets.QPushButton("Back", btns_widget) + back_btn.setObjectName("BackButton") + back_btn.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "arrow_back", + })) + confirm_btn = QtWidgets.QPushButton("Confirm", btns_widget) + confirm_btn.setObjectName("ConfirmButton") + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(back_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(text_input, 0) + main_layout.addWidget(btns_sep, 0) + main_layout.addWidget(btns_widget, 0) + + filter_timeout.timeout.connect(self._on_filter_timeout) + text_input.textChanged.connect(self.value_changed) + text_input.returnPressed.connect(self.close_requested) + back_btn.clicked.connect(self.back_requested) + confirm_btn.clicked.connect(self.close_requested) + + self._filter_timeout = filter_timeout + self._text_input = text_input + + def showEvent(self, event): + super().showEvent(event) + + self._filter_timeout.start() + + def get_value(self) -> str: + return self._text_input.text() + + def set_value(self, value: str): + self._text_input.setText(value) + + def set_placeholder_text(self, placeholder_text: str): + self._text_input.setPlaceholderText(placeholder_text) + + def set_text_filter(self, text: str): + kwargs = {} + if text: + kwargs["append_text"] = text + else: + kwargs["backspace"] = True + + set_line_edit_focus(self._text_input, **kwargs) + + def _on_filter_timeout(self): + set_line_edit_focus(self._text_input) + + class FilterValueItemsView(QtWidgets.QWidget): value_changed = QtCore.Signal() close_requested = QtCore.Signal() + back_requested = QtCore.Signal() def __init__(self, parent): super().__init__(parent) @@ -374,29 +454,46 @@ class FilterValueItemsView(QtWidgets.QWidget): scroll_area.setWidget(content_widget) + btns_sep = SeparatorWidget(size=1, parent=self) btns_widget = QtWidgets.QWidget(self) btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + back_btn = QtWidgets.QPushButton("Back", btns_widget) + back_btn.setObjectName("BackButton") + back_btn.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "arrow_back", + })) + select_all_btn = QtWidgets.QPushButton("Select all", btns_widget) clear_btn = QtWidgets.QPushButton("Clear", btns_widget) - swap_btn = QtWidgets.QPushButton("Swap", btns_widget) + swap_btn = QtWidgets.QPushButton("Invert", btns_widget) + + confirm_btn = QtWidgets.QPushButton("Confirm", btns_widget) + confirm_btn.setObjectName("ConfirmButton") + confirm_btn.clicked.connect(self.close_requested) btns_layout = QtWidgets.QHBoxLayout(btns_widget) btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(back_btn, 0) btns_layout.addStretch(1) btns_layout.addWidget(select_all_btn, 0) btns_layout.addWidget(clear_btn, 0) btns_layout.addWidget(swap_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(filter_input, 0) main_layout.addWidget(scroll_area) + main_layout.addWidget(btns_sep, 0) main_layout.addWidget(btns_widget, 0) filter_timeout.timeout.connect(self._on_filter_timeout) filter_input.textChanged.connect(self._on_filter_change) filter_input.returnPressed.connect(self.close_requested) + back_btn.clicked.connect(self.back_requested) select_all_btn.clicked.connect(self._on_select_all) clear_btn.clicked.connect(self._on_clear_selection) swap_btn.clicked.connect(self._on_swap_selection) @@ -619,6 +716,7 @@ class FilterValueItemsView(QtWidgets.QWidget): class FilterValuePopup(QtWidgets.QWidget): value_changed = QtCore.Signal(str) closed = QtCore.Signal(str) + back_requested = QtCore.Signal(str) def __init__(self, parent): super().__init__(parent) @@ -631,7 +729,7 @@ class FilterValuePopup(QtWidgets.QWidget): wrapper = QtWidgets.QWidget(self) wrapper.setObjectName("PopupWrapper") - text_input = QtWidgets.QLineEdit(wrapper) + text_input = FilterValueTextInput(wrapper) text_input.setVisible(False) items_view = FilterValueItemsView(wrapper) @@ -647,11 +745,13 @@ class FilterValuePopup(QtWidgets.QWidget): main_layout.setContentsMargins(2, 2, 2, 2) main_layout.addWidget(wrapper) - text_input.textChanged.connect(self._text_changed) - text_input.returnPressed.connect(self._text_confirmed) + text_input.value_changed.connect(self._text_changed) + text_input.close_requested.connect(self._close_requested) + text_input.back_requested.connect(self._back_requested) items_view.value_changed.connect(self._selection_changed) items_view.close_requested.connect(self._close_requested) + items_view.back_requested.connect(self._back_requested) shadow_frame.stackUnder(wrapper) @@ -678,14 +778,8 @@ class FilterValuePopup(QtWidgets.QWidget): if self._active_widget is None: return - if isinstance(self._active_widget, QtWidgets.QLineEdit): - kwargs = {} - if text: - kwargs["append_text"] = text - else: - kwargs["backspace"] = True - - set_line_edit_focus(self._active_widget, **kwargs) + if self._active_widget is self._text_input: + self._active_widget.set_text_filter(text) def set_filter_item( self, @@ -707,10 +801,10 @@ class FilterValuePopup(QtWidgets.QWidget): else: if value is None: value = "" - self._text_input.setPlaceholderText( + self._text_input.set_placeholder_text( filter_def.placeholder or "" ) - self._text_input.setText(value) + self._text_input.set_value(value) self._active_widget = self._text_input elif filter_def.filter_type == "list": @@ -750,26 +844,27 @@ class FilterValuePopup(QtWidgets.QWidget): def get_value(self): """Get the value from the active widget.""" if self._active_widget is self._text_input: - return self._text_input.text() + return self._text_input.get_value() elif self._active_widget is self._items_view: return self._active_widget.get_value() return None def _text_changed(self): """Handle text change in the text input.""" - if self._active_widget == self._text_input: + if self._active_widget is self._text_input: # Emit value changed signal if text input is active self.value_changed.emit(self._filter_name) - def _text_confirmed(self): - self.close() - def _selection_changed(self): self.value_changed.emit(self._filter_name) def _close_requested(self): self.close() + def _back_requested(self): + self.back_requested.emit(self._filter_name) + self.close() + class FiltersBar(BaseClickableFrame): filter_changed = QtCore.Signal(str) @@ -942,6 +1037,7 @@ class FiltersBar(BaseClickableFrame): filter_value_popup.set_filter_item(filter_def, value) filter_value_popup.value_changed.connect(self._on_filter_value_change) filter_value_popup.closed.connect(self._on_filter_value_closed) + filter_value_popup.back_requested.connect(self._on_filter_value_back) old_popup, self._filter_value_popup = ( self._filter_value_popup, filter_value_popup @@ -978,6 +1074,10 @@ class FiltersBar(BaseClickableFrame): if not value: self._on_item_close_requested(name) + def _on_filter_value_back(self, name): + self._on_filter_value_closed(name) + self.show_filters_popup() + def _on_item_close_requested(self, name): widget = self._widgets_by_name.pop(name, None) if widget is not None: From 48e840622dcc24fc8bfb30acadaca25b0ad78477 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 18 Jun 2025 09:54:21 +0200 Subject: [PATCH 404/781] Use `set[str]` for lookup instead of `list[str]` --- .../ayon_core/plugins/publish/extract_color_transcode.py | 2 +- client/ayon_core/plugins/publish/extract_review.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 1e86b91484..8a276cf608 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -58,7 +58,7 @@ class ExtractOIIOTranscode(publish.Extractor): optional = True # Supported extensions - supported_exts = ["exr", "jpg", "jpeg", "png", "dpx"] + supported_exts = {"exr", "jpg", "jpeg", "png", "dpx"} # Configurable by Settings profiles = None diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 3fc2185d1a..13b1e920ef 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -135,11 +135,11 @@ class ExtractReview(pyblish.api.InstancePlugin): ] # Supported extensions - image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"] - video_exts = ["mov", "mp4"] - supported_exts = image_exts + video_exts + image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"} + video_exts = {"mov", "mp4"} + supported_exts = image_exts.union(video_exts) - alpha_exts = ["exr", "png", "dpx"] + alpha_exts = {"exr", "png", "dpx"} # Preset attributes profiles = [] From 1d55f2a033248c845f2f7ac1432acd85ce49248c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 18 Jun 2025 09:59:09 +0200 Subject: [PATCH 405/781] Update client/ayon_core/pipeline/load/plugins.py --- client/ayon_core/pipeline/load/plugins.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 4f0071b4d3..6a6c6c639f 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -300,7 +300,6 @@ class LoaderHookPlugin: container: dict, # (ayon:container-3.0) context: dict, plugin: LoaderPlugin | None = None, - ): pass From 42808409894750c8494a07af887146fbaf8ef9f1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:50:52 +0200 Subject: [PATCH 406/781] added wheel scrolling --- .../ayon_core/tools/loader/ui/search_bar.py | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index 1e74426949..ab673df1ac 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -925,6 +925,24 @@ class FiltersBar(BaseClickableFrame): super().resizeEvent(event) self._update_filters_geo() + def wheelEvent(self, event): + scroll_speed = 15 + diff = event.angleDelta().y() / 120.0 + pos_x = self._filters_widget.pos().x() + if diff > 0: + pos_x = min(0, pos_x + scroll_speed) + self._filters_widget.move(pos_x, 0) + return + + rect = self._filters_wrap.rect() + size_hint = self._filters_widget.sizeHint() + if size_hint.width() < rect.width(): + return + pos_x = max( + pos_x - scroll_speed, rect.width() - size_hint.width() + ) + self._filters_widget.move(pos_x, 0) + def show_filters_popup(self): filter_defs = [ filter_def @@ -1009,6 +1027,18 @@ class FiltersBar(BaseClickableFrame): self._filters_widget.setGeometry(geo) + def _reposition_filters_widget(self): + rect = self._filters_wrap.rect() + size_hint = self._filters_widget.sizeHint() + if size_hint.width() < rect.width(): + self._filters_widget.move(0, 0) + return + pos_x = self._filters_widget.pos().x() + pos_x = max( + pos_x, rect.width() - size_hint.width() + ) + self._filters_widget.move(pos_x, 0) + def _mouse_release_callback(self): self.show_filters_popup() @@ -1080,10 +1110,13 @@ class FiltersBar(BaseClickableFrame): def _on_item_close_requested(self, name): widget = self._widgets_by_name.pop(name, None) - if widget is not None: - idx = self._filters_layout.indexOf(widget) - if idx > -1: - self._filters_layout.takeAt(idx) - widget.setVisible(False) - widget.deleteLater() - self.filter_changed.emit(name) + if widget is None: + return + idx = self._filters_layout.indexOf(widget) + if idx > -1: + self._filters_layout.takeAt(idx) + widget.setVisible(False) + widget.deleteLater() + self.filter_changed.emit(name) + + self._reposition_filters_widget() From 628c8025d4d7964d47c747be263e4b48c6d98540 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 18 Jun 2025 11:48:27 +0200 Subject: [PATCH 407/781] Update client/ayon_core/plugins/publish/extract_review.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/plugins/publish/extract_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 13b1e920ef..89bc56c670 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -137,7 +137,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # Supported extensions image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"} video_exts = {"mov", "mp4"} - supported_exts = image_exts.union(video_exts) + supported_exts = image_exts | video_exts alpha_exts = {"exr", "png", "dpx"} From 65b9107d0e5275f1c331f887a90007b10d80a5c7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 13:33:20 +0200 Subject: [PATCH 408/781] return studio settings for empty project --- client/ayon_core/tools/launcher/control.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index fb9f950bd1..58d22453be 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -1,6 +1,6 @@ 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.settings import get_project_settings, get_studio_settings from ayon_core.tools.common_models import ProjectsModel, HierarchyModel from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend @@ -85,7 +85,10 @@ class BaseLauncherController( def get_project_settings(self, project_name): if project_name in self._project_settings: return self._project_settings[project_name] - settings = get_project_settings(project_name) + if project_name: + settings = get_project_settings(project_name) + else: + settings = get_studio_settings() self._project_settings[project_name] = settings return settings From c7131de67b51884d5fe1a49c31b58410d29d741e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 13:41:39 +0200 Subject: [PATCH 409/781] implemented deselectable list view --- client/ayon_core/tools/utils/__init__.py | 2 + client/ayon_core/tools/utils/views.py | 58 ++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 8688430c71..c71c4b71dd 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -29,6 +29,7 @@ from .widgets import ( from .views import ( DeselectableTreeView, TreeView, + ListView, ) from .error_dialog import ErrorMessageBox from .lib import ( @@ -114,6 +115,7 @@ __all__ = ( "DeselectableTreeView", "TreeView", + "ListView", "ErrorMessageBox", diff --git a/client/ayon_core/tools/utils/views.py b/client/ayon_core/tools/utils/views.py index d69be9b6a9..2ad1d6c7b5 100644 --- a/client/ayon_core/tools/utils/views.py +++ b/client/ayon_core/tools/utils/views.py @@ -37,7 +37,7 @@ class TreeView(QtWidgets.QTreeView): double_clicked = QtCore.Signal(QtGui.QMouseEvent) def __init__(self, *args, **kwargs): - super(TreeView, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._deselectable = False self._flick_charm_activated = False @@ -60,12 +60,64 @@ class TreeView(QtWidgets.QTreeView): self.clearSelection() # clear the current index self.setCurrentIndex(QtCore.QModelIndex()) - super(TreeView, self).mousePressEvent(event) + super().mousePressEvent(event) def mouseDoubleClickEvent(self, event): self.double_clicked.emit(event) - return super(TreeView, self).mouseDoubleClickEvent(event) + return super().mouseDoubleClickEvent(event) + + def activate_flick_charm(self): + if self._flick_charm_activated: + return + self._flick_charm_activated = True + self._before_flick_scroll_mode = self.verticalScrollMode() + self._flick_charm.activateOn(self) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + + def deactivate_flick_charm(self): + if not self._flick_charm_activated: + return + self._flick_charm_activated = False + self._flick_charm.deactivateFrom(self) + if self._before_flick_scroll_mode is not None: + self.setVerticalScrollMode(self._before_flick_scroll_mode) + + +class ListView(QtWidgets.QListView): + """A tree view that deselects on clicking on an empty area in the view""" + double_clicked = QtCore.Signal(QtGui.QMouseEvent) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._deselectable = False + + self._flick_charm_activated = False + self._flick_charm = FlickCharm(parent=self) + self._before_flick_scroll_mode = None + + def is_deselectable(self): + return self._deselectable + + def set_deselectable(self, deselectable): + self._deselectable = deselectable + + deselectable = property(is_deselectable, set_deselectable) + + def mousePressEvent(self, event): + if self._deselectable: + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + super().mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + self.double_clicked.emit(event) + + return super().mouseDoubleClickEvent(event) def activate_flick_charm(self): if self._flick_charm_activated: From 7e29ac837767cbf0264946c19c4cc999824e891e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 13:42:16 +0200 Subject: [PATCH 410/781] implemented projects widget showing projects as list view --- client/ayon_core/tools/utils/__init__.py | 2 + .../ayon_core/tools/utils/projects_widget.py | 122 ++++++++++++++++-- 2 files changed, 114 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index c71c4b71dd..111b7c614b 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -62,6 +62,7 @@ from .dialogs import ( ) from .projects_widget import ( ProjectsCombobox, + ProjectsWidget, ProjectsQtModel, ProjectSortFilterProxy, PROJECT_NAME_ROLE, @@ -147,6 +148,7 @@ __all__ = ( "PopupUpdateKeys", "ProjectsCombobox", + "ProjectsWidget", "ProjectsQtModel", "ProjectSortFilterProxy", "PROJECT_NAME_ROLE", diff --git a/client/ayon_core/tools/utils/projects_widget.py b/client/ayon_core/tools/utils/projects_widget.py index c3d0e4160a..ddeb381e8d 100644 --- a/client/ayon_core/tools/utils/projects_widget.py +++ b/client/ayon_core/tools/utils/projects_widget.py @@ -11,6 +11,7 @@ from ayon_core.tools.common_models import ( PROJECTS_MODEL_SENDER, ) +from .views import ListView from .lib import RefreshThread, get_qt_icon if typing.TYPE_CHECKING: @@ -328,7 +329,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel): class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): def __init__(self, *args, **kwargs): - super(ProjectSortFilterProxy, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._filter_inactive = True self._filter_standard = False self._filter_library = False @@ -614,9 +615,10 @@ class ProjectsDelegate(QtWidgets.QStyledItemDelegate): }) return self._pin_icon + class ProjectsCombobox(QtWidgets.QWidget): refreshed = QtCore.Signal() - selection_changed = QtCore.Signal() + selection_changed = QtCore.Signal(str) def __init__( self, @@ -672,7 +674,7 @@ class ProjectsCombobox(QtWidgets.QWidget): def refresh(self): self._projects_model.refresh() - def set_selection(self, project_name): + def set_selection(self, project_name: str): """Set selection to a given project. Selection change is ignored if project is not found. @@ -684,8 +686,8 @@ class ProjectsCombobox(QtWidgets.QWidget): bool: True if selection was changed, False otherwise. NOTE: Selection may not be changed if project is not found, or if project is already selected. - """ + """ idx = self._projects_combobox.findData( project_name, PROJECT_NAME_ROLE) if idx < 0: @@ -695,7 +697,7 @@ class ProjectsCombobox(QtWidgets.QWidget): return True return False - def set_listen_to_selection_change(self, listen): + def set_listen_to_selection_change(self, listen: bool): """Disable listening to changes of the selection. Because combobox is triggering selection change when it's model @@ -721,11 +723,11 @@ class ProjectsCombobox(QtWidgets.QWidget): return None return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE) - def set_current_context_project(self, project_name): + def set_current_context_project(self, project_name: str): self._projects_model.set_current_context_project(project_name) self._projects_proxy_model.invalidateFilter() - def set_select_item_visible(self, visible): + def set_select_item_visible(self, visible: bool): self._select_item_visible = visible self._projects_model.set_select_item_visible(visible) self._update_select_item_visiblity() @@ -763,7 +765,7 @@ class ProjectsCombobox(QtWidgets.QWidget): idx, PROJECT_NAME_ROLE) self._update_select_item_visiblity(project_name=project_name) self._controller.set_selected_project(project_name) - self.selection_changed.emit() + self.selection_changed.emit(project_name or "") def _on_model_refresh(self): self._projects_proxy_model.sort(0) @@ -818,5 +820,105 @@ class ProjectsCombobox(QtWidgets.QWidget): class ProjectsWidget(QtWidgets.QWidget): - # TODO implement - pass + """Projects widget showing projects in list. + + Warnings: + This widget does not support expected selection handling. + + """ + refreshed = QtCore.Signal() + selection_changed = QtCore.Signal(str) + + def __init__( + self, + controller: AbstractProjectController, + parent: Optional[QtWidgets.QWidget] = None + ): + super().__init__(parent=parent) + + projects_view = ListView(parent=self) + projects_view.setResizeMode(QtWidgets.QListView.Adjust) + projects_view.setVerticalScrollMode( + QtWidgets.QAbstractItemView.ScrollPerPixel + ) + projects_view.setAlternatingRowColors(False) + projects_view.setWrapping(False) + projects_view.setWordWrap(False) + projects_view.setSpacing(0) + projects_delegate = ProjectsDelegate(projects_view) + projects_view.setItemDelegate(projects_delegate) + projects_view.activate_flick_charm() + projects_view.set_deselectable(True) + + projects_model = ProjectsQtModel(controller) + projects_proxy_model = ProjectSortFilterProxy() + projects_proxy_model.setSourceModel(projects_model) + projects_view.setModel(projects_proxy_model) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(projects_view, 1) + + projects_view.selectionModel().selectionChanged.connect( + self._on_selection_change + ) + projects_model.refreshed.connect(self._on_model_refresh) + + controller.register_event_callback( + "projects.refresh.finished", + self._on_projects_refresh_finished + ) + + self._controller = controller + + self._projects_view = projects_view + self._projects_model = projects_model + self._projects_proxy_model = projects_proxy_model + self._projects_delegate = projects_delegate + + def has_content(self) -> bool: + """Model has at least one project. + + Returns: + bool: True if there is any content in the model. + + """ + return self._projects_model.has_content() + + def set_name_filter(self, text: str): + self._projects_proxy_model.setFilterFixedString(text) + + def set_selected_project(self, project_name: Optional[str]): + selection_model = self._projects_view.selectionModel() + if project_name is None: + selection_model.clearSelection() + return + index = self._projects_model.get_index_by_project_name(project_name) + if not index.isValid(): + return + proxy_index = self._projects_proxy_model.mapFromSource(index) + if proxy_index.isValid(): + selection_model.select( + proxy_index, + QtCore.QItemSelectionModel.ClearAndSelect + ) + + def _on_model_refresh(self): + self._projects_proxy_model.sort(0) + self._projects_proxy_model.invalidateFilter() + self.refreshed.emit() + + def _on_selection_change(self, new_selection, _old_selection): + project_name = None + for index in new_selection.indexes(): + name = index.data(PROJECT_NAME_ROLE) + if name: + project_name = name + break + self.selection_changed.emit(project_name or "") + self._controller.set_selected_project(project_name) + + def _on_projects_refresh_finished(self, event): + if event["sender"] != PROJECTS_MODEL_SENDER: + self._projects_model.refresh() + From 53803f040dac4d916569d915bc53ced5cdd31869 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 13:44:04 +0200 Subject: [PATCH 411/781] launcher is using projects widget from utils --- .../tools/launcher/ui/projects_widget.py | 154 ------------------ client/ayon_core/tools/launcher/ui/window.py | 46 +++++- 2 files changed, 41 insertions(+), 159 deletions(-) delete mode 100644 client/ayon_core/tools/launcher/ui/projects_widget.py diff --git a/client/ayon_core/tools/launcher/ui/projects_widget.py b/client/ayon_core/tools/launcher/ui/projects_widget.py deleted file mode 100644 index e2af54b55d..0000000000 --- a/client/ayon_core/tools/launcher/ui/projects_widget.py +++ /dev/null @@ -1,154 +0,0 @@ -from qtpy import QtWidgets, QtCore - -from ayon_core.tools.flickcharm import FlickCharm -from ayon_core.tools.utils import ( - PlaceholderLineEdit, - RefreshButton, - ProjectsQtModel, - ProjectSortFilterProxy, -) -from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER - - -class ProjectIconView(QtWidgets.QListView): - """Styled ListView that allows to toggle between icon and list mode. - - Toggling between the two modes is done by Right Mouse Click. - """ - - IconMode = 0 - ListMode = 1 - - def __init__(self, parent=None, mode=ListMode): - super(ProjectIconView, self).__init__(parent=parent) - - # Workaround for scrolling being super slow or fast when - # toggling between the two visual modes - self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) - self.setObjectName("IconView") - - self._mode = None - self.set_mode(mode) - - def set_mode(self, mode): - if mode == self._mode: - return - - self._mode = mode - - if mode == self.IconMode: - self.setViewMode(QtWidgets.QListView.IconMode) - self.setResizeMode(QtWidgets.QListView.Adjust) - self.setWrapping(True) - self.setWordWrap(True) - self.setGridSize(QtCore.QSize(151, 90)) - self.setIconSize(QtCore.QSize(50, 50)) - self.setSpacing(0) - self.setAlternatingRowColors(False) - - self.setProperty("mode", "icon") - self.style().polish(self) - - self.verticalScrollBar().setSingleStep(30) - - elif self.ListMode: - self.setProperty("mode", "list") - self.style().polish(self) - - self.setViewMode(QtWidgets.QListView.ListMode) - self.setResizeMode(QtWidgets.QListView.Adjust) - self.setWrapping(False) - self.setWordWrap(False) - self.setIconSize(QtCore.QSize(20, 20)) - self.setGridSize(QtCore.QSize(100, 25)) - self.setSpacing(0) - self.setAlternatingRowColors(False) - - self.verticalScrollBar().setSingleStep(34) - - def mousePressEvent(self, event): - if event.button() == QtCore.Qt.RightButton: - self.set_mode(int(not self._mode)) - return super(ProjectIconView, self).mousePressEvent(event) - - -class ProjectsWidget(QtWidgets.QWidget): - """Projects Page""" - - refreshed = QtCore.Signal() - - def __init__(self, controller, parent=None): - super(ProjectsWidget, self).__init__(parent=parent) - - header_widget = QtWidgets.QWidget(self) - - projects_filter_text = PlaceholderLineEdit(header_widget) - projects_filter_text.setPlaceholderText("Filter projects...") - - refresh_btn = RefreshButton(header_widget) - - header_layout = QtWidgets.QHBoxLayout(header_widget) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.addWidget(projects_filter_text, 1) - header_layout.addWidget(refresh_btn, 0) - - projects_view = ProjectIconView(parent=self) - projects_view.setSelectionMode(QtWidgets.QListView.NoSelection) - flick = FlickCharm(parent=self) - flick.activateOn(projects_view) - projects_model = ProjectsQtModel(controller) - projects_proxy_model = ProjectSortFilterProxy() - projects_proxy_model.setSourceModel(projects_model) - - projects_view.setModel(projects_proxy_model) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.addWidget(header_widget, 0) - main_layout.addWidget(projects_view, 1) - - projects_view.clicked.connect(self._on_view_clicked) - projects_model.refreshed.connect(self.refreshed) - projects_filter_text.textChanged.connect( - self._on_project_filter_change) - refresh_btn.clicked.connect(self._on_refresh_clicked) - - controller.register_event_callback( - "projects.refresh.finished", - self._on_projects_refresh_finished - ) - - self._controller = controller - - self._projects_view = projects_view - self._projects_model = projects_model - self._projects_proxy_model = projects_proxy_model - - def has_content(self): - """Model has at least one project. - - Returns: - bool: True if there is any content in the model. - """ - - return self._projects_model.has_content() - - def _on_view_clicked(self, index): - if not index.isValid(): - return - model = index.model() - flags = model.flags(index) - if not flags & QtCore.Qt.ItemIsEnabled: - return - project_name = index.data(QtCore.Qt.DisplayRole) - self._controller.set_selected_project(project_name) - - def _on_project_filter_change(self, text): - self._projects_proxy_model.setFilterFixedString(text) - - def _on_refresh_clicked(self): - self._controller.refresh() - - def _on_projects_refresh_finished(self, event): - if event["sender"] != PROJECTS_MODEL_SENDER: - self._projects_model.refresh() diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index 7fde8518b0..819e141d59 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -3,9 +3,13 @@ from qtpy import QtWidgets, QtCore, QtGui from ayon_core import style, resources from ayon_core.tools.launcher.control import BaseLauncherController -from ayon_core.tools.utils import MessageOverlayObject +from ayon_core.tools.utils import ( + MessageOverlayObject, + PlaceholderLineEdit, + RefreshButton, + ProjectsWidget, +) -from .projects_widget import ProjectsWidget from .hierarchy_page import HierarchyPage from .actions_widget import ActionsWidget @@ -50,7 +54,25 @@ class LauncherWindow(QtWidgets.QWidget): pages_widget = QtWidgets.QWidget(content_body) # - First page - Projects - projects_page = ProjectsWidget(controller, pages_widget) + projects_page = QtWidgets.QWidget(pages_widget) + projects_header_widget = QtWidgets.QWidget(projects_page) + + projects_filter_text = PlaceholderLineEdit(projects_header_widget) + projects_filter_text.setPlaceholderText("Filter projects...") + + refresh_btn = RefreshButton(projects_header_widget) + + projects_header_layout = QtWidgets.QHBoxLayout(projects_header_widget) + projects_header_layout.setContentsMargins(0, 0, 0, 0) + projects_header_layout.addWidget(projects_filter_text, 1) + projects_header_layout.addWidget(refresh_btn, 0) + + projects_widget = ProjectsWidget(controller, pages_widget) + + projects_layout = QtWidgets.QVBoxLayout(projects_page) + projects_layout.setContentsMargins(0, 0, 0, 0) + projects_layout.addWidget(projects_header_widget, 0) + projects_layout.addWidget(projects_widget, 1) # - Second page - Hierarchy (folders & tasks) hierarchy_page = HierarchyPage(controller, pages_widget) @@ -102,12 +124,16 @@ class LauncherWindow(QtWidgets.QWidget): page_slide_anim.setEndValue(1.0) page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) - projects_page.refreshed.connect(self._on_projects_refresh) + refresh_btn.clicked.connect(self._on_refresh_request) + projects_widget.refreshed.connect(self._on_projects_refresh) + actions_refresh_timer.timeout.connect( self._on_actions_refresh_timeout) page_slide_anim.valueChanged.connect( self._on_page_slide_value_changed) page_slide_anim.finished.connect(self._on_page_slide_finished) + projects_filter_text.textChanged.connect( + self._on_project_filter_change) controller.register_event_callback( "selection.project.changed", @@ -142,6 +168,7 @@ class LauncherWindow(QtWidgets.QWidget): self._pages_widget = pages_widget self._pages_layout = pages_layout self._projects_page = projects_page + self._projects_widget = projects_widget self._hierarchy_page = hierarchy_page self._actions_widget = actions_widget # self._action_history = action_history @@ -194,6 +221,12 @@ class LauncherWindow(QtWidgets.QWidget): elif self._is_on_projects_page: self._go_to_hierarchy_page(project_name) + def _on_project_filter_change(self, text): + self._projects_widget.set_name_filter(text) + + def _on_refresh_request(self): + self._controller.refresh() + def _on_projects_refresh(self): # Refresh only actions on projects page if self._is_on_projects_page: @@ -201,7 +234,7 @@ class LauncherWindow(QtWidgets.QWidget): return # No projects were found -> go back to projects page - if not self._projects_page.has_content(): + if not self._projects_widget.has_content(): self._go_to_projects_page() return @@ -280,6 +313,9 @@ class LauncherWindow(QtWidgets.QWidget): def _go_to_projects_page(self): if self._is_on_projects_page: return + + # Deselect project in projects widget + self._projects_widget.set_selected_project(None) self._is_on_projects_page = True self._hierarchy_page.set_page_visible(False) From 79553d0ce7cb17f2e1acda47ad10ad62ffe4419b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 13:49:41 +0200 Subject: [PATCH 412/781] minor modifications of selection --- client/ayon_core/tools/utils/projects_widget.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/utils/projects_widget.py b/client/ayon_core/tools/utils/projects_widget.py index ddeb381e8d..d10a68cfeb 100644 --- a/client/ayon_core/tools/utils/projects_widget.py +++ b/client/ayon_core/tools/utils/projects_widget.py @@ -889,15 +889,17 @@ class ProjectsWidget(QtWidgets.QWidget): self._projects_proxy_model.setFilterFixedString(text) def set_selected_project(self, project_name: Optional[str]): - selection_model = self._projects_view.selectionModel() if project_name is None: - selection_model.clearSelection() + self._projects_view.clearSelection() + self._projects_view.setCurrentIndex(QtCore.QModelIndex()) return + index = self._projects_model.get_index_by_project_name(project_name) if not index.isValid(): return proxy_index = self._projects_proxy_model.mapFromSource(index) if proxy_index.isValid(): + selection_model = self._projects_view.selectionModel() selection_model.select( proxy_index, QtCore.QItemSelectionModel.ClearAndSelect From 1d397ec97794ddc273418660418a5130fc39cc27 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 13:59:16 +0200 Subject: [PATCH 413/781] handle text margins --- client/ayon_core/tools/utils/projects_widget.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/ayon_core/tools/utils/projects_widget.py b/client/ayon_core/tools/utils/projects_widget.py index d10a68cfeb..2e3442a3dd 100644 --- a/client/ayon_core/tools/utils/projects_widget.py +++ b/client/ayon_core/tools/utils/projects_widget.py @@ -563,6 +563,12 @@ class ProjectsDelegate(QtWidgets.QStyledItemDelegate): painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text)) painter.drawRect(text_rect.adjusted(0, 0, -1, -1)) + margin = proxy.pixelMetric( + QtWidgets.QStyle.PM_FocusFrameHMargin, None, widget + ) + 1 + text_rect.adjust(margin, 0, -margin, 0) + # NOTE skipping some steps e.g. word wrapping and elided + # text (adding '...' when too long). painter.drawText( text_rect, opt.displayAlignment, From a785ea20e9d310b868391ca2174fb75e4cb9ce69 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:45:19 +0200 Subject: [PATCH 414/781] added more helper functions --- client/ayon_core/tools/utils/projects_widget.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/utils/projects_widget.py b/client/ayon_core/tools/utils/projects_widget.py index 2e3442a3dd..34104ef74a 100644 --- a/client/ayon_core/tools/utils/projects_widget.py +++ b/client/ayon_core/tools/utils/projects_widget.py @@ -419,7 +419,6 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): return r_is_library return super().lessThan(left_index, right_index) - def filterAcceptsRow(self, source_row, source_parent): index = self.sourceModel().index(source_row, 0, source_parent) project_name = index.data(PROJECT_NAME_ROLE) @@ -834,6 +833,7 @@ class ProjectsWidget(QtWidgets.QWidget): """ refreshed = QtCore.Signal() selection_changed = QtCore.Signal(str) + double_clicked = QtCore.Signal() def __init__( self, @@ -868,6 +868,7 @@ class ProjectsWidget(QtWidgets.QWidget): projects_view.selectionModel().selectionChanged.connect( self._on_selection_change ) + projects_view.double_clicked.connect(self.double_clicked) projects_model.refreshed.connect(self._on_model_refresh) controller.register_event_callback( @@ -882,6 +883,9 @@ class ProjectsWidget(QtWidgets.QWidget): self._projects_proxy_model = projects_proxy_model self._projects_delegate = projects_delegate + def refresh(self): + self._projects_model.refresh() + def has_content(self) -> bool: """Model has at least one project. @@ -894,6 +898,14 @@ class ProjectsWidget(QtWidgets.QWidget): def set_name_filter(self, text: str): self._projects_proxy_model.setFilterFixedString(text) + def get_selected_project(self) -> Optional[str]: + selection_model = self._projects_view.selectionModel() + for index in selection_model.selectedIndexes(): + project_name = index.data(PROJECT_NAME_ROLE) + if project_name: + return project_name + return None + def set_selected_project(self, project_name: Optional[str]): if project_name is None: self._projects_view.clearSelection() From 68d9fc16ad278f15022f936a15557f3069aa458d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:47:09 +0200 Subject: [PATCH 415/781] convert pinned projects to pinned set --- client/ayon_core/tools/common_models/projects.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 9bbdc8a75c..f2599c9c9b 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -441,6 +441,7 @@ class ProjectsModel(object): .get("frontendPreferences", {}) .get("pinnedProjects") ) or [] + pinned_projects = set(pinned_projects) project_items = _get_project_items_from_entitiy(list(projects)) for project in project_items: project.is_pinned = project.name in pinned_projects From 12d25d805c1bb385b7a41074d4109cd6d933cdba Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:56:15 +0200 Subject: [PATCH 416/781] formatting fixes --- client/ayon_core/tools/utils/projects_widget.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/utils/projects_widget.py b/client/ayon_core/tools/utils/projects_widget.py index 34104ef74a..1c87d79a58 100644 --- a/client/ayon_core/tools/utils/projects_widget.py +++ b/client/ayon_core/tools/utils/projects_widget.py @@ -22,7 +22,6 @@ if typing.TYPE_CHECKING: current: Optional[str] selected: Optional[str] - class ExpectedSelectionData(TypedDict): project: ExpectedProjectSelectionData @@ -554,7 +553,9 @@ class ProjectsDelegate(QtWidgets.QStyledItemDelegate): cg = QtGui.QPalette.Normal if opt.state & QtWidgets.QStyle.State_Selected: - painter.setPen(opt.palette.color(cg, QtGui.QPalette.HighlightedText)) + painter.setPen( + opt.palette.color(cg, QtGui.QPalette.HighlightedText) + ) else: painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text)) @@ -941,4 +942,3 @@ class ProjectsWidget(QtWidgets.QWidget): def _on_projects_refresh_finished(self, event): if event["sender"] != PROJECTS_MODEL_SENDER: self._projects_model.refresh() - 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 417/781] 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 418/781] 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 f4af01f702b7f7fc339f19231e0b88a7ee56fc33 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 18 Jun 2025 18:59:39 +0200 Subject: [PATCH 419/781] :burn: remove `TypedDict` to retain compatibility with pythpn 3.7 but we should get it back (or dataclasses) when we get out of Middle Ages. --- client/ayon_core/tools/loader/abstract.py | 169 +++------------------- 1 file changed, 17 insertions(+), 152 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 804956f875..de0a1c7dd8 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -2,7 +2,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import List, Optional, TypedDict +from typing import Any, List, Optional from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, @@ -10,62 +10,16 @@ from ayon_core.lib.attribute_definitions import ( serialize_attr_defs, ) -IconData = TypedDict("IconData", { - "type": str, - "name": str, - "color": str -}) - -ProductBaseTypeItemData = TypedDict("ProductBaseTypeItemData", { - "name": str, - "icon": IconData -}) - - -VersionItemData = TypedDict("VersionItemData", { - "version_id": str, - "version": int, - "is_hero": bool, - "product_id": str, - "task_id": Optional[str], - "thumbnail_id": Optional[str], - "published_time": Optional[str], - "author": Optional[str], - "status": Optional[str], - "frame_range": Optional[str], - "duration": Optional[int], - "handles": Optional[str], - "step": Optional[int], - "comment": Optional[str], - "source": Optional[str] -}) - - -ProductItemData = TypedDict("ProductItemData", { - "product_id": str, - "product_type": str, - "product_base_type": str, - "product_name": str, - "product_icon": IconData, - "product_type_icon": IconData, - "product_base_type_icon": IconData, - "group_name": str, - "folder_id": str, - "folder_label": str, - "version_items": dict[str, VersionItemData], - "product_in_scene": bool -}) - class ProductTypeItem: """Item representing product type. Args: name (str): Product type name. - icon (IconData): Product type icon definition. + icon (dict[str, str]): Product type icon definition. """ - def __init__(self, name: str, icon: IconData): + def __init__(self, name: str, icon: dict[str, str]): self.name = name self.icon = icon @@ -83,16 +37,16 @@ class ProductTypeItem: class ProductBaseTypeItem: """Item representing the product base type.""" - def __init__(self, name: str, icon: IconData): + def __init__(self, name: str, icon: dict[str, str]): """Initialize product base type item.""" self.name = name self.icon = icon - def to_data(self) -> ProductBaseTypeItemData: + def to_data(self) -> dict[str, Any]: """Convert item to data dictionary. Returns: - ProductBaseTypeItemData: Data representation of the item. + dict[str, Any]: Data representation of the item. """ return { @@ -102,11 +56,11 @@ class ProductBaseTypeItem: @classmethod def from_data( - cls, data: ProductBaseTypeItemData) -> ProductBaseTypeItem: + cls, data: dict[str, Any]) -> ProductBaseTypeItem: """Create item from data dictionary. Args: - data (ProductBaseTypeItemData): Data to create item from. + data (dict[str, Any]): Data to create item from. Returns: ProductBaseTypeItem: Item created from the provided data. @@ -122,8 +76,8 @@ class ProductItem: product_id (str): Product id. product_type (str): Product type. product_name (str): Product name. - product_icon (IconData): Product icon definition. - product_type_icon (IconData): Product type icon definition. + product_icon (dict[str, str]): Product icon definition. + product_type_icon (dict[str, str]): Product type icon definition. product_in_scene (bool): Is product in scene (only when used in DCC). group_name (str): Group name. folder_id (str): Folder id. @@ -137,9 +91,9 @@ class ProductItem: product_type: str, product_base_type: str, product_name: str, - product_icon: IconData, - product_type_icon: IconData, - product_base_type_icon: IconData, + product_icon: dict[str, str], + product_type_icon: dict[str, str], + product_base_type_icon: dict[str, str], group_name: str, folder_id: str, folder_label: str, @@ -159,7 +113,7 @@ class ProductItem: self.folder_label = folder_label self.version_items = version_items - def to_data(self) -> ProductItemData: + def to_data(self) -> dict[str, Any]: return { "product_id": self.product_id, "product_type": self.product_type, @@ -408,10 +362,8 @@ class ActionItem: # future development of detached UI tools it would be better to be # prepared for it. raise NotImplementedError( - "{}.to_data is not implemented. Use Attribute definitions" - " from 'ayon_core.lib' instead of 'qargparse'.".format( - self.__class__.__name__ - ) + f"{self.__class__.__name__}.to_data is not implemented. Use Attribute definitions" + " from 'ayon_core.lib' instead of 'qargparse'." ) def to_data(self): @@ -470,8 +422,6 @@ class _BaseLoaderController(ABC): dict[str, Union[str, None]]: Context data. """ - pass - @abstractmethod def reset(self): """Reset all cached data to reload everything. @@ -480,8 +430,6 @@ class _BaseLoaderController(ABC): "controller.reset.finished". """ - pass - # Model wrappers @abstractmethod def get_folder_items(self, project_name, sender=None): @@ -495,8 +443,6 @@ class _BaseLoaderController(ABC): list[FolderItem]: Folder items for the project. """ - pass - # Expected selection helpers @abstractmethod def get_expected_selection_data(self): @@ -510,8 +456,6 @@ class _BaseLoaderController(ABC): dict[str, Any]: Expected selection data. """ - pass - @abstractmethod def set_expected_selection(self, project_name, folder_id): """Set expected selection. @@ -521,8 +465,6 @@ class _BaseLoaderController(ABC): folder_id (str): Id of folder to be selected. """ - pass - class BackendLoaderController(_BaseLoaderController): """Backend loader controller abstraction. @@ -542,8 +484,6 @@ class BackendLoaderController(_BaseLoaderController): source (Optional[str]): Event source. """ - pass - @abstractmethod def get_loaded_product_ids(self): """Return set of loaded product ids. @@ -552,8 +492,6 @@ class BackendLoaderController(_BaseLoaderController): set[str]: Set of loaded product ids. """ - pass - class FrontendLoaderController(_BaseLoaderController): @abstractmethod @@ -565,8 +503,6 @@ class FrontendLoaderController(_BaseLoaderController): callback (func): Callback triggered when the event is emitted. """ - pass - # Expected selection helpers @abstractmethod def expected_project_selected(self, project_name): @@ -576,8 +512,6 @@ class FrontendLoaderController(_BaseLoaderController): project_name (str): Project name. """ - pass - @abstractmethod def expected_folder_selected(self, folder_id): """Expected folder was selected in frontend. @@ -586,8 +520,6 @@ class FrontendLoaderController(_BaseLoaderController): folder_id (str): Folder id. """ - pass - # Model wrapper calls @abstractmethod def get_project_items(self, sender=None): @@ -609,8 +541,6 @@ class FrontendLoaderController(_BaseLoaderController): list[ProjectItem]: List of project items. """ - pass - @abstractmethod def get_folder_type_items(self, project_name, sender=None): """Folder type items for a project. @@ -629,7 +559,6 @@ class FrontendLoaderController(_BaseLoaderController): list[FolderTypeItem]: Folder type information. """ - pass @abstractmethod def get_task_items(self, project_name, folder_ids, sender=None): @@ -644,7 +573,6 @@ class FrontendLoaderController(_BaseLoaderController): list[TaskItem]: List of task items. """ - pass @abstractmethod def get_task_type_items(self, project_name, sender=None): @@ -664,7 +592,6 @@ class FrontendLoaderController(_BaseLoaderController): list[TaskTypeItem]: Task type information. """ - pass @abstractmethod def get_folder_labels(self, project_name, folder_ids): @@ -678,7 +605,6 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, Optional[str]]: Folder labels by folder id. """ - pass @abstractmethod def get_project_status_items(self, project_name, sender=None): @@ -699,8 +625,6 @@ class FrontendLoaderController(_BaseLoaderController): list[StatusItem]: List of status items. """ - pass - @abstractmethod def get_product_items(self, project_name, folder_ids, sender=None): """Product items for folder ids. @@ -722,8 +646,6 @@ class FrontendLoaderController(_BaseLoaderController): list[ProductItem]: List of product items. """ - pass - @abstractmethod def get_product_item(self, project_name, product_id): """Receive single product item. @@ -736,8 +658,6 @@ class FrontendLoaderController(_BaseLoaderController): Union[ProductItem, None]: Product info or None if not found. """ - pass - @abstractmethod def get_product_type_items(self, project_name): """Product type items for a project. @@ -751,8 +671,6 @@ class FrontendLoaderController(_BaseLoaderController): list[ProductTypeItem]: List of product type items for a project. """ - pass - @abstractmethod def get_representation_items( self, project_name, version_ids, sender=None @@ -776,8 +694,6 @@ class FrontendLoaderController(_BaseLoaderController): list[RepreItem]: List of representation items. """ - pass - @abstractmethod def get_version_thumbnail_ids(self, project_name, version_ids): """Get thumbnail ids for version ids. @@ -790,8 +706,6 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, Union[str, Any]]: Thumbnail id by version id. """ - pass - @abstractmethod def get_folder_thumbnail_ids(self, project_name, folder_ids): """Get thumbnail ids for folder ids. @@ -804,14 +718,11 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, Union[str, Any]]: Thumbnail id by folder id. """ - pass - @abstractmethod def get_versions_representation_count( self, project_name, version_ids, sender=None ): - """ - Args: + """Args: project_name (str): Project name. version_ids (Iterable[str]): Version ids. sender (Optional[str]): Sender who requested the items. @@ -820,8 +731,6 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, int]: Representation count by version id. """ - pass - @abstractmethod def get_thumbnail_paths( self, @@ -844,8 +753,6 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, Union[str, None]]: Thumbnail path by entity id. """ - pass - # Selection model wrapper calls @abstractmethod def get_selected_project_name(self): @@ -857,8 +764,6 @@ class FrontendLoaderController(_BaseLoaderController): Union[str, None]: Selected project name. """ - pass - @abstractmethod def get_selected_folder_ids(self): """Get selected folder ids. @@ -869,7 +774,6 @@ class FrontendLoaderController(_BaseLoaderController): list[str]: Selected folder ids. """ - pass @abstractmethod def get_selected_task_ids(self): @@ -881,7 +785,6 @@ class FrontendLoaderController(_BaseLoaderController): list[str]: Selected folder ids. """ - pass @abstractmethod def set_selected_tasks(self, task_ids): @@ -891,7 +794,6 @@ class FrontendLoaderController(_BaseLoaderController): task_ids (Iterable[str]): Selected task ids. """ - pass @abstractmethod def get_selected_version_ids(self): @@ -903,7 +805,6 @@ class FrontendLoaderController(_BaseLoaderController): list[str]: Selected version ids. """ - pass @abstractmethod def get_selected_representation_ids(self): @@ -915,8 +816,6 @@ class FrontendLoaderController(_BaseLoaderController): list[str]: Selected representation ids. """ - pass - @abstractmethod def set_selected_project(self, project_name): """Set selected project. @@ -931,8 +830,6 @@ class FrontendLoaderController(_BaseLoaderController): project_name (Union[str, None]): Selected project name. """ - pass - @abstractmethod def set_selected_folders(self, folder_ids): """Set selected folders. @@ -948,8 +845,6 @@ class FrontendLoaderController(_BaseLoaderController): folder_ids (Iterable[str]): Selected folder ids. """ - pass - @abstractmethod def set_selected_versions(self, version_ids): """Set selected versions. @@ -966,8 +861,6 @@ class FrontendLoaderController(_BaseLoaderController): version_ids (Iterable[str]): Selected version ids. """ - pass - @abstractmethod def set_selected_representations(self, repre_ids): """Set selected representations. @@ -985,8 +878,6 @@ class FrontendLoaderController(_BaseLoaderController): repre_ids (Iterable[str]): Selected representation ids. """ - pass - # Load action items @abstractmethod def get_versions_action_items(self, project_name, version_ids): @@ -1000,8 +891,6 @@ class FrontendLoaderController(_BaseLoaderController): list[ActionItem]: List of action items. """ - pass - @abstractmethod def get_representations_action_items( self, project_name, representation_ids @@ -1016,8 +905,6 @@ class FrontendLoaderController(_BaseLoaderController): list[ActionItem]: List of action items. """ - pass - @abstractmethod def trigger_action_item( self, @@ -1050,8 +937,6 @@ class FrontendLoaderController(_BaseLoaderController): representation_ids (Iterable[str]): Representation ids. """ - pass - @abstractmethod def change_products_group(self, project_name, product_ids, group_name): """Change group of products. @@ -1070,8 +955,6 @@ class FrontendLoaderController(_BaseLoaderController): group_name (str): New group name. """ - pass - @abstractmethod def fill_root_in_source(self, source): """Fill root in source path. @@ -1081,8 +964,6 @@ class FrontendLoaderController(_BaseLoaderController): rootless workfile path. """ - pass - # NOTE: Methods 'is_loaded_products_supported' and # 'is_standard_projects_filter_enabled' are both based on being in host # or not. Maybe we could implement only single method 'is_in_host'? @@ -1094,8 +975,6 @@ class FrontendLoaderController(_BaseLoaderController): bool: True if it is supported. """ - pass - @abstractmethod def is_standard_projects_filter_enabled(self): """Is standard projects filter enabled. @@ -1108,8 +987,6 @@ class FrontendLoaderController(_BaseLoaderController): current context project. """ - pass - # Site sync functions @abstractmethod def is_sitesync_enabled(self, project_name=None): @@ -1127,8 +1004,6 @@ class FrontendLoaderController(_BaseLoaderController): bool: True if site sync is enabled. """ - pass - @abstractmethod def get_active_site_icon_def(self, project_name): """Active site icon definition. @@ -1141,8 +1016,6 @@ class FrontendLoaderController(_BaseLoaderController): is not enabled for the project. """ - pass - @abstractmethod def get_remote_site_icon_def(self, project_name): """Remote site icon definition. @@ -1155,8 +1028,6 @@ class FrontendLoaderController(_BaseLoaderController): is not enabled for the project. """ - pass - @abstractmethod def get_version_sync_availability(self, project_name, version_ids): """Version sync availability. @@ -1169,8 +1040,6 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, tuple[int, int]]: Sync availability by version id. """ - pass - @abstractmethod def get_representations_sync_status( self, project_name, representation_ids @@ -1185,8 +1054,6 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, tuple[int, int]]: Sync status by representation id. """ - pass - @abstractmethod def get_product_types_filter(self): """Return product type filter for current context. @@ -1194,5 +1061,3 @@ class FrontendLoaderController(_BaseLoaderController): Returns: ProductTypesFilter: Product type filter for current context """ - - pass From ae1bfc71f78bd43a0e930387561178c799184c43 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 18 Jun 2025 19:00:35 +0200 Subject: [PATCH 420/781] :recycle: change loader filtering --- client/ayon_core/pipeline/load/plugins.py | 81 ++++++++++++++--------- 1 file changed, 48 insertions(+), 33 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 32b96e3e7a..5f206df364 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,16 +1,18 @@ """Plugins for loading representations and products into host applications.""" from __future__ import annotations -import os -import logging -from ayon_core.settings import get_project_settings +import logging +import os + from ayon_core.pipeline.plugin_discover import ( + deregister_plugin, + deregister_plugin_path, discover, register_plugin, register_plugin_path, - deregister_plugin, - deregister_plugin_path ) +from ayon_core.settings import get_project_settings + from .utils import get_representation_path_from_context @@ -61,12 +63,12 @@ class LoaderPlugin(list): if not plugin_settings: return - print(">>> We have preset for {}".format(plugin_name)) + print(f">>> We have preset for {plugin_name}") for option, value in plugin_settings.items(): if option == "enabled" and value is False: print(" - is disabled by preset") else: - print(" - setting `{}`: `{}`".format(option, value)) + print(f" - setting `{option}`: `{value}`") setattr(cls, option, value) @classmethod @@ -79,7 +81,6 @@ class LoaderPlugin(list): Returns: bool: Representation has valid extension """ - if "*" in cls.extensions: return True @@ -122,10 +123,21 @@ class LoaderPlugin(list): Returns: bool: Is loader compatible for context. """ + """ + product_types: set[str] = set() + product_base_types: set[str] = set() + representations = set() + extensions = {"*"} + """ plugin_repre_names = cls.get_representations() plugin_product_types = cls.product_types plugin_product_base_types = cls.product_base_types + repre_entity = context.get("representation") + product_entity = context["product"] + + # If no representation names, product types or extensions are defined + # then loader is not compatible with any context. if ( not plugin_repre_names or (not plugin_product_types and not plugin_product_base_types) @@ -133,38 +145,45 @@ class LoaderPlugin(list): ): return False - repre_entity = context.get("representation") + # If no representation entity is provided then loader is not + # compatible with context. if not repre_entity: return False + # Check the compatibility with the representation names. plugin_repre_names = set(plugin_repre_names) - if ( + if not plugin_repre_names or ( "*" not in plugin_repre_names and repre_entity["name"] not in plugin_repre_names ): return False + # Check the compatibility with the extension of the representation. if not cls.has_valid_extension(repre_entity): return False plugin_product_types = set(plugin_product_types) - if "*" in plugin_product_types: - return True - - plugin_product_base_types = set(plugin_product_base_types) - if "*" in plugin_product_base_types: - # If plugin supports all product base types, then it is compatible - # with any product type. - return True - - product_entity = context["product"] - product_type = product_entity["productType"] + product_type = product_entity.get("productType") product_base_type = product_entity.get("productBaseType") - if product_type in plugin_product_types: + # Use product base type if defined, otherwise use product type. + product_filter = product_base_type + # If there is no product base type defined in the product entity, + # then we will use the product type. + if product_filter is None: + product_filter = product_type + + # If no product type isn't defined on the loader plugin, + # then we will use the product types. + plugin_product_filter = ( + plugin_product_base_types or plugin_product_types) + + # If wildcard is used in product types or base types, + # then we will consider the loader compatible with any product type. + if "*" in plugin_product_filter: return True - return product_base_type in plugin_product_base_types + return product_filter in plugin_product_filter @classmethod def get_representations(cls): @@ -219,19 +238,17 @@ class LoaderPlugin(list): bool: Whether the container was deleted """ - raise NotImplementedError("Loader.remove() must be " "implemented by subclass") @classmethod def get_options(cls, contexts): - """ - Returns static (cls) options or could collect from 'contexts'. + """Returns static (cls) options or could collect from 'contexts'. - Args: - contexts (list): of repre or product contexts - Returns: - (list) + Args: + contexts (list): of repre or product contexts + Returns: + (list) """ return cls.options or [] @@ -279,9 +296,7 @@ def discover_loader_plugins(project_name=None): plugin.apply_settings(project_settings) except Exception: log.warning( - "Failed to apply settings to loader {}".format( - plugin.__name__ - ), + f"Failed to apply settings to loader {plugin.__name__}", exc_info=True ) return plugins From 1c63b75a272421e2f0ebdd88df791802b9097204 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 19 Jun 2025 10:06:29 +0200 Subject: [PATCH 421/781] :recycle: make product type and product base types None by default --- client/ayon_core/pipeline/load/plugins.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 5f206df364..966b418db8 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import os +from typing import Optional from ayon_core.pipeline.plugin_discover import ( deregister_plugin, @@ -19,8 +20,8 @@ from .utils import get_representation_path_from_context class LoaderPlugin(list): """Load representation into host application""" - product_types: set[str] = set() - product_base_types: set[str] = set() + product_types: Optional[set[str]] = None + product_base_types: Optional[set[str]] = None representations = set() extensions = {"*"} order = 0 From a3c04d232a6c91ed303ca40f1b4386601be0d21c Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 19 Jun 2025 10:07:20 +0200 Subject: [PATCH 422/781] :recycle: revert more `TypedDict` changes and fix line length --- client/ayon_core/tools/loader/abstract.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index de0a1c7dd8..c585160672 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -242,7 +242,7 @@ class VersionItem: def __le__(self, other): return self.__eq__(other) or self.__lt__(other) - def to_data(self) -> VersionItemData: + def to_data(self) -> dict[str, Any]: return { "version_id": self.version_id, "product_id": self.product_id, @@ -262,7 +262,7 @@ class VersionItem: } @classmethod - def from_data(cls, data: VersionItemData): + def from_data(cls, data: dict[str, Any]) -> VersionItem: return cls(**data) @@ -362,8 +362,9 @@ class ActionItem: # future development of detached UI tools it would be better to be # prepared for it. raise NotImplementedError( - f"{self.__class__.__name__}.to_data is not implemented. Use Attribute definitions" - " from 'ayon_core.lib' instead of 'qargparse'." + f"{self.__class__.__name__}.to_data is not implemented. " + "Use Attribute definitions " + "from 'ayon_core.lib' instead of 'qargparse'." ) def to_data(self): From e003ef2960727bdc997da5a737f3458786237af0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 19 Jun 2025 10:15:02 +0200 Subject: [PATCH 423/781] :fire: revert some code cleanup --- client/ayon_core/tools/loader/abstract.py | 96 ++++++++++++++++++++++- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index c585160672..21f2349544 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -362,9 +362,10 @@ class ActionItem: # future development of detached UI tools it would be better to be # prepared for it. raise NotImplementedError( - f"{self.__class__.__name__}.to_data is not implemented. " - "Use Attribute definitions " - "from 'ayon_core.lib' instead of 'qargparse'." + "{}.to_data is not implemented. Use Attribute definitions" + " from 'ayon_core.lib' instead of 'qargparse'.".format( + self.__class__.__name__ + ) ) def to_data(self): @@ -423,6 +424,8 @@ class _BaseLoaderController(ABC): dict[str, Union[str, None]]: Context data. """ + pass + @abstractmethod def reset(self): """Reset all cached data to reload everything. @@ -431,6 +434,8 @@ class _BaseLoaderController(ABC): "controller.reset.finished". """ + pass + # Model wrappers @abstractmethod def get_folder_items(self, project_name, sender=None): @@ -444,6 +449,8 @@ class _BaseLoaderController(ABC): list[FolderItem]: Folder items for the project. """ + pass + # Expected selection helpers @abstractmethod def get_expected_selection_data(self): @@ -457,6 +464,8 @@ class _BaseLoaderController(ABC): dict[str, Any]: Expected selection data. """ + pass + @abstractmethod def set_expected_selection(self, project_name, folder_id): """Set expected selection. @@ -466,6 +475,8 @@ class _BaseLoaderController(ABC): folder_id (str): Id of folder to be selected. """ + pass + class BackendLoaderController(_BaseLoaderController): """Backend loader controller abstraction. @@ -485,6 +496,8 @@ class BackendLoaderController(_BaseLoaderController): source (Optional[str]): Event source. """ + pass + @abstractmethod def get_loaded_product_ids(self): """Return set of loaded product ids. @@ -493,6 +506,8 @@ class BackendLoaderController(_BaseLoaderController): set[str]: Set of loaded product ids. """ + pass + class FrontendLoaderController(_BaseLoaderController): @abstractmethod @@ -504,6 +519,8 @@ class FrontendLoaderController(_BaseLoaderController): callback (func): Callback triggered when the event is emitted. """ + pass + # Expected selection helpers @abstractmethod def expected_project_selected(self, project_name): @@ -513,6 +530,8 @@ class FrontendLoaderController(_BaseLoaderController): project_name (str): Project name. """ + pass + @abstractmethod def expected_folder_selected(self, folder_id): """Expected folder was selected in frontend. @@ -521,6 +540,8 @@ class FrontendLoaderController(_BaseLoaderController): folder_id (str): Folder id. """ + pass + # Model wrapper calls @abstractmethod def get_project_items(self, sender=None): @@ -542,6 +563,8 @@ class FrontendLoaderController(_BaseLoaderController): list[ProjectItem]: List of project items. """ + pass + @abstractmethod def get_folder_type_items(self, project_name, sender=None): """Folder type items for a project. @@ -560,6 +583,7 @@ class FrontendLoaderController(_BaseLoaderController): list[FolderTypeItem]: Folder type information. """ + pass @abstractmethod def get_task_items(self, project_name, folder_ids, sender=None): @@ -574,6 +598,7 @@ class FrontendLoaderController(_BaseLoaderController): list[TaskItem]: List of task items. """ + pass @abstractmethod def get_task_type_items(self, project_name, sender=None): @@ -593,6 +618,7 @@ class FrontendLoaderController(_BaseLoaderController): list[TaskTypeItem]: Task type information. """ + pass @abstractmethod def get_folder_labels(self, project_name, folder_ids): @@ -606,6 +632,7 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, Optional[str]]: Folder labels by folder id. """ + pass @abstractmethod def get_project_status_items(self, project_name, sender=None): @@ -626,6 +653,8 @@ class FrontendLoaderController(_BaseLoaderController): list[StatusItem]: List of status items. """ + pass + @abstractmethod def get_product_items(self, project_name, folder_ids, sender=None): """Product items for folder ids. @@ -647,6 +676,8 @@ class FrontendLoaderController(_BaseLoaderController): list[ProductItem]: List of product items. """ + pass + @abstractmethod def get_product_item(self, project_name, product_id): """Receive single product item. @@ -659,6 +690,8 @@ class FrontendLoaderController(_BaseLoaderController): Union[ProductItem, None]: Product info or None if not found. """ + pass + @abstractmethod def get_product_type_items(self, project_name): """Product type items for a project. @@ -672,6 +705,8 @@ class FrontendLoaderController(_BaseLoaderController): list[ProductTypeItem]: List of product type items for a project. """ + pass + @abstractmethod def get_representation_items( self, project_name, version_ids, sender=None @@ -695,6 +730,8 @@ class FrontendLoaderController(_BaseLoaderController): list[RepreItem]: List of representation items. """ + pass + @abstractmethod def get_version_thumbnail_ids(self, project_name, version_ids): """Get thumbnail ids for version ids. @@ -707,6 +744,8 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, Union[str, Any]]: Thumbnail id by version id. """ + pass + @abstractmethod def get_folder_thumbnail_ids(self, project_name, folder_ids): """Get thumbnail ids for folder ids. @@ -719,11 +758,14 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, Union[str, Any]]: Thumbnail id by folder id. """ + pass + @abstractmethod def get_versions_representation_count( self, project_name, version_ids, sender=None ): - """Args: + """ + Args: project_name (str): Project name. version_ids (Iterable[str]): Version ids. sender (Optional[str]): Sender who requested the items. @@ -732,6 +774,8 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, int]: Representation count by version id. """ + pass + @abstractmethod def get_thumbnail_paths( self, @@ -754,6 +798,8 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, Union[str, None]]: Thumbnail path by entity id. """ + pass + # Selection model wrapper calls @abstractmethod def get_selected_project_name(self): @@ -765,6 +811,8 @@ class FrontendLoaderController(_BaseLoaderController): Union[str, None]: Selected project name. """ + pass + @abstractmethod def get_selected_folder_ids(self): """Get selected folder ids. @@ -775,6 +823,7 @@ class FrontendLoaderController(_BaseLoaderController): list[str]: Selected folder ids. """ + pass @abstractmethod def get_selected_task_ids(self): @@ -786,6 +835,7 @@ class FrontendLoaderController(_BaseLoaderController): list[str]: Selected folder ids. """ + pass @abstractmethod def set_selected_tasks(self, task_ids): @@ -795,6 +845,7 @@ class FrontendLoaderController(_BaseLoaderController): task_ids (Iterable[str]): Selected task ids. """ + pass @abstractmethod def get_selected_version_ids(self): @@ -806,6 +857,7 @@ class FrontendLoaderController(_BaseLoaderController): list[str]: Selected version ids. """ + pass @abstractmethod def get_selected_representation_ids(self): @@ -817,6 +869,8 @@ class FrontendLoaderController(_BaseLoaderController): list[str]: Selected representation ids. """ + pass + @abstractmethod def set_selected_project(self, project_name): """Set selected project. @@ -831,6 +885,8 @@ class FrontendLoaderController(_BaseLoaderController): project_name (Union[str, None]): Selected project name. """ + pass + @abstractmethod def set_selected_folders(self, folder_ids): """Set selected folders. @@ -846,6 +902,8 @@ class FrontendLoaderController(_BaseLoaderController): folder_ids (Iterable[str]): Selected folder ids. """ + pass + @abstractmethod def set_selected_versions(self, version_ids): """Set selected versions. @@ -862,6 +920,8 @@ class FrontendLoaderController(_BaseLoaderController): version_ids (Iterable[str]): Selected version ids. """ + pass + @abstractmethod def set_selected_representations(self, repre_ids): """Set selected representations. @@ -879,6 +939,8 @@ class FrontendLoaderController(_BaseLoaderController): repre_ids (Iterable[str]): Selected representation ids. """ + pass + # Load action items @abstractmethod def get_versions_action_items(self, project_name, version_ids): @@ -892,6 +954,8 @@ class FrontendLoaderController(_BaseLoaderController): list[ActionItem]: List of action items. """ + pass + @abstractmethod def get_representations_action_items( self, project_name, representation_ids @@ -906,6 +970,8 @@ class FrontendLoaderController(_BaseLoaderController): list[ActionItem]: List of action items. """ + pass + @abstractmethod def trigger_action_item( self, @@ -938,6 +1004,8 @@ class FrontendLoaderController(_BaseLoaderController): representation_ids (Iterable[str]): Representation ids. """ + pass + @abstractmethod def change_products_group(self, project_name, product_ids, group_name): """Change group of products. @@ -956,6 +1024,8 @@ class FrontendLoaderController(_BaseLoaderController): group_name (str): New group name. """ + pass + @abstractmethod def fill_root_in_source(self, source): """Fill root in source path. @@ -965,6 +1035,8 @@ class FrontendLoaderController(_BaseLoaderController): rootless workfile path. """ + pass + # NOTE: Methods 'is_loaded_products_supported' and # 'is_standard_projects_filter_enabled' are both based on being in host # or not. Maybe we could implement only single method 'is_in_host'? @@ -976,6 +1048,8 @@ class FrontendLoaderController(_BaseLoaderController): bool: True if it is supported. """ + pass + @abstractmethod def is_standard_projects_filter_enabled(self): """Is standard projects filter enabled. @@ -988,6 +1062,8 @@ class FrontendLoaderController(_BaseLoaderController): current context project. """ + pass + # Site sync functions @abstractmethod def is_sitesync_enabled(self, project_name=None): @@ -1005,6 +1081,8 @@ class FrontendLoaderController(_BaseLoaderController): bool: True if site sync is enabled. """ + pass + @abstractmethod def get_active_site_icon_def(self, project_name): """Active site icon definition. @@ -1017,6 +1095,8 @@ class FrontendLoaderController(_BaseLoaderController): is not enabled for the project. """ + pass + @abstractmethod def get_remote_site_icon_def(self, project_name): """Remote site icon definition. @@ -1029,6 +1109,8 @@ class FrontendLoaderController(_BaseLoaderController): is not enabled for the project. """ + pass + @abstractmethod def get_version_sync_availability(self, project_name, version_ids): """Version sync availability. @@ -1041,6 +1123,8 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, tuple[int, int]]: Sync availability by version id. """ + pass + @abstractmethod def get_representations_sync_status( self, project_name, representation_ids @@ -1055,6 +1139,8 @@ class FrontendLoaderController(_BaseLoaderController): dict[str, tuple[int, int]]: Sync status by representation id. """ + pass + @abstractmethod def get_product_types_filter(self): """Return product type filter for current context. @@ -1062,3 +1148,5 @@ class FrontendLoaderController(_BaseLoaderController): Returns: ProductTypesFilter: Product type filter for current context """ + + pass From e8dcec0510dd78099f8ab2a3bea000cdd91a6ce5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 19 Jun 2025 10:52:34 +0200 Subject: [PATCH 424/781] Changed typing to support 3.7 We still support older Maya --- client/ayon_core/pipeline/load/plugins.py | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 6a6c6c639f..e9ba5a37c3 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,7 +1,7 @@ from __future__ import annotations import os import logging -from typing import Any, Type +from typing import Any, Type, Optional from abc import abstractmethod from ayon_core.settings import get_project_settings @@ -275,10 +275,10 @@ class LoaderHookPlugin: def pre_load( self, context: dict, - name: str | None = None, - namespace: str | None = None, - options: dict | None = None, - plugin: LoaderPlugin | None = None, + name: Optional[str] = None, + namespace: Optional[str] = None, + options: Optional[dict] = None, + plugin: Optional[LoaderPlugin] = None, ): pass @@ -286,10 +286,10 @@ class LoaderHookPlugin: def post_load( self, context: dict, - name: str | None = None, - namespace: str | None = None, - options: dict | None = None, - plugin: LoaderPlugin | None = None, + name: Optional[str] = None, + namespace: Optional[str] = None, + options: Optional[dict] = None, + plugin: Optional[LoaderPlugin] = None, result: Any = None, ): pass @@ -299,7 +299,7 @@ class LoaderHookPlugin: self, container: dict, # (ayon:container-3.0) context: dict, - plugin: LoaderPlugin | None = None, + plugin: Optional[LoaderPlugin] = None, ): pass @@ -308,7 +308,7 @@ class LoaderHookPlugin: self, container: dict, # (ayon:container-3.0) context: dict, - plugin: LoaderPlugin | None = None, + plugin: Optional[LoaderPlugin] = None, result: Any = None, ): pass @@ -317,7 +317,7 @@ class LoaderHookPlugin: def pre_remove( self, container: dict, # (ayon:container-3.0) - plugin: LoaderPlugin | None = None, + plugin: Optional[LoaderPlugin] = None, ): pass @@ -325,7 +325,7 @@ class LoaderHookPlugin: def post_remove( self, container: dict, # (ayon:container-3.0) - plugin: LoaderPlugin | None = None, + plugin: Optional[LoaderPlugin] = None, result: Any = None, ): pass From b0e472ebe9be6741676860bdf9dd6852bc219a03 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 19 Jun 2025 11:18:34 +0200 Subject: [PATCH 425/781] Changed order of plugin, result Plugin is required so it makes sense to bump it up to first position and pass it before args --- client/ayon_core/pipeline/load/plugins.py | 35 +++++++++-------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index e9ba5a37c3..5f4056d2d5 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -274,59 +274,59 @@ class LoaderHookPlugin: @abstractmethod def pre_load( self, + plugin: LoaderPlugin, context: dict, name: Optional[str] = None, namespace: Optional[str] = None, options: Optional[dict] = None, - plugin: Optional[LoaderPlugin] = None, ): pass @abstractmethod def post_load( self, + plugin: LoaderPlugin, + result: Any, context: dict, name: Optional[str] = None, namespace: Optional[str] = None, options: Optional[dict] = None, - plugin: Optional[LoaderPlugin] = None, - result: Any = None, ): pass @abstractmethod def pre_update( self, + plugin: LoaderPlugin, container: dict, # (ayon:container-3.0) context: dict, - plugin: Optional[LoaderPlugin] = None, ): pass @abstractmethod def post_update( self, + plugin: LoaderPlugin, + result: Any, container: dict, # (ayon:container-3.0) context: dict, - plugin: Optional[LoaderPlugin] = None, - result: Any = None, ): pass @abstractmethod def pre_remove( self, + plugin: LoaderPlugin, container: dict, # (ayon:container-3.0) - plugin: Optional[LoaderPlugin] = None, ): pass @abstractmethod def post_remove( self, + plugin: LoaderPlugin, + result: Any, container: dict, # (ayon:container-3.0) - plugin: Optional[LoaderPlugin] = None, - result: Any = None, ): pass @@ -377,30 +377,21 @@ def add_hooks_to_loader( # Call pre_ on all hooks pre_hook_name = f"pre_{method_name}" - # Pass the LoaderPlugin instance to the hooks - hook_kwargs = kwargs.copy() - hook_kwargs["plugin"] = self - hooks: list[LoaderHookPlugin] = [] - for Hook in loader_class._load_hooks: - hook = Hook() # Instantiate the hook + for cls in loader_class._load_hooks: + hook = cls() # Instantiate the hook hooks.append(hook) pre_hook = getattr(hook, pre_hook_name, None) if callable(pre_hook): - pre_hook(*args, **hook_kwargs) - + pre_hook(hook, *args, **kwargs) # Call original method result = original_method(self, *args, **kwargs) - - # Add result to kwargs for post hooks from the original method - hook_kwargs["result"] = result - # Call post_ on all hooks post_hook_name = f"post_{method_name}" for hook in hooks: post_hook = getattr(hook, post_hook_name, None) if callable(post_hook): - post_hook(*args, **hook_kwargs) + post_hook(hook, result, *args, **kwargs) return result From d12273a6b82fc948fd1a1b2987c7dc7415826bec Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 19 Jun 2025 11:24:13 +0200 Subject: [PATCH 426/781] Removed None assignment --- client/ayon_core/pipeline/load/plugins.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 5f4056d2d5..73cc93797d 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -276,9 +276,9 @@ class LoaderHookPlugin: self, plugin: LoaderPlugin, context: dict, - name: Optional[str] = None, - namespace: Optional[str] = None, - options: Optional[dict] = None, + name: Optional[str], + namespace: Optional[str], + options: Optional[dict], ): pass @@ -288,9 +288,9 @@ class LoaderHookPlugin: plugin: LoaderPlugin, result: Any, context: dict, - name: Optional[str] = None, - namespace: Optional[str] = None, - options: Optional[dict] = None, + name: Optional[str], + namespace: Optional[str], + options: Optional[dict], ): pass From 68751b8f2287734dcc50e081a3ff6b6172ace11d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 19 Jun 2025 12:08:27 +0200 Subject: [PATCH 427/781] Fix wrong usage of Hook, should be LoaderPlugin Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- 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 73cc93797d..d2e6cd1abf 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -391,7 +391,7 @@ def add_hooks_to_loader( for hook in hooks: post_hook = getattr(hook, post_hook_name, None) if callable(post_hook): - post_hook(hook, result, *args, **kwargs) + post_hook(self, result, *args, **kwargs) return result From e82716978d32638c9aa20aff5b5114a293469c7d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 19 Jun 2025 12:08:37 +0200 Subject: [PATCH 428/781] Fix wrong usage of Hook, should be LoaderPlugin Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- 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 d2e6cd1abf..1dac8a4048 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -383,7 +383,7 @@ def add_hooks_to_loader( hooks.append(hook) pre_hook = getattr(hook, pre_hook_name, None) if callable(pre_hook): - pre_hook(hook, *args, **kwargs) + pre_hook(self, *args, **kwargs) # Call original method result = original_method(self, *args, **kwargs) # Call post_ on all hooks From 9738c2cc448371b5d18941c94268fdfa98b4558b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:54:19 +0200 Subject: [PATCH 429/781] Update client/ayon_core/pipeline/load/plugins.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- 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 966b418db8..ccee23c5c2 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -20,7 +20,7 @@ from .utils import get_representation_path_from_context class LoaderPlugin(list): """Load representation into host application""" - product_types: Optional[set[str]] = None + product_types: set[str] = set() product_base_types: Optional[set[str]] = None representations = set() extensions = {"*"} From 929418c8cbb82e3f67534884446844a70ac610ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Thu, 19 Jun 2025 16:54:36 +0200 Subject: [PATCH 430/781] Update client/ayon_core/pipeline/load/plugins.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/load/plugins.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index ccee23c5c2..0d0b073ad7 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -124,12 +124,6 @@ class LoaderPlugin(list): Returns: bool: Is loader compatible for context. """ - """ - product_types: set[str] = set() - product_base_types: set[str] = set() - representations = set() - extensions = {"*"} - """ plugin_repre_names = cls.get_representations() plugin_product_types = cls.product_types From 6effd688914e55f9dd1b82d507b3213189f96490 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:56:45 +0200 Subject: [PATCH 431/781] show settings icon only on hover --- client/ayon_core/tools/launcher/ui/actions_widget.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 0459999958..3f292bd358 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -77,14 +77,22 @@ class ActionOverlayWidget(QtWidgets.QFrame): settings_icon = LauncherSettingsLabel(self) settings_icon.setToolTip("Right click for options") + settings_icon.setVisible(False) main_layout = QtWidgets.QGridLayout(self) main_layout.setContentsMargins(5, 5, 0, 0) main_layout.addWidget(settings_icon, 0, 0) main_layout.setColumnStretch(1, 1) main_layout.setRowStretch(1, 1) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + def enterEvent(self, event): + super().enterEvent(event) + self._settings_icon.setVisible(True) + + def leaveEvent(self, event): + super().leaveEvent(event) + self._settings_icon.setVisible(False) class ActionsQtModel(QtGui.QStandardItemModel): From 057bdd1fbaebc91bcecee29f2b4a82f01e08bab5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:58:37 +0200 Subject: [PATCH 432/781] settings icon has width of 1/6 of parent --- .../tools/launcher/ui/actions_widget.py | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 3f292bd358..32069ee807 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -51,12 +51,13 @@ def _variant_label_sort_getter(action_item): # --- Replacement for QAction for action variants --- -class LauncherSettingsLabel(PixmapLabel): +class LauncherSettingsLabel(QtWidgets.QWidget): _settings_icon = None def __init__(self, parent): + super().__init__(parent) icon = self._get_settings_icon() - super().__init__(icon.pixmap(64, 64), parent) + self._pixmap = icon.pixmap(64, 64) @classmethod def _get_settings_icon(cls): @@ -67,6 +68,34 @@ class LauncherSettingsLabel(PixmapLabel): }) return cls._settings_icon + def paintEvent(self, event): + painter = QtGui.QPainter() + painter.begin(self) + + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + + rect = event.rect() + size = min(rect.height(), rect.width()) + pix_rect = QtCore.QRect( + rect.x(), rect.y(), + size, size + ) + pixmap = self._pixmap.scaled( + pix_rect.size(), + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + + ) + painter.setRenderHints(render_hints) + painter.drawPixmap(0, 0, pixmap) + + painter.end() + class ActionOverlayWidget(QtWidgets.QFrame): config_requested = QtCore.Signal(str) @@ -82,8 +111,9 @@ class ActionOverlayWidget(QtWidgets.QFrame): main_layout = QtWidgets.QGridLayout(self) main_layout.setContentsMargins(5, 5, 0, 0) main_layout.addWidget(settings_icon, 0, 0) - main_layout.setColumnStretch(1, 1) - main_layout.setRowStretch(1, 1) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 5) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) def enterEvent(self, event): From a52c02581608651bbe6b36779dfef39e5ed7770c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 20 Jun 2025 11:00:19 +0200 Subject: [PATCH 433/781] store settings variable --- client/ayon_core/tools/launcher/ui/actions_widget.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 32069ee807..4e3a604d44 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -116,6 +116,8 @@ class ActionOverlayWidget(QtWidgets.QFrame): self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + self._settings_icon = settings_icon + def enterEvent(self, event): super().enterEvent(event) self._settings_icon.setVisible(True) From 5c515096af9a232dbd8e8183ac814fac024e4220 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 20 Jun 2025 11:31:48 +0200 Subject: [PATCH 434/781] better scaling method --- .../tools/launcher/ui/actions_widget.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 4e3a604d44..64d77e5b33 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -69,15 +69,12 @@ class LauncherSettingsLabel(QtWidgets.QWidget): return cls._settings_icon def paintEvent(self, event): - painter = QtGui.QPainter() - painter.begin(self) + painter = QtGui.QPainter(self) - render_hints = ( + painter.setRenderHints( QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing rect = event.rect() size = min(rect.height(), rect.width()) @@ -85,14 +82,11 @@ class LauncherSettingsLabel(QtWidgets.QWidget): rect.x(), rect.y(), size, size ) - pixmap = self._pixmap.scaled( - pix_rect.size(), - QtCore.Qt.KeepAspectRatio, - QtCore.Qt.SmoothTransformation - + src_rect = QtCore.QRect( + 0, 0, + self._pixmap.width(), self._pixmap.height() ) - painter.setRenderHints(render_hints) - painter.drawPixmap(0, 0, pixmap) + painter.drawPixmap(pix_rect, self._pixmap, src_rect) painter.end() From 92fc2caaf4cc504ef03b15ad1ab45e4603f9eaab Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 20 Jun 2025 11:44:16 +0200 Subject: [PATCH 435/781] fix icons resizing --- .../tools/launcher/ui/actions_widget.py | 57 +++++-------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 64d77e5b33..9a872941d3 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -16,7 +16,6 @@ from ayon_core.lib.attribute_definitions import ( from ayon_core.tools.flickcharm import FlickCharm from ayon_core.tools.utils import ( get_qt_icon, - PixmapLabel, ) from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.launcher.abstract import WebactionContext @@ -54,11 +53,6 @@ def _variant_label_sort_getter(action_item): class LauncherSettingsLabel(QtWidgets.QWidget): _settings_icon = None - def __init__(self, parent): - super().__init__(parent) - icon = self._get_settings_icon() - self._pixmap = icon.pixmap(64, 64) - @classmethod def _get_settings_icon(cls): if cls._settings_icon is None: @@ -82,11 +76,8 @@ class LauncherSettingsLabel(QtWidgets.QWidget): rect.x(), rect.y(), size, size ) - src_rect = QtCore.QRect( - 0, 0, - self._pixmap.width(), self._pixmap.height() - ) - painter.drawPixmap(pix_rect, self._pixmap, src_rect) + pix = self._get_settings_icon().pixmap(size, size) + painter.drawPixmap(pix_rect, pix) painter.end() @@ -626,8 +617,7 @@ class ActionMenuPopup(QtWidgets.QWidget): class ActionDelegate(QtWidgets.QStyledItemDelegate): - _cached_extender = {} - _cached_extender_base_pix = None + _extender_icon = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -687,27 +677,13 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): painter.restore() @classmethod - def _get_extender_pixmap(cls, size): - pix = cls._cached_extender.get(size) - if pix is not None: - return pix - - base_pix = cls._cached_extender_base_pix - if base_pix is None: - icon = get_qt_icon({ + def _get_extender_pixmap(cls): + if cls._extender_icon is None: + cls._extender_icon = get_qt_icon({ "type": "material-symbols", "name": "more_horiz", }) - base_pix = icon.pixmap(64, 64) - cls._cached_extender_base_pix = base_pix - - pix = base_pix.scaled( - size, size, - QtCore.Qt.KeepAspectRatio, - QtCore.Qt.SmoothTransformation - ) - cls._cached_extender[size] = pix - return pix + return cls._extender_icon def paint(self, painter, option, index): painter.setRenderHints( @@ -724,20 +700,15 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): return grid_size = option.widget.gridSize() - x_offset = int( - (grid_size.width() / 2) - - (option.rect.width() / 2) - ) - item_x = option.rect.x() - x_offset - tenth_size = int(grid_size.width() / 10) - extender_size = int(tenth_size * 2.4) + extender_rect = option.rect.adjusted(5, 5, 0, 0) + extender_size = grid_size.width() // 6 + extender_rect.setWidth(extender_size) + extender_rect.setHeight(extender_size) - extender_x = item_x + tenth_size - extender_y = option.rect.y() + tenth_size - - pix = self._get_extender_pixmap(extender_size) - painter.drawPixmap(extender_x, extender_y, pix) + icon = self._get_extender_pixmap() + pix = icon.pixmap(extender_size, extender_size) + painter.drawPixmap(extender_rect, pix) class ActionsProxyModel(QtCore.QSortFilterProxyModel): From 3f8f0b17c827e062c8d77dfef8427c7867ec7f18 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 20 Jun 2025 13:25:23 +0200 Subject: [PATCH 436/781] show popup on click --- .../tools/launcher/ui/actions_widget.py | 70 +++++++------------ 1 file changed, 26 insertions(+), 44 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 9a872941d3..ae9ef05730 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -744,7 +744,6 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): class ActionsView(QtWidgets.QListView): - action_triggered = QtCore.Signal(str) config_requested = QtCore.Signal(str) def __init__(self, parent): @@ -778,18 +777,6 @@ class ActionsView(QtWidgets.QListView): self._overlay_widgets = [] self._flick = flick self._delegate = delegate - self._popup_widget = None - - def mouseMoveEvent(self, event): - """Handle mouse move event.""" - super().mouseMoveEvent(event) - # Update hover state for the item under mouse - index = self.indexAt(event.pos()) - if index.isValid() and index.data(ACTION_IS_GROUP_ROLE): - self._show_group_popup(index) - - elif self._popup_widget is not None: - self._popup_widget.close() def _on_context_menu(self, point): """Creates menu to force skip opening last workfile.""" @@ -799,33 +786,6 @@ class ActionsView(QtWidgets.QListView): action_id = index.data(ACTION_ID_ROLE) self.config_requested.emit(action_id) - def _get_popup_widget(self): - if self._popup_widget is None: - popup_widget = ActionMenuPopup(self) - - popup_widget.action_triggered.connect(self.action_triggered) - popup_widget.config_requested.connect(self.config_requested) - self._popup_widget = popup_widget - return self._popup_widget - - def _show_group_popup(self, index): - action_id = index.data(ACTION_ID_ROLE) - model = self.model() - while hasattr(model, "sourceModel"): - model = model.sourceModel() - - if not hasattr(model, "get_group_items"): - return - - action_items = model.get_group_items(action_id) - rect = self.visualRect(index) - pos = self.mapToGlobal(rect.topLeft()) - - popup_widget = self._get_popup_widget() - popup_widget.show_items( - action_id, action_items, pos - ) - def update_on_refresh(self): viewport = self.viewport() viewport.update() @@ -882,7 +842,6 @@ class ActionsWidget(QtWidgets.QWidget): animation_timer.timeout.connect(self._on_animation) view.clicked.connect(self._on_clicked) - view.action_triggered.connect(self._trigger_action) view.config_requested.connect(self._on_config_request) model.refreshed.connect(self._on_model_refresh) @@ -893,6 +852,8 @@ class ActionsWidget(QtWidgets.QWidget): self._model = model self._proxy_model = proxy_model + self._popup_widget = None + self._set_row_height(1) def refresh(self): @@ -979,10 +940,31 @@ class ActionsWidget(QtWidgets.QWidget): return is_group = index.data(ACTION_IS_GROUP_ROLE) - if is_group: - return action_id = index.data(ACTION_ID_ROLE) - self._trigger_action(action_id, index) + if is_group: + self._show_group_popup(index) + else: + self._trigger_action(action_id, index) + + def _get_popup_widget(self): + if self._popup_widget is None: + popup_widget = ActionMenuPopup(self) + + popup_widget.action_triggered.connect(self._trigger_action) + popup_widget.config_requested.connect(self._on_config_request) + self._popup_widget = popup_widget + return self._popup_widget + + def _show_group_popup(self, index): + action_id = index.data(ACTION_ID_ROLE) + action_items = self._model.get_group_items(action_id) + rect = self._view.visualRect(index) + pos = self.mapToGlobal(rect.topLeft()) + + popup_widget = self._get_popup_widget() + popup_widget.show_items( + action_id, action_items, pos + ) def _trigger_action(self, action_id, index=None): project_name = self._model.get_selected_project_name() From e417a2a335e0d8d496b38742942f8990a93515e1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 20 Jun 2025 13:42:43 +0200 Subject: [PATCH 437/781] get rid of mode property --- client/ayon_core/style/style.css | 10 +++++----- client/ayon_core/tools/launcher/ui/actions_widget.py | 1 - 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 4ef903540e..375545e90b 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -829,7 +829,7 @@ HintedLineEditButton { } /* Launcher specific stylesheets */ -ActionsView[mode="icon"] { +ActionsView { /* font size can't be set on items */ font-size: 9pt; border: 0px; @@ -837,25 +837,25 @@ ActionsView[mode="icon"] { margin: 0px; } -ActionsView[mode="icon"]::item { +ActionsView::item { padding-top: 8px; padding-bottom: 4px; border: 0px; border-radius: 0.3em; } -ActionsView[mode="icon"]::item:hover { +ActionsView::item:hover { color: {color:font-hover}; background: #424A57; } -ActionsView[mode="icon"]::icon {} +ActionsView::icon {} ActionMenuPopup #Wrapper { border-radius: 0.3em; background: #353B46; } -ActionMenuPopup ActionsView[mode="icon"] { +ActionMenuPopup ActionsView { background: transparent; border: none; } diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index ae9ef05730..f90fa1ec4a 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -748,7 +748,6 @@ class ActionsView(QtWidgets.QListView): def __init__(self, parent): super().__init__(parent) - self.setProperty("mode", "icon") self.setViewMode(QtWidgets.QListView.IconMode) self.setResizeMode(QtWidgets.QListView.Adjust) self.setSelectionMode(QtWidgets.QListView.NoSelection) From 03748aeb3bc11c0de9ded42ae25fe6733bbc6e79 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 20 Jun 2025 14:39:26 +0200 Subject: [PATCH 438/781] show config fields dialog under mouse --- .../tools/launcher/ui/actions_widget.py | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index f90fa1ec4a..28c741c93a 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -83,8 +83,6 @@ class LauncherSettingsLabel(QtWidgets.QWidget): class ActionOverlayWidget(QtWidgets.QFrame): - config_requested = QtCore.Signal(str) - def __init__(self, item_id, parent): super().__init__(parent) self._item_id = item_id @@ -163,6 +161,12 @@ class ActionsQtModel(QtGui.QStandardItemModel): def get_item_by_id(self, action_id): return self._items_by_id.get(action_id) + def get_index_by_id(self, action_id): + item = self.get_item_by_id(action_id) + if item is not None: + return self.indexFromItem(item) + return QtCore.QModelIndex() + def get_group_item_by_action_id(self, action_id): item = self._items_by_id.get(action_id) if item is not None: @@ -370,7 +374,7 @@ class ActionMenuPopupModel(QtGui.QStandardItemModel): class ActionMenuPopup(QtWidgets.QWidget): action_triggered = QtCore.Signal(str) - config_requested = QtCore.Signal(str) + config_requested = QtCore.Signal(str, QtCore.QPoint) def __init__(self, parent): super().__init__(parent) @@ -412,7 +416,7 @@ class ActionMenuPopup(QtWidgets.QWidget): expand_anim.finished.connect(self._on_expand_finish) view.clicked.connect(self._on_clicked) - view.config_requested.connect(self.config_requested) + view.config_requested.connect(self._on_configs_trigger) self._view = view self._wrapper = wrapper @@ -611,8 +615,8 @@ class ActionMenuPopup(QtWidgets.QWidget): self.action_triggered.emit(action_id) self.close() - def _on_configs_trigger(self, action_id): - self.config_requested.emit(action_id) + def _on_configs_trigger(self, action_id, center_pos): + self.config_requested.emit(action_id, center_pos) self.close() @@ -744,7 +748,7 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): class ActionsView(QtWidgets.QListView): - config_requested = QtCore.Signal(str) + config_requested = QtCore.Signal(str, QtCore.QPoint) def __init__(self, parent): super().__init__(parent) @@ -783,7 +787,9 @@ class ActionsView(QtWidgets.QListView): if not index.isValid(): return action_id = index.data(ACTION_ID_ROLE) - self.config_requested.emit(action_id) + rect = self.visualRect(index) + global_center = self.mapToGlobal(rect.center()) + self.config_requested.emit(action_id, global_center) def update_on_refresh(self): viewport = self.viewport() @@ -801,9 +807,6 @@ class ActionsView(QtWidgets.QListView): if has_configs: item_id = index.data(ACTION_ID_ROLE) widget = ActionOverlayWidget(item_id, viewport) - widget.config_requested.connect( - self.config_requested - ) overlay_widgets.append(widget) self.setIndexWidget(index, widget) @@ -841,7 +844,7 @@ class ActionsWidget(QtWidgets.QWidget): animation_timer.timeout.connect(self._on_animation) view.clicked.connect(self._on_clicked) - view.config_requested.connect(self._on_config_request) + view.config_requested.connect(self._show_config_dialog) model.refreshed.connect(self._on_model_refresh) self._animated_items = set() @@ -950,7 +953,7 @@ class ActionsWidget(QtWidgets.QWidget): popup_widget = ActionMenuPopup(self) popup_widget.action_triggered.connect(self._trigger_action) - popup_widget.config_requested.connect(self._on_config_request) + popup_widget.config_requested.connect(self._show_config_dialog) self._popup_widget = popup_widget return self._popup_widget @@ -997,10 +1000,7 @@ class ActionsWidget(QtWidgets.QWidget): if index is not None: self._start_animation(index) - def _on_config_request(self, action_id): - self._show_config_dialog(action_id) - - def _show_config_dialog(self, action_id): + def _show_config_dialog(self, action_id, center_point): action_item = self._model.get_action_item_by_id(action_id) config_fields = self._model.get_action_config_fields(action_id) if not config_fields: @@ -1026,11 +1026,31 @@ class ActionsWidget(QtWidgets.QWidget): "Cancel", ) dialog.set_values(values) + dialog.show() + self._center_dialog(dialog, center_point) result = dialog.exec_() if result == QtWidgets.QDialog.Accepted: new_values = dialog.get_values() self._controller.set_action_config_values(context, new_values) + @staticmethod + def _center_dialog(dialog, target_center_pos): + dialog_geo = dialog.geometry() + dialog_geo.moveCenter(target_center_pos) + + screen = dialog.screen() + screen_geo = screen.availableGeometry() + if screen_geo.left() > dialog_geo.left(): + dialog_geo.moveLeft(screen_geo.left()) + elif screen_geo.right() < dialog_geo.right(): + dialog_geo.moveRight(screen_geo.right()) + + if screen_geo.top() > dialog_geo.top(): + dialog_geo.moveTop(screen_geo.top()) + elif screen_geo.bottom() < dialog_geo.bottom(): + dialog_geo.moveBottom(screen_geo.bottom()) + dialog.move(dialog_geo.topLeft()) + def _create_attrs_dialog( self, config_fields, From 5bc3529434e15b62bd8e08a16e7891b6e4c99f27 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 20 Jun 2025 15:07:25 +0200 Subject: [PATCH 439/781] make font smaller --- client/ayon_core/style/style.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 375545e90b..6f47a34956 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -831,7 +831,7 @@ HintedLineEditButton { /* Launcher specific stylesheets */ ActionsView { /* font size can't be set on items */ - font-size: 9pt; + font-size: 8pt; border: 0px; padding: 0px; margin: 0px; From deacb2853e429296b1603d949b8b5006c1821ef6 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 20 Jun 2025 16:13:07 +0200 Subject: [PATCH 440/781] :recycle: refactor filtering and add some tests --- client/ayon_core/pipeline/load/plugins.py | 20 +++-- .../ayon_core/pipeline/load/test_loaders.py | 88 +++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 tests/client/ayon_core/pipeline/load/test_loaders.py diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 39343b76c6..5725133432 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -129,6 +129,10 @@ class LoaderPlugin(list): plugin_repre_names = cls.get_representations() plugin_product_types = cls.product_types plugin_product_base_types = cls.product_base_types + # If product type isn't defined on the loader plugin, + # then we will use the product types. + plugin_product_filter = ( + plugin_product_base_types or plugin_product_types) repre_entity = context.get("representation") product_entity = context["product"] @@ -136,8 +140,8 @@ class LoaderPlugin(list): # then loader is not compatible with any context. if ( not plugin_repre_names - or (not plugin_product_types and not plugin_product_base_types) - or not cls.extensions + and not plugin_product_filter + and not cls.extensions ): return False @@ -148,7 +152,7 @@ class LoaderPlugin(list): # Check the compatibility with the representation names. plugin_repre_names = set(plugin_repre_names) - if not plugin_repre_names or ( + if ( "*" not in plugin_repre_names and repre_entity["name"] not in plugin_repre_names ): @@ -169,8 +173,6 @@ class LoaderPlugin(list): if product_filter is None: product_filter = product_type - # If no product type isn't defined on the loader plugin, - # then we will use the product types. plugin_product_filter = ( plugin_product_base_types or plugin_product_types) @@ -179,6 +181,14 @@ class LoaderPlugin(list): if "*" in plugin_product_filter: return True + # compatibility with legacy loader + if cls.product_base_types is None and product_base_type: + cls.log.error( + f"Loader {cls.__name__} is doesn't specify " + "`product_base_types` but product entity has " + f"`productBaseType` defined as `{product_base_type}`. " + ) + return product_filter in plugin_product_filter @classmethod diff --git a/tests/client/ayon_core/pipeline/load/test_loaders.py b/tests/client/ayon_core/pipeline/load/test_loaders.py new file mode 100644 index 0000000000..490efe1b1e --- /dev/null +++ b/tests/client/ayon_core/pipeline/load/test_loaders.py @@ -0,0 +1,88 @@ +"""Test loaders in the pipeline module.""" + +from ayon_core.pipeline.load import LoaderPlugin + + +def test_is_compatible_loader(): + """Test if a loader is compatible with a given representation.""" + from ayon_core.pipeline.load import is_compatible_loader + + # Create a mock representation context + context = { + "loader": "test_loader", + "representation": {"name": "test_representation"}, + } + + # Create a mock loader plugin + class MockLoader(LoaderPlugin): + name = "test_loader" + version = "1.0.0" + + def is_compatible_loader(self, context): + return True + + # Check compatibility + assert is_compatible_loader(MockLoader(), context) is True + + +def test_complex_is_compatible_loader(): + """Test if a loader is compatible with a complex representation.""" + from ayon_core.pipeline.load import is_compatible_loader + + # Create a mock complex representation context + context = { + "loader": "complex_loader", + "representation": { + "name": "complex_representation", + "extension": "exr" + }, + "additional_data": {"key": "value"}, + "product": { + "name": "complex_product", + "productType": "foo", + "productBaseType": "bar", + }, + } + + # Create a mock loader plugin + class ComplexLoaderA(LoaderPlugin): + name = "complex_loaderA" + + # False because the loader doesn't specify any compatibility (missing + # wildcard for product type and product base type) + assert is_compatible_loader(ComplexLoaderA(), context) is False + + class ComplexLoaderB(LoaderPlugin): + name = "complex_loaderB" + product_types = {"*"} + representations = {"*"} + + # True, it is compatible with any product type + assert is_compatible_loader(ComplexLoaderB(), context) is True + + class ComplexLoaderC(LoaderPlugin): + name = "complex_loaderC" + product_base_types = {"*"} + representations = {"*"} + + # True, it is compatible with any product base type + assert is_compatible_loader(ComplexLoaderC(), context) is True + + class ComplexLoaderD(LoaderPlugin): + name = "complex_loaderD" + product_types = {"foo"} + representations = {"*"} + + # legacy loader defining compatibility only with product type + # is compatible provided the same product type is defined in context + assert is_compatible_loader(ComplexLoaderD(), context) is False + + class ComplexLoaderE(LoaderPlugin): + name = "complex_loaderE" + product_types = {"foo"} + representations = {"*"} + + # remove productBaseType from context to simulate legacy behavior + context["product"].pop("productBaseType", None) + + assert is_compatible_loader(ComplexLoaderE(), context) is True From 2944b70267fcb6af41e70886b75593309c0d5945 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:20:57 +0200 Subject: [PATCH 441/781] use viewport margins to calculate size and position --- client/ayon_core/style/style.css | 2 ++ .../tools/launcher/ui/actions_widget.py | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 6f47a34956..9a86bef960 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -855,9 +855,11 @@ ActionMenuPopup #Wrapper { border-radius: 0.3em; background: #353B46; } + ActionMenuPopup ActionsView { background: transparent; border: none; + margin: 4px; } #IconView[mode="icon"] { diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 28c741c93a..6a16dd9f8e 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -396,7 +396,6 @@ class ActionMenuPopup(QtWidgets.QWidget): view = ActionsView(self) view.setGridSize(QtCore.QSize(75, 80)) view.setIconSize(QtCore.QSize(32, 32)) - view.move(QtCore.QPoint(3, 3)) # Background draw wrapper = QtWidgets.QFrame(self) @@ -485,8 +484,9 @@ class ActionMenuPopup(QtWidgets.QWidget): or pos.y() + target_size.height() > window_geo.bottom() ) - pos_x = pos.x() - 5 - pos_y = pos.y() - 4 + viewport_offset = self._view.viewport().geometry().topLeft() + pos_x = pos.x() - (viewport_offset.x() + 2) + pos_y = pos.y() - (viewport_offset.y() + 1) wrap_x = wrap_y = 0 sort_order = QtCore.Qt.DescendingOrder @@ -576,16 +576,19 @@ class ActionMenuPopup(QtWidgets.QWidget): if rows == 1: cols = row_count - m_l, m_t, m_r, m_b = (3, 3, 1, 1) - # QUESTION how to get the margins from Qt? - border = 2 * 1 + viewport_geo = self._view.viewport().geometry() + viewport_offset = viewport_geo.topLeft() + # QUESTION how to get the bottom and right margins from Qt? + vp_lr = viewport_offset.x() + vp_tb = viewport_offset.y() + m_l, m_t, m_r, m_b = (vp_lr, vp_tb, vp_lr, vp_tb) single_width = ( grid_size.width() - + self._view.horizontalOffset() + border + m_l + m_r + 1 + + self._view.horizontalOffset() + m_l + m_r + 1 ) single_height = ( grid_size.height() - + self._view.verticalOffset() + border + m_b + m_t + 1 + + self._view.verticalOffset() + m_b + m_t + 1 ) total_width = single_width total_height = single_height From 0f32e26c7590185b81f95b42c035a52a1ed2833a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:21:20 +0200 Subject: [PATCH 442/781] added shadow frame --- client/ayon_core/style/style.css | 9 +++- .../tools/launcher/ui/actions_widget.py | 54 +++++++++++++------ 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 9a86bef960..5f661274af 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -841,7 +841,7 @@ ActionsView::item { padding-top: 8px; padding-bottom: 4px; border: 0px; - border-radius: 0.3em; + border-radius: 5px; } ActionsView::item:hover { @@ -851,8 +851,13 @@ ActionsView::item:hover { ActionsView::icon {} +ActionMenuPopup #ShadowFrame { + border-radius: 5px; + background: rgba(0, 0, 0, 0.3); +} + ActionMenuPopup #Wrapper { - border-radius: 0.3em; + border-radius: 5px; background: #353B46; } diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 6a16dd9f8e..0e2a56babf 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -21,6 +21,7 @@ from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.launcher.abstract import WebactionContext ANIMATION_LEN = 7 +SHADOW_FRAME_MARGINS = (2, 2, 2, 2) ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 ACTION_TYPE_ROLE = QtCore.Qt.UserRole + 2 @@ -392,15 +393,25 @@ class ActionMenuPopup(QtWidgets.QWidget): expand_anim.setDuration(60) expand_anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad) + sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS + # View with actions view = ActionsView(self) view.setGridSize(QtCore.QSize(75, 80)) view.setIconSize(QtCore.QSize(32, 32)) + view.move(QtCore.QPoint(sh_l, sh_t)) # Background draw + bg_frame = QtWidgets.QFrame(self) + bg_frame.setObjectName("ShadowFrame") + bg_frame.stackUnder(view) + wrapper = QtWidgets.QFrame(self) wrapper.setObjectName("Wrapper") - wrapper.stackUnder(view) + + bg_layout = QtWidgets.QVBoxLayout(bg_frame) + bg_layout.setContentsMargins(sh_l, sh_t, sh_r, sh_b) + bg_layout.addWidget(wrapper) model = ActionMenuPopupModel() proxy_model = ActionsProxyModel() @@ -418,7 +429,7 @@ class ActionMenuPopup(QtWidgets.QWidget): view.config_requested.connect(self._on_configs_trigger) self._view = view - self._wrapper = wrapper + self._bg_frame = bg_frame self._model = model self._proxy_model = proxy_model @@ -484,22 +495,23 @@ class ActionMenuPopup(QtWidgets.QWidget): or pos.y() + target_size.height() > window_geo.bottom() ) + sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS viewport_offset = self._view.viewport().geometry().topLeft() - pos_x = pos.x() - (viewport_offset.x() + 2) - pos_y = pos.y() - (viewport_offset.y() + 1) + pos_x = pos.x() - (sh_l + viewport_offset.x() + 2) + pos_y = pos.y() - (sh_t + viewport_offset.y() + 1) - wrap_x = wrap_y = 0 + bg_x = bg_y = 0 sort_order = QtCore.Qt.DescendingOrder if right_to_left: sort_order = QtCore.Qt.AscendingOrder size_diff = target_size - size pos_x -= size_diff.width() pos_y -= size_diff.height() - wrap_x = size_diff.width() - wrap_y = size_diff.height() + bg_x = size_diff.width() + bg_y = size_diff.height() - wrap_geo = QtCore.QRect( - wrap_x, wrap_y, size.width(), size.height() + bg_geo = QtCore.QRect( + bg_x, bg_y, size.width(), size.height() ) if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: self._expand_anim.stop() @@ -508,10 +520,10 @@ class ActionMenuPopup(QtWidgets.QWidget): self._proxy_model.sort(0, sort_order) self.setUpdatesEnabled(False) - self._view.setMask(wrap_geo) + self._view.setMask(bg_geo.adjusted(sh_l, sh_t, -sh_r, -sh_b)) self._view.setMinimumWidth(target_size.width()) self._view.setMaximumWidth(target_size.width()) - self._wrapper.setGeometry(wrap_geo) + self._bg_frame.setGeometry(bg_geo) self.setGeometry( pos_x, pos_y, target_size.width(), target_size.height() @@ -540,9 +552,9 @@ class ActionMenuPopup(QtWidgets.QWidget): self._expand_anim.stop() return - wrapper_geo = self._wrapper.geometry() - wrapper_geo.setWidth(value.width()) - wrapper_geo.setHeight(value.height()) + bg_geo = self._bg_frame.geometry() + bg_geo.setWidth(value.width()) + bg_geo.setHeight(value.height()) if self._right_to_left: geo = self.geometry() @@ -550,10 +562,11 @@ class ActionMenuPopup(QtWidgets.QWidget): geo.width() - value.width(), geo.height() - value.height(), ) - wrapper_geo.setTopLeft(pos) + bg_geo.setTopLeft(pos) - self._view.setMask(wrapper_geo) - self._wrapper.setGeometry(wrapper_geo) + sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS + self._view.setMask(bg_geo.adjusted(sh_l, sh_t, -sh_r, -sh_b)) + self._bg_frame.setGeometry(bg_geo) def _on_expand_finish(self): # Make sure that size is recalculated if src and targe size is same @@ -582,6 +595,13 @@ class ActionMenuPopup(QtWidgets.QWidget): vp_lr = viewport_offset.x() vp_tb = viewport_offset.y() m_l, m_t, m_r, m_b = (vp_lr, vp_tb, vp_lr, vp_tb) + m_l, m_t, m_r, m_b = ( + s_m + vp_m + for s_m, vp_m in zip( + SHADOW_FRAME_MARGINS, + (vp_lr, vp_tb, vp_lr, vp_tb) + ) + ) single_width = ( grid_size.width() + self._view.horizontalOffset() + m_l + m_r + 1 From 56fd29f20a58dc73c313789f750d84b1944fb099 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:21:54 +0200 Subject: [PATCH 443/781] remove unnecessary line --- client/ayon_core/tools/launcher/ui/actions_widget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 0e2a56babf..334107680b 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -594,7 +594,6 @@ class ActionMenuPopup(QtWidgets.QWidget): # QUESTION how to get the bottom and right margins from Qt? vp_lr = viewport_offset.x() vp_tb = viewport_offset.y() - m_l, m_t, m_r, m_b = (vp_lr, vp_tb, vp_lr, vp_tb) m_l, m_t, m_r, m_b = ( s_m + vp_m for s_m, vp_m in zip( From 0df7ff3338487c9e1d3b73f0c0f1fb5b95d6ecfa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:29:38 +0200 Subject: [PATCH 444/781] added TextAntialiasing hint --- client/ayon_core/tools/launcher/ui/actions_widget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 334107680b..20e9903f97 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -714,6 +714,7 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): def paint(self, painter, option, index): painter.setRenderHints( QtGui.QPainter.Antialiasing + | QtGui.QPainter.TextAntialiasing | QtGui.QPainter.SmoothPixmapTransform ) From 8b98c56ee87959a36a82de575d888de1f89447d1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 23 Jun 2025 09:14:42 +0200 Subject: [PATCH 445/781] Handles missing data keys safely Uses `get` method to safely access optional entities in the data dictionary, preventing potential KeyError when keys are absent. --- client/ayon_core/hooks/pre_global_host_data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/hooks/pre_global_host_data.py b/client/ayon_core/hooks/pre_global_host_data.py index 23f725901c..83c4118136 100644 --- a/client/ayon_core/hooks/pre_global_host_data.py +++ b/client/ayon_core/hooks/pre_global_host_data.py @@ -32,8 +32,8 @@ class GlobalHostDataHook(PreLaunchHook): "app": app, "project_entity": self.data["project_entity"], - "folder_entity": self.data["folder_entity"], - "task_entity": self.data["task_entity"], + "folder_entity": self.data.get("folder_entity"), + "task_entity": self.data.get("task_entity"), "anatomy": self.data["anatomy"], From bf6ac48a66e5dcdf5f06af42007250eccc9a7423 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 23 Jun 2025 14:06:26 +0200 Subject: [PATCH 446/781] better shadow --- client/ayon_core/style/style.css | 2 +- client/ayon_core/tools/launcher/ui/actions_widget.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 5f661274af..2e3bf3954f 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -853,7 +853,7 @@ ActionsView::icon {} ActionMenuPopup #ShadowFrame { border-radius: 5px; - background: rgba(0, 0, 0, 0.3); + background: rgba(12, 13, 24, 0.5); } ActionMenuPopup #Wrapper { diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 20e9903f97..8c5c7c7062 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -21,7 +21,7 @@ from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.launcher.abstract import WebactionContext ANIMATION_LEN = 7 -SHADOW_FRAME_MARGINS = (2, 2, 2, 2) +SHADOW_FRAME_MARGINS = (1, 1, 1, 1) ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 ACTION_TYPE_ROLE = QtCore.Qt.UserRole + 2 @@ -409,6 +409,10 @@ class ActionMenuPopup(QtWidgets.QWidget): wrapper = QtWidgets.QFrame(self) wrapper.setObjectName("Wrapper") + effect = QtWidgets.QGraphicsBlurEffect(wrapper) + effect.setBlurRadius(3.0) + wrapper.setGraphicsEffect(effect) + bg_layout = QtWidgets.QVBoxLayout(bg_frame) bg_layout.setContentsMargins(sh_l, sh_t, sh_r, sh_b) bg_layout.addWidget(wrapper) @@ -430,6 +434,7 @@ class ActionMenuPopup(QtWidgets.QWidget): self._view = view self._bg_frame = bg_frame + self._effect = effect self._model = model self._proxy_model = proxy_model From b47496e47e9300f5e8f23de0cd884dfe428dbc3b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 23 Jun 2025 14:12:25 +0200 Subject: [PATCH 447/781] define minimum view height --- client/ayon_core/tools/launcher/ui/actions_widget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 8c5c7c7062..953821d778 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -528,6 +528,7 @@ class ActionMenuPopup(QtWidgets.QWidget): self._view.setMask(bg_geo.adjusted(sh_l, sh_t, -sh_r, -sh_b)) self._view.setMinimumWidth(target_size.width()) self._view.setMaximumWidth(target_size.width()) + self._view.setMinimumHeight(target_size.height()) self._bg_frame.setGeometry(bg_geo) self.setGeometry( pos_x, pos_y, From 953c584381610b6be3e12ea68faf9294a72a8e7b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:05:44 +0200 Subject: [PATCH 448/781] show tooltips for actions --- client/ayon_core/tools/launcher/ui/actions_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 953821d778..118debe123 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -252,7 +252,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): item.setFlags(QtCore.Qt.ItemIsEnabled) item.setData(label, QtCore.Qt.DisplayRole) - # item.setData(label, QtCore.Qt.ToolTipRole) + item.setData(label, QtCore.Qt.ToolTipRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(is_group, ACTION_IS_GROUP_ROLE) item.setData(has_configs, ACTION_HAS_CONFIGS_ROLE) @@ -325,8 +325,8 @@ class ActionMenuPopupModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem() item.setFlags(QtCore.Qt.ItemIsEnabled) - # item.setData(action_item.full_label, QtCore.Qt.ToolTipRole) item.setData(action_item.full_label, QtCore.Qt.DisplayRole) + item.setData(action_item.full_label, QtCore.Qt.ToolTipRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(action_item.identifier, ACTION_ID_ROLE) item.setData( From 6de993af25a416a4f1e6bb515da9a884189eae67 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:06:53 +0200 Subject: [PATCH 449/781] remove usage of deprecated 'HighQualityAntialiasing' flag --- .../ayon_core/tools/publisher/widgets/screenshot_widget.py | 2 -- .../ayon_core/tools/publisher/widgets/thumbnail_widget.py | 6 ------ client/ayon_core/tools/utils/color_widgets/color_inputs.py | 2 +- .../ayon_core/tools/utils/color_widgets/color_triangle.py | 2 +- client/ayon_core/tools/utils/lib.py | 3 --- client/ayon_core/tools/utils/sliders.py | 2 +- client/ayon_core/tools/utils/thumbnail_paint_widget.py | 6 ------ client/ayon_core/tools/utils/widgets.py | 4 ---- 8 files changed, 3 insertions(+), 24 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py index 0706299f32..e9749c5b07 100644 --- a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py +++ b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py @@ -348,8 +348,6 @@ class ScreenMarquee(QtCore.QObject): # QtGui.QPainter.Antialiasing # | QtGui.QPainter.SmoothPixmapTransform # ) - # if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - # render_hints |= QtGui.QPainter.HighQualityAntialiasing # pix_painter.setRenderHints(render_hints) # for item in screen_pixes: # (screen_pix, offset) = item diff --git a/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py b/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py index 261dcfb43d..f767fdf325 100644 --- a/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py +++ b/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py @@ -135,8 +135,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing pix_painter.setRenderHints(render_hints) pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) @@ -171,8 +169,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing pix_painter.setRenderHints(render_hints) tiled_rect = QtCore.QRectF( @@ -265,8 +261,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing final_painter.setRenderHints(render_hints) diff --git a/client/ayon_core/tools/utils/color_widgets/color_inputs.py b/client/ayon_core/tools/utils/color_widgets/color_inputs.py index 795b80fc1e..5a1c2dc50b 100644 --- a/client/ayon_core/tools/utils/color_widgets/color_inputs.py +++ b/client/ayon_core/tools/utils/color_widgets/color_inputs.py @@ -65,7 +65,7 @@ class AlphaSlider(QtWidgets.QSlider): painter.fillRect(event.rect(), QtCore.Qt.transparent) - painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + painter.setRenderHint(QtGui.QPainter.Antialiasing) horizontal = self.orientation() == QtCore.Qt.Horizontal diff --git a/client/ayon_core/tools/utils/color_widgets/color_triangle.py b/client/ayon_core/tools/utils/color_widgets/color_triangle.py index 290a33f0b0..7691c3e78d 100644 --- a/client/ayon_core/tools/utils/color_widgets/color_triangle.py +++ b/client/ayon_core/tools/utils/color_widgets/color_triangle.py @@ -261,7 +261,7 @@ class QtColorTriangle(QtWidgets.QWidget): pix = self.bg_image.copy() pix_painter = QtGui.QPainter(pix) - pix_painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + pix_painter.setRenderHint(QtGui.QPainter.Antialiasing) trigon_path = QtGui.QPainterPath() trigon_path.moveTo(self.point_a) diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index f7919a3317..a99c46199b 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -118,9 +118,6 @@ def paint_image_with_color(image, color): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - # Deprecated since 5.14 - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing painter.setRenderHints(render_hints) painter.setClipRegion(alpha_region) diff --git a/client/ayon_core/tools/utils/sliders.py b/client/ayon_core/tools/utils/sliders.py index ea1e01b9ea..c762b6ade0 100644 --- a/client/ayon_core/tools/utils/sliders.py +++ b/client/ayon_core/tools/utils/sliders.py @@ -58,7 +58,7 @@ class NiceSlider(QtWidgets.QSlider): painter.fillRect(event.rect(), QtCore.Qt.transparent) - painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + painter.setRenderHint(QtGui.QPainter.Antialiasing) horizontal = self.orientation() == QtCore.Qt.Horizontal diff --git a/client/ayon_core/tools/utils/thumbnail_paint_widget.py b/client/ayon_core/tools/utils/thumbnail_paint_widget.py index 9dbc2bcdd0..e67b820417 100644 --- a/client/ayon_core/tools/utils/thumbnail_paint_widget.py +++ b/client/ayon_core/tools/utils/thumbnail_paint_widget.py @@ -205,8 +205,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing pix_painter.setRenderHints(render_hints) pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) @@ -241,8 +239,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing pix_painter.setRenderHints(render_hints) tiled_rect = QtCore.QRectF( @@ -335,8 +331,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing final_painter.setRenderHints(render_hints) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index af0745af1f..388eb34cd1 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -624,8 +624,6 @@ class ClassicExpandBtnLabel(ExpandBtnLabel): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing painter.setRenderHints(render_hints) painter.drawPixmap(QtCore.QPoint(pos_x, pos_y), pixmap) painter.end() @@ -788,8 +786,6 @@ class PixmapButtonPainter(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing painter.setRenderHints(render_hints) if self._cached_pixmap is None: From c1b9eff2df4a2502c5f831c348ea0763fcafddf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 23 Jun 2025 16:59:45 +0200 Subject: [PATCH 450/781] :bug: fix comment and condition --- client/ayon_core/pipeline/load/plugins.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 5725133432..62fe8150ae 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -129,10 +129,12 @@ class LoaderPlugin(list): plugin_repre_names = cls.get_representations() plugin_product_types = cls.product_types plugin_product_base_types = cls.product_base_types - # If product type isn't defined on the loader plugin, + + # If the product base type isn't defined on the loader plugin, # then we will use the product types. - plugin_product_filter = ( - plugin_product_base_types or plugin_product_types) + plugin_product_filter = plugin_product_base_types + if plugin_product_filter is None: + plugin_product_filter = plugin_product_types repre_entity = context.get("representation") product_entity = context["product"] 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 451/781] 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 2f9cd88111196ba61632dfc4e6cdf918a32775f0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 24 Jun 2025 11:44:27 +0200 Subject: [PATCH 452/781] revert conditions --- client/ayon_core/pipeline/load/plugins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 62fe8150ae..05584e60e9 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -142,8 +142,8 @@ class LoaderPlugin(list): # then loader is not compatible with any context. if ( not plugin_repre_names - and not plugin_product_filter - and not cls.extensions + or not plugin_product_filter + or not cls.extensions ): return False From 4aefacaf44e567e2695dbe3c1db2b91f43b1ea35 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 24 Jun 2025 11:44:54 +0200 Subject: [PATCH 453/781] remove unncessary product base type filters redefinitins --- client/ayon_core/pipeline/load/plugins.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 05584e60e9..dc5bb0f66f 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -127,14 +127,16 @@ class LoaderPlugin(list): """ plugin_repre_names = cls.get_representations() - plugin_product_types = cls.product_types - plugin_product_base_types = cls.product_base_types # If the product base type isn't defined on the loader plugin, # then we will use the product types. - plugin_product_filter = plugin_product_base_types + plugin_product_filter = cls.product_base_types if plugin_product_filter is None: - plugin_product_filter = plugin_product_types + plugin_product_filter = cls.product_types + + if plugin_product_filter: + plugin_product_filter = set(plugin_product_filter) + repre_entity = context.get("representation") product_entity = context["product"] @@ -164,7 +166,6 @@ class LoaderPlugin(list): if not cls.has_valid_extension(repre_entity): return False - plugin_product_types = set(plugin_product_types) product_type = product_entity.get("productType") product_base_type = product_entity.get("productBaseType") @@ -175,9 +176,6 @@ class LoaderPlugin(list): if product_filter is None: product_filter = product_type - plugin_product_filter = ( - plugin_product_base_types or plugin_product_types) - # If wildcard is used in product types or base types, # then we will consider the loader compatible with any product type. if "*" in plugin_product_filter: From 39d45b9fbe11625f344f83194ac014b02390e73b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 24 Jun 2025 12:06:14 +0200 Subject: [PATCH 454/781] remove not existing 'IconData' --- client/ayon_core/tools/loader/models/products.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 41919461d0..16ba91e7d5 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -12,7 +12,6 @@ from ayon_api.operations import OperationsSession from ayon_core.lib import NestedCacheItem from ayon_core.style import get_default_entity_icon_color from ayon_core.tools.loader.abstract import ( - IconData, ProductTypeItem, ProductBaseTypeItem, ProductItem, @@ -113,7 +112,7 @@ def product_item_from_entity( product_type_icon = product_type_item.icon product_base_type_icon = product_base_type_item.icon - product_icon: IconData = { + product_icon = { "type": "awesome-font", "name": "fa.file-o", "color": get_default_entity_icon_color(), @@ -144,7 +143,7 @@ def product_type_item_from_data( # TODO implement icon implementation # icon = product_type_data["icon"] # color = product_type_data["color"] - icon: IconData = { + icon = { "type": "awesome-font", "name": "fa.folder", "color": "#0091B2", @@ -165,7 +164,7 @@ def product_base_type_item_from_data( ProductBaseTypeDict: Product base type item. """ - icon: IconData = { + icon = { "type": "awesome-font", "name": "fa.folder", "color": "#0091B2", @@ -176,7 +175,7 @@ def product_base_type_item_from_data( def create_default_product_type_item(product_type: str) -> ProductTypeItem: - icon: IconData = { + icon = { "type": "awesome-font", "name": "fa.folder", "color": "#0091B2", @@ -194,7 +193,7 @@ def create_default_product_base_type_item( Returns: ProductBaseTypeItem: Default product base type item. """ - icon: IconData = { + icon = { "type": "awesome-font", "name": "fa.folder", "color": "#0091B2", From 8ecb0331f594ed94c2df01063d0fdb1a86adf298 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 24 Jun 2025 12:06:25 +0200 Subject: [PATCH 455/781] use 'get_project_product_base_types' only if is implemented --- client/ayon_core/tools/loader/models/products.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 16ba91e7d5..8291203697 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -283,8 +283,11 @@ class ProductsModel: cache = self._product_base_type_items_cache[project_name] if not cache.is_valid: - product_base_types = ayon_api.get_project_product_base_types( - project_name) + product_base_types = [] + if hasattr(ayon_api, "get_project_product_base_types"): + product_base_types = ayon_api.get_project_product_base_types( + project_name + ) cache.update_data([ product_base_type_item_from_data(product_base_type) for product_base_type in product_base_types From 5f4d4d72c21a088ea40bc8347258f3d49d1a7c38 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 24 Jun 2025 12:17:48 +0200 Subject: [PATCH 456/781] add todo --- client/ayon_core/tools/loader/models/products.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 8291203697..c177be4557 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -284,6 +284,8 @@ class ProductsModel: cache = self._product_base_type_items_cache[project_name] if not cache.is_valid: product_base_types = [] + # TODO add temp implementation here when it is actually + # implemented and available on server. if hasattr(ayon_api, "get_project_product_base_types"): product_base_types = ayon_api.get_project_product_base_types( project_name From 9e7f5f5256123d610a408c2734557faad22206c6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 24 Jun 2025 13:41:53 +0200 Subject: [PATCH 457/781] Sort the existing variants list --- client/ayon_core/tools/publisher/widgets/create_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index aecea2ec44..b9b3afd895 100644 --- a/client/ayon_core/tools/publisher/widgets/create_widget.py +++ b/client/ayon_core/tools/publisher/widgets/create_widget.py @@ -683,7 +683,7 @@ class CreateWidget(QtWidgets.QWidget): options = list(self._current_creator_variant_hints) if options: options.append("---") - options.extend(variant_hints) + options.extend(sorted(variant_hints)) # Add hints to actions self._variant_widget.set_options(options) From 698aca8656a6dbd3404a37106b728aa0344f6b9b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 24 Jun 2025 15:42:32 +0200 Subject: [PATCH 458/781] Extract source_resolution_* fields on representation Comes from requirement for sources from freelancers uploaded from TP or WP. --- client/ayon_core/plugins/publish/extract_review.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 89bc56c670..d650ff7688 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -1598,6 +1598,10 @@ class ExtractReview(pyblish.api.InstancePlugin): "FFprobe couldn't read resolution from input file: \"{}\"" ).format(full_input_path_single_file)) + # collect source values to be potentially used in burnins later + new_repre["source_resolution_width"] = input_width + new_repre["source_resolution_height"] = input_height + # NOTE Setting only one of `width` or `height` is not allowed # - settings value can't have None but has value of 0 output_width = output_def["width"] or output_width or None From 3daa7263ada61b883d805b266f78d91d08fe31d8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 24 Jun 2025 15:43:10 +0200 Subject: [PATCH 459/781] Provide new templates source_resolution_* for burnin text --- client/ayon_core/plugins/publish/extract_burnin.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/ayon_core/plugins/publish/extract_burnin.py b/client/ayon_core/plugins/publish/extract_burnin.py index 3f7c2f4cba..fa7fd4e504 100644 --- a/client/ayon_core/plugins/publish/extract_burnin.py +++ b/client/ayon_core/plugins/publish/extract_burnin.py @@ -757,6 +757,15 @@ class ExtractBurnin(publish.Extractor): ) }) + # burnin source resolution which might be different than on review + repre_source_resolution_width = repre.get("source_resolution_width") + repre_source_resolution_height = repre.get("source_resolution_height") + if repre_source_resolution_width and repre_source_resolution_height: + burnin_data.update({ + "source_resolution_width": repre_source_resolution_width, + "source_resolution_height": repre_source_resolution_height + }) + def filter_burnins_defs(self, profile, instance): """Filter outputs by their values from settings. From 0f4718beb545e54d16c1db02093a76026a04ce46 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 24 Jun 2025 17:40:21 +0200 Subject: [PATCH 460/781] always update positions and set default geometry --- client/ayon_core/tools/launcher/ui/actions_widget.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 118debe123..c5f66aa5f7 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -470,21 +470,15 @@ class ActionMenuPopup(QtWidgets.QWidget): self._close_timer.stop() - update_position = False if action_id != self._current_id: - update_position = True + self.setGeometry(pos.x(), pos.y(), 1, 1) self._current_id = action_id self._update_items(action_items) # Make sure is visible if not self._showed: - update_position = True self.show() - if not update_position: - self.raise_() - return - # Set geometry to position # - first make sure widget changes from '_update_items' # are recalculated From bdbbb218f8213db38e407e2fc7bf03cb9066f206 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 24 Jun 2025 17:40:32 +0200 Subject: [PATCH 461/781] use variant label --- client/ayon_core/tools/launcher/ui/actions_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index c5f66aa5f7..ac00e7fe85 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -325,7 +325,7 @@ class ActionMenuPopupModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem() item.setFlags(QtCore.Qt.ItemIsEnabled) - item.setData(action_item.full_label, QtCore.Qt.DisplayRole) + item.setData(action_item.variant_label, QtCore.Qt.DisplayRole) item.setData(action_item.full_label, QtCore.Qt.ToolTipRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(action_item.identifier, ACTION_ID_ROLE) From 5eba658bd15e32fc24b0d694252543fdb8dcc8c5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 24 Jun 2025 17:42:05 +0200 Subject: [PATCH 462/781] adde group label showed on top of popup --- client/ayon_core/style/style.css | 6 ++ .../tools/launcher/ui/actions_widget.py | 90 ++++++++++++++----- 2 files changed, 72 insertions(+), 24 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 2e3bf3954f..97aef4ff91 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -851,6 +851,12 @@ ActionsView::item:hover { ActionsView::icon {} +ActionMenuPopup #GroupLabel { + padding: 5px; + border-radius: 3px; + background: #1C2C40; + color: #ffffff; +} ActionMenuPopup #ShadowFrame { border-radius: 5px; background: rgba(12, 13, 24, 0.5); diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index ac00e7fe85..5ad539ffca 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -395,12 +395,17 @@ class ActionMenuPopup(QtWidgets.QWidget): sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS + group_label = QtWidgets.QLabel("|", self) + group_label.setObjectName("GroupLabel") + # View with actions view = ActionsView(self) view.setGridSize(QtCore.QSize(75, 80)) view.setIconSize(QtCore.QSize(32, 32)) view.move(QtCore.QPoint(sh_l, sh_t)) + view.stackUnder(group_label) + # Background draw bg_frame = QtWidgets.QFrame(self) bg_frame.setObjectName("ShadowFrame") @@ -432,6 +437,7 @@ class ActionMenuPopup(QtWidgets.QWidget): view.clicked.connect(self._on_clicked) view.config_requested.connect(self._on_configs_trigger) + self._group_label = group_label self._view = view self._bg_frame = bg_frame self._effect = effect @@ -461,7 +467,8 @@ class ActionMenuPopup(QtWidgets.QWidget): super().leaveEvent(event) self._close_timer.start() - def show_items(self, action_id, action_items, pos): + def show_items(self, group_label, action_id, action_items, pos): + self._group_label.setText(group_label) if not action_items: if self._showed: self._close_timer.start() @@ -484,53 +491,62 @@ class ActionMenuPopup(QtWidgets.QWidget): # are recalculated app = QtWidgets.QApplication.instance() app.processEvents() - items_count, size, target_size = self._get_size_hint() + items_count, start_size, target_size = self._get_size_hint() self._model.fill_to_count(items_count) + label_y_offset = self._get_label_y_offset() window = self.screen() window_geo = window.geometry() + _target_x = pos.x() + target_size.width() + _target_y = pos.y() + target_size.height() + label_y_offset right_to_left = ( - pos.x() + target_size.width() > window_geo.right() - or pos.y() + target_size.height() > window_geo.bottom() + _target_x > window_geo.right() + or _target_y > window_geo.bottom() ) sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS viewport_offset = self._view.viewport().geometry().topLeft() pos_x = pos.x() - (sh_l + viewport_offset.x() + 2) pos_y = pos.y() - (sh_t + viewport_offset.y() + 1) - - bg_x = bg_y = 0 + bg_x = 0 + bg_y = label_y_offset sort_order = QtCore.Qt.DescendingOrder if right_to_left: sort_order = QtCore.Qt.AscendingOrder - size_diff = target_size - size + size_diff = target_size - start_size pos_x -= size_diff.width() pos_y -= size_diff.height() bg_x = size_diff.width() - bg_y = size_diff.height() bg_geo = QtCore.QRect( - bg_x, bg_y, size.width(), size.height() + bg_x, bg_y, start_size.width(), start_size.height() ) if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: self._expand_anim.stop() - self._first_anim_frame = True + self._right_to_left = right_to_left self._proxy_model.sort(0, sort_order) self.setUpdatesEnabled(False) - self._view.setMask(bg_geo.adjusted(sh_l, sh_t, -sh_r, -sh_b)) + self._view.setMask( + bg_geo.adjusted( + sh_l, sh_t - label_y_offset, + -sh_r, -(sh_b + label_y_offset) + ) + ) self._view.setMinimumWidth(target_size.width()) self._view.setMaximumWidth(target_size.width()) self._view.setMinimumHeight(target_size.height()) - self._bg_frame.setGeometry(bg_geo) + self._view.move(0, label_y_offset) self.setGeometry( - pos_x, pos_y, - target_size.width(), target_size.height() + pos_x, pos_y - label_y_offset, + target_size.width(), target_size.height() + label_y_offset ) + self._bg_frame.setGeometry(bg_geo) self.setUpdatesEnabled(True) + self._expand_anim.updateCurrentTime(0) - self._expand_anim.setStartValue(size) + self._expand_anim.setStartValue(start_size) self._expand_anim.setEndValue(target_size) self._expand_anim.start() @@ -546,6 +562,11 @@ class ActionMenuPopup(QtWidgets.QWidget): action_id = index.data(ACTION_ID_ROLE) self.action_triggered.emit(action_id) + def _get_label_y_offset(self): + height = self._group_label.sizeHint().height() + # Is over view but does not cover the settings icon + return height - 5 + def _on_expand_anim(self, value): if not self._showed: if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: @@ -553,20 +574,40 @@ class ActionMenuPopup(QtWidgets.QWidget): return bg_geo = self._bg_frame.geometry() + + label_y_offset = self._get_label_y_offset() + if self._right_to_left: + popup_geo = self.geometry() + diff_size = popup_geo.size() - value + pos = QtCore.QPoint( + diff_size.width(), diff_size.height() + ) + + bg_geo.moveTopLeft(pos) + bg_geo.setWidth(value.width()) bg_geo.setHeight(value.height()) - if self._right_to_left: - geo = self.geometry() - pos = QtCore.QPoint( - geo.width() - value.width(), - geo.height() - value.height(), - ) - bg_geo.setTopLeft(pos) + label_width = self._group_label.sizeHint().width() + label_pos_x = 0 + bgeo_tl = bg_geo.topLeft() + label_pos_y = bgeo_tl.y() - label_y_offset + if label_width < value.width(): + label_pos_x = bgeo_tl.x() + (value.width() - label_width) // 2 + + label_pos = QtCore.QPoint(label_pos_x, label_pos_y) sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS - self._view.setMask(bg_geo.adjusted(sh_l, sh_t, -sh_r, -sh_b)) + self.setUpdatesEnabled(False) + self._view.setMask( + bg_geo.adjusted( + sh_l, sh_t - label_y_offset, + -sh_r, -(sh_b + label_y_offset) + ) + ) + self._group_label.move(label_pos) self._bg_frame.setGeometry(bg_geo) + self.setUpdatesEnabled(True) def _on_expand_finish(self): # Make sure that size is recalculated if src and targe size is same @@ -982,13 +1023,14 @@ class ActionsWidget(QtWidgets.QWidget): def _show_group_popup(self, index): action_id = index.data(ACTION_ID_ROLE) + group_label = index.data(QtCore.Qt.DisplayRole) action_items = self._model.get_group_items(action_id) rect = self._view.visualRect(index) pos = self.mapToGlobal(rect.topLeft()) popup_widget = self._get_popup_widget() popup_widget.show_items( - action_id, action_items, pos + group_label, action_id, action_items, pos ) def _trigger_action(self, action_id, index=None): From 231958c21c3ebffb1a76efd7888491f3ed9eeb7a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 24 Jun 2025 18:10:08 +0200 Subject: [PATCH 463/781] the label is painted over background --- client/ayon_core/style/style.css | 3 +- .../tools/launcher/ui/actions_widget.py | 57 +++++++++---------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 97aef4ff91..0179d10697 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -853,10 +853,9 @@ ActionsView::icon {} ActionMenuPopup #GroupLabel { padding: 5px; - border-radius: 3px; - background: #1C2C40; color: #ffffff; } + ActionMenuPopup #ShadowFrame { border-radius: 5px; background: rgba(12, 13, 24, 0.5); diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 5ad539ffca..ddb1c20221 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -494,11 +494,12 @@ class ActionMenuPopup(QtWidgets.QWidget): items_count, start_size, target_size = self._get_size_hint() self._model.fill_to_count(items_count) - label_y_offset = self._get_label_y_offset() + label_sh = self._group_label.sizeHint() + label_width, label_height = label_sh.width(), label_sh.height() window = self.screen() window_geo = window.geometry() _target_x = pos.x() + target_size.width() - _target_y = pos.y() + target_size.height() + label_y_offset + _target_y = pos.y() + target_size.height() + label_height right_to_left = ( _target_x > window_geo.right() or _target_y > window_geo.bottom() @@ -508,8 +509,7 @@ class ActionMenuPopup(QtWidgets.QWidget): viewport_offset = self._view.viewport().geometry().topLeft() pos_x = pos.x() - (sh_l + viewport_offset.x() + 2) pos_y = pos.y() - (sh_t + viewport_offset.y() + 1) - bg_x = 0 - bg_y = label_y_offset + bg_x = bg_y = 0 sort_order = QtCore.Qt.DescendingOrder if right_to_left: sort_order = QtCore.Qt.AscendingOrder @@ -517,10 +517,18 @@ class ActionMenuPopup(QtWidgets.QWidget): pos_x -= size_diff.width() pos_y -= size_diff.height() bg_x = size_diff.width() + bg_y = size_diff.height() - label_height bg_geo = QtCore.QRect( - bg_x, bg_y, start_size.width(), start_size.height() + bg_x, bg_y, + start_size.width(), start_size.height() + label_height ) + + label_pos_x = sh_l + label_pos_y = bg_y + sh_t + if label_width < start_size.width(): + label_pos_x = bg_x + (start_size.width() - label_width) // 2 + if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: self._expand_anim.stop() @@ -529,20 +537,18 @@ class ActionMenuPopup(QtWidgets.QWidget): self._proxy_model.sort(0, sort_order) self.setUpdatesEnabled(False) self._view.setMask( - bg_geo.adjusted( - sh_l, sh_t - label_y_offset, - -sh_r, -(sh_b + label_y_offset) - ) + bg_geo.adjusted(sh_l, sh_t, -sh_r, -sh_b) ) self._view.setMinimumWidth(target_size.width()) self._view.setMaximumWidth(target_size.width()) self._view.setMinimumHeight(target_size.height()) - self._view.move(0, label_y_offset) + self._view.move(0, label_height) self.setGeometry( - pos_x, pos_y - label_y_offset, - target_size.width(), target_size.height() + label_y_offset + pos_x, pos_y - label_height, + target_size.width(), target_size.height() + label_height ) self._bg_frame.setGeometry(bg_geo) + self._group_label.move(label_pos_x, label_pos_y) self.setUpdatesEnabled(True) self._expand_anim.updateCurrentTime(0) @@ -562,11 +568,6 @@ class ActionMenuPopup(QtWidgets.QWidget): action_id = index.data(ACTION_ID_ROLE) self.action_triggered.emit(action_id) - def _get_label_y_offset(self): - height = self._group_label.sizeHint().height() - # Is over view but does not cover the settings icon - return height - 5 - def _on_expand_anim(self, value): if not self._showed: if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: @@ -575,37 +576,33 @@ class ActionMenuPopup(QtWidgets.QWidget): bg_geo = self._bg_frame.geometry() - label_y_offset = self._get_label_y_offset() + label_sh = self._group_label.sizeHint() + label_width, label_height = label_sh.width(), label_sh.height() if self._right_to_left: popup_geo = self.geometry() diff_size = popup_geo.size() - value pos = QtCore.QPoint( - diff_size.width(), diff_size.height() + diff_size.width(), diff_size.height() - label_height ) bg_geo.moveTopLeft(pos) bg_geo.setWidth(value.width()) - bg_geo.setHeight(value.height()) + bg_geo.setHeight(value.height() + label_height) label_width = self._group_label.sizeHint().width() - label_pos_x = 0 bgeo_tl = bg_geo.topLeft() - label_pos_y = bgeo_tl.y() - label_y_offset + sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS + + label_pos_x = sh_l if label_width < value.width(): label_pos_x = bgeo_tl.x() + (value.width() - label_width) // 2 - label_pos = QtCore.QPoint(label_pos_x, label_pos_y) - - sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS self.setUpdatesEnabled(False) self._view.setMask( - bg_geo.adjusted( - sh_l, sh_t - label_y_offset, - -sh_r, -(sh_b + label_y_offset) - ) + bg_geo.adjusted(sh_l, sh_t, -sh_r, -sh_b) ) - self._group_label.move(label_pos) + self._group_label.move(label_pos_x, sh_t) self._bg_frame.setGeometry(bg_geo) self.setUpdatesEnabled(True) From 7bd8578cd28c613fa876a06cd1b7c3aac03b4480 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 24 Jun 2025 18:16:41 +0200 Subject: [PATCH 464/781] fix view position --- client/ayon_core/tools/launcher/ui/actions_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index ddb1c20221..fddf88bed6 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -402,7 +402,7 @@ class ActionMenuPopup(QtWidgets.QWidget): view = ActionsView(self) view.setGridSize(QtCore.QSize(75, 80)) view.setIconSize(QtCore.QSize(32, 32)) - view.move(QtCore.QPoint(sh_l, sh_t)) + view.move(sh_l, sh_t) view.stackUnder(group_label) @@ -542,7 +542,7 @@ class ActionMenuPopup(QtWidgets.QWidget): self._view.setMinimumWidth(target_size.width()) self._view.setMaximumWidth(target_size.width()) self._view.setMinimumHeight(target_size.height()) - self._view.move(0, label_height) + self._view.move(sh_l, sh_t + label_height) self.setGeometry( pos_x, pos_y - label_height, target_size.width(), target_size.height() + label_height From 9a40f533038b968a553b6cb3059f3a64743b1077 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 24 Jun 2025 18:23:01 +0200 Subject: [PATCH 465/781] added some docstring --- .../tools/launcher/ui/actions_widget.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index fddf88bed6..51cb8e73bc 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -374,6 +374,22 @@ class ActionMenuPopupModel(QtGui.QStandardItemModel): class ActionMenuPopup(QtWidgets.QWidget): + """Popup widget for group varaints. + + The popup is handling most of the layout and showing of the items + manually. + + There 4 parts: + 1. Shadow - semi transparent black widget used as shadow. + 2. Background - painted over the shadow with blur effect. All + other items are painted over. + 3. Label - show group label and positioned manually at the top + of the popup. + 4. View - View with variant action items. View is positioned + and resized manually according to the items in the group and then + animated using mask region. + + """ action_triggered = QtCore.Signal(str) config_requested = QtCore.Signal(str, QtCore.QPoint) From 93557a8a69c2feaa83f9b3aeaea12932605f2b87 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 24 Jun 2025 22:34:37 +0200 Subject: [PATCH 466/781] Refactor `conditionalEnum` -> `conditional_enum` Avoid logs like: ``` DEBUG settings.settings_field | Deprecated argument: conditionalEnum ``` --- server/settings/main.py | 4 ++-- server/settings/publish_plugins.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/settings/main.py b/server/settings/main.py index dd6af0a104..93cedf2d65 100644 --- a/server/settings/main.py +++ b/server/settings/main.py @@ -106,7 +106,7 @@ class FallbackProductModel(BaseSettingsModel): fallback_type: str = SettingsField( title="Fallback config type", enum_resolver=_fallback_ocio_config_profile_types, - conditionalEnum=True, + conditional_enum=True, default="builtin_path", description=( "Type of config which needs to be used in case published " @@ -162,7 +162,7 @@ class CoreImageIOConfigProfilesModel(BaseSettingsModel): type: str = SettingsField( title="Profile type", enum_resolver=_ocio_config_profile_types, - conditionalEnum=True, + conditional_enum=True, default="builtin_path", section="---", ) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 793ca659e5..d690d79607 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -340,7 +340,7 @@ class ResizeModel(BaseSettingsModel): title="Type", description="Type of resizing", enum_resolver=lambda: _resize_types_enum, - conditionalEnum=True, + conditional_enum=True, default="source" ) @@ -373,7 +373,7 @@ class ExtractThumbnailOIIODefaultsModel(BaseSettingsModel): title="Type", description="Transcoding type", enum_resolver=lambda: _thumbnail_oiio_transcoding_type, - conditionalEnum=True, + conditional_enum=True, default="colorspace" ) @@ -476,7 +476,7 @@ class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): "colorspace", title="Transcoding type", enum_resolver=_extract_oiio_transcoding_type, - conditionalEnum=True, + conditional_enum=True, description=( "Select the transcoding type for your output, choosing either " "*Colorspace* or *Display&View* transform." From e9bc6e07f487d688bbd066f74b430a6b4036e928 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 25 Jun 2025 11:40:58 +0200 Subject: [PATCH 467/781] Do not overwrite if previously collected Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/plugins/publish/extract_review.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index d650ff7688..0ee4cbff07 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -1599,8 +1599,10 @@ class ExtractReview(pyblish.api.InstancePlugin): ).format(full_input_path_single_file)) # collect source values to be potentially used in burnins later - new_repre["source_resolution_width"] = input_width - new_repre["source_resolution_height"] = input_height + if "source_resolution_width" not in new_repre: + new_repre["source_resolution_width"] = input_width + if "source_resolution_height" not in new_repre: + new_repre["source_resolution_height"] = input_height # NOTE Setting only one of `width` or `height` is not allowed # - settings value can't have None but has value of 0 From fd49121831d4856478b0a6dadd9ac39df144e48b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:33:43 +0200 Subject: [PATCH 468/781] added support for project webactions --- client/ayon_core/tools/launcher/models/actions.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 0ed4bdad3a..adb8d371ed 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -352,9 +352,6 @@ class ActionsModel: ) def _get_webaction_request_data(self, selection: LauncherActionSelection): - if not selection.is_project_selected: - return None - entity_type = None entity_id = None entity_subtypes = [] @@ -368,6 +365,13 @@ class ActionsModel: entity_id = selection.folder_entity["id"] entity_subtypes = [selection.folder_entity["folderType"]] + elif selection.is_project_selected: + # Project actions are supported since AYON 1.9.1 + ma, mi, pa, _, _ = ayon_api.get_server_version_tuple() + if (ma, mi, pa) < (1, 9, 1): + return None + entity_type = "project" + entity_ids = [] if entity_id: entity_ids.append(entity_id) @@ -381,10 +385,10 @@ class ActionsModel: } def _get_webactions(self, selection: LauncherActionSelection): - if not selection.is_project_selected: + request_data = self._get_webaction_request_data(selection) + if request_data is None: return [] - request_data = self._get_webaction_request_data(selection) project_name = selection.project_name entity_id = None if request_data["entityIds"]: From b94b6f2de4c0fd3b06e0c3eb741ea12748a7ab5f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:24:20 +0200 Subject: [PATCH 469/781] make folders view deselectable --- client/ayon_core/tools/launcher/ui/hierarchy_page.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index 7c34989947..65efdc27ac 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -68,6 +68,7 @@ class HierarchyPage(QtWidgets.QWidget): # - Folders widget folders_widget = FoldersWidget(controller, content_body) folders_widget.set_header_visible(True) + folders_widget.set_deselectable(True) # - Tasks widget tasks_widget = TasksWidget(controller, content_body) 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 470/781] 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 471/781] 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 472/781] 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 473/781] 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 474/781] 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 cd344e671068c4374352309c8847baffedef6114 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 27 Jun 2025 10:37:58 +0200 Subject: [PATCH 475/781] don't use 'annotations' import in pyblish plugins --- .../plugins/publish/extract_review.py | 15 +++--- .../plugins/publish/integrate_traits.py | 47 +++++++++---------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 89bc56c670..9864e3a320 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -1,4 +1,3 @@ -from __future__ import annotations import os import re import copy @@ -52,7 +51,7 @@ class TempData: pixel_aspect: float resolution_width: int resolution_height: int - origin_repre: dict[str, Any] + origin_repre: "dict[str, Any]" input_is_sequence: bool first_sequence_frame: int input_allow_bg: bool @@ -60,12 +59,12 @@ class TempData: without_handles: bool handles_are_set: bool input_ext: str - explicit_input_paths: list[str] - paths_to_remove: list[str] + explicit_input_paths: "list[str]" + paths_to_remove: "list[str]" # Set later full_output_path: str = "" - filled_files: dict[int, str] = field(default_factory=dict) + filled_files: "dict[int, str]" = field(default_factory=dict) output_ext_is_image: bool = True output_is_sequence: bool = True @@ -1020,7 +1019,7 @@ class ExtractReview(pyblish.api.InstancePlugin): current_repre_name: str, start_frame: int, end_frame: int - ) -> Optional[dict[int, str]]: + ) -> Optional["dict[int, str]"]: """Tries to replace missing frames from ones from last version""" repre_file_paths = self._get_last_version_files( instance, current_repre_name) @@ -1108,7 +1107,7 @@ class ExtractReview(pyblish.api.InstancePlugin): resolution_height: int, extension: str, temp_data: TempData - ) -> Optional[dict[int, str]]: + ) -> Optional["dict[int, str]"]: """Fills missing files by blank frame.""" blank_frame_path = None @@ -1164,7 +1163,7 @@ class ExtractReview(pyblish.api.InstancePlugin): staging_dir: str, start_frame: int, end_frame: int - ) -> dict[int, str]: + ) -> "dict[int, str]": """Fill missing files in sequence by duplicating existing ones. This will take nearest frame file and copy it with so as to fill diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 38c9ecdeb4..5829510bdb 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -1,6 +1,4 @@ """Integrate representations with traits.""" -from __future__ import annotations - import contextlib import copy import hashlib @@ -87,7 +85,7 @@ class TransferItem: size: int checksum: str template: str - template_data: dict[str, Any] + template_data: "dict[str, Any]" representation: Representation related_trait: FileLocation @@ -134,7 +132,7 @@ class TemplateItem: """ anatomy: Anatomy template: str - template_data: dict[str, Any] + template_data: "dict[str, Any]" template_object: AnatomyTemplateItem @@ -144,14 +142,14 @@ class RepresentationEntity: id: str versionId: str # noqa: N815 name: str - files: dict[str, Any] - attrib: dict[str, Any] + files: "dict[str, Any]" + attrib: "dict[str, Any]" data: str - tags: list[str] + tags: "list[str]" status: str -def get_instance_families(instance: pyblish.api.Instance) -> list[str]: +def get_instance_families(instance: pyblish.api.Instance) -> "list[str]": """Get all families of the instance. Todo: @@ -177,7 +175,7 @@ def get_instance_families(instance: pyblish.api.Instance) -> list[str]: def get_changed_attributes( - old_entity: dict, new_entity: dict) -> (dict[str, Any]): + old_entity: dict, new_entity: dict) -> ("dict[str, Any]"): """Prepare changes for entity update. Todo: @@ -212,7 +210,7 @@ def get_changed_attributes( return changes -def prepare_for_json(data: dict[str, Any]) -> dict[str, Any]: +def prepare_for_json(data: "dict[str, Any]") -> "dict[str, Any]": """Prepare data for JSON serialization. If there are values that json cannot serialize, this function will @@ -354,7 +352,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def get_transfers_from_representations( self, instance: pyblish.api.Instance, - representations: list[Representation]) -> list[TransferItem]: + representations: "list[Representation]") -> "list[TransferItem]": """Get transfers from representations. This method will go through all representations and prepare transfers @@ -376,7 +374,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): """ template: str = self.get_publish_template(instance) instance_template_data: dict[str, str] = {} - transfers: list[TransferItem] = [] + transfers: "list[TransferItem]" = [] # prepare template and data to format it for representation in representations: @@ -471,7 +469,8 @@ class IntegrateTraits(pyblish.api.InstancePlugin): @staticmethod def filter_lifecycle( - representations: list[Representation]) -> list[Representation]: + representations: "list[Representation]" + ) -> "list[Representation]": """Filter representations based on LifeCycle traits. Args: @@ -887,7 +886,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def get_transfers_from_file_locations( representation: Representation, template_item: TemplateItem, - transfers: list[TransferItem]) -> None: + transfers: "list[TransferItem]") -> None: """Get transfers from FileLocations trait. Args: @@ -928,7 +927,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def get_transfers_from_sequence( representation: Representation, template_item: TemplateItem, - transfers: list[TransferItem] + transfers: "list[TransferItem]" ) -> None: """Get transfers from Sequence trait. @@ -949,7 +948,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): # template is higher, us the one from the template dst_padding = representation.get_trait( Sequence).frame_padding - frames: list[int] = sequence.get_frame_list( + frames: "list[int]" = sequence.get_frame_list( representation.get_trait(FileLocations), regex=sequence.frame_regex) template_padding = template_item.anatomy.templates_obj.frame_padding @@ -1000,7 +999,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def get_transfers_from_udim( representation: Representation, template_item: TemplateItem, - transfers: list[TransferItem] + transfers: "list[TransferItem]" ) -> None: """Get transfers from UDIM trait. @@ -1056,7 +1055,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def get_transfers_from_file_location( representation: Representation, template_item: TemplateItem, - transfers: list[TransferItem] + transfers: "list[TransferItem]" ) -> None: """Get transfers from FileLocation trait. @@ -1114,7 +1113,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def get_transfers_from_bundle( representation: Representation, template_item: TemplateItem, - transfers: list[TransferItem] + transfers: "list[TransferItem]" ) -> None: """Get transfers from Bundle trait. @@ -1152,7 +1151,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): ) def _prepare_file_info( - self, path: Path, anatomy: Anatomy) -> dict[str, Any]: + self, path: Path, anatomy: Anatomy) -> "dict[str, Any]": """Prepare information for one file (asset or resource). Arguments: @@ -1181,10 +1180,10 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def _get_legacy_files_for_representation( self, - transfer_items: list[TransferItem], + transfer_items: "list[TransferItem]", representation: Representation, anatomy: Anatomy, - ) -> list[dict[str, str]]: + ) -> "list[dict[str, str]]": """Get legacy files for a given representation. This expects the file to exist - it must run after the transfer @@ -1194,13 +1193,13 @@ class IntegrateTraits(pyblish.api.InstancePlugin): list: List of legacy files. """ - selected: list[TransferItem] = [] + selected: "list[TransferItem]" = [] selected.extend( item for item in transfer_items if item.representation == representation ) - files: list[dict[str, str]] = [] + files: "list[dict[str, str]]" = [] files.extend( self._prepare_file_info(item.destination, anatomy) for item in selected From cff69e19d04bd05f2d648e52ba420a32e61649fe Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 27 Jun 2025 11:35:09 +0200 Subject: [PATCH 476/781] fix typehints --- .../plugins/publish/integrate_traits.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 5829510bdb..7edd7c9cb5 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -130,10 +130,10 @@ class TemplateItem: template_data (dict[str, Any]): Template data. template_object (AnatomyTemplateItem): Template object """ - anatomy: Anatomy + anatomy: "Anatomy" template: str template_data: "dict[str, Any]" - template_object: AnatomyTemplateItem + template_object: "AnatomyTemplateItem" @dataclass @@ -534,7 +534,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): return path_template_obj.template.replace("\\", "/") def get_publish_template_object( - self, instance: pyblish.api.Instance) -> AnatomyTemplateItem: + self, instance: pyblish.api.Instance) -> "AnatomyTemplateItem": """Return anatomy template object to use for integration. Note: What is the actual type of the object? @@ -755,7 +755,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): return version_data - def get_rootless_path(self, anatomy: Anatomy, path: str) -> str: + def get_rootless_path(self, anatomy: "Anatomy", path: str) -> str: r"""Get rootless variant of the path. Returns, if possible, a path without an absolute portion from the root @@ -1014,7 +1014,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): """ udim: UDIM = representation.get_trait(UDIM) - path_template_object: AnatomyStringTemplate = ( + path_template_object: "AnatomyStringTemplate" = ( template_item.template_object["path"] ) for file_loc in representation.get_trait( @@ -1069,7 +1069,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template_item (TemplateItem): Template item. """ - path_template_object: AnatomyStringTemplate = ( + path_template_object: "AnatomyStringTemplate" = ( template_item.template_object["path"] ) template_item.template_data["ext"] = ( @@ -1151,7 +1151,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): ) def _prepare_file_info( - self, path: Path, anatomy: Anatomy) -> "dict[str, Any]": + self, path: Path, anatomy: "Anatomy") -> "dict[str, Any]": """Prepare information for one file (asset or resource). Arguments: @@ -1182,7 +1182,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): self, transfer_items: "list[TransferItem]", representation: Representation, - anatomy: Anatomy, + anatomy: "Anatomy", ) -> "list[dict[str, str]]": """Get legacy files for a given representation. From 0fb5220738326ef5b55afe72e71572cedd77fc25 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 27 Jun 2025 11:37:06 +0200 Subject: [PATCH 477/781] one more fix --- client/ayon_core/plugins/publish/integrate_traits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 7edd7c9cb5..7961170f3b 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -244,7 +244,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): label = "Integrate Traits of an Asset" order = pyblish.api.IntegratorOrder - log: logging.Logger + log: "logging.Logger" def process(self, instance: pyblish.api.Instance) -> None: """Integrate representations with traits. From 3c3b165e36d985b0d477e29de5270e40b61e45b3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 27 Jun 2025 13:16:03 +0200 Subject: [PATCH 478/781] don't use dataclass for now --- .../plugins/publish/extract_review.py | 91 +++++++++++++------ 1 file changed, 61 insertions(+), 30 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 005229e305..c9596a26dd 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import re import copy @@ -6,7 +7,6 @@ import shutil import subprocess from abc import ABC, abstractmethod from typing import Any, Optional -from dataclasses import dataclass, field import tempfile import clique @@ -36,37 +36,68 @@ from ayon_core.pipeline.publish import ( from ayon_core.pipeline.publish.lib import add_repre_files_for_cleanup -@dataclass class TempData: """Temporary data used across extractor's process.""" - fps: float - frame_start: int - frame_end: int - handle_start: int - handle_end: int - frame_start_handle: int - frame_end_handle: int - output_frame_start: int - output_frame_end: int - pixel_aspect: float - resolution_width: int - resolution_height: int - origin_repre: "dict[str, Any]" - input_is_sequence: bool - first_sequence_frame: int - input_allow_bg: bool - with_audio: bool - without_handles: bool - handles_are_set: bool - input_ext: str - explicit_input_paths: "list[str]" - paths_to_remove: "list[str]" + def __init__( + self, + fps: float, + frame_start: int, + frame_end: int, + handle_start: int, + handle_end: int, + frame_start_handle: int, + frame_end_handle: int, + output_frame_start: int, + output_frame_end: int, + pixel_aspect: float, + resolution_width: int, + resolution_height: int, + origin_repre: dict[str, Any], + input_is_sequence: bool, + first_sequence_frame: int, + input_allow_bg: bool, + with_audio: bool, + without_handles: bool, + handles_are_set: bool, + input_ext: str, + explicit_input_paths: list[str], + paths_to_remove: list[str], - # Set later - full_output_path: str = "" - filled_files: "dict[int, str]" = field(default_factory=dict) - output_ext_is_image: bool = True - output_is_sequence: bool = True + # Set later + full_output_path: str = "", + filled_files: dict[int, str] = None, + output_ext_is_image: bool = True, + output_is_sequence: bool = True, + ): + if filled_files is None: + filled_files = {} + self.fps = fps + self.frame_start = frame_start + self.frame_end = frame_end + self.handle_start = handle_start + self.handle_end = handle_end + self.frame_start_handle = frame_start_handle + self.frame_end_handle = frame_end_handle + self.output_frame_start = output_frame_start + self.output_frame_end = output_frame_end + self.pixel_aspect = pixel_aspect + self.resolution_width = resolution_width + self.resolution_height = resolution_height + self.origin_repre = origin_repre + self.input_is_sequence = input_is_sequence + self.first_sequence_frame = first_sequence_frame + self.input_allow_bg = input_allow_bg + self.with_audio = with_audio + self.without_handles = without_handles + self.handles_are_set = handles_are_set + self.input_ext = input_ext + self.explicit_input_paths = explicit_input_paths + self.paths_to_remove = paths_to_remove + + self.full_output_path = full_output_path + self.filled_files = filled_files + self.output_ext_is_image = output_ext_is_image + self.output_is_sequence = output_is_sequence def frame_to_timecode(frame: int, fps: float) -> str: @@ -1019,7 +1050,7 @@ class ExtractReview(pyblish.api.InstancePlugin): current_repre_name: str, start_frame: int, end_frame: int - ) -> Optional["dict[int, str]"]: + ) -> Optional[dict[int, str]]: """Tries to replace missing frames from ones from last version""" repre_file_paths = self._get_last_version_files( instance, current_repre_name) From 358efdb8989351cc68e9bd131191a6317605493d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 27 Jun 2025 13:30:46 +0200 Subject: [PATCH 479/781] :recycle: remove dataclasses we can't use dataclasses in pyblish plugins until new version of pyblish-base is propagated to AYON ecosystem --- .../plugins/publish/integrate_traits.py | 88 ++++++++++++++++--- 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 7961170f3b..9f1471d090 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -1,9 +1,9 @@ """Integrate representations with traits.""" +from __future__ import annotations import contextlib import copy import hashlib import json -from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any @@ -62,7 +62,6 @@ if TYPE_CHECKING: ) -@dataclass(frozen=True) class TransferItem: """Represents a single transfer item. @@ -85,10 +84,29 @@ class TransferItem: size: int checksum: str template: str - template_data: "dict[str, Any]" + template_data: dict[str, Any] representation: Representation related_trait: FileLocation + def __init__(self, + source: Path, + destination: Path, + size: int, + checksum: str, + template: str, + template_data: dict[str, Any], + representation: Representation, + related_trait: FileLocation): + + self.source = source + self.destination = destination + self.size = size + self.checksum = checksum + self.template = template + self.template_data = template_data + self.representation = representation + self.related_trait = related_trait + @staticmethod def get_size(file_path: Path) -> int: """Get the size of the file. @@ -118,7 +136,6 @@ class TransferItem: ).hexdigest() -@dataclass class TemplateItem: """Represents single template item. @@ -130,24 +147,73 @@ class TemplateItem: template_data (dict[str, Any]): Template data. template_object (AnatomyTemplateItem): Template object """ - anatomy: "Anatomy" + anatomy: Anatomy template: str - template_data: "dict[str, Any]" - template_object: "AnatomyTemplateItem" + template_data: dict[str, Any] + template_object: AnatomyTemplateItem + + def __init__(self, + anatomy: Anatomy, + template: str, + template_data: dict[str, Any], + template_object: AnatomyTemplateItem): + """Initialize TemplateItem. + + Args: + anatomy (Anatomy): Anatomy object. + template (str): Template path. + template_data (dict[str, Any]): Template data. + template_object (AnatomyTemplateItem): Template object. + + """ + self.anatomy = anatomy + self.template = template + self.template_data = template_data + self.template_object = template_object -@dataclass class RepresentationEntity: """Representation entity data.""" id: str versionId: str # noqa: N815 name: str - files: "dict[str, Any]" - attrib: "dict[str, Any]" + files: dict[str, Any] + attrib: dict[str, Any] data: str - tags: "list[str]" + tags: list[str] status: str + def __init__(self, + id: str, + versionId: str, # noqa: N815 + name: str, + files: dict[str, Any], + attrib: dict[str, Any], + data: str, + tags: list[str], + status: str): + """Initialize RepresentationEntity. + + Args: + id (str): Entity ID. + versionId (str): Version ID. + name (str): Representation name. + files (dict[str, Any]): Files in the representation. + attrib (dict[str, Any]): Attributes of the representation. + data (str): Data of the representation. + tags (list[str]): Tags of the representation. + status (str): Status of the representation. + + """ + self.id = id + self.versionId = versionId + self.name = name + self.files = files + self.attrib = attrib + self.data = data + self.tags = tags + self.status = status + def get_instance_families(instance: pyblish.api.Instance) -> "list[str]": """Get all families of the instance. From 6dc0a0e698f624232e284ff1a6c7e130bd9ba592 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 27 Jun 2025 13:36:44 +0200 Subject: [PATCH 480/781] use ayon-core's publish plugin discovery --- client/ayon_core/pipeline/publish/lib.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 464b2b6d8f..fbd6ed0b0b 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -1052,16 +1052,15 @@ def main_cli_publish( log.info("Running publish ...") - plugins = pyblish.api.discover() - print("Using plugins:") - for plugin in plugins: - print(plugin) + discover_result = publish_plugins_discover() + publish_plugins = discover_result.plugins + print("\n".join(discover_result.get_report(only_errors=False))) # Error exit as soon as any error occurs. error_format = ("Failed {plugin.__name__}: " "{error} -- {error.traceback}") - for result in pyblish.util.publish_iter(): + for result in pyblish.util.publish_iter(plugins=publish_plugins): if result["error"]: log.error(error_format.format(**result)) # uninstall() From 8ad408c50f554c0002a3b77ed6b8a9d0749570b0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 27 Jun 2025 13:56:29 +0200 Subject: [PATCH 481/781] revert stringified typehints --- .../plugins/publish/extract_review.py | 4 +-- .../plugins/publish/integrate_traits.py | 36 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index c9596a26dd..a5f541225c 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -1138,7 +1138,7 @@ class ExtractReview(pyblish.api.InstancePlugin): resolution_height: int, extension: str, temp_data: TempData - ) -> Optional["dict[int, str]"]: + ) -> Optional[dict[int, str]]: """Fills missing files by blank frame.""" blank_frame_path = None @@ -1194,7 +1194,7 @@ class ExtractReview(pyblish.api.InstancePlugin): staging_dir: str, start_frame: int, end_frame: int - ) -> "dict[int, str]": + ) -> dict[int, str]: """Fill missing files in sequence by duplicating existing ones. This will take nearest frame file and copy it with so as to fill diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 9f1471d090..3fbf57be88 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -215,7 +215,7 @@ class RepresentationEntity: self.status = status -def get_instance_families(instance: pyblish.api.Instance) -> "list[str]": +def get_instance_families(instance: pyblish.api.Instance) -> list[str]: """Get all families of the instance. Todo: @@ -241,7 +241,7 @@ def get_instance_families(instance: pyblish.api.Instance) -> "list[str]": def get_changed_attributes( - old_entity: dict, new_entity: dict) -> ("dict[str, Any]"): + old_entity: dict, new_entity: dict) -> dict[str, Any]: """Prepare changes for entity update. Todo: @@ -276,7 +276,7 @@ def get_changed_attributes( return changes -def prepare_for_json(data: "dict[str, Any]") -> "dict[str, Any]": +def prepare_for_json(data: dict[str, Any]) -> dict[str, Any]: """Prepare data for JSON serialization. If there are values that json cannot serialize, this function will @@ -418,7 +418,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def get_transfers_from_representations( self, instance: pyblish.api.Instance, - representations: "list[Representation]") -> "list[TransferItem]": + representations: list[Representation]) -> list[TransferItem]: """Get transfers from representations. This method will go through all representations and prepare transfers @@ -440,7 +440,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): """ template: str = self.get_publish_template(instance) instance_template_data: dict[str, str] = {} - transfers: "list[TransferItem]" = [] + transfers: list[TransferItem] = [] # prepare template and data to format it for representation in representations: @@ -535,8 +535,8 @@ class IntegrateTraits(pyblish.api.InstancePlugin): @staticmethod def filter_lifecycle( - representations: "list[Representation]" - ) -> "list[Representation]": + representations: list[Representation] + ) -> list[Representation]: """Filter representations based on LifeCycle traits. Args: @@ -952,7 +952,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def get_transfers_from_file_locations( representation: Representation, template_item: TemplateItem, - transfers: "list[TransferItem]") -> None: + transfers: list[TransferItem]) -> None: """Get transfers from FileLocations trait. Args: @@ -993,7 +993,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def get_transfers_from_sequence( representation: Representation, template_item: TemplateItem, - transfers: "list[TransferItem]" + transfers: list[TransferItem] ) -> None: """Get transfers from Sequence trait. @@ -1014,7 +1014,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): # template is higher, us the one from the template dst_padding = representation.get_trait( Sequence).frame_padding - frames: "list[int]" = sequence.get_frame_list( + frames: list[int] = sequence.get_frame_list( representation.get_trait(FileLocations), regex=sequence.frame_regex) template_padding = template_item.anatomy.templates_obj.frame_padding @@ -1065,7 +1065,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def get_transfers_from_udim( representation: Representation, template_item: TemplateItem, - transfers: "list[TransferItem]" + transfers: list[TransferItem] ) -> None: """Get transfers from UDIM trait. @@ -1121,7 +1121,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def get_transfers_from_file_location( representation: Representation, template_item: TemplateItem, - transfers: "list[TransferItem]" + transfers: list[TransferItem] ) -> None: """Get transfers from FileLocation trait. @@ -1179,7 +1179,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def get_transfers_from_bundle( representation: Representation, template_item: TemplateItem, - transfers: "list[TransferItem]" + transfers: list[TransferItem] ) -> None: """Get transfers from Bundle trait. @@ -1217,7 +1217,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): ) def _prepare_file_info( - self, path: Path, anatomy: "Anatomy") -> "dict[str, Any]": + self, path: Path, anatomy: "Anatomy") -> dict[str, Any]: """Prepare information for one file (asset or resource). Arguments: @@ -1246,10 +1246,10 @@ class IntegrateTraits(pyblish.api.InstancePlugin): def _get_legacy_files_for_representation( self, - transfer_items: "list[TransferItem]", + transfer_items: list[TransferItem], representation: Representation, anatomy: "Anatomy", - ) -> "list[dict[str, str]]": + ) -> list[dict[str, str]]: """Get legacy files for a given representation. This expects the file to exist - it must run after the transfer @@ -1259,13 +1259,13 @@ class IntegrateTraits(pyblish.api.InstancePlugin): list: List of legacy files. """ - selected: "list[TransferItem]" = [] + selected: list[TransferItem] = [] selected.extend( item for item in transfer_items if item.representation == representation ) - files: "list[dict[str, str]]" = [] + files: list[dict[str, str]] = [] files.extend( self._prepare_file_info(item.destination, anatomy) for item in selected From 06186a2ef04b43da22b1c80f6913a140218781b7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 27 Jun 2025 13:57:48 +0200 Subject: [PATCH 482/781] stringify not imported types --- client/ayon_core/plugins/publish/integrate_traits.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 3fbf57be88..45f32be4a0 100644 --- a/client/ayon_core/plugins/publish/integrate_traits.py +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -150,13 +150,13 @@ class TemplateItem: anatomy: Anatomy template: str template_data: dict[str, Any] - template_object: AnatomyTemplateItem + template_object: "AnatomyTemplateItem" def __init__(self, - anatomy: Anatomy, + anatomy: "Anatomy", template: str, template_data: dict[str, Any], - template_object: AnatomyTemplateItem): + template_object: "AnatomyTemplateItem"): """Initialize TemplateItem. Args: From bdc3285681ee2f36b473c077aa4a02cd2a379ec4 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Fri, 27 Jun 2025 12:36:01 +0000 Subject: [PATCH 483/781] [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 11fc31799b..8531e3fc42 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.3.2+dev" +__version__ = "1.4.0" diff --git a/package.py b/package.py index 908d34ffa8..2ea60bd4e9 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.3.2+dev" +version = "1.4.0" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index f4a452a2b9..f5a272849f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.3.2+dev" +version = "1.4.0" description = "" authors = ["Ynput Team "] readme = "README.md" From 2352d812cc13929563a51ed4a69798be588477f5 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Fri, 27 Jun 2025 12:36:35 +0000 Subject: [PATCH 484/781] [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 8531e3fc42..df92396802 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.0" +__version__ = "1.4.0+dev" diff --git a/package.py b/package.py index 2ea60bd4e9..efed91b6cf 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.4.0" +version = "1.4.0+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index f5a272849f..91579f04fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.4.0" +version = "1.4.0+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 08f8548268f150420c65747356ecb3e2ea71acb5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Jun 2025 12:37:29 +0000 Subject: [PATCH 485/781] 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 2cef7d13b0..eff53116a2 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.4.0 - 1.3.2 - 1.3.1 - 1.3.0 From fddaf75bffebe7b1686f47ab20eb19ffc76d1a16 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 27 Jun 2025 15:16:46 +0200 Subject: [PATCH 486/781] Do not register publish plug-in paths twice --- client/ayon_core/pipeline/publish/lib.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 464b2b6d8f..5d51b75b0c 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -1022,12 +1022,6 @@ def main_cli_publish( if addons_manager is None: addons_manager = AddonsManager() - # TODO validate if this has to happen - # - it should happen during 'install_ayon_plugins' - publish_paths = addons_manager.collect_plugin_paths()["publish"] - for plugin_path in publish_paths: - pyblish.api.register_plugin_path(plugin_path) - applications_addon = addons_manager.get_enabled_addon("applications") if applications_addon is not None: context = get_global_context() From fb7e442a06a97eeb1cddf38a0e095813cae4c99d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 27 Jun 2025 17:42:29 +0200 Subject: [PATCH 487/781] updated type hints in create context --- client/ayon_core/pipeline/create/context.py | 359 +++++++++++--------- 1 file changed, 205 insertions(+), 154 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index f0d9fa8927..1a838a3bd8 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import sys import copy @@ -10,13 +12,8 @@ import typing from typing import ( Optional, Iterable, - Tuple, - List, - Set, - Dict, Any, Callable, - Union, ) import pyblish.logic @@ -27,7 +24,7 @@ from ayon_core.settings import get_project_settings from ayon_core.lib import is_func_signature_supported from ayon_core.lib.events import QueuedEventSystem from ayon_core.lib.attribute_definitions import get_default_values -from ayon_core.host import IPublishHost, IWorkfileHost +from ayon_core.host import IWorkfileHost from ayon_core.pipeline import Anatomy from ayon_core.pipeline.template_data import get_template_data from ayon_core.pipeline.plugin_discover import DiscoverResult @@ -52,7 +49,15 @@ from .creator_plugins import ( discover_convertor_plugins, ) if typing.TYPE_CHECKING: + from ayon_core.host import HostBase, IPublishHost + from ayon_core.lib import AbstractAttrDef + from ayon_core.lib.events import EventCallback + 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 @@ -157,16 +162,20 @@ class CreateContext: context which should be handled by host. Args: - host(ModuleType): Host implementation which handles implementation and + host (PublishHost): 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. - discover_publish_plugins(bool): Discover publish plugins during reset + headless (bool): Context is created out of UI (Current not used). + reset (bool): Reset context on initialization. + discover_publish_plugins (bool): Discover publish plugins during reset phase. """ def __init__( - self, host, headless=False, reset=True, discover_publish_plugins=True + self, + host: "PublishHost", + headless: bool = False, + reset: bool = True, + discover_publish_plugins: bool = True, ): self.host = host @@ -267,15 +276,15 @@ class CreateContext: self.reset(discover_publish_plugins) @property - def instances(self): + def instances(self) -> Iterable["CreatedInstance"]: return self._instances_by_id.values() @property - def instances_by_id(self): + def instances_by_id(self) -> dict[str, "CreatedInstance"]: return self._instances_by_id @property - def publish_attributes(self): + def publish_attributes(self) -> PublishAttributes: """Access to global publish attributes.""" return self._publish_attributes @@ -294,15 +303,17 @@ class CreateContext: """ return self._instances_by_id.get(instance_id) - def get_sorted_creators(self, identifiers=None): + def get_sorted_creators( + self, identifiers: Optional[Iterable[str]] = None + ) -> list["BaseCreator"]: """Sorted creators by 'order' attribute. Args: - identifiers (Iterable[str]): Filter creators by identifiers. All - creators are returned if 'None' is passed. + identifiers (Optional[Iterable[str]]): Filter creators by + identifiers. All creators are returned if 'None' is passed. Returns: - List[BaseCreator]: Sorted creator plugins by 'order' value. + list[BaseCreator]: Sorted creator plugins by 'order' value. """ if identifiers is not None: @@ -320,21 +331,21 @@ class CreateContext: ) @property - def sorted_creators(self): + def sorted_creators(self) -> list["BaseCreator"]: """Sorted creators by 'order' attribute. Returns: - List[BaseCreator]: Sorted creator plugins by 'order' value. + list[BaseCreator]: Sorted creator plugins by 'order' value. """ return self.get_sorted_creators() @property - def sorted_autocreators(self): + def sorted_autocreators(self) -> list["AutoCreator"]: """Sorted auto-creators by 'order' attribute. Returns: - List[AutoCreator]: Sorted plugins by 'order' value. + list[AutoCreator]: Sorted plugins by 'order' value. """ return sorted( @@ -365,38 +376,38 @@ class CreateContext: return self.host.name return os.environ["AYON_HOST_NAME"] - def get_current_project_name(self) -> Optional[str]: + def get_current_project_name(self) -> str: """Project name which was used as current context on context reset. Returns: - Union[str, None]: Project name. - """ + Optional[str]: Project name. + """ return self._current_project_name def get_current_folder_path(self) -> Optional[str]: """Folder path which was used as current context on context reset. Returns: - Union[str, None]: Folder path. - """ + Optional[str]: Folder path. + """ return self._current_folder_path def get_current_task_name(self) -> Optional[str]: """Task name which was used as current context on context reset. Returns: - Union[str, None]: Task name. - """ + Optional[str]: Task name. + """ return self._current_task_name def get_current_task_type(self) -> Optional[str]: """Task type which was used as current context on context reset. Returns: - Union[str, None]: Task type. + Optional[str]: Task type. """ if self._current_task_type is _NOT_SET: @@ -407,11 +418,11 @@ class CreateContext: self._current_task_type = task_type return self._current_task_type - def get_current_project_entity(self) -> Optional[Dict[str, Any]]: + def get_current_project_entity(self) -> Optional[dict[str, Any]]: """Project entity for current context project. Returns: - Union[dict[str, Any], None]: Folder entity. + Optional[dict[str, Any]]: Folder entity. """ if self._current_project_entity is not _NOT_SET: @@ -423,7 +434,7 @@ class CreateContext: self._current_project_entity = project_entity return copy.deepcopy(self._current_project_entity) - def get_current_folder_entity(self) -> Optional[Dict[str, Any]]: + def get_current_folder_entity(self) -> Optional[dict[str, Any]]: """Folder entity for current context folder. Returns: @@ -437,11 +448,11 @@ class CreateContext: self._current_folder_entity = self.get_folder_entity(folder_path) return copy.deepcopy(self._current_folder_entity) - def get_current_task_entity(self) -> Optional[Dict[str, Any]]: + def get_current_task_entity(self) -> Optional[dict[str, Any]]: """Task entity for current context task. Returns: - Union[dict[str, Any], None]: Task entity. + Optional[dict[str, Any]]: Task entity. """ if self._current_task_entity is not _NOT_SET: @@ -454,16 +465,16 @@ class CreateContext: ) return copy.deepcopy(self._current_task_entity) - def get_current_workfile_path(self): + def get_current_workfile_path(self) -> Optional[str]: """Workfile path which was opened on context reset. Returns: - Union[str, None]: Workfile path. - """ + Optional[str]: Workfile path. + """ return self._current_workfile_path - def get_current_project_anatomy(self): + def get_current_project_anatomy(self) -> Anatomy: """Project anatomy for current project. Returns: @@ -475,7 +486,7 @@ class CreateContext: self._current_project_name) return self._current_project_anatomy - def get_current_project_settings(self): + def get_current_project_settings(self) -> dict[str, Any]: if self._current_project_settings is None: self._current_project_settings = get_project_settings( self.get_current_project_name()) @@ -483,7 +494,7 @@ class CreateContext: def get_template_data( self, folder_path: Optional[str], task_name: Optional[str] - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Prepare template data for given context. Method is using cached entities and settings to prepare template data. @@ -512,7 +523,7 @@ class CreateContext: ) @property - def context_has_changed(self): + def context_has_changed(self) -> bool: """Host context has changed. As context is used project, folder, task name and workfile path if @@ -520,8 +531,8 @@ class CreateContext: Returns: bool: Context changed. - """ + """ project_name, folder_path, task_name, workfile_path = ( self._get_current_host_context() ) @@ -532,17 +543,17 @@ class CreateContext: or self._current_workfile_path != workfile_path ) - project_name = property(get_current_project_name) - project_anatomy = property(get_current_project_anatomy) + project_name: str = property(get_current_project_name) + project_anatomy: Anatomy = property(get_current_project_anatomy) @property - def log(self): + def log(self) -> logging.Logger: """Dynamic access to logger.""" if self._log is None: self._log = logging.getLogger(self.__class__.__name__) return self._log - def reset(self, discover_publish_plugins=True): + def reset(self, discover_publish_plugins: bool = True) -> None: """Reset context with all plugins and instances. All changes will be lost if were not saved explicitely. @@ -561,7 +572,7 @@ class CreateContext: self.reset_finalization() - def refresh_thumbnails(self): + def refresh_thumbnails(self) -> None: """Cleanup thumbnail paths. Remove all thumbnail filepaths that are empty or lead to files which @@ -584,7 +595,7 @@ class CreateContext: for instance_id in invalid: self.thumbnail_paths_by_instance_id.pop(instance_id) - def reset_preparation(self): + def reset_preparation(self) -> None: """Prepare attributes that must be prepared/cleaned before reset.""" # Give ability to store shared data for collection phase @@ -598,14 +609,16 @@ class CreateContext: self._event_hub.clear_callbacks() - def reset_finalization(self): + def reset_finalization(self) -> None: """Cleanup of attributes after reset.""" # Stop access to collection shared data self._collection_shared_data = None self.refresh_thumbnails() - def _get_current_host_context(self): + def _get_current_host_context( + self + ) -> tuple[str, Optional[str], Optional[str], Optional[str]]: project_name = folder_path = task_name = workfile_path = None if hasattr(self.host, "get_current_context"): host_context = self.host.get_current_context() @@ -619,7 +632,7 @@ class CreateContext: return project_name, folder_path, task_name, workfile_path - def reset_current_context(self): + def reset_current_context(self) -> None: """Refresh current context. Reset is based on optional host implementation of `get_current_context` @@ -653,7 +666,7 @@ class CreateContext: self._current_project_anatomy = None self._current_project_settings = None - def reset_plugins(self, discover_publish_plugins=True): + def reset_plugins(self, discover_publish_plugins: bool = True) -> None: """Reload plugins. Reloads creators from preregistered paths and can load publish plugins @@ -664,7 +677,7 @@ class CreateContext: self._reset_creator_plugins() self._reset_convertor_plugins() - def _reset_publish_plugins(self, discover_publish_plugins): + def _reset_publish_plugins(self, discover_publish_plugins: bool) -> None: from ayon_core.pipeline import AYONPyblishPluginMixin from ayon_core.pipeline.publish import ( publish_plugins_discover @@ -718,7 +731,7 @@ class CreateContext: self.publish_plugins = plugins_by_targets self.plugins_with_defs = plugins_with_defs - def _reset_creator_plugins(self): + def _reset_creator_plugins(self) -> None: # Prepare settings project_settings = self.get_current_project_settings() @@ -784,7 +797,7 @@ class CreateContext: self.creators = creators self.disabled_creators = disabled_creators - def _reset_convertor_plugins(self): + def _reset_convertor_plugins(self) -> None: convertors_plugins = {} report = discover_convertor_plugins(return_report=True) self.convertor_discover_result = report @@ -807,7 +820,7 @@ class CreateContext: self.convertors_plugins = convertors_plugins - def reset_context_data(self): + def reset_context_data(self) -> None: """Reload context data using host implementation. These data are not related to any instance but may be needed for whole @@ -846,7 +859,9 @@ class CreateContext: plugin.__name__, attr_defs ) - def add_instances_added_callback(self, callback): + def add_instances_added_callback( + self, callback: Callable + ) -> "EventCallback": """Register callback for added instances. Event is triggered when instances are already available in context @@ -872,7 +887,9 @@ class CreateContext: """ return self._event_hub.add_callback(INSTANCE_ADDED_TOPIC, callback) - def add_instances_removed_callback(self, callback): + def add_instances_removed_callback( + self, callback: Callable + ) -> "EventCallback": """Register callback for removed instances. Event is triggered when instances are already removed from context. @@ -895,9 +912,11 @@ class CreateContext: stop listening. """ - self._event_hub.add_callback(INSTANCE_REMOVED_TOPIC, callback) + return self._event_hub.add_callback(INSTANCE_REMOVED_TOPIC, callback) - def add_value_changed_callback(self, callback): + def add_value_changed_callback( + self, callback: Callable + ) -> "EventCallback": """Register callback to listen value changes. Event is triggered when any value changes on any instance or @@ -931,9 +950,11 @@ class CreateContext: stop listening. """ - self._event_hub.add_callback(VALUE_CHANGED_TOPIC, callback) + return self._event_hub.add_callback(VALUE_CHANGED_TOPIC, callback) - def add_pre_create_attr_defs_change_callback(self, callback): + def add_pre_create_attr_defs_change_callback( + self, callback: Callable + ) -> "EventCallback": """Register callback to listen pre-create attribute changes. Create plugin can trigger refresh of pre-create attributes. Usage of @@ -957,11 +978,13 @@ class CreateContext: stop listening. """ - self._event_hub.add_callback( + return self._event_hub.add_callback( PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC, callback ) - def add_create_attr_defs_change_callback(self, callback): + def add_create_attr_defs_change_callback( + self, callback: Callable + ) -> "EventCallback": """Register callback to listen create attribute changes. Create plugin changed attribute definitions of instance. @@ -984,9 +1007,13 @@ class CreateContext: stop listening. """ - self._event_hub.add_callback(CREATE_ATTR_DEFS_CHANGED_TOPIC, callback) + return self._event_hub.add_callback( + CREATE_ATTR_DEFS_CHANGED_TOPIC, callback + ) - def add_publish_attr_defs_change_callback(self, callback): + def add_publish_attr_defs_change_callback( + self, callback: Callable + ) -> "EventCallback": """Register callback to listen publish attribute changes. Publish plugin changed attribute definitions of instance of context. @@ -1018,11 +1045,11 @@ class CreateContext: stop listening. """ - self._event_hub.add_callback( + return self._event_hub.add_callback( PUBLISH_ATTR_DEFS_CHANGED_TOPIC, callback ) - def context_data_to_store(self): + def context_data_to_store(self) -> dict[str, Any]: """Data that should be stored by host function. The same data should be returned on loading. @@ -1031,19 +1058,21 @@ class CreateContext: "publish_attributes": self._publish_attributes.data_to_store() } - def context_data_changes(self): + def context_data_changes(self) -> TrackChangesItem: """Changes of attributes.""" return TrackChangesItem( self._original_context_data, self.context_data_to_store() ) - def set_context_publish_plugin_attr_defs(self, plugin_name, attr_defs): + def set_context_publish_plugin_attr_defs( + self, plugin_name: str, attr_defs: list["AbstractAttrDef"] + ) -> None: """Set attribute definitions for CreateContext publish plugin. Args: plugin_name(str): Name of publish plugin. - attr_defs(List[AbstractAttrDef]): Attribute definitions. + attr_defs(list[AbstractAttrDef]): Attribute definitions. """ self.publish_attributes.set_publish_plugin_attr_defs( @@ -1053,7 +1082,7 @@ class CreateContext: None, plugin_name ) - def creator_adds_instance(self, instance: "CreatedInstance"): + def creator_adds_instance(self, instance: "CreatedInstance") -> None: """Creator adds new instance to context. Instances should be added only from creators. @@ -1078,7 +1107,7 @@ class CreateContext: with self.bulk_add_instances() as bulk_info: bulk_info.append(instance) - def _get_creator_in_create(self, identifier): + def _get_creator_in_create(self, identifier: str) -> "BaseCreator": """Creator by identifier with unified error. Helper method to get creator by identifier with same error when creator @@ -1104,13 +1133,13 @@ class CreateContext: def create( self, - creator_identifier, - variant, - folder_entity=None, - task_entity=None, - pre_create_data=None, - active=None - ): + creator_identifier: str, + variant: str, + folder_entity: Optional[dict[str, Any]] = None, + task_entity: Optional[dict[str, Any]] = None, + pre_create_data: Optional[dict[str, Any]] = None, + active: Optional[bool] = None, + ) -> Any: """Trigger create of plugins with standartized arguments. Arguments 'folder_entity' and 'task_name' use current context as @@ -1123,10 +1152,10 @@ class CreateContext: Args: creator_identifier (str): Identifier of creator plugin. variant (str): Variant used for product name. - folder_entity (Dict[str, Any]): Folder entity which define context + folder_entity (dict[str, Any]): Folder entity which define context of creation (possible context of created instance/s). - task_entity (Dict[str, Any]): Task entity. - pre_create_data (Dict[str, Any]): Pre-create attribute values. + task_entity (dict[str, Any]): Task entity. + pre_create_data (dict[str, Any]): Pre-create attribute values. active (Optional[bool]): Whether the created instance defaults to be active or not. @@ -1209,7 +1238,9 @@ class CreateContext: _pre_create_data ) - def create_with_unified_error(self, identifier, *args, **kwargs): + def create_with_unified_error( + self, identifier: str, *args, **kwargs + ) -> Any: """Trigger create but raise only one error if anything fails. Added to raise unified exception. Capture any possible issues and @@ -1217,8 +1248,8 @@ class CreateContext: Args: identifier (str): Identifier of creator. - *args (Tuple[Any]): Arguments for create method. - **kwargs (Dict[Any, Any]): Keyword argument for create method. + *args (tuple[Any]): Arguments for create method. + **kwargs (dict[Any, Any]): Keyword argument for create method. Raises: CreatorsCreateFailed: When creation fails due to any possible @@ -1233,7 +1264,7 @@ class CreateContext: raise CreatorsCreateFailed([fail_info]) return result - def creator_removed_instance(self, instance: "CreatedInstance"): + def creator_removed_instance(self, instance: "CreatedInstance") -> None: """When creator removes instance context should be acknowledged. If creator removes instance context should know about it to avoid @@ -1246,57 +1277,61 @@ class CreateContext: self._remove_instances([instance]) - def add_convertor_item(self, convertor_identifier, label): + def add_convertor_item( + self, convertor_identifier: str, label: str + ) -> None: self.convertor_items_by_id[convertor_identifier] = ConvertorItem( convertor_identifier, label ) - def remove_convertor_item(self, convertor_identifier): + def remove_convertor_item(self, convertor_identifier: str) -> None: self.convertor_items_by_id.pop(convertor_identifier, None) @contextmanager - def bulk_add_instances(self, sender=None): + def bulk_add_instances(self, sender: Optional[str] = None): with self._bulk_context("add", sender) as bulk_info: yield bulk_info @contextmanager - def bulk_instances_collection(self, sender=None): + def bulk_instances_collection(self, sender: Optional[str] = None): """DEPRECATED use 'bulk_add_instances' instead.""" # TODO add warning with self.bulk_add_instances(sender) as bulk_info: yield bulk_info @contextmanager - def bulk_remove_instances(self, sender=None): + def bulk_remove_instances(self, sender: Optional[str] = None): with self._bulk_context("remove", sender) as bulk_info: yield bulk_info @contextmanager - def bulk_value_changes(self, sender=None): + def bulk_value_changes(self, sender: Optional[str] = None): with self._bulk_context("change", sender) as bulk_info: yield bulk_info @contextmanager - def bulk_pre_create_attr_defs_change(self, sender=None): + def bulk_pre_create_attr_defs_change(self, sender: Optional[str] = None): with self._bulk_context( "pre_create_attrs_change", sender ) as bulk_info: yield bulk_info @contextmanager - def bulk_create_attr_defs_change(self, sender=None): + def bulk_create_attr_defs_change(self, sender: Optional[str] = None): with self._bulk_context( "create_attrs_change", sender ) as bulk_info: yield bulk_info @contextmanager - def bulk_publish_attr_defs_change(self, sender=None): + def bulk_publish_attr_defs_change(self, sender: Optional[str] = None): with self._bulk_context("publish_attrs_change", sender) as bulk_info: yield bulk_info # --- instance change callbacks --- - def create_plugin_pre_create_attr_defs_changed(self, identifier: str): + def create_plugin_pre_create_attr_defs_changed( + self, identifier: str + ) -> None: """Create plugin pre-create attributes changed. Triggered by 'Creator'. @@ -1308,7 +1343,7 @@ class CreateContext: with self.bulk_pre_create_attr_defs_change() as bulk_item: bulk_item.append(identifier) - def instance_create_attr_defs_changed(self, instance_id: str): + def instance_create_attr_defs_changed(self, instance_id: str) -> None: """Instance attribute definitions changed. Triggered by instance 'CreatorAttributeValues' on instance. @@ -1323,7 +1358,7 @@ class CreateContext: def instance_publish_attr_defs_changed( self, instance_id: Optional[str], plugin_name: str - ): + ) -> None: """Instance attribute definitions changed. Triggered by instance 'PublishAttributeValues' on instance. @@ -1339,8 +1374,8 @@ class CreateContext: bulk_item.append((instance_id, plugin_name)) def instance_values_changed( - self, instance_id: Optional[str], new_values: Dict[str, Any] - ): + self, instance_id: Optional[str], new_values: dict[str, Any] + ) -> None: """Instance value changed. Triggered by `CreatedInstance, 'CreatorAttributeValues' @@ -1348,7 +1383,7 @@ class CreateContext: Args: instance_id (Optional[str]): Instance id or None for context. - new_values (Dict[str, Any]): Changed values. + new_values (dict[str, Any]): Changed values. """ if self._is_instance_events_ready(instance_id): @@ -1357,15 +1392,15 @@ class CreateContext: # --- context change callbacks --- def publish_attribute_value_changed( - self, plugin_name: str, value: Dict[str, Any] - ): + self, plugin_name: str, value: dict[str, Any] + ) -> None: """Context publish attribute values changed. Triggered by instance 'PublishAttributeValues' on context. Args: plugin_name (str): Plugin name which changed value. - value (Dict[str, Any]): Changed values. + value (dict[str, Any]): Changed values. """ self.instance_values_changed( @@ -1377,7 +1412,7 @@ class CreateContext: }, ) - def reset_instances(self): + def reset_instances(self) -> None: """Reload instances""" self._instances_by_id = collections.OrderedDict() @@ -1417,7 +1452,7 @@ class CreateContext: if failed_info: raise CreatorsCollectionFailed(failed_info) - def find_convertor_items(self): + def find_convertor_items(self) -> None: """Go through convertor plugins to look for items to convert. Raises: @@ -1448,7 +1483,7 @@ class CreateContext: if failed_info: raise ConvertorsFindFailed(failed_info) - def execute_autocreators(self): + def execute_autocreators(self) -> None: """Execute discovered AutoCreator plugins. Reset instances if any autocreator executed properly. @@ -1464,14 +1499,16 @@ class CreateContext: if failed_info: raise CreatorsCreateFailed(failed_info) - def get_folder_entities(self, folder_paths: Iterable[str]): + def get_folder_entities( + self, folder_paths: Iterable[str] + ) -> dict[str, Optional[dict[str, Any]]]: """Get folder entities by paths. Args: folder_paths (Iterable[str]): Folder paths. Returns: - Dict[str, Optional[Dict[str, Any]]]: Folder entities by path. + dict[str, Optional[dict[str, Any]]]: Folder entities by path. """ output = { @@ -1511,18 +1548,18 @@ class CreateContext: def get_task_entities( self, - task_names_by_folder_paths: Dict[str, Set[str]] - ) -> Dict[str, Dict[str, Optional[Dict[str, Any]]]]: + task_names_by_folder_paths: dict[str, set[str]] + ) -> dict[str, dict[str, Optional[dict[str, Any]]]]: """Get task entities by folder path and task name. Entities are cached until reset. Args: - task_names_by_folder_paths (Dict[str, Set[str]]): Task names by + task_names_by_folder_paths (dict[str, set[str]]): Task names by folder path. Returns: - Dict[str, Dict[str, Dict[str, Any]]]: Task entities by folder path + dict[str, dict[str, dict[str, Any]]]: Task entities by folder path and task name. """ @@ -1609,7 +1646,7 @@ class CreateContext: def get_folder_entity( self, folder_path: Optional[str], - ) -> Optional[Dict[str, Any]]: + ) -> Optional[dict[str, Any]]: """Get folder entity by path. Entities are cached until reset. @@ -1618,7 +1655,7 @@ class CreateContext: folder_path (Optional[str]): Folder path. Returns: - Optional[Dict[str, Any]]: Folder entity. + Optional[dict[str, Any]]: Folder entity. """ if not folder_path: @@ -1629,7 +1666,7 @@ class CreateContext: self, folder_path: Optional[str], task_name: Optional[str], - ) -> Optional[Dict[str, Any]]: + ) -> Optional[dict[str, Any]]: """Get task entity by name and folder path. Entities are cached until reset. @@ -1639,7 +1676,7 @@ class CreateContext: task_name (Optional[str]): Task name. Returns: - Optional[Dict[str, Any]]: Task entity. + Optional[dict[str, Any]]: Task entity. """ if not folder_path or not task_name: @@ -1650,7 +1687,7 @@ class CreateContext: def get_instances_folder_entities( self, instances: Optional[Iterable["CreatedInstance"]] = None - ) -> Dict[str, Optional[Dict[str, Any]]]: + ) -> dict[str, Optional[dict[str, Any]]]: if instances is None: instances = self._instances_by_id.values() instances = list(instances) @@ -1674,7 +1711,7 @@ class CreateContext: def get_instances_task_entities( self, instances: Optional[Iterable["CreatedInstance"]] = None - ): + ) -> dict[str, Optional[dict[str, Any]]]: """Get task entities for instances. Args: @@ -1682,7 +1719,7 @@ class CreateContext: get task entities. If not provided all instances are used. Returns: - Dict[str, Optional[Dict[str, Any]]]: Task entity by instance id. + dict[str, Optional[dict[str, Any]]]: Task entity by instance id. """ if instances is None: @@ -1720,7 +1757,7 @@ class CreateContext: def get_instances_context_info( self, instances: Optional[Iterable["CreatedInstance"]] = None - ) -> Dict[str, InstanceContextInfo]: + ) -> dict[str, InstanceContextInfo]: """Validate 'folder' and 'task' instance context. Args: @@ -1728,7 +1765,7 @@ class CreateContext: validate. If not provided all instances are validated. Returns: - Dict[str, InstanceContextInfo]: Validation results by instance id. + dict[str, InstanceContextInfo]: Validation results by instance id. """ # Use all instances from context if 'instances' are not passed @@ -1862,7 +1899,7 @@ class CreateContext: context_info.task_is_valid = True return info_by_instance_id - def save_changes(self): + def save_changes(self) -> None: """Save changes. Update all changed values.""" if not self.host_is_valid: missing_methods = self.get_host_misssing_methods(self.host) @@ -1871,14 +1908,14 @@ class CreateContext: self._save_context_changes() self._save_instance_changes() - def _save_context_changes(self): + def _save_context_changes(self) -> None: """Save global context values.""" changes = self.context_data_changes() if changes: data = self.context_data_to_store() self.host.update_context_data(data, changes) - def _save_instance_changes(self): + def _save_instance_changes(self) -> None: """Save instance specific values.""" instances_by_identifier = collections.defaultdict(list) for instance in self._instances_by_id.values(): @@ -1938,14 +1975,18 @@ class CreateContext: if failed_info: raise CreatorsSaveFailed(failed_info) - def remove_instances(self, instances, sender=None): + def remove_instances( + self, + instances: list["CreatedInstances"], + sender: Optional[str] = None, + ) -> None: """Remove instances from context. All instances that don't have creator identifier leading to existing creator are just removed from context. Args: - instances (List[CreatedInstance]): Instances that should be + instances (list[CreatedInstance]): Instances that should be removed. Remove logic is done using creator, which may require to do other cleanup than just remove instance from context. sender (Optional[str]): Sender of the event. @@ -2013,11 +2054,11 @@ class CreateContext: raise CreatorsRemoveFailed(failed_info) @property - def collection_shared_data(self): + def collection_shared_data(self) -> dict[str, Any]: """Access to shared data that can be used during creator's collection. Returns: - Dict[str, Any]: Shared data. + dict[str, Any]: Shared data. Raises: UnavailableSharedData: When called out of collection phase. @@ -2029,7 +2070,7 @@ class CreateContext: ) return self._collection_shared_data - def run_convertor(self, convertor_identifier): + def run_convertor(self, convertor_identifier: str) -> None: """Run convertor plugin by identifier. Conversion is skipped if convertor is not available. @@ -2042,14 +2083,14 @@ class CreateContext: if convertor is not None: convertor.convert() - def run_convertors(self, convertor_identifiers): + def run_convertors(self, convertor_identifiers: Iterable[str]) -> None: """Run convertor plugins by identifiers. Conversion is skipped if convertor is not available. It is recommended to trigger reset after conversion to reload instances. Args: - convertor_identifiers (Iterator[str]): Identifiers of convertors + convertor_identifiers (Iterable[str]): Identifiers of convertors to run. Raises: @@ -2077,21 +2118,27 @@ class CreateContext: if failed_info: raise ConvertorsConversionFailed(failed_info) - def _register_event_callback(self, topic: str, callback: Callable): + def _register_event_callback( + self, topic: str, callback: Callable + ) -> "EventCallback": return self._event_hub.add_callback(topic, callback) def _emit_event( self, topic: str, - data: Optional[Dict[str, Any]] = None, + data: Optional[dict[str, Any]] = None, sender: Optional[str] = None, - ): + ) -> "Event": if data is None: data = {} data.setdefault("create_context", self) return self._event_hub.emit(topic, data, sender) - def _remove_instances(self, instances, sender=None): + def _remove_instances( + self, + instances: Iterable[CreatedInstance], + sender: Optional[str] = None, + ) -> None: with self.bulk_remove_instances(sender) as bulk_info: for instance in instances: obj = self._instances_by_id.pop(instance.id, None) @@ -2099,8 +2146,12 @@ class CreateContext: bulk_info.append(obj) def _create_with_unified_error( - self, identifier, creator, *args, **kwargs - ): + self, + identifier: str, + creator: Optional["BaseCreator"], + *args, + **kwargs + ) -> tuple[Optional[Any], Optional[dict[str, Any]]]: error_message = "Failed to run Creator with identifier \"{}\". {}" label = None @@ -2168,7 +2219,7 @@ class CreateContext: if bulk_info: self._bulk_finished(key) - def _bulk_finished(self, key: str): + def _bulk_finished(self, key: str) -> None: if self._bulk_order[0] != key: return @@ -2182,7 +2233,7 @@ class CreateContext: self._bulk_order.pop(0) self._bulk_finish(key) - def _bulk_finish(self, key: str): + def _bulk_finish(self, key: str) -> None: bulk_info = self._bulk_info[key] sender = bulk_info.get_sender() data = bulk_info.pop_data() @@ -2201,9 +2252,9 @@ class CreateContext: def _bulk_add_instances_finished( self, - instances_to_validate: List["CreatedInstance"], + instances_to_validate: list["CreatedInstance"], sender: Optional[str] - ): + ) -> None: if not instances_to_validate: return @@ -2264,9 +2315,9 @@ class CreateContext: def _bulk_remove_instances_finished( self, - instances_to_remove: List["CreatedInstance"], + instances_to_remove: list["CreatedInstance"], sender: Optional[str] - ): + ) -> None: if not instances_to_remove: return @@ -2280,9 +2331,9 @@ class CreateContext: def _bulk_values_change_finished( self, - changes: Tuple[Union[str, None], Dict[str, Any]], + changes: list[tuple[Optional[str], dict[str, Any]]], sender: Optional[str], - ): + ) -> None: if not changes: return item_data_by_id = {} @@ -2335,8 +2386,8 @@ class CreateContext: ) def _bulk_pre_create_attrs_change_finished( - self, identifiers: List[str], sender: Optional[str] - ): + self, identifiers: list[str], sender: Optional[str] + ) -> None: if not identifiers: return identifiers = list(set(identifiers)) @@ -2349,8 +2400,8 @@ class CreateContext: ) def _bulk_create_attrs_change_finished( - self, instance_ids: List[str], sender: Optional[str] - ): + self, instance_ids: list[str], sender: Optional[str] + ) -> None: if not instance_ids: return @@ -2368,9 +2419,9 @@ class CreateContext: def _bulk_publish_attrs_change_finished( self, - attr_info: Tuple[str, Union[str, None]], + attr_info: list[tuple[str, Optional[str]]], sender: Optional[str], - ): + ) -> None: if not attr_info: return From cef876b339551782f5d91752364c9821805b8980 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:54:43 +0200 Subject: [PATCH 488/781] formatting fixes --- client/ayon_core/pipeline/create/context.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 1a838a3bd8..d997f464fb 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -51,7 +51,7 @@ from .creator_plugins import ( if typing.TYPE_CHECKING: from ayon_core.host import HostBase, IPublishHost from ayon_core.lib import AbstractAttrDef - from ayon_core.lib.events import EventCallback + from ayon_core.lib.events import EventCallback, Event from .structures import CreatedInstance from .creator_plugins import BaseCreator @@ -162,8 +162,8 @@ class CreateContext: context which should be handled by host. Args: - host (PublishHost): Host implementation which handles implementation and - global metadata. + host (PublishHost): 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. discover_publish_plugins (bool): Discover publish plugins during reset @@ -1977,7 +1977,7 @@ class CreateContext: def remove_instances( self, - instances: list["CreatedInstances"], + instances: list["CreatedInstance"], sender: Optional[str] = None, ) -> None: """Remove instances from context. From f5d69e8d8dfa8210f7a8db17b2c2a1e41454fbb5 Mon Sep 17 00:00:00 2001 From: "Sveinbjorn J. Tryggvason" Date: Tue, 1 Jul 2025 09:29:48 +0000 Subject: [PATCH 489/781] add gaffer to plugin hosts list --- 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 bafc075888..d671e136d7 100644 --- a/client/ayon_core/hooks/pre_add_last_workfile_arg.py +++ b/client/ayon_core/hooks/pre_add_last_workfile_arg.py @@ -30,8 +30,9 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "aftereffects", "wrap", "openrv", - "cinema4d", + "cinema4d" "silhouette", + "gaffer", } 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 d1a02e613d..1e33ea7805 100644 --- a/client/ayon_core/hooks/pre_ocio_hook.py +++ b/client/ayon_core/hooks/pre_ocio_hook.py @@ -21,8 +21,9 @@ class OCIOEnvHook(PreLaunchHook): "hiero", "resolve", "openrv", - "cinema4d", + "cinema4d" "silhouette", + "gaffer", } 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 4f9e84aee0..7656687b4f 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", "silhouette"] + "cinema4d", "silhouette", "gaffer"] actions = [SaveByVersionUpAction, ShowWorkfilesAction] def process(self, context): From 2c9d579a4bf50450c3cc232d57f10c9be8eb4360 Mon Sep 17 00:00:00 2001 From: "Sveinbjorn J. Tryggvason" Date: Tue, 1 Jul 2025 09:38:01 +0000 Subject: [PATCH 490/781] list items are separated by commas, duh --- client/ayon_core/hooks/pre_add_last_workfile_arg.py | 2 +- client/ayon_core/hooks/pre_ocio_hook.py | 2 +- 2 files changed, 2 insertions(+), 2 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 d671e136d7..d6110ea367 100644 --- a/client/ayon_core/hooks/pre_add_last_workfile_arg.py +++ b/client/ayon_core/hooks/pre_add_last_workfile_arg.py @@ -30,7 +30,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "aftereffects", "wrap", "openrv", - "cinema4d" + "cinema4d", "silhouette", "gaffer", } diff --git a/client/ayon_core/hooks/pre_ocio_hook.py b/client/ayon_core/hooks/pre_ocio_hook.py index 1e33ea7805..12f9454a88 100644 --- a/client/ayon_core/hooks/pre_ocio_hook.py +++ b/client/ayon_core/hooks/pre_ocio_hook.py @@ -21,7 +21,7 @@ class OCIOEnvHook(PreLaunchHook): "hiero", "resolve", "openrv", - "cinema4d" + "cinema4d", "silhouette", "gaffer", } From 449549b72bb76142fd669f3da9dd197e87d4bad8 Mon Sep 17 00:00:00 2001 From: "Sveinbjorn J. Tryggvason" Date: Tue, 1 Jul 2025 11:11:18 +0000 Subject: [PATCH 491/781] allow merging of file sequence entries --- .../tools/attribute_defs/files_widget.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/client/ayon_core/tools/attribute_defs/files_widget.py b/client/ayon_core/tools/attribute_defs/files_widget.py index 8a40b3ff38..edac8059f0 100644 --- a/client/ayon_core/tools/attribute_defs/files_widget.py +++ b/client/ayon_core/tools/attribute_defs/files_widget.py @@ -892,6 +892,28 @@ class FilesWidget(QtWidgets.QFrame): self._add_filepaths(new_items) self._remove_item_by_ids(item_ids) + def _on_merge_request(self): + if self._multivalue: + return + + item_ids = self._files_view.get_selected_item_ids() + if not item_ids: + return + + all_paths = [] + for item_id in item_ids: + file_item = self._files_model.get_file_item_by_id(item_id) + if not file_item: + return + + new_items = file_item.split_sequence() + for nitem in new_items: + all_paths.append(os.path.join(nitem.directory, nitem.filenames[0])) + unique_all_pahts = list(set(all_paths)) + self._remove_item_by_ids(item_ids) + new_items = FileDefItem.from_value(unique_all_pahts, True) + self._add_filepaths(new_items) + def _on_remove_requested(self): if self._multivalue: return @@ -911,6 +933,9 @@ class FilesWidget(QtWidgets.QFrame): split_action.triggered.connect(self._on_split_request) menu.addAction(split_action) + merge_action = QtWidgets.QAction("Merge sequence", menu) + merge_action.triggered.connect(self._on_merge_request) + menu.addAction(merge_action) remove_action = QtWidgets.QAction("Remove", menu) remove_action.triggered.connect(self._on_remove_requested) menu.addAction(remove_action) From f26f69eae6a0afce08e0ae73e8a4b402d962ae80 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 1 Jul 2025 16:08:48 +0200 Subject: [PATCH 492/781] Fix `IPublishHost` import --- client/ayon_core/pipeline/create/context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index d997f464fb..05531afd05 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -24,7 +24,7 @@ from ayon_core.settings import get_project_settings from ayon_core.lib import is_func_signature_supported from ayon_core.lib.events import QueuedEventSystem from ayon_core.lib.attribute_definitions import get_default_values -from ayon_core.host import IWorkfileHost +from ayon_core.host import IWorkfileHost, IPublishHost from ayon_core.pipeline import Anatomy from ayon_core.pipeline.template_data import get_template_data from ayon_core.pipeline.plugin_discover import DiscoverResult @@ -49,7 +49,7 @@ from .creator_plugins import ( discover_convertor_plugins, ) if typing.TYPE_CHECKING: - from ayon_core.host import HostBase, IPublishHost + from ayon_core.host import HostBase from ayon_core.lib import AbstractAttrDef from ayon_core.lib.events import EventCallback, Event 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 493/781] 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 494/781] 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 aed5a5e6e09088801b8c1968e3c769efd18c32d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 2 Jul 2025 10:54:55 +0200 Subject: [PATCH 495/781] :recycle: add env tool functions to `ayon.lib` init --- client/ayon_core/lib/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 477eb29c28..5ccc8d03e5 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -50,8 +50,10 @@ from .attribute_definitions import ( ) from .env_tools import ( + compute_env_variables_structure, env_value_to_bool, get_paths_from_environ, + merge_env_variables, ) from .terminal import Terminal @@ -166,8 +168,10 @@ __all__ = [ "path_to_subprocess_arg", "CREATE_NO_WINDOW", + "compute_env_variables_structure", "env_value_to_bool", "get_paths_from_environ", + "merge_env_variables", "ToolNotFoundError", "find_executable", From 1b2d3606191fc7f294c113c35b90c3ebdf852f3f Mon Sep 17 00:00:00 2001 From: sjt-rvx <72554834+sjt-rvx@users.noreply.github.com> Date: Wed, 2 Jul 2025 10:22:35 +0000 Subject: [PATCH 496/781] Update client/ayon_core/tools/attribute_defs/files_widget.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../tools/attribute_defs/files_widget.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/attribute_defs/files_widget.py b/client/ayon_core/tools/attribute_defs/files_widget.py index edac8059f0..f1b0f06dbc 100644 --- a/client/ayon_core/tools/attribute_defs/files_widget.py +++ b/client/ayon_core/tools/attribute_defs/files_widget.py @@ -900,18 +900,20 @@ class FilesWidget(QtWidgets.QFrame): if not item_ids: return - all_paths = [] + all_paths = set() + merged_item_ids = set() for item_id in item_ids: file_item = self._files_model.get_file_item_by_id(item_id) - if not file_item: - return - - new_items = file_item.split_sequence() - for nitem in new_items: - all_paths.append(os.path.join(nitem.directory, nitem.filenames[0])) - unique_all_pahts = list(set(all_paths)) - self._remove_item_by_ids(item_ids) - new_items = FileDefItem.from_value(unique_all_pahts, True) + if file_item is None: + continue + merged_item_ids.add(item_id) + all_paths |= { + os.path.join(file_item.directory, filename) + for filename in file_item.filenames + } + + self._remove_item_by_ids(merged_item_ids) + new_items = FileDefItem.from_value(list(all_paths), True) self._add_filepaths(new_items) def _on_remove_requested(self): From b3d87eac82a84305ee788f357fd73cac20abcc53 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:59:13 +0200 Subject: [PATCH 497/781] added 'is_mandatory' to instance --- .../ayon_core/pipeline/create/structures.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index d7ba6b9c24..1bc20501cc 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -507,6 +507,7 @@ class CreatedInstance: if transient_data is None: transient_data = {} self._transient_data = transient_data + self._is_mandatory = False # Create a copy of passed data to avoid changing them on the fly data = copy.deepcopy(data or {}) @@ -605,6 +606,12 @@ class CreatedInstance: if key in self._data and self._data[key] == value: return + if self.is_mandatory and key == "active" and value is not True: + raise ImmutableKeyError( + key, + "Instance is mandatory and can't be disabled." + ) + self._data[key] = value self._create_context.instance_values_changed( self.id, {key: value} @@ -718,6 +725,33 @@ class CreatedInstance: return self._transient_data + @property + def is_mandatory(self) -> bool: + """Check if instance is mandatory. + + Returns: + bool: True if instance is mandatory, False otherwise. + + """ + return self._is_mandatory + + def set_mandatory(self, value: bool) -> None: + """Set instance as mandatory or not. + + Mandatory instance can't be disabled in UI. + + Args: + value (bool): True if instance should be mandatory, False + otherwise. + + """ + if value is self._is_mandatory: + return + self._is_mandatory = value + if value is True: + self["active"] = True + self._create_context.instance_state_changed(self.id) + def changes(self): """Calculate and return changes.""" From 541c7c328a5fa2bfee59bcc7c97205925b3c9e35 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:59:24 +0200 Subject: [PATCH 498/781] capture state changes of instance --- client/ayon_core/pipeline/create/context.py | 75 +++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 05531afd05..14b13613b6 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -79,6 +79,7 @@ _NOT_SET = object() INSTANCE_ADDED_TOPIC = "instances.added" INSTANCE_REMOVED_TOPIC = "instances.removed" VALUE_CHANGED_TOPIC = "values.changed" +INSTANCE_STATE_CHANGED_TOPIC = "instance.stated.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" @@ -257,6 +258,10 @@ class CreateContext: "create_attrs_change": BulkInfo(), # Publish attribute definitions changed "publish_attrs_change": BulkInfo(), + # Instance state changed + # - right now used only for 'mandatory' state but can be extended + # in future + "state_change": BulkInfo(), } self._bulk_order = [] @@ -1049,6 +1054,35 @@ class CreateContext: PUBLISH_ATTR_DEFS_CHANGED_TOPIC, callback ) + def add_instance_state_change_callback( + self, callback: Callable + ) -> "EventCallback": + """Register callback to listen instance state changes. + + Create plugin changed attribute definitions of instance. + + Data structure of event:: + + ```python + { + "instances": [CreatedInstance, ...], + "create_context": CreateContext + } + ``` + + Args: + callback (Callable): Callback function that will be called when + create attributes changed. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ + return self._event_hub.add_callback( + INSTANCE_STATE_CHANGED_TOPIC, callback + ) + def context_data_to_store(self) -> dict[str, Any]: """Data that should be stored by host function. @@ -1323,6 +1357,13 @@ class CreateContext: ) as bulk_info: yield bulk_info + @contextmanager + def bulk_instance_state_change(self, sender: Optional[str] = None): + with self._bulk_context( + "state_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: @@ -1390,6 +1431,19 @@ class CreateContext: with self.bulk_value_changes() as bulk_item: bulk_item.append((instance_id, new_values)) + def instance_state_changed(self, instance_id: str) -> None: + """Instance state changed. + + Triggered by `CreatedInstance`. + + Args: + instance_id (Optional[str]): Instance id. + + """ + if self._is_instance_events_ready(instance_id): + with self.bulk_instance_state_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] @@ -2249,6 +2303,8 @@ class CreateContext: self._bulk_create_attrs_change_finished(data, sender) elif key == "publish_attrs_change": self._bulk_publish_attrs_change_finished(data, sender) + elif key == "state_change": + self._bulk_instance_state_change_finished(data, sender) def _bulk_add_instances_finished( self, @@ -2443,3 +2499,22 @@ class CreateContext: {"instance_changes": instance_changes}, sender, ) + + def _bulk_instance_state_change_finished( + self, + instance_ids: list[str], + sender: Optional[str], + ) -> None: + if not instance_ids: + return + + instances = [ + self.get_instance_by_id(instance_id) + for instance_id in set(instance_ids) + ] + + self._emit_event( + INSTANCE_STATE_CHANGED_TOPIC, + {"instances": instances}, + sender, + ) From 9f69202538071dfe2770e9d07c142deb66986f7e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:59:40 +0200 Subject: [PATCH 499/781] handle mandatory property in publisher --- client/ayon_core/tools/publisher/control.py | 1 + .../tools/publisher/models/create.py | 20 +++++++++++++++++++ .../publisher/widgets/card_view_widgets.py | 4 ++++ .../publisher/widgets/list_view_widgets.py | 5 +++++ .../publisher/widgets/overview_widget.py | 12 ++++++++++- 5 files changed, 41 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index ef2e122692..b551b21bd4 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -53,6 +53,7 @@ class PublisherController( changed. "create.context.create.attrs.changed" - Create attributes changed. "create.context.publish.attrs.changed" - Publish attributes changed. + "create.context.instance.state.changed" - Instance state changed. "create.context.removed.instance" - Instance removed from context. "create.model.instances.context.changed" - Instances changed context. like folder, task or variant. diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 900168eaef..7f44b374e6 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -217,6 +217,7 @@ class InstanceItem: folder_path: Optional[str], task_name: Optional[str], is_active: bool, + is_mandatory: bool, has_promised_context: bool, ): self._instance_id: str = instance_id @@ -229,6 +230,7 @@ class InstanceItem: self._folder_path: Optional[str] = folder_path self._task_name: Optional[str] = task_name self._is_active: bool = is_active + self._is_mandatory: bool = is_mandatory self._has_promised_context: bool = has_promised_context @property @@ -251,6 +253,10 @@ class InstanceItem: def product_type(self): return self._product_type + @property + def is_mandatory(self): + return self._is_mandatory + @property def has_promised_context(self): return self._has_promised_context @@ -304,6 +310,7 @@ class InstanceItem: instance["folderPath"], instance["task"], instance["active"], + instance.is_mandatory, instance.has_promised_context, ) @@ -476,6 +483,9 @@ class CreateModel: self._create_context.add_publish_attr_defs_change_callback( self._cc_publish_attr_changed ) + self._create_context.add_instance_state_change_callback( + self._cc_instance_state_changed + ) self._create_context.reset_finalization() @@ -1171,6 +1181,16 @@ class CreateModel: event_data, ) + def _cc_instance_state_changed(self, event): + instance_ids = { + instance.id + for instance in event.data["instances"] + } + self._emit_event( + "create.context.instance.state.changed", + {"instance_ids": instance_ids}, + ) + def _get_allowed_creators_pattern(self) -> Union[Pattern, None]: """Provide regex pattern for configured creator labels in this context 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 2f633b3149..8a4eddf058 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -482,6 +482,9 @@ class InstanceCardWidget(CardWidget): if checkbox_value != new_value: self._active_checkbox.setChecked(new_value) + def _set_is_mandatory(self, is_mandatory: bool) -> None: + self._active_checkbox.setVisible(not is_mandatory) + def update_instance(self, instance, context_info): """Update instance object and update UI.""" self.instance = instance @@ -525,6 +528,7 @@ class InstanceCardWidget(CardWidget): """Update instance data""" self._update_product_name() self._set_active(self.instance.is_active) + self._set_is_mandatory(self.instance.is_mandatory) self._validate_context(context_info) def _set_expanded(self, expanded=None): 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 bc3353ba5e..8d9f509b0e 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -132,6 +132,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): active_checkbox = NiceCheckbox(parent=self) active_checkbox.setChecked(instance.is_active) + active_checkbox.setVisible(not instance.is_mandatory) layout = QtWidgets.QHBoxLayout(self) content_margins = layout.contentsMargins() @@ -192,6 +193,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): 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) @@ -203,6 +205,9 @@ class InstanceListItemWidget(QtWidgets.QWidget): def set_active_toggle_enabled(self, enabled): self._active_checkbox.setEnabled(enabled) + def _set_is_mandatory(self, is_mandatory: bool) -> None: + self._active_checkbox.setVisible(not is_mandatory) + class ListContextWidget(QtWidgets.QFrame): """Context (or global attributes) widget.""" diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index c6c3b774f0..44bb09d4fc 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -155,6 +155,10 @@ class OverviewWidget(QtWidgets.QFrame): "create.model.instances.context.changed", self._on_instance_context_change ) + controller.register_event_callback( + "create.model.instance.state.changed", + self._on_instance_state_changed + ) self._product_content_widget = product_content_widget self._product_content_layout = product_content_layout @@ -352,6 +356,12 @@ class OverviewWidget(QtWidgets.QFrame): ) def _on_instance_context_change(self, event): + self._refresh_instance_states(event["instance_ids"]) + + def _on_instance_state_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()): if idx == current_idx: @@ -361,7 +371,7 @@ class OverviewWidget(QtWidgets.QFrame): widget.set_refreshed(False) current_widget = self._product_views_layout.widget(current_idx) - current_widget.refresh_instance_states(event["instance_ids"]) + current_widget.refresh_instance_states(instance_ids) def _on_convert_requested(self): self.convert_requested.emit() From 39d011b89f63f0268367ab86fbbf19d92ab6f17b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Jul 2025 10:20:03 +0200 Subject: [PATCH 500/781] don't change active state of mandatory instances --- .../publisher/widgets/list_view_widgets.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 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 8d9f509b0e..969bec11e5 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -152,6 +152,8 @@ class InstanceListItemWidget(QtWidgets.QWidget): self._has_valid_context = None + self._checkbox_enabled = not instance.is_mandatory + self._set_valid_property(context_info.is_valid) def mouseDoubleClickEvent(self, event): @@ -185,6 +187,10 @@ class InstanceListItemWidget(QtWidgets.QWidget): 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 + def update_instance(self, instance, context_info): """Update instance object.""" # Check product name @@ -206,6 +212,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): 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) @@ -954,11 +961,17 @@ class InstanceListView(AbstractInstanceView): return 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) - if instance_id is not None: + 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) @@ -968,6 +981,10 @@ class InstanceListView(AbstractInstanceView): 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): if self._convertor_group_widget is not None: return True From b1e0a925059eb5c077a35c60e663bb61bd58a27d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Jul 2025 13:05:31 +0200 Subject: [PATCH 501/781] define logging config for AYON processes --- client/ayon_core/cli.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 2340696ad9..5978e8ba19 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -2,6 +2,7 @@ """Package for handling AYON command line arguments.""" import os import sys +import logging import code import traceback from pathlib import Path @@ -23,6 +24,9 @@ from ayon_core.lib.env_tools import ( merge_env_variables, ) +logging.basicConfig() +log = logging.getLogger() + @click.group(invoke_without_command=True) @click.pass_context From f2b67e3d79497b6f2c9b26fc33edd353a784235d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 4 Jul 2025 23:28:41 +0200 Subject: [PATCH 502/781] Add "blender" to hosts list for Validate File Saved --- client/ayon_core/plugins/publish/validate_file_saved.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/validate_file_saved.py b/client/ayon_core/plugins/publish/validate_file_saved.py index 7656687b4f..f8fdd27342 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", "silhouette", "gaffer"] + "cinema4d", "silhouette", "gaffer", "blender"] actions = [SaveByVersionUpAction, ShowWorkfilesAction] def process(self, context): 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 503/781] 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 504/781] 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 505/781] 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 506/781] 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 507/781] 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 54ecd2b834ec773b75e600beb13445df5fa7799e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 7 Jul 2025 12:40:34 +0200 Subject: [PATCH 508/781] move basicconfig to main --- client/ayon_core/cli.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 5978e8ba19..ca3dcc86ee 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -24,9 +24,6 @@ from ayon_core.lib.env_tools import ( merge_env_variables, ) -logging.basicConfig() -log = logging.getLogger() - @click.group(invoke_without_command=True) @click.pass_context @@ -310,6 +307,8 @@ def _add_addons(addons_manager): def main(*args, **kwargs): + logging.basicConfig() + initialize_ayon_connection() python_path = os.getenv("PYTHONPATH", "") split_paths = python_path.split(os.pathsep) From c54b7c25178e0c8910f16d7a94a7f78d17c33059 Mon Sep 17 00:00:00 2001 From: sjt-rvx <72554834+sjt-rvx@users.noreply.github.com> Date: Mon, 7 Jul 2025 10:58:18 +0000 Subject: [PATCH 509/781] Update client/ayon_core/tools/attribute_defs/files_widget.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/attribute_defs/files_widget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/attribute_defs/files_widget.py b/client/ayon_core/tools/attribute_defs/files_widget.py index f1b0f06dbc..4c55ae5620 100644 --- a/client/ayon_core/tools/attribute_defs/files_widget.py +++ b/client/ayon_core/tools/attribute_defs/files_widget.py @@ -911,7 +911,6 @@ class FilesWidget(QtWidgets.QFrame): os.path.join(file_item.directory, filename) for filename in file_item.filenames } - self._remove_item_by_ids(merged_item_ids) new_items = FileDefItem.from_value(list(all_paths), True) self._add_filepaths(new_items) 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 510/781] 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 511/781] 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 512/781] 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 513/781] 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 514/781] 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 515/781] 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 516/781] 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 517/781] 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 df9d58c384058241d54adde81780d7358907d1fa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 8 Jul 2025 15:23:56 +0200 Subject: [PATCH 518/781] fix typo --- client/ayon_core/pipeline/create/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 14b13613b6..e9f259e53b 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -79,7 +79,7 @@ _NOT_SET = object() INSTANCE_ADDED_TOPIC = "instances.added" INSTANCE_REMOVED_TOPIC = "instances.removed" VALUE_CHANGED_TOPIC = "values.changed" -INSTANCE_STATE_CHANGED_TOPIC = "instance.stated.changed" +INSTANCE_STATE_CHANGED_TOPIC = "instance.state.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" From 72bde0349b0112f558d3b742128d864de13d055f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Jul 2025 17:04:35 +0200 Subject: [PATCH 519/781] Allow to push to other projects not only Library --- .../tools/push_to_project/control.py | 10 +++++++ .../tools/push_to_project/ui/window.py | 26 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index fb080d158b..f24d11d0b7 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -40,6 +40,8 @@ class PushToContextController: self.set_source(project_name, version_id) + self._library_only = True + # Events system def emit_event(self, topic, data=None, source=None): """Use implemented event system to trigger event.""" @@ -128,6 +130,14 @@ class PushToContextController: self._src_label = self._prepare_source_label() return self._src_label + def get_library_only(self): + """Returns state of library filter""" + return self._library_only + + def set_library_only(self, state: bool): + """Change state of library filter""" + self._library_only = state + def get_project_items(self, sender=None): return self._projects_model.get_project_items(sender) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index a69c512fcd..566a0fc605 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -85,6 +85,14 @@ class PushToContextSelectWindow(QtWidgets.QWidget): header_widget = QtWidgets.QWidget(main_context_widget) + library_only = self._controller.get_library_only() + library_only_label = QtWidgets.QLabel( + "Show only libraries", + header_widget + ) + library_only_checkbox = NiceCheckbox( + library_only, parent=header_widget) + header_label = QtWidgets.QLabel( controller.get_source_label(), header_widget @@ -93,6 +101,14 @@ class PushToContextSelectWindow(QtWidgets.QWidget): header_layout = QtWidgets.QHBoxLayout(header_widget) header_layout.setContentsMargins(0, 0, 0, 0) header_layout.addWidget(header_label) + header_layout.addStretch() + + library_only_layout = QtWidgets.QHBoxLayout() + library_only_layout.addWidget(library_only_label) + library_only_layout.addWidget(library_only_checkbox) + library_only_layout.setSpacing(5) # or whatever spacing you prefer + + header_layout.addLayout(library_only_layout) main_splitter = QtWidgets.QSplitter( QtCore.Qt.Horizontal, main_context_widget @@ -102,7 +118,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): projects_combobox = ProjectsCombobox(controller, context_widget) projects_combobox.set_select_item_visible(True) - projects_combobox.set_standard_filter_enabled(True) + projects_combobox.set_standard_filter_enabled(library_only) context_splitter = QtWidgets.QSplitter( QtCore.Qt.Vertical, context_widget @@ -240,6 +256,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): folder_name_input.textChanged.connect(self._on_new_folder_change) variant_input.textChanged.connect(self._on_variant_change) comment_input.textChanged.connect(self._on_comment_change) + library_only_checkbox.stateChanged.connect(self._on_library_only_change) publish_btn.clicked.connect(self._on_select_click) cancel_btn.clicked.connect(self._on_close_click) @@ -394,6 +411,13 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._comment_input_text = text self._user_input_changed_timer.start() + def _on_library_only_change(self, state: int) -> None: + """Change toggle state, reset filter, recalculate dropdown""" + state = bool(state) + self._controller.set_library_only(state) + self._projects_combobox.set_standard_filter_enabled(state) + self._projects_combobox.refresh() + def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled folder_name = self._new_folder_name_input_text From e880b2983896cb79493266c61f08089e16d109a4 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Jul 2025 17:06:11 +0200 Subject: [PATCH 520/781] Changed label of action Push to --- client/ayon_core/plugins/load/push_to_library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/load/push_to_library.py b/client/ayon_core/plugins/load/push_to_library.py index 981028d734..825192c15e 100644 --- a/client/ayon_core/plugins/load/push_to_library.py +++ b/client/ayon_core/plugins/load/push_to_library.py @@ -14,7 +14,7 @@ class PushToLibraryProject(load.ProductLoaderPlugin): representations = {"*"} product_types = {"*"} - label = "Push to Library project" + label = "Push to (Library) project" order = 35 icon = "send" color = "#d8d8d8" From 031ccebb45a548d0e54a936d9ab8f3770c5dd226 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:39:47 +0200 Subject: [PATCH 521/781] use requirement instead of state --- client/ayon_core/pipeline/create/context.py | 34 +++++++++---------- .../ayon_core/pipeline/create/structures.py | 2 +- client/ayon_core/tools/publisher/control.py | 3 +- .../tools/publisher/models/create.py | 8 ++--- .../publisher/widgets/overview_widget.py | 2 +- 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index e9f259e53b..fd66881344 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -79,7 +79,7 @@ _NOT_SET = object() INSTANCE_ADDED_TOPIC = "instances.added" INSTANCE_REMOVED_TOPIC = "instances.removed" VALUE_CHANGED_TOPIC = "values.changed" -INSTANCE_STATE_CHANGED_TOPIC = "instance.state.changed" +INSTANCE_REQUIREMENT_CHANGED_TOPIC = "instance.requirement.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" @@ -258,10 +258,10 @@ class CreateContext: "create_attrs_change": BulkInfo(), # Publish attribute definitions changed "publish_attrs_change": BulkInfo(), - # Instance state changed - # - right now used only for 'mandatory' state but can be extended + # Instance requirement changed + # - right now used only for 'mandatory' but can be extended # in future - "state_change": BulkInfo(), + "requirement_change": BulkInfo(), } self._bulk_order = [] @@ -1054,10 +1054,10 @@ class CreateContext: PUBLISH_ATTR_DEFS_CHANGED_TOPIC, callback ) - def add_instance_state_change_callback( + def add_instance_requirement_change_callback( self, callback: Callable ) -> "EventCallback": - """Register callback to listen instance state changes. + """Register callback to listen instance requirement changes. Create plugin changed attribute definitions of instance. @@ -1072,7 +1072,7 @@ class CreateContext: Args: callback (Callable): Callback function that will be called when - create attributes changed. + instance requirement changed. Returns: EventCallback: Created callback object which can be used to @@ -1080,7 +1080,7 @@ class CreateContext: """ return self._event_hub.add_callback( - INSTANCE_STATE_CHANGED_TOPIC, callback + INSTANCE_REQUIREMENT_CHANGED_TOPIC, callback ) def context_data_to_store(self) -> dict[str, Any]: @@ -1358,9 +1358,9 @@ class CreateContext: yield bulk_info @contextmanager - def bulk_instance_state_change(self, sender: Optional[str] = None): + def bulk_instance_requirement_change(self, sender: Optional[str] = None): with self._bulk_context( - "state_change", sender + "requirement_change", sender ) as bulk_info: yield bulk_info @@ -1431,8 +1431,8 @@ class CreateContext: with self.bulk_value_changes() as bulk_item: bulk_item.append((instance_id, new_values)) - def instance_state_changed(self, instance_id: str) -> None: - """Instance state changed. + def instance_requirement_changed(self, instance_id: str) -> None: + """Instance requirement changed. Triggered by `CreatedInstance`. @@ -1441,7 +1441,7 @@ class CreateContext: """ if self._is_instance_events_ready(instance_id): - with self.bulk_instance_state_change() as bulk_item: + with self.bulk_instance_requirement_change() as bulk_item: bulk_item.append(instance_id) # --- context change callbacks --- @@ -2303,8 +2303,8 @@ class CreateContext: self._bulk_create_attrs_change_finished(data, sender) elif key == "publish_attrs_change": self._bulk_publish_attrs_change_finished(data, sender) - elif key == "state_change": - self._bulk_instance_state_change_finished(data, sender) + elif key == "requirement_change": + self._bulk_instance_requirement_change_finished(data, sender) def _bulk_add_instances_finished( self, @@ -2500,7 +2500,7 @@ class CreateContext: sender, ) - def _bulk_instance_state_change_finished( + def _bulk_instance_requirement_change_finished( self, instance_ids: list[str], sender: Optional[str], @@ -2514,7 +2514,7 @@ class CreateContext: ] self._emit_event( - INSTANCE_STATE_CHANGED_TOPIC, + INSTANCE_REQUIREMENT_CHANGED_TOPIC, {"instances": instances}, sender, ) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 1bc20501cc..a4c68d2502 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -750,7 +750,7 @@ class CreatedInstance: self._is_mandatory = value if value is True: self["active"] = True - self._create_context.instance_state_changed(self.id) + self._create_context.instance_requirement_changed(self.id) def changes(self): """Calculate and return changes.""" diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index b551b21bd4..038816c6fc 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -53,7 +53,8 @@ class PublisherController( changed. "create.context.create.attrs.changed" - Create attributes changed. "create.context.publish.attrs.changed" - Publish attributes changed. - "create.context.instance.state.changed" - Instance state changed. + "create.context.instance.requirement.changed" - Instance requirement + changed. "create.context.removed.instance" - Instance removed from context. "create.model.instances.context.changed" - Instances changed context. like folder, task or variant. diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 7f44b374e6..75ed2c73fe 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -483,8 +483,8 @@ class CreateModel: self._create_context.add_publish_attr_defs_change_callback( self._cc_publish_attr_changed ) - self._create_context.add_instance_state_change_callback( - self._cc_instance_state_changed + self._create_context.add_instance_requirement_change_callback( + self._cc_instance_requirement_changed ) self._create_context.reset_finalization() @@ -1181,13 +1181,13 @@ class CreateModel: event_data, ) - def _cc_instance_state_changed(self, event): + def _cc_instance_requirement_changed(self, event): instance_ids = { instance.id for instance in event.data["instances"] } self._emit_event( - "create.context.instance.state.changed", + "create.model.instance.requirement.changed", {"instance_ids": instance_ids}, ) diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index 44bb09d4fc..a7e099c0d0 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -156,7 +156,7 @@ class OverviewWidget(QtWidgets.QFrame): self._on_instance_context_change ) controller.register_event_callback( - "create.model.instance.state.changed", + "create.model.instance.requirement.changed", self._on_instance_state_changed ) From 4a9132132b7b06202b552b06aaab4653c32633a7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:42:01 +0200 Subject: [PATCH 522/781] one more method name change --- client/ayon_core/tools/publisher/widgets/overview_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index a7e099c0d0..46395328e0 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -157,7 +157,7 @@ class OverviewWidget(QtWidgets.QFrame): ) controller.register_event_callback( "create.model.instance.requirement.changed", - self._on_instance_state_changed + self._on_instance_requirement_changed ) self._product_content_widget = product_content_widget @@ -358,7 +358,7 @@ class OverviewWidget(QtWidgets.QFrame): def _on_instance_context_change(self, event): self._refresh_instance_states(event["instance_ids"]) - def _on_instance_state_changed(self, event): + def _on_instance_requirement_changed(self, event): self._refresh_instance_states(event["instance_ids"]) def _refresh_instance_states(self, instance_ids): 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 523/781] 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 524/781] 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 525/781] 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 526/781] 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 527/781] 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 528/781] 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 ef4c5d676187fa6bb31a0e0857be2359b73d1723 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:31:29 +0200 Subject: [PATCH 529/781] added anotations import --- client/ayon_core/tools/loader/control.py | 2 ++ client/ayon_core/tools/loader/ui/_multicombobox.py | 1 + client/ayon_core/tools/loader/ui/products_delegates.py | 3 +-- client/ayon_core/tools/loader/ui/products_widget.py | 1 + client/ayon_core/tools/loader/ui/search_bar.py | 2 ++ client/ayon_core/tools/loader/ui/window.py | 2 ++ 6 files changed, 9 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 95f48b3519..7ba42a0981 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import uuid diff --git a/client/ayon_core/tools/loader/ui/_multicombobox.py b/client/ayon_core/tools/loader/ui/_multicombobox.py index 393272fdf9..b4e1707242 100644 --- a/client/ayon_core/tools/loader/ui/_multicombobox.py +++ b/client/ayon_core/tools/loader/ui/_multicombobox.py @@ -1,4 +1,5 @@ from __future__ import annotations + import typing from typing import List, Tuple, Optional, Iterable, Any diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index e78b32ceb1..b500b86b97 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -2,7 +2,6 @@ from __future__ import annotations import numbers import uuid -from typing import Dict from qtpy import QtWidgets, QtCore, QtGui @@ -249,7 +248,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._editor_by_id: Dict[str, VersionComboBox] = {} + self._editor_by_id: dict[str, VersionComboBox] = {} self._task_ids_filter = None self._statuses_filter = None self._version_tags_filter = None diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index f1c82ee83d..e5bb75a208 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -1,4 +1,5 @@ from __future__ import annotations + import collections from typing import Optional diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py index ab673df1ac..a855a3c452 100644 --- a/client/ayon_core/tools/loader/ui/search_bar.py +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import copy import uuid from dataclasses import dataclass diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index d056b62b13..df5beb708f 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from qtpy import QtWidgets, QtCore, QtGui from ayon_core.resources import get_ayon_icon_filepath From 0927358cd862bec7c3ae253c5af6db01bc59250c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:51:36 +0200 Subject: [PATCH 530/781] add more annotations imports --- client/ayon_core/tools/loader/models/actions.py | 2 ++ client/ayon_core/tools/loader/models/products.py | 1 + client/ayon_core/tools/loader/models/selection.py | 3 +++ client/ayon_core/tools/loader/models/sitesync.py | 2 ++ 4 files changed, 8 insertions(+) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 40331d73a4..b792f92dfd 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys import traceback import inspect diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index edf8efc3b3..87e2406c81 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -1,5 +1,6 @@ """Products model for loader tools.""" from __future__ import annotations + import collections import contextlib from typing import TYPE_CHECKING, Iterable, Optional diff --git a/client/ayon_core/tools/loader/models/selection.py b/client/ayon_core/tools/loader/models/selection.py index 04add26f86..f2148352cd 100644 --- a/client/ayon_core/tools/loader/models/selection.py +++ b/client/ayon_core/tools/loader/models/selection.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + class SelectionModel(object): """Model handling selection changes. diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index c7f0038df4..3a54a1b5f8 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import collections from ayon_api import ( 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 531/781] 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 532/781] 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 533/781] 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 534/781] 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 535/781] 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 8f995df928f5550eeed0b023c73c5ba89da733e3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 10 Jul 2025 08:29:44 +0200 Subject: [PATCH 536/781] fix docstring --- client/ayon_core/pipeline/create/context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index fd66881344..cd06b06ea3 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1057,9 +1057,9 @@ class CreateContext: def add_instance_requirement_change_callback( self, callback: Callable ) -> "EventCallback": - """Register callback to listen instance requirement changes. + """Register callback to listen to instance requirement changes. - Create plugin changed attribute definitions of instance. + Instance changed requirement of active state. Data structure of event:: From d1fe9d4af6928c5ae259d843142e04842c0633d0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 10 Jul 2025 08:29:55 +0200 Subject: [PATCH 537/781] removed doubled doble colon --- client/ayon_core/pipeline/create/context.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index cd06b06ea3..929cc59d2a 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -872,7 +872,7 @@ class CreateContext: Event is triggered when instances are already available in context and have set create/publish attribute definitions. - Data structure of event:: + Data structure of event: ```python { @@ -899,7 +899,7 @@ class CreateContext: Event is triggered when instances are already removed from context. - Data structure of event:: + Data structure of event: ```python { @@ -927,7 +927,7 @@ class CreateContext: Event is triggered when any value changes on any instance or context data. - Data structure of event:: + Data structure of event: ```python { @@ -965,7 +965,7 @@ class CreateContext: Create plugin can trigger refresh of pre-create attributes. Usage of this event is mainly for publisher UI. - Data structure of event:: + Data structure of event: ```python { @@ -994,7 +994,7 @@ class CreateContext: Create plugin changed attribute definitions of instance. - Data structure of event:: + Data structure of event: ```python { @@ -1023,7 +1023,7 @@ class CreateContext: Publish plugin changed attribute definitions of instance of context. - Data structure of event:: + Data structure of event: ```python { @@ -1061,7 +1061,7 @@ class CreateContext: Instance changed requirement of active state. - Data structure of event:: + Data structure of event: ```python { From bb4131e0e5148d509b59d097f6b3d579dce0e582 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 10 Jul 2025 06:41:18 +0000 Subject: [PATCH 538/781] [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 df92396802..963fc9ecdc 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.0+dev" +__version__ = "1.4.1" diff --git a/package.py b/package.py index efed91b6cf..acc27aa7b8 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.4.0+dev" +version = "1.4.1" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 91579f04fb..9b89ab23ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.4.0+dev" +version = "1.4.1" description = "" authors = ["Ynput Team "] readme = "README.md" From f6d39301ecab441c75457c8935283dd455c1c5ae Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 10 Jul 2025 06:41:50 +0000 Subject: [PATCH 539/781] [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 963fc9ecdc..509c4a8d14 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" +__version__ = "1.4.1+dev" diff --git a/package.py b/package.py index acc27aa7b8..039bf0379c 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.4.1" +version = "1.4.1+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 9b89ab23ac..9609729420 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.4.1" +version = "1.4.1+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From f6077eea2d089e7aafa696acfccd4958263ab23d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 10 Jul 2025 06:42:43 +0000 Subject: [PATCH 540/781] 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 eff53116a2..9fb6ee645d 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.4.1 - 1.4.0 - 1.3.2 - 1.3.1 From c35c86440bedd9d15475cd7db9d9685965c1777c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 10 Jul 2025 11:39:01 +0200 Subject: [PATCH 541/781] Used different layout Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/ui/window.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 566a0fc605..49093b8a00 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -101,14 +101,9 @@ class PushToContextSelectWindow(QtWidgets.QWidget): header_layout = QtWidgets.QHBoxLayout(header_widget) header_layout.setContentsMargins(0, 0, 0, 0) header_layout.addWidget(header_label) - header_layout.addStretch() - - library_only_layout = QtWidgets.QHBoxLayout() - library_only_layout.addWidget(library_only_label) - library_only_layout.addWidget(library_only_checkbox) - library_only_layout.setSpacing(5) # or whatever spacing you prefer - - header_layout.addLayout(library_only_layout) + header_layout.addStretch(1) + header_layout.addWidget(library_only_label, 0) + header_layout.addWidget(library_only_checkbox, 0) main_splitter = QtWidgets.QSplitter( QtCore.Qt.Horizontal, main_context_widget From 63da40c2025739bdfc864ae38e5031fe20dcc0a9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 11 Jul 2025 13:59:34 +0200 Subject: [PATCH 542/781] Added thumbnail copy from source to target --- .../tools/push_to_project/models/integrate.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 6bd4279219..fd20a7faba 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -3,6 +3,7 @@ import re import copy import itertools import sys +import tempfile import traceback import uuid @@ -484,6 +485,7 @@ class ProjectPushItemProcess: self._make_sure_version_exists() self._log_info("Prerequirements were prepared") self._integrate_representations() + self._copy_version_thumbnail() self._log_info("Integration finished") except PushToProjectError as exc: @@ -1145,8 +1147,39 @@ class ProjectPushItemProcess: "representation", repre_entity["id"], {"active": False} + + def _copy_version_thumbnail(self): + version_thumbnail = ayon_api.get_version_thumbnail( + self._item.src_project_name, self._src_version_entity["id"]) + if not version_thumbnail or not version_thumbnail.id: + return + + temp_file_name = None + try: + with tempfile.NamedTemporaryFile(mode='w+b', delete=False) as fp: + fp.write(version_thumbnail.content) + temp_file_name = fp.name + + new_thumbnail_id = ayon_api.create_thumbnail( + self._item.dst_project_name, + temp_file_name ) + task_id = None + if self._task_info: + task_id = self._task_info["id"] + + self._operations.update_version( + project_name=self._item.dst_project_name, + version_id=self._version_entity["id"], + task_id=task_id, + thumbnail_id=new_thumbnail_id + ) + self._operations.commit() + finally: + if temp_file_name and os.path.exists(temp_file_name): + os.remove(temp_file_name) + class IntegrateModel: def __init__(self, controller): From dce81ba92d66adb9aafe67ad4c3df38f17708727 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 14 Jul 2025 12:03:38 +0200 Subject: [PATCH 543/781] 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 544/781] 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 545/781] 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 7161de78fabaddd55d5d9e1c1d6f01e167da7910 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Jul 2025 16:33:44 +0200 Subject: [PATCH 546/781] Fix typo --- client/ayon_core/tools/push_to_project/models/integrate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index fd20a7faba..341858148b 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1147,6 +1147,7 @@ class ProjectPushItemProcess: "representation", repre_entity["id"], {"active": False} + ) def _copy_version_thumbnail(self): version_thumbnail = ayon_api.get_version_thumbnail( 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 547/781] 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 548/781] 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 549/781] 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 550/781] 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 551/781] 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 552/781] 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 bcea66c9314eb9f2796cefeb6176c2f9fc485fdc Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 17 Jul 2025 14:31:23 +0200 Subject: [PATCH 553/781] Label update Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/plugins/load/push_to_library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/load/push_to_library.py b/client/ayon_core/plugins/load/push_to_library.py index 825192c15e..22c10bbad7 100644 --- a/client/ayon_core/plugins/load/push_to_library.py +++ b/client/ayon_core/plugins/load/push_to_library.py @@ -14,7 +14,7 @@ class PushToLibraryProject(load.ProductLoaderPlugin): representations = {"*"} product_types = {"*"} - label = "Push to (Library) project" + label = "Push to project" order = 35 icon = "send" color = "#d8d8d8" From 1c4f466181a892ecb954f523de061e791d435e7e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 17 Jul 2025 14:31:34 +0200 Subject: [PATCH 554/781] Formatting change Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/models/integrate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 341858148b..20fa5c98e5 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1148,6 +1148,7 @@ class ProjectPushItemProcess: repre_entity["id"], {"active": False} ) + ) def _copy_version_thumbnail(self): version_thumbnail = ayon_api.get_version_thumbnail( 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 555/781] 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 556/781] 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 557/781] 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 558/781] 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 559/781] 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 560/781] 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 561/781] 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 562/781] 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 563/781] 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 564/781] 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 565/781] 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 566/781] 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 567/781] 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 568/781] 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 569/781] 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 570/781] 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 571/781] 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 572/781] 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 573/781] 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 574/781] 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 575/781] 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 576/781] 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 577/781] 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 578/781] 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 579/781] :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 580/781] :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 581/781] 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 582/781] 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 583/781] 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 584/781] 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 585/781] 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 586/781] 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 587/781] 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 588/781] 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 589/781] 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 590/781] [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 591/781] [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 592/781] 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 593/781] 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 594/781] 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 595/781] 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 596/781] 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 597/781] 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 598/781] 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 599/781] 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 600/781] 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 601/781] 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 602/781] 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 603/781] 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 604/781] 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 605/781] 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 606/781] 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 fd26c2039495b3f83bf8c3c08de2edc5b449c257 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 28 Jul 2025 15:00:44 +0200 Subject: [PATCH 607/781] Fix typo --- client/ayon_core/tools/push_to_project/models/integrate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 20fa5c98e5..341858148b 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1148,7 +1148,6 @@ class ProjectPushItemProcess: repre_entity["id"], {"active": False} ) - ) def _copy_version_thumbnail(self): version_thumbnail = ayon_api.get_version_thumbnail( From bcf87dec1060d1dfef2e8d4249f4ebee698829ba Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 28 Jul 2025 15:01:12 +0200 Subject: [PATCH 608/781] Removed unnecessary _library_only --- client/ayon_core/tools/push_to_project/control.py | 8 -------- client/ayon_core/tools/push_to_project/ui/window.py | 6 ++---- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index f24d11d0b7..eb985a3f8c 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -130,14 +130,6 @@ class PushToContextController: self._src_label = self._prepare_source_label() return self._src_label - def get_library_only(self): - """Returns state of library filter""" - return self._library_only - - def set_library_only(self, state: bool): - """Change state of library filter""" - self._library_only = state - def get_project_items(self, sender=None): return self._projects_model.get_project_items(sender) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 49093b8a00..6b0363adee 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -85,13 +85,12 @@ class PushToContextSelectWindow(QtWidgets.QWidget): header_widget = QtWidgets.QWidget(main_context_widget) - library_only = self._controller.get_library_only() library_only_label = QtWidgets.QLabel( "Show only libraries", header_widget ) library_only_checkbox = NiceCheckbox( - library_only, parent=header_widget) + True, parent=header_widget) header_label = QtWidgets.QLabel( controller.get_source_label(), @@ -113,7 +112,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): projects_combobox = ProjectsCombobox(controller, context_widget) projects_combobox.set_select_item_visible(True) - projects_combobox.set_standard_filter_enabled(library_only) + projects_combobox.set_standard_filter_enabled(True) context_splitter = QtWidgets.QSplitter( QtCore.Qt.Vertical, context_widget @@ -409,7 +408,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): def _on_library_only_change(self, state: int) -> None: """Change toggle state, reset filter, recalculate dropdown""" state = bool(state) - self._controller.set_library_only(state) self._projects_combobox.set_standard_filter_enabled(state) self._projects_combobox.refresh() 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 609/781] 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 610/781] 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 611/781] 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 612/781] 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 613/781] 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 614/781] 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 615/781] 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 616/781] 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 617/781] 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 618/781] 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 619/781] 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 620/781] :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 621/781] 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 622/781] 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 623/781] 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 624/781] 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 625/781] 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 626/781] 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 627/781] 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 628/781] 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 629/781] 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 630/781] 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 631/781] :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 632/781] 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 633/781] 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 634/781] 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 635/781] 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 636/781] 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 637/781] 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 638/781] 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 639/781] 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 640/781] 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 641/781] 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 642/781] 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 643/781] 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 644/781] 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 645/781] 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 646/781] 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 647/781] 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 648/781] 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 649/781] 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 650/781] 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 651/781] :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 652/781] 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 653/781] 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 654/781] 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 655/781] 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 656/781] 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 657/781] :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 658/781] :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 659/781] 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 660/781] [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 661/781] [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 662/781] 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 663/781] 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 664/781] 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 665/781] 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 666/781] [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 667/781] [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 668/781] 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 669/781] 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 670/781] 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 671/781] 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 672/781] 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 673/781] 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 674/781] 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 675/781] 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 676/781] 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 677/781] 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 678/781] 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 679/781] 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 680/781] 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 681/781] :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 682/781] 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 683/781] 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 684/781] 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 685/781] 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 686/781] 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 2757c6efbb7e68c6c0ff1ac43e6d4b0bef2c2971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 12 Aug 2025 18:42:49 +0200 Subject: [PATCH 687/781] :sparkles: very raw WIP version --- .../plugins/load/create_hero_version.py | 415 ++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 client/ayon_core/plugins/load/create_hero_version.py diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py new file mode 100644 index 0000000000..7e1a0d8a3d --- /dev/null +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -0,0 +1,415 @@ + +import os +import copy +import shutil +import errno +import itertools +from concurrent.futures import ThreadPoolExecutor + +from speedcopy import copyfile +import clique +import ayon_api +from ayon_api.operations import OperationsSession, new_version_entity +from ayon_api.utils import create_entity_id +from qtpy import QtWidgets, QtCore +from ayon_core import style +from ayon_core.pipeline import load, Anatomy +from ayon_core.lib import create_hard_link, source_hash +from ayon_core.lib.file_transaction import wait_for_future_errors +from ayon_core.pipeline.publish import get_publish_template_name +from ayon_core.pipeline.template_data import get_template_data + + +def prepare_changes(old_entity, new_entity): + changes = {} + for key in set(new_entity.keys()): + if key == "attrib": + continue + if key in new_entity and new_entity[key] != old_entity.get(key): + changes[key] = new_entity[key] + attrib_changes = {} + if "attrib" in new_entity: + for key, value in new_entity["attrib"].items(): + if value != old_entity["attrib"].get(key): + attrib_changes[key] = value + if attrib_changes: + changes["attrib"] = attrib_changes + return changes + + +class CreateHeroVersion(load.ProductLoaderPlugin): + """Create hero version from selected context.""" + + is_multiple_contexts_compatible = False + representations = {"*"} + product_types = {"*"} + label = "Create Hero Version" + order = 36 + icon = "star" + color = "#ffd700" + + ignored_representation_names = [] + db_representation_context_keys = [ + "project", "folder", "asset", "hierarchy", "task", "product", + "subset", "family", "representation", "username", "user", "output" + ] + use_hardlinks = False + + def message(self, text): + msgBox = QtWidgets.QMessageBox() + msgBox.setText(text) + msgBox.setStyleSheet(style.load_stylesheet()) + msgBox.setWindowFlags( + msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint + ) + msgBox.exec_() + + + def load(self, context, name=None, namespace=None, options=None) -> None: + """Load hero version from context (dict as in context.py).""" + success = True + errors = [] + + # Extract project, product, version, folder from context + project = context.get("project") + product = context.get("product") + version = context.get("version") + folder = context.get("folder") + task_entity = ayon_api.get_task_by_id( + task_id=version.get("taskId"), project_name=project["name"]) + + anatomy = Anatomy(project["name"]) + + version_id = version["id"] + project_name = project["name"] + repres = list( + ayon_api.get_representations(project_name, version_ids={version_id})) + anatomy_data = get_template_data( + project_entity=project, + folder_entity=folder, + task_entity=task_entity, + ) + anatomy_data["product"] = { + "name": product["name"], + "type": product["productType"], + } + published_representations = {} + for repre in repres: + repre_anatomy = anatomy_data + repre_anatomy["ext"] = repre.get("ext", "") + published_representations[repre["id"]] = { + "representation": repre, + "published_files": [f["path"] for f in repre.get("files", [])], + "anatomy_data": repre_anatomy + } + + instance_data = { + "productName": product["name"], + "productType": product["productType"], + "anatomyData": anatomy_data, + "publishDir": "", # TODO: Set to actual publish directory + "published_representations": published_representations, + "versionEntity": version, + } + + try: + self.create_hero_version(instance_data, anatomy, context) + except Exception as exc: + success = False + errors.append(str(exc)) + if success: + self.message("Hero version created successfully.") + else: + self.message( + f"Failed to create hero version:\n{chr(10).join(errors)}") + + def create_hero_version(self, instance_data, anatomy, context): + """Create hero version from instance data.""" + published_repres = instance_data.get("published_representations") + if not published_repres: + raise RuntimeError("No published representations found.") + + project_name = anatomy.project_name + template_key = get_publish_template_name( + project_name, + context.get("hostName"), + instance_data.get("productType"), + instance_data.get("anatomyData", {}).get("task", {}).get("name"), + instance_data.get("anatomyData", {}).get("task", {}).get("type"), + project_settings=context.get("project_settings", {}), + hero=True, + logger=None + ) + hero_template = anatomy.get_template_item("hero", template_key, "path", default=None) + if hero_template is None: + raise RuntimeError(f"Project anatomy does not have hero template key: {template_key}") + + print(f"Hero template: {hero_template.template}") + + hero_publish_dir = self.get_publish_dir(instance_data, anatomy, template_key) + + print(f"Hero publish dir: {hero_publish_dir}") + + src_version_entity = instance_data.get("versionEntity") + filtered_repre_ids = [] + for repre_id, repre_info in published_repres.items(): + repre = repre_info["representation"] + if repre["name"].lower() in self.ignored_representation_names: + filtered_repre_ids.append(repre_id) + for repre_id in filtered_repre_ids: + published_repres.pop(repre_id, None) + if not published_repres: + raise RuntimeError("All published representations were filtered by name.") + + if src_version_entity is None: + src_version_entity = self.version_from_representations(project_name, published_repres) + if not src_version_entity: + raise RuntimeError("Can't find origin version in database.") + if src_version_entity["version"] == 0: + raise RuntimeError("Version 0 cannot have hero version.") + + all_copied_files = [] + transfers = instance_data.get("transfers", list()) + for _src, dst in transfers: + dst = os.path.normpath(dst) + if dst not in all_copied_files: + all_copied_files.append(dst) + hardlinks = instance_data.get("hardlinks", list()) + for _src, dst in hardlinks: + dst = os.path.normpath(dst) + if dst not in all_copied_files: + all_copied_files.append(dst) + + all_repre_file_paths = [] + for repre_info in published_repres.values(): + published_files = repre_info.get("published_files") or [] + for file_path in published_files: + file_path = os.path.normpath(file_path) + if file_path not in all_repre_file_paths: + all_repre_file_paths.append(file_path) + + instance_publish_dir = os.path.normpath(instance_data["publishDir"]) + other_file_paths_mapping = [] + for file_path in all_copied_files: + if not file_path.startswith(instance_publish_dir): + continue + if file_path in all_repre_file_paths: + continue + dst_filepath = file_path.replace(instance_publish_dir, hero_publish_dir) + other_file_paths_mapping.append((file_path, dst_filepath)) + + old_version, old_repres = self.current_hero_ents(project_name, src_version_entity) + inactive_old_repres_by_name = {} + old_repres_by_name = {} + for repre in old_repres: + low_name = repre["name"].lower() + if repre["active"]: + old_repres_by_name[low_name] = repre + else: + inactive_old_repres_by_name[low_name] = repre + + op_session = OperationsSession() + entity_id = old_version["id"] if old_version else None + new_hero_version = new_version_entity( + -src_version_entity["version"], + src_version_entity["productId"], + task_id=src_version_entity.get("taskId"), + data=copy.deepcopy(src_version_entity["data"]), + attribs=copy.deepcopy(src_version_entity["attrib"]), + entity_id=entity_id, + ) + if old_version: + update_data = prepare_changes(old_version, new_hero_version) + op_session.update_entity(project_name, "version", old_version["id"], update_data) + else: + op_session.create_entity(project_name, "version", new_hero_version) + + # Store hero entity to instance_data + instance_data["heroVersionEntity"] = new_hero_version + + old_repres_to_replace = {} + old_repres_to_delete = {} + for repre_info in published_repres.values(): + repre = repre_info["representation"] + repre_name_low = repre["name"].lower() + if repre_name_low in old_repres_by_name: + old_repres_to_replace[repre_name_low] = old_repres_by_name.pop(repre_name_low) + if old_repres_by_name: + old_repres_to_delete = old_repres_by_name + + backup_hero_publish_dir = None + if os.path.exists(hero_publish_dir): + backup_hero_publish_dir = hero_publish_dir + ".BACKUP" + max_idx = 10 + idx = 0 + _backup_hero_publish_dir = backup_hero_publish_dir + while os.path.exists(_backup_hero_publish_dir): + try: + shutil.rmtree(_backup_hero_publish_dir) + backup_hero_publish_dir = _backup_hero_publish_dir + break + except Exception: + _backup_hero_publish_dir = backup_hero_publish_dir + str(idx) + if not os.path.exists(_backup_hero_publish_dir): + backup_hero_publish_dir = _backup_hero_publish_dir + break + if idx > max_idx: + raise AssertionError(f"Backup folders are fully occupied to max index {max_idx}") + idx += 1 + try: + os.rename(hero_publish_dir, backup_hero_publish_dir) + except PermissionError: + raise AssertionError( + "Could not create hero version because it is " + "not possible to replace current hero files.") + + try: + src_to_dst_file_paths = [] + repre_integrate_data = [] + path_template_obj = anatomy.get_template_item( + "hero", template_key, "path") + for repre_info in published_repres.values(): + published_files = repre_info["published_files"] + if len(published_files) == 0: + continue + anatomy_data = copy.deepcopy(repre_info["anatomy_data"]) + anatomy_data.pop("version", None) + template_filled = path_template_obj.format_strict(anatomy_data) + repre_context = template_filled.used_values + for key in self.db_representation_context_keys: + value = anatomy_data.get(key) + if value is not None: + repre_context[key] = value + repre_entity = copy.deepcopy(repre_info["representation"]) + repre_entity.pop("id", None) + repre_entity["versionId"] = new_hero_version["id"] + repre_entity["context"] = repre_context + repre_entity["attrib"] = { + "path": str(template_filled), + "template": hero_template.template + } + dst_paths = [] + if len(published_files) == 1: + dst_paths.append(str(template_filled)) + src_to_dst_file_paths.append((published_files[0], template_filled)) + print(f"Single published file: {published_files[0]} -> {template_filled}") + else: + collections, remainders = clique.assemble(published_files) + if remainders or not collections or len(collections) > 1: + raise Exception( + "Integrity error. Files of published representation is " + "combination of frame collections and single files.") + src_col = collections[0] + frame_splitter = "_-_FRAME_SPLIT_-_" + anatomy_data["frame"] = frame_splitter + _template_filled = path_template_obj.format_strict(anatomy_data) + head, tail = _template_filled.split(frame_splitter) + padding = anatomy.templates_obj.frame_padding + dst_col = clique.Collection(head=head, padding=padding, tail=tail) + dst_col.indexes.clear() + dst_col.indexes.update(src_col.indexes) + for src_file, dst_file in zip(src_col, dst_col): + src_to_dst_file_paths.append((src_file, dst_file)) + dst_paths.append(dst_file) + print(f"Collection published file: {src_file} -> {dst_file}") + repre_integrate_data.append((repre_entity, dst_paths)) + + # Copy files + with ThreadPoolExecutor(max_workers=8) as executor: + futures = [ + executor.submit(self.copy_file, src_path, dst_path) + for src_path, dst_path in itertools.chain(src_to_dst_file_paths, other_file_paths_mapping) + ] + wait_for_future_errors(executor, futures) + + # Update/create representations + for repre_entity, dst_paths in repre_integrate_data: + repre_files = self.get_files_info(dst_paths, anatomy) + repre_entity["files"] = repre_files + repre_name_low = repre_entity["name"].lower() + if repre_name_low in old_repres_to_replace: + old_repre = old_repres_to_replace.pop(repre_name_low) + repre_entity["id"] = old_repre["id"] + update_data = prepare_changes(old_repre, repre_entity) + op_session.update_entity(project_name, "representation", old_repre["id"], update_data) + elif repre_name_low in inactive_old_repres_by_name: + inactive_repre = inactive_old_repres_by_name.pop(repre_name_low) + repre_entity["id"] = inactive_repre["id"] + update_data = prepare_changes(inactive_repre, repre_entity) + op_session.update_entity(project_name, "representation", inactive_repre["id"], update_data) + else: + op_session.create_entity(project_name, "representation", repre_entity) + + for repre in old_repres_to_delete.values(): + op_session.update_entity(project_name, "representation", repre["id"], {"active": False}) + + op_session.commit() + + if backup_hero_publish_dir is not None and os.path.exists(backup_hero_publish_dir): + shutil.rmtree(backup_hero_publish_dir) + + except Exception: + if backup_hero_publish_dir is not None and os.path.exists(backup_hero_publish_dir): + if os.path.exists(hero_publish_dir): + shutil.rmtree(hero_publish_dir) + os.rename(backup_hero_publish_dir, hero_publish_dir) + raise + + def get_files_info(self, filepaths, anatomy): + file_infos = [] + for filepath in filepaths: + file_info = self.prepare_file_info(filepath, anatomy) + file_infos.append(file_info) + return file_infos + + def prepare_file_info(self, path, anatomy): + return { + "id": create_entity_id(), + "name": os.path.basename(path), + "path": self.get_rootless_path(anatomy, path), + "size": os.path.getsize(path), + "hash": source_hash(path), + "hash_type": "op3", + } + + def get_publish_dir(self, instance_data, anatomy, template_key): + template_data = copy.deepcopy(instance_data.get("anatomyData", {})) + if "originalBasename" in instance_data: + template_data["originalBasename"] = instance_data["originalBasename"] + template_obj = anatomy.get_template_item("hero", template_key, "directory") + return os.path.normpath(template_obj.format_strict(template_data)) + + def get_rootless_path(self, anatomy, path): + success, rootless_path = anatomy.find_root_template_from_path(path) + if success: + path = rootless_path + return path + + def copy_file(self, src_path, dst_path): + dirname = os.path.dirname(dst_path) + try: + os.makedirs(dirname) + except OSError as exc: + if exc.errno != errno.EEXIST: + raise + if self.use_hardlinks: + try: + create_hard_link(src_path, dst_path) + return + except OSError as exc: + if exc.errno not in [errno.EXDEV, errno.EINVAL]: + raise + copyfile(src_path, dst_path) + + def version_from_representations(self, project_name, repres): + for repre_info in repres.values(): + version = ayon_api.get_version_by_id(project_name, repre_info["representation"]["versionId"]) + if version: + return version + + def current_hero_ents(self, project_name, version): + hero_version = ayon_api.get_hero_version_by_product_id(project_name, version["productId"]) + if not hero_version: + return (None, []) + hero_repres = list(ayon_api.get_representations(project_name, version_ids={hero_version["id"]})) + return (hero_version, hero_repres) 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 688/781] 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 689/781] 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 690/781] 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 691/781] 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 692/781] 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 693/781] 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 694/781] [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 695/781] [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 696/781] 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 697/781] 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 698/781] 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 65d03327b8af6e630b41f17bd629115e6fd1a83d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 13 Aug 2025 15:18:46 +0200 Subject: [PATCH 699/781] Fix typo --- client/ayon_core/tools/push_to_project/ui/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 6b0363adee..344295f177 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -551,7 +551,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._main_thread_timer_can_stop = False self._main_thread_timer.start() self._main_layout.setCurrentWidget(self._overlay_widget) - self._overlay_label.setText("Submittion started") + self._overlay_label.setText("Submission started") def _on_controller_submit_end(self): self._main_thread_timer_can_stop = True 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 700/781] 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 701/781] 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 702/781] 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 703/781] 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 704/781] 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 705/781] 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 706/781] 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 707/781] 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 708/781] 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 709/781] 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 710/781] 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 711/781] 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 712/781] 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 713/781] 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 714/781] 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 715/781] 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 716/781] 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 717/781] 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 718/781] 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", From 9ffaa15dfb4ec0484a5303ed1a8a04bd2805c7e9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 15 Aug 2025 22:09:15 +0800 Subject: [PATCH 719/781] make sure the hero version can be created successfully by ensuring to copy the path into the right path --- .../plugins/load/create_hero_version.py | 141 ++++++++++++++---- 1 file changed, 110 insertions(+), 31 deletions(-) diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index 7e1a0d8a3d..b18e874644 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -14,7 +14,7 @@ from ayon_api.utils import create_entity_id from qtpy import QtWidgets, QtCore from ayon_core import style from ayon_core.pipeline import load, Anatomy -from ayon_core.lib import create_hard_link, source_hash +from ayon_core.lib import create_hard_link, source_hash, StringTemplate from ayon_core.lib.file_transaction import wait_for_future_errors from ayon_core.pipeline.publish import get_publish_template_name from ayon_core.pipeline.template_data import get_template_data @@ -83,7 +83,10 @@ class CreateHeroVersion(load.ProductLoaderPlugin): version_id = version["id"] project_name = project["name"] repres = list( - ayon_api.get_representations(project_name, version_ids={version_id})) + ayon_api.get_representations( + project_name, version_ids={version_id} + ) + ) anatomy_data = get_template_data( project_entity=project, folder_entity=folder, @@ -95,8 +98,9 @@ class CreateHeroVersion(load.ProductLoaderPlugin): } published_representations = {} for repre in repres: - repre_anatomy = anatomy_data - repre_anatomy["ext"] = repre.get("ext", "") + repre_anatomy = copy.deepcopy(anatomy_data) + if "ext" not in repre_anatomy: + repre_anatomy["ext"] = repre.get("context", {}).get("ext", "") published_representations[repre["id"]] = { "representation": repre, "published_files": [f["path"] for f in repre.get("files", [])], @@ -140,13 +144,18 @@ class CreateHeroVersion(load.ProductLoaderPlugin): hero=True, logger=None ) - hero_template = anatomy.get_template_item("hero", template_key, "path", default=None) + hero_template = anatomy.get_template_item( + "hero", template_key, "path", default=None + ) if hero_template is None: - raise RuntimeError(f"Project anatomy does not have hero template key: {template_key}") + raise RuntimeError("Project anatomy does not have hero " + f"template key: {template_key}") print(f"Hero template: {hero_template.template}") - hero_publish_dir = self.get_publish_dir(instance_data, anatomy, template_key) + hero_publish_dir = self.get_publish_dir( + instance_data, anatomy, template_key + ) print(f"Hero publish dir: {hero_publish_dir}") @@ -162,7 +171,8 @@ class CreateHeroVersion(load.ProductLoaderPlugin): raise RuntimeError("All published representations were filtered by name.") if src_version_entity is None: - src_version_entity = self.version_from_representations(project_name, published_repres) + src_version_entity = self.version_from_representations( + project_name, published_repres) if not src_version_entity: raise RuntimeError("Can't find origin version in database.") if src_version_entity["version"] == 0: @@ -195,10 +205,14 @@ class CreateHeroVersion(load.ProductLoaderPlugin): continue if file_path in all_repre_file_paths: continue - dst_filepath = file_path.replace(instance_publish_dir, hero_publish_dir) + dst_filepath = file_path.replace( + instance_publish_dir, hero_publish_dir + ) other_file_paths_mapping.append((file_path, dst_filepath)) - old_version, old_repres = self.current_hero_ents(project_name, src_version_entity) + old_version, old_repres = self.current_hero_ents( + project_name, src_version_entity + ) inactive_old_repres_by_name = {} old_repres_by_name = {} for repre in old_repres: @@ -220,7 +234,9 @@ class CreateHeroVersion(load.ProductLoaderPlugin): ) if old_version: update_data = prepare_changes(old_version, new_hero_version) - op_session.update_entity(project_name, "version", old_version["id"], update_data) + op_session.update_entity( + project_name, "version", old_version["id"], update_data + ) else: op_session.create_entity(project_name, "version", new_hero_version) @@ -233,7 +249,9 @@ class CreateHeroVersion(load.ProductLoaderPlugin): repre = repre_info["representation"] repre_name_low = repre["name"].lower() if repre_name_low in old_repres_by_name: - old_repres_to_replace[repre_name_low] = old_repres_by_name.pop(repre_name_low) + old_repres_to_replace[repre_name_low] = ( + old_repres_by_name.pop(repre_name_low) + ) if old_repres_by_name: old_repres_to_delete = old_repres_by_name @@ -254,7 +272,9 @@ class CreateHeroVersion(load.ProductLoaderPlugin): backup_hero_publish_dir = _backup_hero_publish_dir break if idx > max_idx: - raise AssertionError(f"Backup folders are fully occupied to max index {max_idx}") + raise AssertionError( + f"Backup folders are fully occupied to max index {max_idx}" + ) idx += 1 try: os.rename(hero_publish_dir, backup_hero_publish_dir) @@ -268,6 +288,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin): repre_integrate_data = [] path_template_obj = anatomy.get_template_item( "hero", template_key, "path") + anatomy_root = {"root": anatomy.roots} for repre_info in published_repres.values(): published_files = repre_info["published_files"] if len(published_files) == 0: @@ -289,10 +310,21 @@ class CreateHeroVersion(load.ProductLoaderPlugin): "template": hero_template.template } dst_paths = [] + if len(published_files) == 1: dst_paths.append(str(template_filled)) - src_to_dst_file_paths.append((published_files[0], template_filled)) - print(f"Single published file: {published_files[0]} -> {template_filled}") + mapped_published_file = StringTemplate( + published_files[0]).format_strict( + anatomy_root + ) + src_to_dst_file_paths.append( + (mapped_published_file, template_filled) + ) + print( + f"Single published file: {mapped_published_file} -> " + f"{template_filled}" + ) + # src_to_dst_file_paths being wrong else: collections, remainders = clique.assemble(published_files) if remainders or not collections or len(collections) > 1: @@ -302,23 +334,34 @@ class CreateHeroVersion(load.ProductLoaderPlugin): src_col = collections[0] frame_splitter = "_-_FRAME_SPLIT_-_" anatomy_data["frame"] = frame_splitter - _template_filled = path_template_obj.format_strict(anatomy_data) + _template_filled = path_template_obj.format_strict( + anatomy_data + ) head, tail = _template_filled.split(frame_splitter) padding = anatomy.templates_obj.frame_padding - dst_col = clique.Collection(head=head, padding=padding, tail=tail) + dst_col = clique.Collection( + head=head, padding=padding, tail=tail + ) dst_col.indexes.clear() dst_col.indexes.update(src_col.indexes) for src_file, dst_file in zip(src_col, dst_col): + src_file = StringTemplate(src_file).format_strict( + anatomy_root + ) src_to_dst_file_paths.append((src_file, dst_file)) dst_paths.append(dst_file) - print(f"Collection published file: {src_file} -> {dst_file}") + print( + f"Collection published file: {src_file} " + f"-> {dst_file}" + ) repre_integrate_data.append((repre_entity, dst_paths)) # Copy files with ThreadPoolExecutor(max_workers=8) as executor: futures = [ executor.submit(self.copy_file, src_path, dst_path) - for src_path, dst_path in itertools.chain(src_to_dst_file_paths, other_file_paths_mapping) + for src_path, dst_path in itertools.chain( + src_to_dst_file_paths, other_file_paths_mapping) ] wait_for_future_errors(executor, futures) @@ -331,25 +374,49 @@ class CreateHeroVersion(load.ProductLoaderPlugin): old_repre = old_repres_to_replace.pop(repre_name_low) repre_entity["id"] = old_repre["id"] update_data = prepare_changes(old_repre, repre_entity) - op_session.update_entity(project_name, "representation", old_repre["id"], update_data) + op_session.update_entity( + project_name, + "representation", + old_repre["id"], + update_data + ) elif repre_name_low in inactive_old_repres_by_name: - inactive_repre = inactive_old_repres_by_name.pop(repre_name_low) + inactive_repre = inactive_old_repres_by_name.pop( + repre_name_low + ) repre_entity["id"] = inactive_repre["id"] update_data = prepare_changes(inactive_repre, repre_entity) - op_session.update_entity(project_name, "representation", inactive_repre["id"], update_data) + op_session.update_entity( + project_name, + "representation", + inactive_repre["id"], + update_data + ) else: - op_session.create_entity(project_name, "representation", repre_entity) + op_session.create_entity( + project_name, + "representation", + repre_entity + ) for repre in old_repres_to_delete.values(): - op_session.update_entity(project_name, "representation", repre["id"], {"active": False}) + op_session.update_entity( + project_name, + "representation", + repre["id"], + {"active": False} + ) op_session.commit() - if backup_hero_publish_dir is not None and os.path.exists(backup_hero_publish_dir): + if backup_hero_publish_dir is not None and os.path.exists( + backup_hero_publish_dir + ): shutil.rmtree(backup_hero_publish_dir) except Exception: - if backup_hero_publish_dir is not None and os.path.exists(backup_hero_publish_dir): + if backup_hero_publish_dir is not None and os.path.exists( + backup_hero_publish_dir): if os.path.exists(hero_publish_dir): shutil.rmtree(hero_publish_dir) os.rename(backup_hero_publish_dir, hero_publish_dir) @@ -375,8 +442,12 @@ class CreateHeroVersion(load.ProductLoaderPlugin): def get_publish_dir(self, instance_data, anatomy, template_key): template_data = copy.deepcopy(instance_data.get("anatomyData", {})) if "originalBasename" in instance_data: - template_data["originalBasename"] = instance_data["originalBasename"] - template_obj = anatomy.get_template_item("hero", template_key, "directory") + template_data["originalBasename"] = ( + instance_data["originalBasename"] + ) + template_obj = anatomy.get_template_item( + "hero", template_key, "directory" + ) return os.path.normpath(template_obj.format_strict(template_data)) def get_rootless_path(self, anatomy, path): @@ -403,13 +474,21 @@ class CreateHeroVersion(load.ProductLoaderPlugin): def version_from_representations(self, project_name, repres): for repre_info in repres.values(): - version = ayon_api.get_version_by_id(project_name, repre_info["representation"]["versionId"]) + version = ayon_api.get_version_by_id( + project_name, repre_info["representation"]["versionId"] + ) if version: return version def current_hero_ents(self, project_name, version): - hero_version = ayon_api.get_hero_version_by_product_id(project_name, version["productId"]) + hero_version = ayon_api.get_hero_version_by_product_id( + project_name, version["productId"] + ) if not hero_version: return (None, []) - hero_repres = list(ayon_api.get_representations(project_name, version_ids={hero_version["id"]})) + hero_repres = list( + ayon_api.get_representations( + project_name, version_ids={hero_version["id"]} + ) + ) return (hero_version, hero_repres) From 7e9493736d1abbddda6b2efa37302ac03a864fe3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 15 Aug 2025 22:27:22 +0800 Subject: [PATCH 720/781] ruff fix --- .../plugins/load/create_hero_version.py | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index b18e874644..d741dafcce 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -64,7 +64,6 @@ class CreateHeroVersion(load.ProductLoaderPlugin): ) msgBox.exec_() - def load(self, context, name=None, namespace=None, options=None) -> None: """Load hero version from context (dict as in context.py).""" success = True @@ -168,7 +167,9 @@ class CreateHeroVersion(load.ProductLoaderPlugin): for repre_id in filtered_repre_ids: published_repres.pop(repre_id, None) if not published_repres: - raise RuntimeError("All published representations were filtered by name.") + raise RuntimeError( + "All published representations were filtered by name." + ) if src_version_entity is None: src_version_entity = self.version_from_representations( @@ -266,15 +267,18 @@ class CreateHeroVersion(load.ProductLoaderPlugin): shutil.rmtree(_backup_hero_publish_dir) backup_hero_publish_dir = _backup_hero_publish_dir break - except Exception: - _backup_hero_publish_dir = backup_hero_publish_dir + str(idx) + except Exception as exc: + _backup_hero_publish_dir = ( + backup_hero_publish_dir + str(idx) + ) if not os.path.exists(_backup_hero_publish_dir): backup_hero_publish_dir = _backup_hero_publish_dir break if idx > max_idx: raise AssertionError( - f"Backup folders are fully occupied to max index {max_idx}" - ) + "Backup folders are fully occupied to max index " + f"{max_idx}" + ) from exc idx += 1 try: os.rename(hero_publish_dir, backup_hero_publish_dir) @@ -324,13 +328,16 @@ class CreateHeroVersion(load.ProductLoaderPlugin): f"Single published file: {mapped_published_file} -> " f"{template_filled}" ) - # src_to_dst_file_paths being wrong else: collections, remainders = clique.assemble(published_files) if remainders or not collections or len(collections) > 1: raise Exception( - "Integrity error. Files of published representation is " - "combination of frame collections and single files.") + ( + "Integrity error. Files of published " + "representation is combination of frame " + "collections and single files." + ) + ) src_col = collections[0] frame_splitter = "_-_FRAME_SPLIT_-_" anatomy_data["frame"] = frame_splitter From 60558e440c93a167c1c58e8614178ff15e9af23f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 13:16:17 +0200 Subject: [PATCH 721/781] Removed unnecessary field --- client/ayon_core/tools/push_to_project/control.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index eb985a3f8c..b52eeb5fad 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -40,7 +40,6 @@ class PushToContextController: self.set_source(project_name, version_id) - self._library_only = True # Events system def emit_event(self, topic, data=None, source=None): From 86130207186b40667ff436d077712958cecab31f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 13:17:10 +0200 Subject: [PATCH 722/781] Change formatting --- client/ayon_core/tools/push_to_project/ui/window.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 344295f177..b2f3983557 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -99,8 +99,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): header_layout = QtWidgets.QHBoxLayout(header_widget) header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.addWidget(header_label) - header_layout.addStretch(1) + header_layout.addWidget(header_label, 1) header_layout.addWidget(library_only_label, 0) header_layout.addWidget(library_only_checkbox, 0) From e7c0c8dab4fb91b010e587ac5f43694ac2beed50 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 13:19:04 +0200 Subject: [PATCH 723/781] Removed unnecessary refresh --- client/ayon_core/tools/push_to_project/ui/window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index b2f3983557..38c343b023 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -410,6 +410,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._projects_combobox.set_standard_filter_enabled(state) self._projects_combobox.refresh() + def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled folder_name = self._new_folder_name_input_text From 92ecc854c9396e6bef33d9b68619320cc4ecf08e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 13:23:51 +0200 Subject: [PATCH 724/781] Simplified _copy_version_thumbnail logic Used cached get_thumbnail_path --- .../tools/push_to_project/models/integrate.py | 50 ++++++++----------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 341858148b..b180892d62 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -22,6 +22,7 @@ from ayon_core.lib import ( source_hash, ) from ayon_core.lib.file_transaction import FileTransaction +from ayon_core.pipeline.thumbnails import get_thumbnail_path from ayon_core.settings import get_project_settings from ayon_core.pipeline import Anatomy from ayon_core.pipeline.version_start import get_versioning_start @@ -1150,36 +1151,27 @@ class ProjectPushItemProcess: ) def _copy_version_thumbnail(self): - version_thumbnail = ayon_api.get_version_thumbnail( - self._item.src_project_name, self._src_version_entity["id"]) - if not version_thumbnail or not version_thumbnail.id: + thumbnail_id = self._src_version_entity["thumbnailId"] + if not thumbnail_id: return - - temp_file_name = None - try: - with tempfile.NamedTemporaryFile(mode='w+b', delete=False) as fp: - fp.write(version_thumbnail.content) - temp_file_name = fp.name - - new_thumbnail_id = ayon_api.create_thumbnail( - self._item.dst_project_name, - temp_file_name - ) - - task_id = None - if self._task_info: - task_id = self._task_info["id"] - - self._operations.update_version( - project_name=self._item.dst_project_name, - version_id=self._version_entity["id"], - task_id=task_id, - thumbnail_id=new_thumbnail_id - ) - self._operations.commit() - finally: - if temp_file_name and os.path.exists(temp_file_name): - os.remove(temp_file_name) + path = get_thumbnail_path( + self._item.src_project_name, + "version", + self._src_version_entity["id"], + thumbnail_id + ) + if not path: + return + new_thumbnail_id = ayon_api.create_thumbnail( + self._item.dst_project_name, + path + ) + self._operations.update_version( + project_name=self._item.dst_project_name, + version_id=self._version_entity["id"], + thumbnail_id=new_thumbnail_id + ) + self._operations.commit() class IntegrateModel: From 3faee05cf6c05e089d8be8d42708493e7114933b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 13:28:24 +0200 Subject: [PATCH 725/781] Added toggle for keeping original names of publishes --- .../ayon_core/tools/push_to_project/control.py | 10 ++++++---- .../tools/push_to_project/ui/window.py | 18 +++++++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index b52eeb5fad..5e1f758d79 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -40,6 +40,8 @@ class PushToContextController: self.set_source(project_name, version_id) + self._use_original_name = False + # Events system def emit_event(self, topic, data=None, source=None): @@ -315,6 +317,8 @@ class PushToContextController: return product_name def _check_submit_validations(self): + if self._use_original_name: + return True if not self._user_values.is_valid: return False @@ -339,10 +343,8 @@ class PushToContextController: ) def _submit_callback(self): - process_item_id = self._process_item_id - if process_item_id is None: - return - self._integrate_model.integrate_item(process_item_id) + for process_item_id in self._process_item_ids: + self._integrate_model.integrate_item(process_item_id) self._emit_event("submit.finished", {}) if process_item_id == self._process_item_id: self._process_item_id = None diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 38c343b023..1f40958a66 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -133,6 +133,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): inputs_widget = QtWidgets.QWidget(main_splitter) new_folder_checkbox = NiceCheckbox(True, parent=inputs_widget) + original_names_checkbox = NiceCheckbox(False, parent=inputs_widget) folder_name_input = PlaceholderLineEdit(inputs_widget) folder_name_input.setPlaceholderText("< Name of new folder >") @@ -151,6 +152,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget): inputs_layout.addRow("Create new folder", new_folder_checkbox) inputs_layout.addRow("New folder name", folder_name_input) inputs_layout.addRow("Variant", variant_input) + inputs_layout.addRow( + "Use original product names", original_names_checkbox) inputs_layout.addRow("Comment", comment_input) main_splitter.addWidget(context_widget) @@ -250,6 +253,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget): variant_input.textChanged.connect(self._on_variant_change) comment_input.textChanged.connect(self._on_comment_change) library_only_checkbox.stateChanged.connect(self._on_library_only_change) + original_names_checkbox.stateChanged.connect( + self._on_original_names_change) publish_btn.clicked.connect(self._on_select_click) cancel_btn.clicked.connect(self._on_close_click) @@ -408,8 +413,15 @@ class PushToContextSelectWindow(QtWidgets.QWidget): """Change toggle state, reset filter, recalculate dropdown""" state = bool(state) self._projects_combobox.set_standard_filter_enabled(state) - self._projects_combobox.refresh() + def _on_original_names_change(self, state: int) -> None: + use_original_name = bool(state) + self._new_folder_name_enabled = not use_original_name + self._new_folder_checkbox.setEnabled(not use_original_name) + self._folder_name_input.setEnabled(not use_original_name) + self._variant_input.setEnabled(not use_original_name) + self._controller._use_original_name = use_original_name + self.refresh() def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled @@ -466,6 +478,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._header_label.setText(self._controller.get_source_label()) def _invalidate_new_folder_name(self, folder_name, is_valid): + if self._controller._use_original_name: + is_valid = True self._tasks_widget.setVisible(folder_name is None) if self._folder_is_valid is is_valid: return @@ -478,6 +492,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget): ) def _invalidate_variant(self, is_valid): + if self._controller._use_original_name: + is_valid = True if self._variant_is_valid is is_valid: return self._variant_is_valid = is_valid From ef9ab3bcdc106854472608a5a0772888ed128089 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 13:31:31 +0200 Subject: [PATCH 726/781] Changed main input to accept multiple version ids --- client/ayon_core/tools/push_to_project/main.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/main.py b/client/ayon_core/tools/push_to_project/main.py index a6ff38c16f..3a80dc2bb2 100644 --- a/client/ayon_core/tools/push_to_project/main.py +++ b/client/ayon_core/tools/push_to_project/main.py @@ -4,28 +4,28 @@ from ayon_core.tools.utils import get_ayon_qt_app from ayon_core.tools.push_to_project.ui import PushToContextSelectWindow -def main_show(project_name, version_id): +def main_show(project_name, version_ids): app = get_ayon_qt_app() window = PushToContextSelectWindow() window.show() - window.set_source(project_name, version_id) + window.set_source(project_name, version_ids) app.exec_() @click.command() @click.option("--project", help="Source project name") -@click.option("--version", help="Source version id") -def main(project, version): +@click.option("--versions", help="Source version ids") +def main(project, versions): """Run PushToProject tool to integrate version in different project. Args: project (str): Source project name. - version (str): Version id. + versions (str): comma separated versions for same context """ - main_show(project, version) + main_show(project, versions) if __name__ == "__main__": From 965f937e28022e6154e4f2f5ace34e429580a764 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 13:32:15 +0200 Subject: [PATCH 727/781] Implemented loader action to push multiple versions --- client/ayon_core/plugins/load/push_to_library.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/plugins/load/push_to_library.py b/client/ayon_core/plugins/load/push_to_library.py index 22c10bbad7..42a63a8625 100644 --- a/client/ayon_core/plugins/load/push_to_library.py +++ b/client/ayon_core/plugins/load/push_to_library.py @@ -28,25 +28,22 @@ class PushToLibraryProject(load.ProductLoaderPlugin): if not filtered_contexts: raise LoadError("Nothing to push for your selection") - if len(filtered_contexts) > 1: - raise LoadError("Please select only one item") - - context = tuple(filtered_contexts)[0] - push_tool_script_path = os.path.join( AYON_CORE_ROOT, "tools", "push_to_project", "main.py" ) + project_name = tuple(filtered_contexts)[0]["project"]["name"] - project_name = context["project"]["name"] - version_id = context["version"]["id"] + version_ids = [] + for context in filtered_contexts: + version_ids.append(context["version"]["id"]) args = get_ayon_launcher_args( "run", push_tool_script_path, "--project", project_name, - "--version", version_id + "--versions", ",".join(version_ids) ) run_detached_process(args) From 073f8bfec58f4ab97d04ee74e4f88b20857e87ec Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 13:35:05 +0200 Subject: [PATCH 728/781] Implemented processing of multiple items --- .../tools/push_to_project/control.py | 121 ++++++++++-------- .../tools/push_to_project/ui/window.py | 46 ++++--- 2 files changed, 100 insertions(+), 67 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 5e1f758d79..88031d2a8a 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -27,11 +27,11 @@ class PushToContextController: self._user_values = UserPublishValuesModel(self) self._src_project_name = None - self._src_version_id = None + self._src_version_ids = [] self._src_folder_entity = None self._src_folder_task_entities = {} - self._src_product_entity = None - self._src_version_entity = None + self._src_product_entities = [] + self._src_version_entities = [] self._src_label = None self._submission_enabled = False @@ -54,38 +54,43 @@ class PushToContextController: def register_event_callback(self, topic, callback): self._event_system.add_callback(topic, callback) - def set_source(self, project_name, version_id): + def set_source(self, project_name, version_ids): """Set source project and version. Args: project_name (Union[str, None]): Source project name. - version_id (Union[str, None]): Source version id. + version_id (Union[str, None]): Comma separated source version ids. """ if ( project_name == self._src_project_name - and version_id == self._src_version_id + and version_ids == self._src_version_ids ): return self._src_project_name = project_name - self._src_version_id = version_id + self._src_version_ids = version_ids.split(",") self._src_label = None folder_entity = None task_entities = {} - product_entity = None - version_entity = None - if project_name and version_id: - version_entity = ayon_api.get_version_by_id( - project_name, version_id + product_entities = None + version_entities = None + if project_name and self._src_version_ids: + version_entities = list(ayon_api.get_versions( + project_name, version_ids=self._src_version_ids)) + + if version_entities: + product_ids = [ + version_entity["productId"] + for version_entity in version_entities + ] + product_entities = list(ayon_api.get_products( + project_name, product_ids=product_ids) ) - if version_entity: - product_entity = ayon_api.get_product_by_id( - project_name, version_entity["productId"] - ) - - if product_entity: + if product_entities: + # all products for same folder + product_entity = product_entities[0] folder_entity = ayon_api.get_folder_by_id( project_name, product_entity["folderId"] ) @@ -100,15 +105,15 @@ class PushToContextController: self._src_folder_entity = folder_entity self._src_folder_task_entities = task_entities - self._src_product_entity = product_entity - self._src_version_entity = version_entity - if folder_entity: + self._src_product_entities = product_entities + self._src_version_entities = version_entities + if folder_entity and len(list(version_entities)) == 1: self._user_values.set_new_folder_name(folder_entity["name"]) variant = self._get_src_variant() if variant: self._user_values.set_variant(variant) - comment = version_entity["attrib"].get("comment") + comment = version_entities[0]["attrib"].get("comment") if comment: self._user_values.set_comment(comment) @@ -116,7 +121,7 @@ class PushToContextController: "source.changed", { "project_name": project_name, - "version_id": version_id + "version_ids": self._src_version_ids } ) @@ -179,29 +184,32 @@ class PushToContextController: if self._process_thread is not None: return - item_id = self._integrate_model.create_process_item( - self._src_project_name, - self._src_version_id, - self._selection_model.get_selected_project_name(), - self._selection_model.get_selected_folder_id(), - self._selection_model.get_selected_task_name(), - self._user_values.variant, - comment=self._user_values.comment, - new_folder_name=self._user_values.new_folder_name, - dst_version=1 - ) + item_ids = [] + for src_version_entity in self._src_version_entities: + item_id = self._integrate_model.create_process_item( + self._src_project_name, + src_version_entity["id"], + self._selection_model.get_selected_project_name(), + self._selection_model.get_selected_folder_id(), + self._selection_model.get_selected_task_name(), + self._user_values.variant, + comment=self._user_values.comment, + new_folder_name=self._user_values.new_folder_name, + dst_version=1, + ) + item_ids.append(item_id) - self._process_item_id = item_id + self._process_item_ids = item_ids self._emit_event("submit.started") if wait: self._submit_callback() - self._process_item_id = None + self._process_item_ids = [] return item_id thread = threading.Thread(target=self._submit_callback) self._process_thread = thread thread.start() - return item_id + return item_ids def wait_for_process_thread(self): if self._process_thread is None: @@ -210,22 +218,34 @@ class PushToContextController: self._process_thread = None def _prepare_source_label(self): - if not self._src_project_name or not self._src_version_id: + if not self._src_project_name or not self._src_version_ids: return "Source is not defined" folder_entity = self._src_folder_entity if not folder_entity: return "Source is invalid" + no_of_products = len(self._src_product_entities) + no_of_versions = len(self._src_version_entities) + if no_of_products != no_of_versions: + return (f"Not matching number of products {no_of_products} and " + f"versions {no_of_versions}") + folder_path = folder_entity["path"] - product_entity = self._src_product_entity - version_entity = self._src_version_entity - return "Source: {}{}/{}/v{:0>3}".format( - self._src_project_name, - folder_path, - product_entity["name"], - version_entity["version"] - ) + src_labels = [] + for idx in range(0, no_of_versions): + product_entity = self._src_product_entities[idx] + version_entity = self._src_version_entities[idx] + src_labels.append( + "Source: {}{}/{}/v{:0>3}".format( + self._src_project_name, + folder_path, + product_entity["name"], + version_entity["version"], + ) + ) + + return "\n".join(src_labels) def _get_task_info_from_repre_entities( self, task_entities, repre_entities @@ -258,8 +278,9 @@ class PushToContextController: return None, None def _get_src_variant(self): + """Could be triggered only if single version is moved.""" project_name = self._src_project_name - version_entity = self._src_version_entity + version_entity = self._src_version_entities[0] task_entities = self._src_folder_task_entities repre_entities = ayon_api.get_representations( project_name, version_ids={version_entity["id"]} @@ -269,7 +290,7 @@ class PushToContextController: ) project_settings = get_project_settings(project_name) - product_type = self._src_product_entity["productType"] + product_type = self._src_product_entities[0]["productType"] template = get_product_name_template( self._src_project_name, product_type, @@ -303,7 +324,7 @@ class PushToContextController: print("Failed format", exc) return "" - product_name = self._src_product_entity["name"] + product_name = self._src_product_entities[0]["name"] if ( (product_s and not product_name.startswith(product_s)) or (product_e and not product_name.endswith(product_e)) @@ -346,8 +367,6 @@ class PushToContextController: for process_item_id in self._process_item_ids: self._integrate_model.integrate_item(process_item_id) self._emit_event("submit.finished", {}) - if process_item_id == self._process_item_id: - self._process_item_id = None def _emit_event(self, topic, data=None): if data is None: diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 1f40958a66..147191e659 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -331,7 +331,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._main_thread_timer = main_thread_timer self._main_thread_timer_can_stop = True self._last_submit_message = None - self._process_item_id = None + self._process_item_ids = [] self._variant_is_valid = None self._folder_is_valid = None @@ -342,17 +342,17 @@ class PushToContextSelectWindow(QtWidgets.QWidget): overlay_try_btn.setVisible(False) # Support of public api function of controller - def set_source(self, project_name, version_id): + def set_source(self, project_name, version_ids): """Set source project and version. Call the method on controller. Args: project_name (Union[str, None]): Name of project. - version_id (Union[str, None]): Version id. + version_id (Union[str, None]): Version ids. """ - self._controller.set_source(project_name, version_id) + self._controller.set_source(project_name, version_ids) def showEvent(self, event): super(PushToContextSelectWindow, self).showEvent(event) @@ -528,31 +528,45 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._overlay_label.setText(self._last_submit_message) self._last_submit_message = None - process_status = self._controller.get_process_item_status( - self._process_item_id - ) - push_failed = process_status["failed"] - fail_traceback = process_status["full_traceback"] + failed_pushes = [] + fail_tracebacks = [] + for process_item_id in self._process_item_ids: + process_status = self._controller.get_process_item_status( + process_item_id + ) + if process_status["failed"]: + failed_pushes.append(process_status) + # push_failed = process_status["failed"] + # fail_traceback = process_status["full_traceback"] if self._main_thread_timer_can_stop: self._main_thread_timer.stop() self._overlay_close_btn.setVisible(True) - if push_failed: + if failed_pushes: self._overlay_try_btn.setVisible(True) - if fail_traceback: + fail_tracebacks = [ + process_status["full_traceback"] + for process_status in failed_pushes + if process_status["full_traceback"] + ] + if fail_tracebacks: self._show_detail_btn.setVisible(True) - if push_failed: - reason = process_status["fail_reason"] - if fail_traceback: + if failed_pushes: + reasons = [ + process_status["fail_reason"] + for process_status in failed_pushes + ] + if fail_tracebacks: + reason = "\n".join(reasons) message = ( "Unhandled error happened." " Check error detail for more information." ) self._error_detail_dialog.set_detail( - reason, fail_traceback + reason, "\n".join(fail_tracebacks) ) else: - message = f"Push Failed:\n{reason}" + message = f"Push Failed:\n{reasons}" self._overlay_label.setText(message) set_style_property(self._overlay_close_btn, "state", "error") From c7c28e1153777d12f4c69b3f76095fcd2bb667df Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 13:37:03 +0200 Subject: [PATCH 729/781] Pushed use_original_name to ProjectPushItem --- .../tools/push_to_project/control.py | 1 + .../tools/push_to_project/models/integrate.py | 66 +++++++++++-------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 88031d2a8a..483efdd22d 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -196,6 +196,7 @@ class PushToContextController: comment=self._user_values.comment, new_folder_name=self._user_values.new_folder_name, dst_version=1, + use_original_name=self._use_original_name, ) item_ids.append(item_id) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index b180892d62..c66c74219c 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -90,6 +90,7 @@ class ProjectPushItem: new_folder_name, dst_version, item_id=None, + use_original_name=False ): if not item_id: item_id = uuid.uuid4().hex @@ -104,6 +105,7 @@ class ProjectPushItem: self.comment = comment or "" self.item_id = item_id self._repr_value = None + self.use_original_name = use_original_name @property def _repr(self): @@ -115,7 +117,8 @@ class ProjectPushItem: str(self.dst_folder_id), str(self.new_folder_name), str(self.dst_task_name), - str(self.dst_version) + str(self.dst_version), + self.use_original_name ]) return self._repr_value @@ -134,6 +137,7 @@ class ProjectPushItem: "comment": self.comment, "new_folder_name": self.new_folder_name, "item_id": self.item_id, + "use_original_name": self.use_original_name } @classmethod @@ -373,7 +377,7 @@ class ProjectPushRepreItem: resource_files.append(ResourceFile(filepath, relative_path)) continue - filepath = os.path.join(src_dirpath, basename) + # filepath = os.path.join(src_dirpath, basename) frame = None udim = None for item in src_basename_regex.finditer(basename): @@ -819,31 +823,34 @@ class ProjectPushItemProcess: self._template_name = template_name def _determine_product_name(self): - product_type = self._product_type - task_info = self._task_info - task_name = task_type = None - if task_info: - task_name = task_info["name"] - task_type = task_info["taskType"] + if self._item.use_original_name: + product_name = self._src_product_entity["name"] + else: + product_type = self._product_type + task_info = self._task_info + task_name = task_type = None + if task_info: + task_name = task_info["name"] + task_type = task_info["taskType"] - try: - product_name = get_product_name( - self._item.dst_project_name, - task_name, - task_type, - self.host_name, - product_type, - self._item.variant, - project_settings=self._project_settings - ) - except TaskNotSetError: - self._status.set_failed( - "Target product name template requires task name. To continue" - " you have to select target task or change settings" - " ayon+settings://core/tools/creator/product_name_profiles" - f"?project={self._item.dst_project_name}." - ) - raise PushToProjectError(self._status.fail_reason) + try: + product_name = get_product_name( + self._item.dst_project_name, + task_name, + task_type, + self.host_name, + product_type, + self._item.variant, + project_settings=self._project_settings + ) + except TaskNotSetError: + self._status.set_failed( + "Target product name template requires task name. To " + "continue you have to select target task or change settings " + " ayon+settings://core/tools/creator/product_name_profiles" + f"?project={self._item.dst_project_name}." + ) + raise PushToProjectError(self._status.fail_reason) self._log_info( f"Push will be integrating to product with name '{product_name}'" @@ -1137,7 +1144,7 @@ class ProjectPushItemProcess: self._item.dst_project_name, "representation", entity_id, - changes + changes, ) existing_repre_names = set(existing_repres_by_low_name.keys()) @@ -1196,6 +1203,7 @@ class IntegrateModel: comment, new_folder_name, dst_version, + use_original_name ): """Create new item for integration. @@ -1209,6 +1217,7 @@ class IntegrateModel: comment (Union[str, None]): Comment. new_folder_name (Union[str, None]): New folder name. dst_version (int): Destination version number. + use_original_name (bool): If original product names should be used Returns: str: Item id. The id can be used to trigger integration or get @@ -1224,7 +1233,8 @@ class IntegrateModel: variant, comment=comment, new_folder_name=new_folder_name, - dst_version=dst_version + dst_version=dst_version, + use_original_name=use_original_name ) process_item = ProjectPushItemProcess(self, item) self._process_items[item.item_id] = process_item From 4a44570799f6d5a020a72b6cef60446163782600 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 13:37:36 +0200 Subject: [PATCH 730/781] Invalidate all input fields after refresh --- client/ayon_core/tools/push_to_project/ui/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 147191e659..d07488e719 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -370,7 +370,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._invalidate_new_folder_name( new_folder_name, user_values["is_new_folder_name_valid"] ) - + self._controller._invalidate() self._projects_combobox.refresh() def _on_first_show(self): From 6c6be3508f5292324123fad320d78d98644b7de9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 13:53:14 +0200 Subject: [PATCH 731/781] Propagate taskId to limit json parse issue --- client/ayon_core/tools/push_to_project/models/integrate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index b180892d62..0c654a5495 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1169,6 +1169,7 @@ class ProjectPushItemProcess: self._operations.update_version( project_name=self._item.dst_project_name, version_id=self._version_entity["id"], + task_id=self._version_entity.get("taskId"), thumbnail_id=new_thumbnail_id ) self._operations.commit() From 7860c7d875c539f52bfdd78c590ebc77a80c5af6 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 13:55:49 +0200 Subject: [PATCH 732/781] Removed unnecessary refresh --- client/ayon_core/tools/push_to_project/ui/window.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 38c343b023..495ef83ce6 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -408,8 +408,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): """Change toggle state, reset filter, recalculate dropdown""" state = bool(state) self._projects_combobox.set_standard_filter_enabled(state) - self._projects_combobox.refresh() - def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled From d79bd055bcf771553076016b5cfd02e9ad4c5468 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 14:03:08 +0200 Subject: [PATCH 733/781] Removed unnecessary import --- client/ayon_core/tools/push_to_project/models/integrate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 0c654a5495..ac2f506112 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -3,7 +3,6 @@ import re import copy import itertools import sys -import tempfile import traceback import uuid From 930439ba12990611d8b01a7114fc4cdd5a77dab2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 14:26:28 +0200 Subject: [PATCH 734/781] Fix copy of frames based representations --- client/ayon_core/tools/push_to_project/models/integrate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index ac2f506112..40c418e513 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -372,7 +372,6 @@ class ProjectPushRepreItem: resource_files.append(ResourceFile(filepath, relative_path)) continue - filepath = os.path.join(src_dirpath, basename) frame = None udim = None for item in src_basename_regex.finditer(basename): From 18ad64e2260124407cef7d01d37e5ceba0527f20 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 14:43:29 +0200 Subject: [PATCH 735/781] Updated _copy_version_thumbnail logic --- .../tools/push_to_project/models/integrate.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 40c418e513..08fafcbf2d 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -484,7 +484,6 @@ class ProjectPushItemProcess: self._make_sure_version_exists() self._log_info("Prerequirements were prepared") self._integrate_representations() - self._copy_version_thumbnail() self._log_info("Integration finished") except PushToProjectError as exc: @@ -918,14 +917,19 @@ class ProjectPushItemProcess: task_name=self._task_info["name"], task_type=self._task_info["taskType"], product_type=product_type, - product_name=product_entity["name"] + product_name=product_entity["name"], ) existing_version_entity = ayon_api.get_version_by_name( project_name, version, product_id ) + thumbnail_id = self._copy_version_thumbnail() + # Update existing version if existing_version_entity: + updata_data = {"attrib": dst_attrib} + if thumbnail_id: + updata_data["thumbnailId"] = thumbnail_id self._operations.update_entity( project_name, "version", @@ -940,6 +944,7 @@ class ProjectPushItemProcess: version, product_id, attribs=dst_attrib, + thumbnail_id=thumbnail_id, ) self._operations.create_entity( project_name, "version", version_entity @@ -1160,17 +1165,10 @@ class ProjectPushItemProcess: ) if not path: return - new_thumbnail_id = ayon_api.create_thumbnail( + return ayon_api.create_thumbnail( self._item.dst_project_name, path ) - self._operations.update_version( - project_name=self._item.dst_project_name, - version_id=self._version_entity["id"], - task_id=self._version_entity.get("taskId"), - thumbnail_id=new_thumbnail_id - ) - self._operations.commit() class IntegrateModel: From c2b6204c0a1c10b463e9810009015b3e326df8ba Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 15:09:17 +0200 Subject: [PATCH 736/781] Formatting change --- client/ayon_core/tools/push_to_project/control.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index b52eeb5fad..fb080d158b 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -40,7 +40,6 @@ class PushToContextController: self.set_source(project_name, version_id) - # Events system def emit_event(self, topic, data=None, source=None): """Use implemented event system to trigger event.""" From 8586431f17ac6503f604e7c1cf9be440f6483e6b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 15:34:54 +0200 Subject: [PATCH 737/781] Fix version id argument --- client/ayon_core/tools/push_to_project/control.py | 9 +++++---- client/ayon_core/tools/push_to_project/ui/window.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 1ccda9440d..b90e938cf3 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -16,7 +16,7 @@ from .models import ( class PushToContextController: - def __init__(self, project_name=None, version_id=None): + def __init__(self, project_name=None, version_ids=None): self._event_system = self._create_event_system() self._projects_model = ProjectsModel(self) @@ -38,7 +38,7 @@ class PushToContextController: self._process_thread = None self._process_item_id = None - self.set_source(project_name, version_id) + self.set_source(project_name, version_ids) self._use_original_name = False @@ -58,9 +58,10 @@ class PushToContextController: Args: project_name (Union[str, None]): Source project name. - version_id (Union[str, None]): Comma separated source version ids. + version_ids (Union[str, None]): Comma separated source version ids. """ - + if not project_name or not version_ids: + return if ( project_name == self._src_project_name and version_ids == self._src_version_ids diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index d07488e719..3fb1822d92 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -349,7 +349,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): Args: project_name (Union[str, None]): Name of project. - version_id (Union[str, None]): Version ids. + version_ids (Union[str, None]): comma separated Version ids. """ self._controller.set_source(project_name, version_ids) From cb09825b8b36802b443d1ad5a06ac250361ca004 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 16:33:57 +0200 Subject: [PATCH 738/781] Removed set_source in init --- client/ayon_core/tools/push_to_project/control.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index b90e938cf3..d02cd4dfc0 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -38,8 +38,6 @@ class PushToContextController: self._process_thread = None self._process_item_id = None - self.set_source(project_name, version_ids) - self._use_original_name = False # Events system From 629794f6d64d4569fa03f48b13e5d2013697ff4e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 16:41:08 +0200 Subject: [PATCH 739/781] Return formatting change Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 08fafcbf2d..73a00a5cd9 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1156,7 +1156,7 @@ class ProjectPushItemProcess: def _copy_version_thumbnail(self): thumbnail_id = self._src_version_entity["thumbnailId"] if not thumbnail_id: - return + return None path = get_thumbnail_path( self._item.src_project_name, "version", From 78cd71138337f54fef8544a34625cf5d06b9e53b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 16:41:18 +0200 Subject: [PATCH 740/781] Return formatting change Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 73a00a5cd9..197cefe819 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1164,7 +1164,7 @@ class ProjectPushItemProcess: thumbnail_id ) if not path: - return + return None return ayon_api.create_thumbnail( self._item.dst_project_name, path From 8d4bcd1310c5c71e74785fbb11a421f8a8c45e72 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 16:41:31 +0200 Subject: [PATCH 741/781] Typing Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 197cefe819..c512d3ef68 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1153,7 +1153,7 @@ class ProjectPushItemProcess: {"active": False} ) - def _copy_version_thumbnail(self): + def _copy_version_thumbnail(self) -> Optional[str]: thumbnail_id = self._src_version_entity["thumbnailId"] if not thumbnail_id: return None From 3b9b5e8063d3c44615ffa8cda43cd2fcd279cc98 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 16:50:55 +0200 Subject: [PATCH 742/781] Removed run argument to not filter out project argument Current develop filters out 'project' cli argument as it is now used as key world for bundle per project implementation. --- client/ayon_core/plugins/load/push_to_library.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/load/push_to_library.py b/client/ayon_core/plugins/load/push_to_library.py index 22c10bbad7..3c7c7e503d 100644 --- a/client/ayon_core/plugins/load/push_to_library.py +++ b/client/ayon_core/plugins/load/push_to_library.py @@ -44,7 +44,6 @@ class PushToLibraryProject(load.ProductLoaderPlugin): version_id = context["version"]["id"] args = get_ayon_launcher_args( - "run", push_tool_script_path, "--project", project_name, "--version", version_id From ef34e9f79eebe12f3f1fdf845e1963a8dd83cd0b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 16:53:33 +0200 Subject: [PATCH 743/781] Renamed loader --- .../plugins/load/{push_to_library.py => push_to_project.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename client/ayon_core/plugins/load/{push_to_library.py => push_to_project.py} (91%) diff --git a/client/ayon_core/plugins/load/push_to_library.py b/client/ayon_core/plugins/load/push_to_project.py similarity index 91% rename from client/ayon_core/plugins/load/push_to_library.py rename to client/ayon_core/plugins/load/push_to_project.py index 3c7c7e503d..dccac42444 100644 --- a/client/ayon_core/plugins/load/push_to_library.py +++ b/client/ayon_core/plugins/load/push_to_project.py @@ -6,8 +6,8 @@ from ayon_core.pipeline import load from ayon_core.pipeline.load import LoadError -class PushToLibraryProject(load.ProductLoaderPlugin): - """Export selected versions to folder structure from Template""" +class PushToProject(load.ProductLoaderPlugin): + """Export selected versions to different project""" is_multiple_contexts_compatible = True From bae1f64a91d8b4ec74bdb059048d42b96b4e346e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 16:55:28 +0200 Subject: [PATCH 744/781] Fix typing --- client/ayon_core/tools/push_to_project/models/integrate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index c512d3ef68..89cd78cb0e 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -5,6 +5,7 @@ import itertools import sys import traceback import uuid +from typing import Optional import ayon_api from ayon_api.utils import create_entity_id From 12f415a639781b16c299e4b16edf128e9381e1a7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 18 Aug 2025 17:01:42 +0200 Subject: [PATCH 745/781] Added tooltip --- client/ayon_core/tools/push_to_project/ui/window.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 3fb1822d92..b58904a31a 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -208,6 +208,10 @@ class PushToContextSelectWindow(QtWidgets.QWidget): show_detail_btn.setToolTip( "Show error detail dialog to copy full error." ) + original_names_checkbox.setToolTip( + "Required for multi copy, doesn't allow changes in folder or " + "variant values." + ) overlay_close_btn = QtWidgets.QPushButton( "Close", overlay_btns_widget From e5bf5d3070ea27d8b4f7755cf64f5d0967a500b3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 20 Aug 2025 10:13:21 +0200 Subject: [PATCH 746/781] Split versions directly in main Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/main.py b/client/ayon_core/tools/push_to_project/main.py index 3a80dc2bb2..d3c9d3a537 100644 --- a/client/ayon_core/tools/push_to_project/main.py +++ b/client/ayon_core/tools/push_to_project/main.py @@ -25,7 +25,7 @@ def main(project, versions): versions (str): comma separated versions for same context """ - main_show(project, versions) + main_show(project, versions.split(",")) if __name__ == "__main__": From 26ab3671039ea6ea68198288a384d514058c6426 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 20 Aug 2025 10:13:43 +0200 Subject: [PATCH 747/781] Keep set_source Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/control.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index d02cd4dfc0..42f45ae500 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -39,6 +39,8 @@ class PushToContextController: self._process_item_id = None self._use_original_name = False + + self.set_source(project_name, version_ids) # Events system def emit_event(self, topic, data=None, source=None): From cd7b6212ccbf2fc48f2bfdbbeafd160d34e1d288 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 20 Aug 2025 10:13:59 +0200 Subject: [PATCH 748/781] Update docstrign Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/control.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 42f45ae500..c1c5a1bd37 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -58,7 +58,7 @@ class PushToContextController: Args: project_name (Union[str, None]): Source project name. - version_ids (Union[str, None]): Comma separated source version ids. + version_ids (Optional[list[str]]): Version ids. """ if not project_name or not version_ids: return From 015e7c11a500957392c0bb4e0c0adce9421a48fe Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 20 Aug 2025 10:14:17 +0200 Subject: [PATCH 749/781] Split done before Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/control.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index c1c5a1bd37..cbcfb75157 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -69,7 +69,7 @@ class PushToContextController: return self._src_project_name = project_name - self._src_version_ids = version_ids.split(",") + self._src_version_ids = version_ids self._src_label = None folder_entity = None task_entities = {} From 2bea321e9b34c2a48ce94e9912c1a4ecb38eeaad Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 20 Aug 2025 10:14:39 +0200 Subject: [PATCH 750/781] Update initializations Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/control.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index cbcfb75157..d28cb17c98 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -73,8 +73,8 @@ class PushToContextController: self._src_label = None folder_entity = None task_entities = {} - product_entities = None - version_entities = None + product_entities = [] + version_entities = [] if project_name and self._src_version_ids: version_entities = list(ayon_api.get_versions( project_name, version_ids=self._src_version_ids)) From 0574fa46b8f37d3f04823125e8c503617ad50695 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 20 Aug 2025 10:27:18 +0200 Subject: [PATCH 751/781] Fix formatting --- client/ayon_core/tools/push_to_project/control.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index d28cb17c98..9d5a1cb90c 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -39,7 +39,7 @@ class PushToContextController: self._process_item_id = None self._use_original_name = False - + self.set_source(project_name, version_ids) # Events system From e4305cc37a095511f89b87c791136b9c212a5168 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 20 Aug 2025 10:30:23 +0200 Subject: [PATCH 752/781] Removed product_entities Used only on 2 places --- .../tools/push_to_project/control.py | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 9d5a1cb90c..666a9a94a2 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -30,7 +30,6 @@ class PushToContextController: self._src_version_ids = [] self._src_folder_entity = None self._src_folder_task_entities = {} - self._src_product_entities = [] self._src_version_entities = [] self._src_label = None @@ -105,7 +104,6 @@ class PushToContextController: self._src_folder_entity = folder_entity self._src_folder_task_entities = task_entities - self._src_product_entities = product_entities self._src_version_entities = version_entities if folder_entity and len(list(version_entities)) == 1: self._user_values.set_new_folder_name(folder_entity["name"]) @@ -226,17 +224,13 @@ class PushToContextController: if not folder_entity: return "Source is invalid" - no_of_products = len(self._src_product_entities) - no_of_versions = len(self._src_version_entities) - if no_of_products != no_of_versions: - return (f"Not matching number of products {no_of_products} and " - f"versions {no_of_versions}") - folder_path = folder_entity["path"] src_labels = [] - for idx in range(0, no_of_versions): - product_entity = self._src_product_entities[idx] - version_entity = self._src_version_entities[idx] + for version_entity in self._src_version_entities: + product_entity = ayon_api.get_product_by_id( + self._src_project_name, + version_entity["productId"] + ) src_labels.append( "Source: {}{}/{}/v{:0>3}".format( self._src_project_name, @@ -289,9 +283,13 @@ class PushToContextController: task_name, task_type = self._get_task_info_from_repre_entities( task_entities, repre_entities ) + product_entity = ayon_api.get_product_by_id( + project_name, + version_entity["productId"] + ) project_settings = get_project_settings(project_name) - product_type = self._src_product_entities[0]["productType"] + product_type = product_entity["productType"] template = get_product_name_template( self._src_project_name, product_type, @@ -325,7 +323,7 @@ class PushToContextController: print("Failed format", exc) return "" - product_name = self._src_product_entities[0]["name"] + product_name = product_entity["name"] if ( (product_s and not product_name.startswith(product_s)) or (product_e and not product_name.endswith(product_e)) From cbc227ae2df9920339fc8a423c33f4eb69910ccc Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 20 Aug 2025 10:42:51 +0200 Subject: [PATCH 753/781] Parse variant and folder name even for multi push --- client/ayon_core/tools/push_to_project/control.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 666a9a94a2..c661c05d5d 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -105,7 +105,7 @@ class PushToContextController: self._src_folder_entity = folder_entity self._src_folder_task_entities = task_entities self._src_version_entities = version_entities - if folder_entity and len(list(version_entities)) == 1: + if folder_entity: self._user_values.set_new_folder_name(folder_entity["name"]) variant = self._get_src_variant() if variant: @@ -273,8 +273,8 @@ class PushToContextController: return None, None def _get_src_variant(self): - """Could be triggered only if single version is moved.""" project_name = self._src_project_name + # parse variant only from first version version_entity = self._src_version_entities[0] task_entities = self._src_folder_task_entities repre_entities = ayon_api.get_representations( From 1e9d6997731e7bf0ec72b9a855d93f316c763a04 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 20 Aug 2025 10:50:26 +0200 Subject: [PATCH 754/781] Allow folder create even if Use original name --- client/ayon_core/tools/push_to_project/ui/window.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index b58904a31a..d63b2582e4 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -209,7 +209,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): "Show error detail dialog to copy full error." ) original_names_checkbox.setToolTip( - "Required for multi copy, doesn't allow changes in folder or " + "Required for multi copy, doesn't allow changes " "variant values." ) @@ -420,9 +420,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): def _on_original_names_change(self, state: int) -> None: use_original_name = bool(state) - self._new_folder_name_enabled = not use_original_name - self._new_folder_checkbox.setEnabled(not use_original_name) - self._folder_name_input.setEnabled(not use_original_name) self._variant_input.setEnabled(not use_original_name) self._controller._use_original_name = use_original_name self.refresh() @@ -482,8 +479,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._header_label.setText(self._controller.get_source_label()) def _invalidate_new_folder_name(self, folder_name, is_valid): - if self._controller._use_original_name: - is_valid = True self._tasks_widget.setVisible(folder_name is None) if self._folder_is_valid is is_valid: return From 13e1cc71030323e6afb1040adcb172e6aae70ede Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 11:33:51 +0200 Subject: [PATCH 755/781] Fix project_name Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/plugins/load/push_to_project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/load/push_to_project.py b/client/ayon_core/plugins/load/push_to_project.py index aff3efd6f6..6d641f2a57 100644 --- a/client/ayon_core/plugins/load/push_to_project.py +++ b/client/ayon_core/plugins/load/push_to_project.py @@ -34,7 +34,7 @@ class PushToProject(load.ProductLoaderPlugin): "push_to_project", "main.py" ) - project_name = tuple(filtered_contexts)[0]["project"]["name"] + project_name = filtered_contexts[0]["project"]["name"] version_ids = [] for context in filtered_contexts: From 60e6d4df2f3ba0d819cb867f05ed23efdecdd77c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 11:34:19 +0200 Subject: [PATCH 756/781] Update logic for versions Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/plugins/load/push_to_project.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/load/push_to_project.py b/client/ayon_core/plugins/load/push_to_project.py index 6d641f2a57..d5dd8960a3 100644 --- a/client/ayon_core/plugins/load/push_to_project.py +++ b/client/ayon_core/plugins/load/push_to_project.py @@ -36,9 +36,10 @@ class PushToProject(load.ProductLoaderPlugin): ) project_name = filtered_contexts[0]["project"]["name"] - version_ids = [] - for context in filtered_contexts: - version_ids.append(context["version"]["id"]) + version_ids = { + context["version"]["id"] + for context in filtered_contexts + } args = get_ayon_launcher_args( push_tool_script_path, From bab05592bc242d7ca1f4a1913e19342b8af646f9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 11:35:13 +0200 Subject: [PATCH 757/781] Update when even is emitted Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/control.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index c661c05d5d..6247fe14ce 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -363,10 +363,15 @@ class PushToContextController: ) def _submit_callback(self): - for process_item_id in self._process_item_ids: + process_item_ids = self._process_item_ids + for process_item_id in process_item_ids: self._integrate_model.integrate_item(process_item_id) + self._emit_event("submit.finished", {}) + if process_item_ids is self._process_item_ids: + self._process_item_ids = [] + def _emit_event(self, topic, data=None): if data is None: data = {} From 641d7879820c4b07f4be49ba5303582b19188d99 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 11:36:40 +0200 Subject: [PATCH 758/781] Reordered input fields validations --- client/ayon_core/tools/push_to_project/control.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 6247fe14ce..ea01165859 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -337,11 +337,6 @@ class PushToContextController: return product_name def _check_submit_validations(self): - if self._use_original_name: - return True - if not self._user_values.is_valid: - return False - if not self._selection_model.get_selected_project_name(): return False @@ -350,6 +345,13 @@ class PushToContextController: and not self._selection_model.get_selected_folder_id() ): return False + + if self._use_original_name: + return True + + if not self._user_values.is_valid: + return False + return True def _invalidate(self): From 0933e882e200f09cb194dc847df8e55247056c1c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 13:45:21 +0200 Subject: [PATCH 759/781] Fix wrong repre["context"] content Contained only values used in resolving template. Missed project["name"] etc. --- .../tools/push_to_project/models/integrate.py | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index f9d524ba3a..c888adf733 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1019,10 +1019,18 @@ class ProjectPushItemProcess: self, anatomy, template_name, formatting_data, file_template ): processed_repre_items = [] + repre_context = None for repre_item in self._src_repre_items: repre_entity = repre_item.repre_entity repre_name = repre_entity["name"] repre_format_data = copy.deepcopy(formatting_data) + + if not repre_context: + repre_context = self._update_repre_context( + repre_entity, + formatting_data + ) + repre_format_data["representation"] = repre_name for src_file in repre_item.src_files: ext = os.path.splitext(src_file.path)[-1] @@ -1038,7 +1046,6 @@ class ProjectPushItemProcess: "publish", template_name, "directory" ) folder_path = template_obj.format_strict(formatting_data) - repre_context = folder_path.used_values folder_path_rootless = folder_path.rootless repre_filepaths = [] published_path = None @@ -1061,7 +1068,6 @@ class ProjectPushItemProcess: ) if published_path is None or frame == repre_item.frame: published_path = dst_filepath - repre_context.update(filename.used_values) repre_filepaths.append((dst_filepath, dst_rootless_path)) self._file_transaction.add(src_file.path, dst_filepath) @@ -1178,6 +1184,28 @@ class ProjectPushItemProcess: path ) + def _update_repre_context(self, repre_entity, formatting_data): + """Replace old context value with new ones. + + Folder might change, project definitely changes etc. + """ + repre_context = repre_entity["context"] + for context_key, context_value in repre_context.items(): + if context_value and isinstance(context_value, dict): + for context_sub_key in context_value.keys(): + value_to_update = formatting_data.get(context_key, {}).get( + context_sub_key) + if value_to_update: + repre_context[context_key][ + context_sub_key] = value_to_update + else: + value_to_update = formatting_data.get(context_key) + if value_to_update: + repre_context[context_key] = value_to_update + if "task" not in formatting_data: + repre_context.pop("task") + return repre_context + class IntegrateModel: def __init__(self, controller): From b032d26ec6d417c0d31bfe74c6cb65bc60b50f21 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 13:57:16 +0200 Subject: [PATCH 760/781] Formatting change --- client/ayon_core/tools/push_to_project/control.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index ea01165859..58d06dd19d 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -368,7 +368,7 @@ class PushToContextController: process_item_ids = self._process_item_ids for process_item_id in process_item_ids: self._integrate_model.integrate_item(process_item_id) - + self._emit_event("submit.finished", {}) if process_item_ids is self._process_item_ids: From 3f941a1dff82cce941a7c1f4ef6523441daf43d1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 15:08:09 +0200 Subject: [PATCH 761/781] Fix where to pull process_item_ids --- client/ayon_core/tools/push_to_project/ui/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index d63b2582e4..4d947103be 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -529,7 +529,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): failed_pushes = [] fail_tracebacks = [] - for process_item_id in self._process_item_ids: + for process_item_id in self._controller._process_item_ids: process_status = self._controller.get_process_item_status( process_item_id ) From c7d0f2b9871c59f334ce062d57c0d73af0256675 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 15:08:44 +0200 Subject: [PATCH 762/781] Add more logging to exception handling --- client/ayon_core/tools/push_to_project/models/integrate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index c888adf733..054a5f1b18 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -497,8 +497,11 @@ class ProjectPushItemProcess: except Exception as exc: _exc, _value, _tb = sys.exc_info() + product_name = self._src_product_entity["name"] self._status.set_failed( - "Unhandled error happened: {}".format(str(exc)), + "Unhandled error happened for `{}`: {}".format( + product_name, str(exc) + ), (_exc, _value, _tb) ) From de281f34c39e93cb1a5c0948470c17f6e92ffb79 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 15:09:01 +0200 Subject: [PATCH 763/781] Remove unnecessary _process_item_ids --- client/ayon_core/tools/push_to_project/ui/window.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 4d947103be..f5ee5f247c 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -335,7 +335,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._main_thread_timer = main_thread_timer self._main_thread_timer_can_stop = True self._last_submit_message = None - self._process_item_ids = [] self._variant_is_valid = None self._folder_is_valid = None From cb4df370670edfe7e6a56ed39c4d24afac134867 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 16:15:07 +0200 Subject: [PATCH 764/781] Use property instead private variable --- .../tools/push_to_project/models/integrate.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 054a5f1b18..dadae7e1f9 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -317,7 +317,7 @@ class ProjectPushRepreItem: if self._src_files is not None: return self._src_files, self._resource_files - repre_context = self._repre_entity["context"] + repre_context = self.repre_entity["context"] if "frame" in repre_context or "udim" in repre_context: src_files, resource_files = self._get_source_files_with_frames() else: @@ -334,7 +334,7 @@ class ProjectPushRepreItem: udim_placeholder = "__udim__" src_files = [] resource_files = [] - template = self._repre_entity["attrib"]["template"] + template = self.repre_entity["attrib"]["template"] # Remove padding from 'udim' and 'frame' formatting keys # - "{frame:0>4}" -> "{frame}" for key in ("udim", "frame"): @@ -342,7 +342,7 @@ class ProjectPushRepreItem: replacement = "{{{}}}".format(key) template = re.sub(sub_part, replacement, template) - repre_context = self._repre_entity["context"] + repre_context = self.repre_entity["context"] fill_repre_context = copy.deepcopy(repre_context) if "frame" in fill_repre_context: fill_repre_context["frame"] = frame_placeholder @@ -363,7 +363,7 @@ class ProjectPushRepreItem: .replace(udim_placeholder, "(?P[0-9]+)") ) src_basename_regex = re.compile("^{}$".format(src_basename)) - for file_info in self._repre_entity["files"]: + for file_info in self.repre_entity["files"]: filepath_template = self._clean_path(file_info["path"]) filepath = self._clean_path( filepath_template.format(root=self._roots) @@ -394,8 +394,8 @@ class ProjectPushRepreItem: def _get_source_files(self): src_files = [] resource_files = [] - template = self._repre_entity["attrib"]["template"] - repre_context = self._repre_entity["context"] + template = self.repre_entity["attrib"]["template"] + repre_context = self.repre_entity["context"] fill_repre_context = copy.deepcopy(repre_context) fill_roots = fill_repre_context["root"] for root_name in tuple(fill_roots.keys()): @@ -404,7 +404,7 @@ class ProjectPushRepreItem: fill_repre_context) repre_path = self._clean_path(repre_path) src_dirpath = os.path.dirname(repre_path) - for file_info in self._repre_entity["files"]: + for file_info in self.repre_entity["files"]: filepath_template = self._clean_path(file_info["path"]) filepath = self._clean_path( filepath_template.format(root=self._roots)) From 4229950f361718ffc81748290f96037377e98c75 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 17:16:30 +0200 Subject: [PATCH 765/781] Do not overwrite source entity context --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index dadae7e1f9..f9de351632 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1030,7 +1030,7 @@ class ProjectPushItemProcess: if not repre_context: repre_context = self._update_repre_context( - repre_entity, + copy.deepcopy(repre_entity), formatting_data ) From 2bd5418caeb7a753f155b588891b4a9f16d9c883 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 17:56:13 +0200 Subject: [PATCH 766/781] Simplified querying status of ProjectPushItemProcess Makes error logging more stable, limits hard fails in debugger. --- .../tools/push_to_project/control.py | 7 +++++-- .../tools/push_to_project/models/integrate.py | 19 ++++--------------- .../tools/push_to_project/ui/window.py | 6 ++---- 3 files changed, 11 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 58d06dd19d..466dfcc994 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -1,4 +1,5 @@ import threading +from typing import Dict import ayon_api @@ -13,6 +14,7 @@ from .models import ( UserPublishValuesModel, IntegrateModel, ) +from .models.integrate import ProjectPushItemProcess class PushToContextController: @@ -171,8 +173,9 @@ class PushToContextController: def set_selected_task(self, task_id, task_name): self._selection_model.set_selected_task(task_id, task_name) - def get_process_item_status(self, item_id): - return self._integrate_model.get_item_status(item_id) + def get_process_items(self) -> Dict[str, ProjectPushItemProcess]: + """Returns dict of all ProjectPushItemProcess items """ + return self._integrate_model.get_items() # Processing methods def submit(self, wait=True): diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index f9de351632..ed5c5b31ab 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -5,7 +5,7 @@ import itertools import sys import traceback import uuid -from typing import Optional +from typing import Optional, Dict import ayon_api from ayon_api.utils import create_entity_id @@ -1281,17 +1281,6 @@ class IntegrateModel: return item.integrate() - def get_item_status(self, item_id): - """Status of an item. - - Args: - item_id (str): Item id for which status should be returned. - - Returns: - dict[str, Any]: Status data. - """ - - item = self._process_items.get(item_id) - if item is not None: - return item.get_status_data() - return None + def get_items(self) -> Dict[str, ProjectPushItemProcess]: + """Returns dict of all ProjectPushItemProcess items """ + return self._process_items diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index f5ee5f247c..d01da4cb3f 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -528,10 +528,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget): failed_pushes = [] fail_tracebacks = [] - for process_item_id in self._controller._process_item_ids: - process_status = self._controller.get_process_item_status( - process_item_id - ) + for process_item in self._controller.get_process_items().values(): + process_status = process_item.get_status_data() if process_status["failed"]: failed_pushes.append(process_status) # push_failed = process_status["failed"] From dc987ed64f5cdbc0edb1d0985661905cbfdb6bb1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 18:00:42 +0200 Subject: [PATCH 767/781] Ruff --- client/ayon_core/tools/push_to_project/models/integrate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index ed5c5b31ab..ef49838152 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -847,8 +847,8 @@ class ProjectPushItemProcess: except TaskNotSetError: self._status.set_failed( "Target product name template requires task name. To " - "continue you have to select target task or change settings " - " ayon+settings://core/tools/creator/product_name_profiles" + "continue you have to select target task or change settings " # noqa: E501 + " ayon+settings://core/tools/creator/product_name_profiles" # noqa: E501 f"?project={self._item.dst_project_name}." ) raise PushToProjectError(self._status.fail_reason) From 651fc3f068a5ce0dfc9a976c47ae36a2120286fa Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 21 Aug 2025 18:07:19 +0200 Subject: [PATCH 768/781] Add validation for only single folder products selection --- client/ayon_core/plugins/load/push_to_project.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/plugins/load/push_to_project.py b/client/ayon_core/plugins/load/push_to_project.py index d5dd8960a3..33f9a68b23 100644 --- a/client/ayon_core/plugins/load/push_to_project.py +++ b/client/ayon_core/plugins/load/push_to_project.py @@ -28,6 +28,10 @@ class PushToProject(load.ProductLoaderPlugin): if not filtered_contexts: raise LoadError("Nothing to push for your selection") + folder_ids = [context["folder"]["id"] for context in filtered_contexts] + if len(folder_ids) > 1: + raise LoadError("Please select products from single folder") + push_tool_script_path = os.path.join( AYON_CORE_ROOT, "tools", From 5740b9f495f3c81f7eb405e83d81e804493a19c5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 22 Aug 2025 16:04:27 +0800 Subject: [PATCH 769/781] update publishDir as being part of the instance_data --- .../plugins/load/create_hero_version.py | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index d741dafcce..2d55069abf 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -75,7 +75,8 @@ class CreateHeroVersion(load.ProductLoaderPlugin): version = context.get("version") folder = context.get("folder") task_entity = ayon_api.get_task_by_id( - task_id=version.get("taskId"), project_name=project["name"]) + task_id=version.get("taskId"), project_name=project["name"] + ) anatomy = Anatomy(project["name"]) @@ -95,6 +96,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin): "name": product["name"], "type": product["productType"], } + anatomy_data["version"] = version["version"] published_representations = {} for repre in repres: repre_anatomy = copy.deepcopy(anatomy_data) @@ -105,12 +107,26 @@ class CreateHeroVersion(load.ProductLoaderPlugin): "published_files": [f["path"] for f in repre.get("files", [])], "anatomy_data": repre_anatomy } - + publish_template_key = get_publish_template_name( + project_name, + context.get("hostName"), + product["productType"], + task_name=anatomy_data.get("task", {}).get("name"), + task_type=anatomy_data.get("task", {}).get("type"), + project_settings=context.get("project_settings", {}), + logger=self.log + ) + published_template_obj = anatomy.get_template_item( + "publish", publish_template_key, "directory" + ) + published_dir = os.path.normpath( + published_template_obj.format_strict(anatomy_data) + ) instance_data = { "productName": product["name"], "productType": product["productType"], "anatomyData": anatomy_data, - "publishDir": "", # TODO: Set to actual publish directory + "publishDir": published_dir, "published_representations": published_representations, "versionEntity": version, } @@ -199,7 +215,12 @@ class CreateHeroVersion(load.ProductLoaderPlugin): if file_path not in all_repre_file_paths: all_repre_file_paths.append(file_path) - instance_publish_dir = os.path.normpath(instance_data["publishDir"]) + publish_dir = instance_data.get("publishDir", "") + if not publish_dir: + raise RuntimeError( + "publishDir is empty in instance_data, cannot continue." + ) + instance_publish_dir = os.path.normpath(publish_dir) other_file_paths_mapping = [] for file_path in all_copied_files: if not file_path.startswith(instance_publish_dir): @@ -331,7 +352,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin): else: collections, remainders = clique.assemble(published_files) if remainders or not collections or len(collections) > 1: - raise Exception( + raise RuntimeError( ( "Integrity error. Files of published " "representation is combination of frame " From 22e18cdfa253c7c4c53751bff2928e11206101fa Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 22 Aug 2025 16:07:02 +0800 Subject: [PATCH 770/781] add comment --- client/ayon_core/plugins/load/create_hero_version.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index 2d55069abf..e9dbbfa652 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -107,6 +107,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin): "published_files": [f["path"] for f in repre.get("files", [])], "anatomy_data": repre_anatomy } + # get the publish directory publish_template_key = get_publish_template_name( project_name, context.get("hostName"), From 89d0777bafcda1da9a7355a00da619cf8c326870 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 22 Aug 2025 16:21:37 +0800 Subject: [PATCH 771/781] copilot's feedback on - backup directory loop --- .../plugins/load/create_hero_version.py | 42 ++++++++----------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index e9dbbfa652..adf9d5f669 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -167,13 +167,13 @@ class CreateHeroVersion(load.ProductLoaderPlugin): raise RuntimeError("Project anatomy does not have hero " f"template key: {template_key}") - print(f"Hero template: {hero_template.template}") + self.log.info(f"Hero template: {hero_template.template}") hero_publish_dir = self.get_publish_dir( instance_data, anatomy, template_key ) - print(f"Hero publish dir: {hero_publish_dir}") + self.log.info(f"Hero publish dir: {hero_publish_dir}") src_version_entity = instance_data.get("versionEntity") filtered_repre_ids = [] @@ -280,28 +280,22 @@ class CreateHeroVersion(load.ProductLoaderPlugin): backup_hero_publish_dir = None if os.path.exists(hero_publish_dir): - backup_hero_publish_dir = hero_publish_dir + ".BACKUP" + base_backup_dir = hero_publish_dir + ".BACKUP" max_idx = 10 - idx = 0 - _backup_hero_publish_dir = backup_hero_publish_dir - while os.path.exists(_backup_hero_publish_dir): - try: - shutil.rmtree(_backup_hero_publish_dir) - backup_hero_publish_dir = _backup_hero_publish_dir + # Find the first available backup directory name + for idx in range(max_idx + 1): + if idx == 0: + candidate_backup_dir = base_backup_dir + else: + candidate_backup_dir = f"{base_backup_dir}{idx}" + if not os.path.exists(candidate_backup_dir): + backup_hero_publish_dir = candidate_backup_dir break - except Exception as exc: - _backup_hero_publish_dir = ( - backup_hero_publish_dir + str(idx) - ) - if not os.path.exists(_backup_hero_publish_dir): - backup_hero_publish_dir = _backup_hero_publish_dir - break - if idx > max_idx: - raise AssertionError( - "Backup folders are fully occupied to max index " - f"{max_idx}" - ) from exc - idx += 1 + else: + raise AssertionError( + f"Backup folders are fully occupied to max index {max_idx}" + ) + try: os.rename(hero_publish_dir, backup_hero_publish_dir) except PermissionError: @@ -346,7 +340,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin): src_to_dst_file_paths.append( (mapped_published_file, template_filled) ) - print( + self.log.info( f"Single published file: {mapped_published_file} -> " f"{template_filled}" ) @@ -379,7 +373,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin): ) src_to_dst_file_paths.append((src_file, dst_file)) dst_paths.append(dst_file) - print( + self.log.info( f"Collection published file: {src_file} " f"-> {dst_file}" ) From 8d6f83ffa704fbc4d6bb4a73e6e065a631d3802d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:30:48 +0200 Subject: [PATCH 772/781] restore saved painter --- client/ayon_core/tools/sceneinventory/select_version_dialog.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/tools/sceneinventory/select_version_dialog.py b/client/ayon_core/tools/sceneinventory/select_version_dialog.py index 68284ad1fe..18a39e495c 100644 --- a/client/ayon_core/tools/sceneinventory/select_version_dialog.py +++ b/client/ayon_core/tools/sceneinventory/select_version_dialog.py @@ -127,6 +127,7 @@ class SelectVersionComboBox(QtWidgets.QComboBox): status_text_rect.setLeft(icon_rect.right() + 2) if status_text_rect.width() <= 0: + painter.restore() return if status_text_rect.width() < metrics.width(status_name): @@ -144,6 +145,7 @@ class SelectVersionComboBox(QtWidgets.QComboBox): QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, status_name ) + painter.restore() def set_current_index(self, index): model = self._combo_view.model() From 16828011d22c633f0a2c9473c1f9ca2029397829 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 22 Aug 2025 13:40:26 +0200 Subject: [PATCH 773/781] Fix wrong check on folders --- client/ayon_core/plugins/load/push_to_project.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/load/push_to_project.py b/client/ayon_core/plugins/load/push_to_project.py index 33f9a68b23..0b218d6ea1 100644 --- a/client/ayon_core/plugins/load/push_to_project.py +++ b/client/ayon_core/plugins/load/push_to_project.py @@ -28,7 +28,10 @@ class PushToProject(load.ProductLoaderPlugin): if not filtered_contexts: raise LoadError("Nothing to push for your selection") - folder_ids = [context["folder"]["id"] for context in filtered_contexts] + folder_ids = set( + context["folder"]["id"] + for context in filtered_contexts + ) if len(folder_ids) > 1: raise LoadError("Please select products from single folder") From 941d4aee9ea66b758bd337560fb48b6e38a1b1c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 26 Aug 2025 15:13:52 +0200 Subject: [PATCH 774/781] :recycle: add docstrings and hints --- .../plugins/load/create_hero_version.py | 165 +++++++++++++++--- 1 file changed, 139 insertions(+), 26 deletions(-) diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index adf9d5f669..aef0cf8863 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -1,10 +1,12 @@ - +"""Plugin to create hero version from selected context.""" +from __future__ import annotations import os import copy import shutil import errno import itertools from concurrent.futures import ThreadPoolExecutor +from typing import Any, Optional from speedcopy import copyfile import clique @@ -20,7 +22,17 @@ from ayon_core.pipeline.publish import get_publish_template_name from ayon_core.pipeline.template_data import get_template_data -def prepare_changes(old_entity, new_entity): +def prepare_changes(old_entity: dict, new_entity: dict) -> dict: + """Prepare changes dict for update entity operation. + + Args: + old_entity (dict): Existing entity data from database. + new_entity (dict): New entity data to compare against old. + + Returns: + dict: Changes to apply to old entity to make it like new entity. + + """ changes = {} for key in set(new_entity.keys()): if key == "attrib": @@ -48,19 +60,21 @@ class CreateHeroVersion(load.ProductLoaderPlugin): icon = "star" color = "#ffd700" - ignored_representation_names = [] + ignored_representation_names: list[str] = [] db_representation_context_keys = [ "project", "folder", "asset", "hierarchy", "task", "product", "subset", "family", "representation", "username", "user", "output" ] use_hardlinks = False - def message(self, text): + @staticmethod + def message(text: str) -> None: + """Show message box with text.""" msgBox = QtWidgets.QMessageBox() msgBox.setText(text) msgBox.setStyleSheet(style.load_stylesheet()) msgBox.setWindowFlags( - msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint + msgBox.windowFlags() | QtCore.Qt.WindowType.FramelessWindowHint ) msgBox.exec_() @@ -143,8 +157,31 @@ class CreateHeroVersion(load.ProductLoaderPlugin): self.message( f"Failed to create hero version:\n{chr(10).join(errors)}") - def create_hero_version(self, instance_data, anatomy, context): - """Create hero version from instance data.""" + def create_hero_version( + self, + instance_data: dict[str, Any], + anatomy: Anatomy, + context: dict[str, Any]) -> None: + """Create hero version from instance data. + + Args: + instance_data (dict): Instance data with keys: + - productName (str): Name of the product. + - productType (str): Type of the product. + - anatomyData (dict): Anatomy data for templates. + - publishDir (str): Directory where the product is published. + - published_representations (dict): Published representations. + - versionEntity (dict, optional): Source version entity. + anatomy (Anatomy): Anatomy object for the project. + context (dict): Context data with keys: + - hostName (str): Name of the host application. + - project_settings (dict): Project settings. + + Raises: + RuntimeError: If any required data is missing or an error occurs + during the hero version creation process. + + """ published_repres = instance_data.get("published_representations") if not published_repres: raise RuntimeError("No published representations found.") @@ -158,7 +195,6 @@ class CreateHeroVersion(load.ProductLoaderPlugin): instance_data.get("anatomyData", {}).get("task", {}).get("type"), project_settings=context.get("project_settings", {}), hero=True, - logger=None ) hero_template = anatomy.get_template_item( "hero", template_key, "path", default=None @@ -197,12 +233,12 @@ class CreateHeroVersion(load.ProductLoaderPlugin): raise RuntimeError("Version 0 cannot have hero version.") all_copied_files = [] - transfers = instance_data.get("transfers", list()) + transfers = instance_data.get("transfers", []) for _src, dst in transfers: dst = os.path.normpath(dst) if dst not in all_copied_files: all_copied_files.append(dst) - hardlinks = instance_data.get("hardlinks", list()) + hardlinks = instance_data.get("hardlinks", []) for _src, dst in hardlinks: dst = os.path.normpath(dst) if dst not in all_copied_files: @@ -267,7 +303,6 @@ class CreateHeroVersion(load.ProductLoaderPlugin): instance_data["heroVersionEntity"] = new_hero_version old_repres_to_replace = {} - old_repres_to_delete = {} for repre_info in published_repres.values(): repre = repre_info["representation"] repre_name_low = repre["name"].lower() @@ -275,12 +310,10 @@ class CreateHeroVersion(load.ProductLoaderPlugin): old_repres_to_replace[repre_name_low] = ( old_repres_by_name.pop(repre_name_low) ) - if old_repres_by_name: - old_repres_to_delete = old_repres_by_name - + old_repres_to_delete = old_repres_by_name or {} backup_hero_publish_dir = None if os.path.exists(hero_publish_dir): - base_backup_dir = hero_publish_dir + ".BACKUP" + base_backup_dir = f"{hero_publish_dir}.BACKUP" max_idx = 10 # Find the first available backup directory name for idx in range(max_idx + 1): @@ -298,10 +331,11 @@ class CreateHeroVersion(load.ProductLoaderPlugin): try: os.rename(hero_publish_dir, backup_hero_publish_dir) - except PermissionError: + except PermissionError as e: raise AssertionError( "Could not create hero version because it is " - "not possible to replace current hero files.") + "not possible to replace current hero files." + ) from e try: src_to_dst_file_paths = [] @@ -445,14 +479,41 @@ class CreateHeroVersion(load.ProductLoaderPlugin): os.rename(backup_hero_publish_dir, hero_publish_dir) raise - def get_files_info(self, filepaths, anatomy): + def get_files_info( + self, filepaths: list[str], anatomy: Anatomy) -> list[dict]: + """Get list of file info dictionaries for given file paths. + + Args: + filepaths (list[str]): List of absolute file paths. + anatomy (Anatomy): Anatomy object for the project. + + Returns: + list[dict]: List of file info dictionaries. + + """ file_infos = [] for filepath in filepaths: file_info = self.prepare_file_info(filepath, anatomy) file_infos.append(file_info) return file_infos - def prepare_file_info(self, path, anatomy): + def prepare_file_info(self, path: str, anatomy: Anatomy) -> dict: + """Prepare file info dictionary for given path. + + Args: + path (str): Absolute file path. + anatomy (Anatomy): Anatomy object for the project. + + Returns: + dict: File info dictionary with keys: + - id (str): Unique identifier for the file. + - name (str): Base name of the file. + - path (str): Rootless file path. + - size (int): Size of the file in bytes. + - hash (str): Hash of the file content. + - hash_type (str): Type of the hash used. + + """ return { "id": create_entity_id(), "name": os.path.basename(path), @@ -462,7 +523,22 @@ class CreateHeroVersion(load.ProductLoaderPlugin): "hash_type": "op3", } - def get_publish_dir(self, instance_data, anatomy, template_key): + @staticmethod + def get_publish_dir( + instance_data: dict, + anatomy: Anatomy, + template_key: str) -> str: + """Get publish directory from instance data and anatomy. + + Args: + instance_data (dict): Instance data with "anatomyData" key. + anatomy (Anatomy): Anatomy object for the project. + template_key (str): Template key for the hero template. + + Returns: + str: Normalized publish directory path. + + """ template_data = copy.deepcopy(instance_data.get("anatomyData", {})) if "originalBasename" in instance_data: template_data["originalBasename"] = ( @@ -473,13 +549,34 @@ class CreateHeroVersion(load.ProductLoaderPlugin): ) return os.path.normpath(template_obj.format_strict(template_data)) - def get_rootless_path(self, anatomy, path): + @staticmethod + def get_rootless_path(anatomy: Anatomy, path: str) -> str: + """Get rootless path from absolute path. + + Args: + anatomy (Anatomy): Anatomy object for the project. + path (str): Absolute file path. + + Returns: + str: Rootless file path if root found, else original path. + + """ success, rootless_path = anatomy.find_root_template_from_path(path) if success: path = rootless_path return path - def copy_file(self, src_path, dst_path): + def copy_file(self, src_path: str, dst_path: str) -> None: + """Copy file from src to dst with creating directories. + + Args: + src_path (str): Source file path. + dst_path (str): Destination file path. + + Raises: + OSError: If copying or linking fails. + + """ dirname = os.path.dirname(dst_path) try: os.makedirs(dirname) @@ -495,23 +592,39 @@ class CreateHeroVersion(load.ProductLoaderPlugin): raise copyfile(src_path, dst_path) - def version_from_representations(self, project_name, repres): + @staticmethod + def version_from_representations( + project_name: str, repres: dict) -> Optional[dict[str, Any]]: + """Find version from representations. + + Args: + project_name (str): Name of the project. + repres (dict): Dictionary of representations info. + + Returns: + Optional[dict]: Version entity if found, else None. + + """ for repre_info in repres.values(): version = ayon_api.get_version_by_id( project_name, repre_info["representation"]["versionId"] ) if version: return version + return None - def current_hero_ents(self, project_name, version): + @staticmethod + def current_hero_ents( + project_name: str, + version: dict[str, Any]) -> tuple[Any, list[dict[str, Any]]]: hero_version = ayon_api.get_hero_version_by_product_id( project_name, version["productId"] ) if not hero_version: - return (None, []) + return None, [] hero_repres = list( ayon_api.get_representations( project_name, version_ids={hero_version["id"]} ) ) - return (hero_version, hero_repres) + return hero_version, hero_repres From 22d6819a322ed126ccde1a3f410bd080ba47e718 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 28 Aug 2025 10:51:39 +0200 Subject: [PATCH 775/781] Updated docstring --- client/ayon_core/tools/push_to_project/control.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 466dfcc994..ad7cc58c5c 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -57,6 +57,9 @@ class PushToContextController: def set_source(self, project_name, version_ids): """Set source project and version. + There is currently assumption that tool is working on products of same + folder. + Args: project_name (Union[str, None]): Source project name. version_ids (Optional[list[str]]): Version ids. From 763c650a9f133623a6e4d1d768ecad9bb99896b3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 28 Aug 2025 11:03:13 +0200 Subject: [PATCH 776/781] Cache product entities --- client/ayon_core/tools/push_to_project/control.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index ad7cc58c5c..2f712337a4 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -33,6 +33,7 @@ class PushToContextController: self._src_folder_entity = None self._src_folder_task_entities = {} self._src_version_entities = [] + self._src_product_entities = {} self._src_label = None self._submission_enabled = False @@ -110,6 +111,10 @@ class PushToContextController: self._src_folder_entity = folder_entity self._src_folder_task_entities = task_entities self._src_version_entities = version_entities + self._src_product_entities = { + product["id"]: product + for product in product_entities + } if folder_entity: self._user_values.set_new_folder_name(folder_entity["name"]) variant = self._get_src_variant() @@ -233,8 +238,7 @@ class PushToContextController: folder_path = folder_entity["path"] src_labels = [] for version_entity in self._src_version_entities: - product_entity = ayon_api.get_product_by_id( - self._src_project_name, + product_entity = self._src_product_entities.get( version_entity["productId"] ) src_labels.append( @@ -289,8 +293,7 @@ class PushToContextController: task_name, task_type = self._get_task_info_from_repre_entities( task_entities, repre_entities ) - product_entity = ayon_api.get_product_by_id( - project_name, + product_entity = self._src_product_entities.get( version_entity["productId"] ) From f6efb6c80dcf86bd2c61f3d3137404099d49b6a1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 29 Aug 2025 14:08:16 +0200 Subject: [PATCH 777/781] Expose check for original names requirement --- client/ayon_core/tools/push_to_project/control.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 2f712337a4..b4e0d56dfd 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -158,6 +158,14 @@ class PushToContextController: def get_user_values(self): return self._user_values.get_data() + def original_names_required(self): + """Checks if original product names must be used. + + Currently simple check if multiple versions, but if multiple products + with different product_type were used, it wouldn't be necessary. + """ + return len(self._src_version_entities) > 1 + def set_user_value_folder_name(self, folder_name): self._user_values.set_new_folder_name(folder_name) self._invalidate() From 47be2d41c5cd06bd6c2d0e077a1334d08559f165 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 29 Aug 2025 14:09:44 +0200 Subject: [PATCH 778/781] Exposed _use_original_names_checkbox --- client/ayon_core/tools/push_to_project/ui/window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index d01da4cb3f..99b4d6ecb3 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -307,6 +307,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._new_folder_checkbox = new_folder_checkbox self._folder_name_input = folder_name_input self._comment_input = comment_input + self._use_original_names_checkbox = original_names_checkbox self._publish_btn = publish_btn From 779fa33be21f65966d708421a9b41f5c9cb77a1c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 29 Aug 2025 14:10:44 +0200 Subject: [PATCH 779/781] Added function to decide state of _use_original_names_checkbox --- .../ayon_core/tools/push_to_project/ui/window.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 99b4d6ecb3..3867e98b3b 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -368,6 +368,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget): user_values = self._controller.get_user_values() new_folder_name = user_values["new_folder_name"] variant = user_values["variant"] + self._invalidate_use_original_names( + self._use_original_names_checkbox.isChecked()) self._folder_name_input.setText(new_folder_name or "") self._variant_input.setText(variant or "") self._invalidate_variant(user_values["is_variant_valid"]) @@ -420,9 +422,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): def _on_original_names_change(self, state: int) -> None: use_original_name = bool(state) - self._variant_input.setEnabled(not use_original_name) - self._controller._use_original_name = use_original_name - self.refresh() + self._invalidate_use_original_names(use_original_name) def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled @@ -499,6 +499,16 @@ class PushToContextSelectWindow(QtWidgets.QWidget): state = "valid" if is_valid else "invalid" set_style_property(self._variant_input, "state", state) + def _invalidate_use_original_names(self, use_original_names): + variant_used = True + if self._controller.original_names_required(): + variant_used = False + use_original_names = True + + self._controller._use_original_name = use_original_names + self._use_original_names_checkbox.setChecked(use_original_names) + self._variant_input.setEnabled(variant_used) + def _on_submission_change(self, event): self._publish_btn.setEnabled(event["enabled"]) From f4f94e75e8c90b2dc72562d491b507f102080528 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 29 Aug 2025 14:21:32 +0200 Subject: [PATCH 780/781] Simplified variant invalidation --- .../tools/push_to_project/ui/window.py | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 3867e98b3b..ed38f24469 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -368,11 +368,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget): user_values = self._controller.get_user_values() new_folder_name = user_values["new_folder_name"] variant = user_values["variant"] - self._invalidate_use_original_names( - self._use_original_names_checkbox.isChecked()) self._folder_name_input.setText(new_folder_name or "") self._variant_input.setText(variant or "") self._invalidate_variant(user_values["is_variant_valid"]) + self._invalidate_use_original_names( + self._use_original_names_checkbox.isChecked()) self._invalidate_new_folder_name( new_folder_name, user_values["is_new_folder_name_valid"] ) @@ -486,28 +486,27 @@ class PushToContextSelectWindow(QtWidgets.QWidget): state = "" if folder_name is not None: state = "valid" if is_valid else "invalid" - set_style_property( - self._folder_name_input, "state", state - ) + set_style_property(self._folder_name_input, "state", state) def _invalidate_variant(self, is_valid): - if self._controller._use_original_name: - is_valid = True - if self._variant_is_valid is is_valid: - return self._variant_is_valid = is_valid state = "valid" if is_valid else "invalid" set_style_property(self._variant_input, "state", state) def _invalidate_use_original_names(self, use_original_names): - variant_used = True + """Checks if original names must be used. + + Invalidates Variant if necessary + """ if self._controller.original_names_required(): - variant_used = False use_original_names = True + if use_original_names: + self._variant_input.setEnabled(not use_original_names) + self._invalidate_variant(not use_original_names) + self._controller._use_original_name = use_original_names self._use_original_names_checkbox.setChecked(use_original_names) - self._variant_input.setEnabled(variant_used) def _on_submission_change(self, event): self._publish_btn.setEnabled(event["enabled"]) From 12618488055958ff0e04c267d02dc16b42eda42e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 29 Aug 2025 14:56:25 +0200 Subject: [PATCH 781/781] Fix resetting invalid variant --- client/ayon_core/tools/push_to_project/ui/window.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index ed38f24469..f382ccce64 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -501,9 +501,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget): if self._controller.original_names_required(): use_original_names = True - if use_original_names: - self._variant_input.setEnabled(not use_original_names) - self._invalidate_variant(not use_original_names) + self._variant_input.setEnabled(not use_original_names) + self._invalidate_variant(not use_original_names) self._controller._use_original_name = use_original_names self._use_original_names_checkbox.setChecked(use_original_names)