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