Source code for platzky.models
"""Pydantic models for blog posts, pages, comments, and CMS modules."""
import datetime
import functools
import warnings
from typing import Annotated
import humanize
from pydantic import BaseModel, BeforeValidator, Field
def _parse_date_string(v: str | datetime.datetime) -> datetime.datetime:
"""Parse date string to datetime for backward compatibility.
Handles string dates in various ISO 8601 formats for backward compatibility.
Emits deprecation warning when parsing strings.
In version 2.0.0, only datetime objects will be accepted.
Args:
v: Either a datetime object or an ISO 8601 date string
Returns:
Timezone-aware datetime object
Raises:
ValueError: If the date string cannot be parsed
"""
if isinstance(v, datetime.datetime):
# If already a datetime object, ensure it's timezone-aware
if v.tzinfo is None:
# Naive datetime - make timezone-aware using UTC
return v.replace(tzinfo=datetime.timezone.utc)
return v
# v must be a string (based on type annotation)
# Emit deprecation warning for string dates
warnings.warn(
f"Passing date as string ('{v}') is deprecated. "
"Please use datetime objects instead. "
"String support will be removed in version 2.0.0.",
DeprecationWarning,
stacklevel=2,
)
# Check for timezone in the original string (before any manipulation)
# Check for: +HH:MM, -HH:MM, or Z suffix
time_part = v.split("T")[-1] if "T" in v else ""
has_timezone = (
v.endswith("Z")
or "+" in time_part
or ("-" in time_part and ":" in time_part.split("-")[-1])
)
# Normalize 'Z' suffix to '+00:00' for fromisoformat
normalized = v.replace("Z", "+00:00") if v.endswith("Z") else v
if has_timezone:
# Parse timezone-aware datetime (handles microseconds automatically)
return datetime.datetime.fromisoformat(normalized)
else:
# Legacy format: naive datetime - make timezone-aware using UTC
warnings.warn(
f"Naive datetime '{v}' interpreted as UTC. "
"Explicitly specify timezone in future versions for clarity.",
DeprecationWarning,
stacklevel=2,
)
try:
parsed = datetime.datetime.fromisoformat(normalized)
return parsed.replace(tzinfo=datetime.timezone.utc)
except ValueError:
# Fallback: date-only format
parsed_date = datetime.date.fromisoformat(normalized)
return datetime.datetime.combine(
parsed_date, datetime.time.min, tzinfo=datetime.timezone.utc
)
# Type alias for datetime fields that accept strings for backward compatibility
# Input: str | datetime.datetime
# Output (after validation): datetime.datetime
DateTimeField = Annotated[
datetime.datetime,
BeforeValidator(_parse_date_string),
# This allows str at the type-checker level while ensuring datetime after validation
]
[docs]
class CmsModule(BaseModel):
"""Represents a CMS module with basic metadata."""
name: str
description: str
template: str
slug: str
# CmsModuleGroup is also a CmsModule, but it contains other CmsModules
[docs]
class CmsModuleGroup(CmsModule):
"""Represents a group of CMS modules, inheriting module properties."""
modules: list[CmsModule] = []
[docs]
class Image(BaseModel):
"""Represents an image with URL and alternate text.
Attributes:
url: URL path to the image resource
alternateText: Descriptive text for accessibility and SEO
"""
url: str = ""
alternateText: str = ""
[docs]
@functools.total_ordering
class Post(BaseModel):
"""Represents a blog post with metadata, content, and comments.
Attributes:
author: Name of the post author
slug: URL-friendly unique identifier for the post
title: Post title
contentInMarkdown: Post content in Markdown format
excerpt: Short summary or preview of the post
coverImage: Cover image for the post
language: Language code for the post content (defaults to 'en')
comments: Optional list of comments on this post
tags: Optional list of tags for categorization
date: Optional datetime when the post was published (timezone-aware recommended)
"""
author: str
slug: str
title: str
contentInMarkdown: str
excerpt: str
coverImage: Image = Field(default_factory=Image)
language: str = "en"
comments: list[Comment] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
date: DateTimeField | None = None
def __lt__(self, other: object) -> bool:
"""Compare posts by date for sorting.
Uses datetime comparison to ensure robust and correct ordering.
Posts without dates are treated as "less than" dated posts, meaning they
appear last when using descending sort (reverse=True, newest-first) and
first when using ascending sort.
Args:
other: Another Post instance to compare against
Returns:
True if this post's date is earlier than the other post's date,
or NotImplemented if comparing with a non-Post object
"""
if isinstance(other, Post):
# Posts without dates are sorted last
if self.date is None and other.date is None:
return False
if self.date is None:
return True # None is "less than" any date (sorted last)
if other.date is None:
return False
return self.date < other.date
return NotImplemented
Page = Post # Page is an alias for Post (static pages use the same structure)
[docs]
class Color(BaseModel):
"""Represents an RGBA color value.
Attributes:
r: Red component (0-255)
g: Green component (0-255)
b: Blue component (0-255)
a: Alpha/transparency component (0-255, where 255 is fully opaque)
"""
r: int = Field(default=0, ge=0, le=255, description="Red component (0-255)")
g: int = Field(default=0, ge=0, le=255, description="Green component (0-255)")
b: int = Field(default=0, ge=0, le=255, description="Blue component (0-255)")
a: int = Field(
default=255, ge=0, le=255, description="Alpha component (0-255, where 255 is fully opaque)"
)