mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 12:54:40 +01:00
♻️ 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:
parent
d228527628
commit
f589cb933c
7 changed files with 237 additions and 18 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
38
client/ayon_core/pipeline/traits/utils.py
Normal file
38
client/ayon_core/pipeline/traits/utils.py
Normal 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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue