From 0a207ad032273365f41b9032631d9eb26dfc0d5e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 20 Jun 2024 20:19:08 +0200 Subject: [PATCH 001/370] 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/370] 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/370] 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/370] 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/370] 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/370] 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/370] 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/370] 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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] 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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] 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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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/370] :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 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 052/370] :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 053/370] :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 054/370] :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 055/370] :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 056/370] :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 057/370] :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 058/370] :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 059/370] :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 060/370] :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 061/370] :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 062/370] :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 063/370] :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 064/370] :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 065/370] :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 066/370] :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 067/370] :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 068/370] :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 069/370] :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 070/370] :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 071/370] :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 072/370] :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 073/370] :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 074/370] :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 075/370] :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 076/370] :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 077/370] :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 078/370] :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 079/370] :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 080/370] :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 081/370] :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 082/370] :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 083/370] :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 084/370] :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 085/370] :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 086/370] :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 087/370] :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 088/370] :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 089/370] :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 090/370] :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 091/370] :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 092/370] :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 093/370] :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 094/370] :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 095/370] :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 096/370] :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 097/370] :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 098/370] :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 099/370] :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 100/370] :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 101/370] :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 102/370] :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 103/370] :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 104/370] :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 105/370] :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 106/370] :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 107/370] :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 108/370] :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 109/370] :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 110/370] :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 111/370] :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 112/370] :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 113/370] :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 114/370] :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 115/370] :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 116/370] 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 117/370] 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 118/370] 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 119/370] :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 120/370] 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 121/370] 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 122/370] 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 123/370] 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 124/370] 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 125/370] 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 126/370] 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 127/370] 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 128/370] :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 4152f9fc5e4cbcf2ef253781ee244ec1619c89fb Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Mar 2025 17:12:15 +0100 Subject: [PATCH 129/370] =?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 130/370] :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 131/370] :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 132/370] :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 133/370] :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 134/370] 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 135/370] 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 136/370] 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 137/370] 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 138/370] 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 139/370] 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 140/370] 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 141/370] 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 142/370] 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 143/370] 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 144/370] 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 145/370] 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 146/370] 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 147/370] 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 148/370] 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 149/370] 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 150/370] 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 151/370] 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 152/370] 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 153/370] 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 154/370] 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 155/370] 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 156/370] 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 157/370] 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 158/370] 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 159/370] 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 160/370] 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 161/370] 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 162/370] :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 163/370] :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 164/370] :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 165/370] :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 166/370] 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 167/370] :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 168/370] :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 169/370] :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 170/370] :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 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 171/370] :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 172/370] :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 173/370] 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 174/370] 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 175/370] :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 176/370] :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 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 177/370] 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 178/370] 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 179/370] 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 180/370] 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 181/370] 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 182/370] 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 183/370] 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 184/370] :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 185/370] :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 6ea717bc3624cd17da53dd676772278704ac87d3 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 6 Jun 2025 10:01:32 +0200 Subject: [PATCH 186/370] :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 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 187/370] :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 188/370] :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 189/370] :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 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 190/370] :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 191/370] :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 192/370] 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 193/370] 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 194/370] 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 195/370] 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 196/370] 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 197/370] 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 198/370] 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 199/370] 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 200/370] 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 201/370] 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 202/370] 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 203/370] 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 204/370] 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 205/370] 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 206/370] 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 207/370] 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 208/370] 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 209/370] 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 210/370] 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 211/370] 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 212/370] 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 213/370] 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 214/370] 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 215/370] 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 216/370] 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 217/370] 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 218/370] 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 219/370] 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 220/370] 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 221/370] 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 d681c87aadbc14404511fd39996d9ac5ce2a3c06 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 12 Jun 2025 13:49:46 +0200 Subject: [PATCH 222/370] 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 223/370] 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 224/370] 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 225/370] 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 226/370] 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 227/370] 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 228/370] 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 229/370] 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 230/370] 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 231/370] 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 232/370] 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 233/370] 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 234/370] 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 235/370] 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 236/370] 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 237/370] 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 238/370] 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 239/370] 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 240/370] 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 241/370] 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 242/370] 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 243/370] 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 244/370] 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 245/370] 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 246/370] 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 247/370] 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 248/370] 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 249/370] 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 250/370] 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 251/370] 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 252/370] 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 253/370] 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 254/370] 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 255/370] 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 256/370] 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 257/370] 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 258/370] 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 259/370] 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 260/370] 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 261/370] 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 262/370] 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 263/370] 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 264/370] 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 265/370] 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 266/370] 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 267/370] 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 268/370] 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 269/370] 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 270/370] 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 cf4f9cfea61b2cbb95aa6caef3fcda10723c36b9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 16 Jun 2025 14:04:47 +0200 Subject: [PATCH 271/370] 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 272/370] 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 273/370] 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 274/370] 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 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 275/370] 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 276/370] 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 277/370] 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 278/370] 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 279/370] 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 280/370] 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 281/370] 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 282/370] 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 283/370] 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 3db2cd046a1f8755b51a2473bd8af2d138385083 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 17 Jun 2025 14:44:24 +0200 Subject: [PATCH 284/370] 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 285/370] 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 286/370] 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 287/370] 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 288/370] 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 289/370] 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 290/370] 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 291/370] 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 292/370] 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 293/370] 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 294/370] 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 295/370] 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 296/370] 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 297/370] 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 298/370] 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 299/370] 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 300/370] 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 301/370] 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 302/370] 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 303/370] 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 304/370] 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 305/370] 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 306/370] 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 f4af01f702b7f7fc339f19231e0b88a7ee56fc33 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 18 Jun 2025 18:59:39 +0200 Subject: [PATCH 307/370] :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 308/370] :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 309/370] :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 310/370] :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 311/370] :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 312/370] 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 313/370] 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 314/370] 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 315/370] 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 316/370] 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 317/370] 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 318/370] 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 319/370] 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 320/370] 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 321/370] 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 322/370] 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 323/370] 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 324/370] 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 325/370] 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 326/370] 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 327/370] 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 328/370] :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 329/370] 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 330/370] 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 331/370] 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 332/370] 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 333/370] 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 334/370] 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 335/370] 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 336/370] 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 337/370] 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 338/370] :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 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 339/370] 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 340/370] 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 341/370] 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 342/370] 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 343/370] 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 344/370] 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 345/370] 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 346/370] 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 347/370] 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 348/370] 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 349/370] 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 350/370] 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 351/370] 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 352/370] 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 353/370] 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 354/370] 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 355/370] 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 356/370] 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 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 357/370] 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 358/370] 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 359/370] 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 360/370] 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 361/370] :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 362/370] 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 363/370] 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 364/370] 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 365/370] [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 366/370] [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 367/370] 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 368/370] 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 369/370] 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 370/370] 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.