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/representation.py b/client/ayon_core/pipeline/traits/representation.py index 34aadd590a..d365ea1ed2 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. @@ -365,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: @@ -375,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: @@ -393,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 @@ -618,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) 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. " 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: 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 319401b91e..e4033c1d28 100644 --- a/tests/client/ayon_core/pipeline/traits/test_traits.py +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -7,33 +7,32 @@ 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: { "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 +167,17 @@ 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: @@ -194,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 @@ -334,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")