Task 4.5: Update Pydantic models to use key as primary identifier

- Updated AnimeSeriesResponse and SearchResult models in anime.py:
  - Changed 'id' field to 'key' as the primary series identifier
  - Added 'folder' as optional metadata field
  - Added field validator to normalize key to lowercase and strip whitespace
  - Added comprehensive docstrings explaining identifier usage

- Updated DownloadItem and DownloadRequest models in download.py:
  - Added field validator for serie_id normalization (lowercase, stripped)
  - Improved documentation for serie_id (primary identifier) vs serie_folder (metadata)

- Updated test_anime_models.py with comprehensive tests:
  - Tests for key normalization and whitespace stripping
  - Tests for folder as optional metadata
  - Reorganized tests into proper class structure

- Updated test_download_models.py with validator tests:
  - Tests for serie_id normalization in DownloadItem
  - Tests for serie_id normalization in DownloadRequest

All 885 tests pass.
This commit is contained in:
2025-11-27 20:01:33 +01:00
parent 3c8ba1d48c
commit 6d2a791a9d
4 changed files with 273 additions and 96 deletions

View File

@@ -1,9 +1,24 @@
"""Anime Pydantic models for the Aniworld web application.
This module defines request/response models used by the anime API
and services. Models are focused on serialization, validation,
and OpenAPI documentation.
Note on identifiers:
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field, HttpUrl
from pydantic import BaseModel, Field, HttpUrl, field_validator
# Regex pattern for valid series keys (URL-safe, lowercase with hyphens)
KEY_PATTERN = re.compile(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$')
class EpisodeInfo(BaseModel):
@@ -31,10 +46,30 @@ class MissingEpisodeInfo(BaseModel):
class AnimeSeriesResponse(BaseModel):
"""Response model for a series with metadata and episodes."""
"""Response model for a series with metadata and episodes.
Note on identifiers:
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
This is the unique key used for all lookups and operations.
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
Used only for display and filesystem operations.
"""
id: str = Field(..., description="Unique series identifier")
key: str = Field(
...,
description=(
"Series key (primary identifier) - provider-assigned URL-safe "
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
)
)
title: str = Field(..., description="Series title")
folder: Optional[str] = Field(
None,
description=(
"Series folder name on disk (metadata only) "
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
)
)
alt_titles: List[str] = Field(default_factory=list, description="Alternative titles")
description: Optional[str] = Field(None, description="Short series description")
total_episodes: Optional[int] = Field(None, ge=0, description="Declared total episode count if known")
@@ -42,20 +77,56 @@ class AnimeSeriesResponse(BaseModel):
missing_episodes: List[MissingEpisodeInfo] = Field(default_factory=list, description="Detected missing episode ranges")
thumbnail: Optional[HttpUrl] = Field(None, description="Optional thumbnail image URL")
@field_validator('key', mode='before')
@classmethod
def normalize_key(cls, v: str) -> str:
"""Normalize key to lowercase."""
if isinstance(v, str):
return v.lower().strip()
return v
class SearchRequest(BaseModel):
"""Request payload for searching series."""
query: str = Field(..., min_length=1)
limit: int = Field(10, ge=1, le=100)
include_adult: bool = Field(False)
query: str = Field(..., min_length=1, description="Search query string")
limit: int = Field(10, ge=1, le=100, description="Maximum number of results")
include_adult: bool = Field(False, description="Include adult content in results")
class SearchResult(BaseModel):
"""Search result item for a series discovery endpoint."""
"""Search result item for a series discovery endpoint.
Note on identifiers:
- key: Primary identifier (provider-assigned, URL-safe, e.g., 'attack-on-titan')
This is the unique key used for all lookups and operations.
- folder: Filesystem folder name (metadata only, e.g., 'Attack on Titan (2013)')
Used only for display and filesystem operations.
"""
id: str
title: str
snippet: Optional[str] = None
thumbnail: Optional[HttpUrl] = None
score: Optional[float] = None
key: str = Field(
...,
description=(
"Series key (primary identifier) - provider-assigned URL-safe "
"key (e.g., 'attack-on-titan'). Used for lookups/identification."
)
)
title: str = Field(..., description="Series title")
folder: Optional[str] = Field(
None,
description=(
"Series folder name on disk (metadata only) "
"(e.g., 'Attack on Titan (2013)'). For display/filesystem ops only."
)
)
snippet: Optional[str] = Field(None, description="Short description or snippet")
thumbnail: Optional[HttpUrl] = Field(None, description="Thumbnail image URL")
score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Search relevance score (0-1)")
@field_validator('key', mode='before')
@classmethod
def normalize_key(cls, v: str) -> str:
"""Normalize key to lowercase."""
if isinstance(v, str):
return v.lower().strip()
return v