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 001/216] :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 002/216] :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 003/216] :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 004/216] :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 005/216] :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 006/216] :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 007/216] :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 008/216] :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 009/216] 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 010/216] :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 011/216] :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 012/216] :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 013/216] :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 014/216] :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 015/216] :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 016/216] :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 017/216] :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 018/216] 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 019/216] :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 020/216] :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 021/216] :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 022/216] :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 023/216] :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 024/216] :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 025/216] :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 026/216] :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 027/216] :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 028/216] :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 029/216] :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 030/216] :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 031/216] :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 032/216] :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 033/216] :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 034/216] :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 035/216] :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 036/216] :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 037/216] :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 038/216] :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 039/216] :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 040/216] :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 041/216] :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 042/216] :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 043/216] :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 044/216] :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 045/216] :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 046/216] :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 047/216] :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 048/216] :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 049/216] :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 050/216] :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 051/216] :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 052/216] :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 053/216] :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 054/216] :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 055/216] :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 056/216] :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 057/216] :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 058/216] :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 059/216] :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 060/216] :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 061/216] :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 062/216] :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 063/216] :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 064/216] :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 065/216] :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 066/216] :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 067/216] :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 068/216] :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 069/216] :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 070/216] :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 071/216] :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 072/216] :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 073/216] :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 074/216] :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 075/216] :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 076/216] :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 077/216] :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 078/216] :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 079/216] :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 080/216] :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 081/216] :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 082/216] :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 083/216] :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 084/216] :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 085/216] :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 086/216] :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 087/216] :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 088/216] :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 089/216] :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 090/216] :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 091/216] :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 092/216] :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 093/216] :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 094/216] :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 095/216] :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 096/216] :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 097/216] :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 098/216] :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 099/216] :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 100/216] :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 101/216] :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 102/216] :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 103/216] :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 104/216] :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 105/216] :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 106/216] :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 107/216] :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 0c5d95d3d105e2d5dde69ec68967e09a85f358b4 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 18 Mar 2025 17:11:43 +0100 Subject: [PATCH 108/216] :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 92cc5cd06b1cffe2308866607bc2b5756d3d127c Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 19 Mar 2025 18:47:09 +0100 Subject: [PATCH 109/216] :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 110/216] =?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 111/216] :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 112/216] :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 113/216] :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 114/216] :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 115/216] 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 116/216] 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 117/216] 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 118/216] 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 119/216] 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 120/216] 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 121/216] 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 122/216] 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 123/216] 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 124/216] 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 125/216] 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 126/216] 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 127/216] 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 128/216] 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 129/216] 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 130/216] 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 131/216] 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 132/216] 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 133/216] 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 134/216] 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 44fc6f75ab1352cc8106543d612cbea64db0d03d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Apr 2025 10:40:52 +0200 Subject: [PATCH 135/216] 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 136/216] 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 137/216] 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 138/216] 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 139/216] 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 140/216] :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 141/216] :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 142/216] :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 143/216] :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 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 144/216] :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 145/216] :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 146/216] :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 147/216] :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 148/216] :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 149/216] :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 150/216] 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 151/216] 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 152/216] :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 153/216] :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 e86450c48df1bc78c400cfa750605192e1c48125 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 27 May 2025 12:57:56 +0200 Subject: [PATCH 154/216] 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 155/216] 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 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 156/216] :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 aec2ee828cdd3686d476b53d761f3cd152b39133 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 Jun 2025 19:23:02 +0200 Subject: [PATCH 157/216] 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 158/216] 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 159/216] 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 160/216] 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 161/216] 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 162/216] 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 163/216] 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 6e095b8c188b09e3f067538b40652aeaa1089821 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 14:35:23 +0200 Subject: [PATCH 164/216] 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 165/216] 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 ee96cdc2c38f1e04b9ba2ccffe4b18ecbe83c6e8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 14:44:14 +0200 Subject: [PATCH 166/216] 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 4457a432cb382e7a21fa8202e3ab1b2f524b7239 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:19:19 +0200 Subject: [PATCH 167/216] 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 168/216] 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 169/216] 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 170/216] 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 171/216] 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 4c113ca5b50e5afbdcfd1291bd637d0c3b665026 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 16:28:02 +0200 Subject: [PATCH 172/216] 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 173/216] 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 174/216] 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 175/216] 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 176/216] 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 177/216] 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 178/216] 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 179/216] 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 0e292eb3560d9771ded3b4c9ab0198a59952ef25 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 13 Jun 2025 17:43:47 +0200 Subject: [PATCH 180/216] 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 181/216] 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 182/216] 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 183/216] 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 184/216] 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 185/216] 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 186/216] 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 187/216] 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 f4556ac697ea9b127451b661f8c0e6c9f546bdd2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 16 Jun 2025 15:58:03 +0200 Subject: [PATCH 188/216] 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 189/216] 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 190/216] 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 191/216] 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 192/216] 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 193/216] 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 194/216] 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 195/216] 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 196/216] 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 197/216] 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 198/216] 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 48e840622dcc24fc8bfb30acadaca25b0ad78477 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 18 Jun 2025 09:54:21 +0200 Subject: [PATCH 199/216] 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 200/216] 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 628c8025d4d7964d47c747be263e4b48c6d98540 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 18 Jun 2025 11:48:27 +0200 Subject: [PATCH 201/216] 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 202/216] 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 203/216] 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 204/216] 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 205/216] 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 206/216] 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 207/216] 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 208/216] 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 209/216] 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 210/216] 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 e8dcec0510dd78099f8ab2a3bea000cdd91a6ce5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 19 Jun 2025 10:52:34 +0200 Subject: [PATCH 211/216] 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 212/216] 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 213/216] 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 214/216] 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 215/216] 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 8b98c56ee87959a36a82de575d888de1f89447d1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 23 Jun 2025 09:14:42 +0200 Subject: [PATCH 216/216] 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"],