♻️ 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.
This commit is contained in:
Ondřej Samohel 2024-11-12 17:12:54 +01:00
parent d228527628
commit f589cb933c
No known key found for this signature in database
GPG key ID: 02376E18990A97C6
7 changed files with 237 additions and 18 deletions

View file

@ -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",
]

View file

@ -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.

View file

@ -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)

View file

@ -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):

View file

@ -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.
"""

View file

@ -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
)

View file

@ -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