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 1/5] :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 2/5] :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 3/5] :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 4/5] :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 5/5] :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")