Skip to content

Commit 4d39d60

Browse files
refactor: consolidate media constraints via generic base classes (#175) (#176)
Replace 6 near-identical media constraint classes with two generic bases (_MediaConstraint[M] and _MediaListConstraint[M]) that use ClassVar dispatch for artifact type and MimeType generics for type-safe supported_mime_types. Update get_constraint_input_type to use _artifact_type ClassVar fast path instead of __call__ introspection.
1 parent 4992573 commit 4d39d60

2 files changed

Lines changed: 62 additions & 148 deletions

File tree

src/celeste/constraints.py

Lines changed: 55 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@
33
import math
44
import re
55
from abc import ABC, abstractmethod
6-
from typing import Any, get_args, get_origin
6+
from typing import Any, ClassVar, get_args, get_origin
77

88
from pydantic import BaseModel, Field, computed_field
99

1010
from celeste.artifacts import AudioArtifact, ImageArtifact, VideoArtifact
1111
from celeste.exceptions import ConstraintViolationError
12-
from celeste.mime_types import AudioMimeType, ImageMimeType, VideoMimeType
13-
from celeste.types import AudioContent, ImageContent, VideoContent
12+
from celeste.mime_types import AudioMimeType, ImageMimeType, MimeType, VideoMimeType
1413

1514

1615
class Constraint(BaseModel, ABC):
@@ -267,193 +266,105 @@ def __call__(self, value: type[BaseModel]) -> type[BaseModel]:
267266
return value
268267

269268

270-
class ImageConstraint(Constraint):
271-
"""Constraint for validating a single image artifact - validates mime_type."""
269+
class _MediaConstraint[M: MimeType](Constraint):
270+
"""Base for single-media-artifact constraints."""
271+
272+
_artifact_type: ClassVar[type]
273+
_media_label: ClassVar[str]
272274

273-
supported_mime_types: list[ImageMimeType] | None = None
274-
"""Supported MIME types for the image."""
275+
supported_mime_types: list[M] | None = None
275276

276-
def __call__(self, value: ImageArtifact) -> ImageArtifact:
277-
"""Validate single image artifact against constraint."""
277+
def __call__(self, value: Any) -> Any: # noqa: ANN401
278+
"""Validate single media artifact against constraint."""
278279
if isinstance(value, list):
279-
msg = "ImageConstraint requires a single ImageArtifact, not a list"
280+
msg = f"{self.__class__.__name__} requires a single {self._artifact_type.__name__}, not a list"
280281
raise ConstraintViolationError(msg)
281-
282-
if not isinstance(value, ImageArtifact):
283-
msg = f"Must be ImageArtifact, got {type(value).__name__}"
282+
if not isinstance(value, self._artifact_type):
283+
msg = f"Must be {self._artifact_type.__name__}, got {type(value).__name__}"
284284
raise ConstraintViolationError(msg)
285-
286285
if (
287286
self.supported_mime_types is not None
288-
and value.mime_type not in self.supported_mime_types
287+
and value.mime_type not in self.supported_mime_types # type: ignore[attr-defined]
289288
):
290289
supported_values = [mt.value for mt in self.supported_mime_types]
291-
got_value = value.mime_type.value if value.mime_type else None
290+
got_value = value.mime_type.value if value.mime_type else None # type: ignore[attr-defined]
292291
msg = f"mime_type must be one of {supported_values}, got {got_value!r}"
293292
raise ConstraintViolationError(msg)
294-
295293
return value
296294

297295

298-
class ImagesConstraint(Constraint):
299-
"""Constraint for validating image artifacts list - validates mime_type and count limits."""
296+
class _MediaListConstraint[M: MimeType](Constraint):
297+
"""Base for plural-media-artifact constraints."""
300298

301-
supported_mime_types: list[ImageMimeType] | None = None
302-
"""Supported MIME types."""
299+
_artifact_type: ClassVar[type]
300+
_media_label: ClassVar[str]
303301

302+
supported_mime_types: list[M] | None = None
304303
max_count: int | None = None
305-
"""Maximum number of images."""
306304

307-
def __call__(self, value: ImageContent) -> list[ImageArtifact]:
308-
"""Validate image artifact(s) against constraint and normalize to list."""
309-
# Normalize: if single ImageArtifact is passed, wrap it in a list
310-
images = value if isinstance(value, list) else [value]
311-
312-
if self.max_count is not None and len(images) > self.max_count:
313-
msg = f"Must have at most {self.max_count} image(s), got {len(images)}"
305+
def __call__(self, value: Any) -> Any: # noqa: ANN401
306+
"""Validate media artifact(s) against constraint and normalize to list."""
307+
items = value if isinstance(value, list) else [value]
308+
if self.max_count is not None and len(items) > self.max_count:
309+
msg = f"Must have at most {self.max_count} {self._media_label}(s), got {len(items)}"
314310
raise ConstraintViolationError(msg)
315-
316311
if self.supported_mime_types is not None:
317-
for i, img in enumerate(images):
318-
if not isinstance(img, ImageArtifact):
319-
msg = f"Image {i + 1}: Must be ImageArtifact, got {type(img).__name__}"
312+
label = self._media_label.capitalize()
313+
for i, item in enumerate(items):
314+
if not isinstance(item, self._artifact_type):
315+
msg = f"{label} {i + 1}: Must be {self._artifact_type.__name__}, got {type(item).__name__}"
320316
raise ConstraintViolationError(msg)
321-
if img.mime_type not in self.supported_mime_types:
317+
if item.mime_type not in self.supported_mime_types: # type: ignore[attr-defined]
322318
supported_values = [mt.value for mt in self.supported_mime_types]
323-
got_value = img.mime_type.value if img.mime_type else None
319+
got_value = item.mime_type.value if item.mime_type else None # type: ignore[attr-defined]
324320
msg = (
325-
f"Image {i + 1}: mime_type must be one of {supported_values}, "
321+
f"{label} {i + 1}: mime_type must be one of {supported_values}, "
326322
f"got {got_value!r}"
327323
)
328324
raise ConstraintViolationError(msg)
325+
return items
329326

330-
return images
331-
332-
333-
class VideoConstraint(Constraint):
334-
"""Constraint for validating a single video artifact - validates mime_type."""
335-
336-
supported_mime_types: list[VideoMimeType] | None = None
337-
"""Supported MIME types for the video."""
338-
339-
def __call__(self, value: VideoArtifact) -> VideoArtifact:
340-
"""Validate single video artifact against constraint."""
341-
if isinstance(value, list):
342-
msg = "VideoConstraint requires a single VideoArtifact, not a list"
343-
raise ConstraintViolationError(msg)
344327

345-
if not isinstance(value, VideoArtifact):
346-
msg = f"Must be VideoArtifact, got {type(value).__name__}"
347-
raise ConstraintViolationError(msg)
328+
class ImageConstraint(_MediaConstraint[ImageMimeType]):
329+
"""Constraint for validating a single image artifact - validates mime_type."""
348330

349-
if (
350-
self.supported_mime_types is not None
351-
and value.mime_type not in self.supported_mime_types
352-
):
353-
supported_values = [mt.value for mt in self.supported_mime_types]
354-
got_value = value.mime_type.value if value.mime_type else None
355-
msg = f"mime_type must be one of {supported_values}, got {got_value!r}"
356-
raise ConstraintViolationError(msg)
331+
_artifact_type = ImageArtifact
332+
_media_label = "image"
357333

358-
return value
359334

335+
class ImagesConstraint(_MediaListConstraint[ImageMimeType]):
336+
"""Constraint for validating image artifacts list - validates mime_type and count limits."""
360337

361-
class VideosConstraint(Constraint):
362-
"""Constraint for validating video artifacts list - validates mime_type and count limits."""
338+
_artifact_type = ImageArtifact
339+
_media_label = "image"
363340

364-
supported_mime_types: list[VideoMimeType] | None = None
365-
"""Supported MIME types."""
366341

367-
max_count: int | None = None
368-
"""Maximum number of videos."""
342+
class VideoConstraint(_MediaConstraint[VideoMimeType]):
343+
"""Constraint for validating a single video artifact - validates mime_type."""
369344

370-
def __call__(self, value: VideoContent) -> list[VideoArtifact]:
371-
"""Validate video artifact(s) against constraint and normalize to list."""
372-
# Normalize: if single VideoArtifact is passed, wrap it in a list
373-
videos = value if isinstance(value, list) else [value]
345+
_artifact_type = VideoArtifact
346+
_media_label = "video"
374347

375-
if self.max_count is not None and len(videos) > self.max_count:
376-
msg = f"Must have at most {self.max_count} video(s), got {len(videos)}"
377-
raise ConstraintViolationError(msg)
378348

379-
if self.supported_mime_types is not None:
380-
for i, vid in enumerate(videos):
381-
if not isinstance(vid, VideoArtifact):
382-
msg = f"Video {i + 1}: Must be VideoArtifact, got {type(vid).__name__}"
383-
raise ConstraintViolationError(msg)
384-
if vid.mime_type not in self.supported_mime_types:
385-
supported_values = [mt.value for mt in self.supported_mime_types]
386-
got_value = vid.mime_type.value if vid.mime_type else None
387-
msg = (
388-
f"Video {i + 1}: mime_type must be one of {supported_values}, "
389-
f"got {got_value!r}"
390-
)
391-
raise ConstraintViolationError(msg)
349+
class VideosConstraint(_MediaListConstraint[VideoMimeType]):
350+
"""Constraint for validating video artifacts list - validates mime_type and count limits."""
392351

393-
return videos
352+
_artifact_type = VideoArtifact
353+
_media_label = "video"
394354

395355

396-
class AudioConstraint(Constraint):
356+
class AudioConstraint(_MediaConstraint[AudioMimeType]):
397357
"""Constraint for validating a single audio artifact - validates mime_type."""
398358

399-
supported_mime_types: list[AudioMimeType] | None = None
400-
"""Supported MIME types for the audio."""
359+
_artifact_type = AudioArtifact
360+
_media_label = "audio"
401361

402-
def __call__(self, value: AudioArtifact) -> AudioArtifact:
403-
"""Validate single audio artifact against constraint."""
404-
if isinstance(value, list):
405-
msg = "AudioConstraint requires a single AudioArtifact, not a list"
406-
raise ConstraintViolationError(msg)
407-
408-
if not isinstance(value, AudioArtifact):
409-
msg = f"Must be AudioArtifact, got {type(value).__name__}"
410-
raise ConstraintViolationError(msg)
411362

412-
if (
413-
self.supported_mime_types is not None
414-
and value.mime_type not in self.supported_mime_types
415-
):
416-
supported_values = [mt.value for mt in self.supported_mime_types]
417-
got_value = value.mime_type.value if value.mime_type else None
418-
msg = f"mime_type must be one of {supported_values}, got {got_value!r}"
419-
raise ConstraintViolationError(msg)
420-
421-
return value
422-
423-
424-
class AudiosConstraint(Constraint):
363+
class AudiosConstraint(_MediaListConstraint[AudioMimeType]):
425364
"""Constraint for validating audio artifacts list - validates mime_type and count limits."""
426365

427-
supported_mime_types: list[AudioMimeType] | None = None
428-
"""Supported MIME types."""
429-
430-
max_count: int | None = None
431-
"""Maximum number of audios."""
432-
433-
def __call__(self, value: AudioContent) -> list[AudioArtifact]:
434-
"""Validate audio artifact(s) against constraint and normalize to list."""
435-
# Normalize: if single AudioArtifact is passed, wrap it in a list
436-
audios = value if isinstance(value, list) else [value]
437-
438-
if self.max_count is not None and len(audios) > self.max_count:
439-
msg = f"Must have at most {self.max_count} audio(s), got {len(audios)}"
440-
raise ConstraintViolationError(msg)
441-
442-
if self.supported_mime_types is not None:
443-
for i, aud in enumerate(audios):
444-
if not isinstance(aud, AudioArtifact):
445-
msg = f"Audio {i + 1}: Must be AudioArtifact, got {type(aud).__name__}"
446-
raise ConstraintViolationError(msg)
447-
if aud.mime_type not in self.supported_mime_types:
448-
supported_values = [mt.value for mt in self.supported_mime_types]
449-
got_value = aud.mime_type.value if aud.mime_type else None
450-
msg = (
451-
f"Audio {i + 1}: mime_type must be one of {supported_values}, "
452-
f"got {got_value!r}"
453-
)
454-
raise ConstraintViolationError(msg)
455-
456-
return audios
366+
_artifact_type = AudioArtifact
367+
_media_label = "audio"
457368

458369

459370
__all__ = [

src/celeste/io.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,20 @@ def _extract_input_type(param_type: type) -> InputType | None:
8585

8686

8787
def get_constraint_input_type(constraint: Constraint) -> InputType | None:
88-
"""Get InputType from constraint's __call__ signature.
89-
90-
Introspects the constraint's __call__ method to find what artifact type
91-
it accepts, then maps to InputType using INPUT_TYPE_MAPPING.
88+
"""Get InputType from constraint's _artifact_type ClassVar or __call__ signature.
9289
9390
Args:
9491
constraint: The constraint to inspect.
9592
9693
Returns:
9794
InputType if the constraint accepts a mapped artifact type, None otherwise.
9895
"""
96+
# Fast path: check _artifact_type ClassVar (media constraints)
97+
artifact_type = getattr(constraint, "_artifact_type", None)
98+
if artifact_type is not None and artifact_type in INPUT_TYPE_MAPPING:
99+
return INPUT_TYPE_MAPPING[artifact_type]
100+
101+
# Fallback: introspect __call__ signature (Str, other constraints)
99102
annotations = inspect.get_annotations(constraint.__call__, eval_str=True)
100103
for param_type in annotations.values():
101104
result = _extract_input_type(param_type)

0 commit comments

Comments
 (0)