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. diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py index 5587d64604..ec6bdfa283 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 ( @@ -88,6 +89,7 @@ class TransferItem: template: str template_data: dict[str, Any] representation: Representation + related_trait: FileLocation @staticmethod def get_size(file_path: Path) -> int: @@ -149,7 +151,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: @@ -159,7 +161,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") @@ -210,10 +212,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 @@ -229,7 +260,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 @@ -290,6 +321,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( @@ -306,8 +341,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( @@ -811,7 +852,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): @@ -944,6 +985,7 @@ class IntegrateTraits(pyblish.api.InstancePlugin): template=template_item.template, template_data=template_item.template_data, representation=representation, + related_trait=file_loc ) ) @@ -1001,6 +1043,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 @@ -1058,6 +1101,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()