Litestar Python.org Architecture¶
Executive Summary¶
This document outlines the comprehensive architecture for migrating python.org from Django to Litestar. The migration preserves all functionality while modernizing the tech stack to leverage async Python, type safety, and modern architectural patterns.
Status: Proposed Version: 1.0 Date: 2025-11-25 Target Litestar Version: 2.x Python Version: 3.12+
System Overview¶
Current Django Architecture¶
The existing python.org Django application consists of:
17 Django Apps: banners, blogs, boxes, cms, codesamples, community, companies, downloads, events, jobs, mailing, minutes, nominations, pages, sponsors, successstories, users, work_groups
41 Database Tables (across all migrations)
Authentication: django-allauth for OAuth and email/username auth
Background Tasks: Celery with Redis broker
Search: Haystack integration
Static Assets: Django Pipeline for CSS/JS compilation
Cache Invalidation: Fastly CDN integration
APIs: Both Tastypie (v1) and Django REST Framework (v2)
Target Litestar Architecture¶
The new architecture will be:
Modular Domain-Driven: Each Django app becomes a domain module
Async-First: Leveraging SQLAlchemy 2.0 async capabilities
Type-Safe: Full type hints with Pydantic v2
Modern Tooling: uv, Ruff, SAQ for background tasks
API-Centric: RESTful API with automatic OpenAPI documentation
Cloud-Native: Container-ready with proper health checks
Technology Stack¶
Core Framework Stack¶
Component |
Technology |
Version |
Rationale |
|---|---|---|---|
Web Framework |
Litestar |
2.x |
Modern async framework with excellent DX |
ORM |
SQLAlchemy |
2.0+ |
Industry standard, async support, type-safe |
ORM Helper |
Advanced Alchemy |
latest |
Litestar-native database utilities |
Migrations |
Alembic |
latest |
SQLAlchemy’s migration tool |
Validation |
Pydantic |
2.x |
Type-safe data validation, Litestar native |
Templates |
Jinja2 |
3.x |
Compatible with Django templates |
Background Tasks |
SAQ |
latest |
Async task queue, simpler than Celery |
Cache |
Redis |
7.x |
Session storage, task queue backend |
Search |
Meilisearch |
latest |
Modern, fast alternative to Elasticsearch |
Package Manager |
uv |
latest |
Fast, reliable dependency management |
Development & Quality Tools¶
Tool |
Purpose |
|---|---|
Ruff |
Linting and formatting |
Mypy |
Static type checking |
Pytest |
Testing framework |
pytest-asyncio |
Async test support |
httpx |
Async HTTP client for testing |
Factory Boy |
Test data generation |
Faker |
Fake data generation |
Infrastructure & DevOps¶
Component |
Technology |
|---|---|
Database |
PostgreSQL 15+ |
Container |
Docker with multi-stage builds |
Orchestration |
Docker Compose (dev), Kubernetes (prod) |
Reverse Proxy |
Nginx or Caddy |
CDN |
Fastly (maintained) |
Monitoring |
Prometheus + Grafana |
Logging |
Structured logging with Structlog |
Project Structure¶
Directory Layout¶
litestar-pydotorg/
├── pyproject.toml # Project metadata & dependencies
├── uv.lock # Locked dependencies
├── .python-version # Python version pin
├── alembic.ini # Database migration config
│
├── src/
│ └── pydotorg/
│ ├── __init__.py
│ ├── main.py # Application entry point
│ ├── config.py # Configuration management
│ ├── deps.py # Dependency injection
│ │
│ ├── core/ # Core infrastructure
│ │ ├── __init__.py
│ │ ├── auth/ # Authentication & authorization
│ │ ├── cache/ # Caching utilities
│ │ ├── database/ # Database configuration
│ │ ├── exceptions/ # Custom exceptions
│ │ ├── middleware/ # Custom middleware
│ │ ├── security/ # Security utilities
│ │ └── templates/ # Template configuration
│ │
│ ├── lib/ # Shared utilities
│ │ ├── __init__.py
│ │ ├── dto/ # Data Transfer Objects
│ │ ├── guards/ # Route guards
│ │ ├── schemas/ # Pydantic schemas
│ │ └── utils/ # Helper functions
│ │
│ ├── domains/ # Domain modules (former Django apps)
│ │ ├── __init__.py
│ │ │
│ │ ├── users/
│ │ │ ├── __init__.py
│ │ │ ├── models.py # SQLAlchemy models
│ │ │ ├── schemas.py # Pydantic schemas
│ │ │ ├── services.py # Business logic
│ │ │ ├── controllers.py # HTTP handlers
│ │ │ ├── dependencies.py # Domain-specific deps
│ │ │ └── guards.py # Domain-specific guards
│ │ │
│ │ ├── pages/
│ │ │ ├── __init__.py
│ │ │ ├── models.py
│ │ │ ├── schemas.py
│ │ │ ├── services.py
│ │ │ ├── controllers.py
│ │ │ └── repositories.py # Data access layer
│ │ │
│ │ ├── downloads/
│ │ ├── events/
│ │ ├── jobs/
│ │ ├── community/
│ │ ├── sponsors/
│ │ ├── blogs/
│ │ └── [... other domains]
│ │
│ ├── tasks/ # Background tasks
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── downloads.py
│ │ ├── events.py
│ │ └── search.py
│ │
│ └── api/ # API versioning
│ ├── __init__.py
│ ├── v1/ # Legacy API compatibility
│ └── v2/ # Modern API
│
├── migrations/ # Alembic migrations
│ ├── versions/
│ └── env.py
│
├── templates/ # Jinja2 templates
│ ├── base.html
│ ├── users/
│ ├── pages/
│ └── [... domain templates]
│
├── static/ # Static assets
│ ├── css/
│ ├── js/
│ └── img/
│
├── tests/ # Test suite
│ ├── conftest.py
│ ├── unit/
│ ├── integration/
│ └── e2e/
│
├── scripts/ # Utility scripts
│ ├── import_django_data.py
│ └── setup_dev.sh
│
└── docs/ # Documentation
├── architecture/
├── api/
└── deployment/
Module Organization Pattern¶
Each domain follows a consistent structure:
# domains/example/models.py
from sqlalchemy.orm import Mapped, mapped_column
from pydotorg.core.database import Base
class Example(Base):
__tablename__ = "example"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
# domains/example/schemas.py
from pydantic import BaseModel, ConfigDict
class ExampleRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
class ExampleCreate(BaseModel):
name: str
# domains/example/services.py
from typing import Protocol
from sqlalchemy.ext.asyncio import AsyncSession
class ExampleService:
def __init__(self, session: AsyncSession):
self.session = session
async def create(self, data: ExampleCreate) -> Example:
...
# domains/example/controllers.py
from litestar import Controller, get, post
from litestar.di import Provide
class ExampleController(Controller):
path = "/examples"
dependencies = {"service": Provide(get_example_service)}
@get("/")
async def list_examples(self, service: ExampleService) -> list[ExampleRead]:
...
@post("/")
async def create_example(
self,
data: ExampleCreate,
service: ExampleService
) -> ExampleRead:
...
Domain Model¶
Domain Mapping (Django Apps → Litestar Domains)¶
Django App |
Litestar Domain |
Primary Responsibility |
|---|---|---|
users |
domains/users |
User management, authentication, membership |
pages |
domains/pages |
CMS pages, flat content |
downloads |
domains/downloads |
Python releases, files, OS listings |
events |
domains/events |
Calendar, events, recurring rules |
jobs |
domains/jobs |
Job board postings, categories |
community |
domains/community |
Posts, media (photos/videos/links) |
sponsors |
domains/sponsors |
Sponsorship management |
blogs |
domains/blogs |
Blog aggregation |
boxes |
domains/boxes |
Content widgets/boxes |
cms |
domains/cms |
Base CMS functionality (mixin patterns) |
banners |
domains/banners |
Banner management |
codesamples |
domains/codesamples |
Code snippet repository |
companies |
domains/companies |
Company directory |
mailing |
domains/mailing |
Mailing list integration |
minutes |
domains/minutes |
Board meeting minutes |
nominations |
domains/nominations |
PSF nominations |
successstories |
domains/successstories |
Success story content |
work_groups |
domains/work_groups |
PSF working groups |
Cross-Domain Dependencies¶
users (core)
↑
├── pages (depends on users for creator)
├── events (depends on users for creator)
├── jobs (depends on users for submitter)
├── community (depends on users for creator)
└── sponsors (depends on users for membership)
downloads
↑
└── boxes (downloads box generation)
events
↑
└── community (event posts)
Design Principle: Minimize circular dependencies. Core domains (users, cms) have no dependencies. Feature domains depend on core but not each other.
Database Architecture¶
Schema Design Principles¶
Async SQLAlchemy 2.0: Use
AsyncSessionthroughoutType Annotations: Full
Mapped[]type hintsBase Model Pattern: Common fields in abstract base
Indexes: Strategic indexing for query patterns
Constraints: Database-level integrity enforcement
Base Model¶
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
"""Base for all database models."""
pass
class TimestampMixin:
"""Mixin for created/updated timestamps."""
created: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
index=True
)
updated: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now()
)
class CreatorMixin:
"""Mixin for creator tracking."""
creator_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True
)
last_modified_by_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True
)
Key Models Overview¶
Users Domain¶
# users/models.py
from sqlalchemy import String, Integer, Boolean, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
class User(Base, TimestampMixin):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(150), unique=True, index=True)
email: Mapped[str] = mapped_column(String(254), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
first_name: Mapped[Optional[str]] = mapped_column(String(150))
last_name: Mapped[Optional[str]] = mapped_column(String(150))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_staff: Mapped[bool] = mapped_column(Boolean, default=False)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
# Profile fields
bio: Mapped[Optional[str]] = mapped_column(Text)
bio_markup_type: Mapped[str] = mapped_column(String(30), default="markdown")
search_visibility: Mapped[int] = mapped_column(Integer, default=1)
email_privacy: Mapped[int] = mapped_column(Integer, default=2)
public_profile: Mapped[bool] = mapped_column(Boolean, default=True)
# Relationships
membership: Mapped[Optional["Membership"]] = relationship(
back_populates="creator",
uselist=False
)
class Membership(Base, TimestampMixin):
__tablename__ = "memberships"
id: Mapped[int] = mapped_column(primary_key=True)
creator_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
unique=True
)
membership_type: Mapped[int] = mapped_column(Integer, default=0)
legal_name: Mapped[str] = mapped_column(String(100))
preferred_name: Mapped[str] = mapped_column(String(100))
email_address: Mapped[str] = mapped_column(String(100))
city: Mapped[Optional[str]] = mapped_column(String(100))
region: Mapped[Optional[str]] = mapped_column(String(100))
country: Mapped[Optional[str]] = mapped_column(String(100))
postal_code: Mapped[Optional[str]] = mapped_column(String(20))
psf_code_of_conduct: Mapped[Optional[bool]]
psf_announcements: Mapped[Optional[bool]]
votes: Mapped[bool] = mapped_column(Boolean, default=False)
last_vote_affirmation: Mapped[Optional[datetime]]
creator: Mapped["User"] = relationship(back_populates="membership")
Pages Domain¶
# pages/models.py
class Page(Base, TimestampMixin, CreatorMixin):
__tablename__ = "pages"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(500))
keywords: Mapped[Optional[str]] = mapped_column(String(1000))
description: Mapped[Optional[str]] = mapped_column(Text)
path: Mapped[str] = mapped_column(
String(500),
unique=True,
index=True
)
content: Mapped[str] = mapped_column(Text)
content_markup_type: Mapped[str] = mapped_column(
String(30),
default="restructuredtext"
)
is_published: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
content_type: Mapped[str] = mapped_column(String(150), default="text/html")
template_name: Mapped[Optional[str]] = mapped_column(String(100))
__table_args__ = (
Index('ix_pages_path_published', 'path', 'is_published'),
)
Downloads Domain¶
# downloads/models.py
class OS(Base, TimestampMixin, CreatorMixin):
__tablename__ = "operating_systems"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200))
slug: Mapped[str] = mapped_column(String(200), unique=True)
releases: Mapped[list["ReleaseFile"]] = relationship(back_populates="os")
class Release(Base, TimestampMixin, CreatorMixin):
__tablename__ = "releases"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200))
slug: Mapped[str] = mapped_column(String(200), unique=True)
version: Mapped[int] = mapped_column(Integer, default=3)
is_latest: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
is_published: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
pre_release: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
show_on_download_page: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
release_date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
release_page_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("pages.id", ondelete="SET NULL")
)
release_notes_url: Mapped[Optional[str]] = mapped_column(String(200))
content: Mapped[str] = mapped_column(Text, default="")
content_markup_type: Mapped[str] = mapped_column(String(30), default="markdown")
files: Mapped[list["ReleaseFile"]] = relationship(back_populates="release")
class ReleaseFile(Base, TimestampMixin, CreatorMixin):
__tablename__ = "release_files"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200))
slug: Mapped[str] = mapped_column(String(200), unique=True)
os_id: Mapped[int] = mapped_column(ForeignKey("operating_systems.id"))
release_id: Mapped[int] = mapped_column(ForeignKey("releases.id"))
description: Mapped[Optional[str]] = mapped_column(Text)
is_source: Mapped[bool] = mapped_column(Boolean, default=False)
url: Mapped[str] = mapped_column(String(500), unique=True, index=True)
gpg_signature_file: Mapped[Optional[str]] = mapped_column(String(500))
sigstore_signature_file: Mapped[Optional[str]] = mapped_column(String(500))
sigstore_cert_file: Mapped[Optional[str]] = mapped_column(String(500))
sigstore_bundle_file: Mapped[Optional[str]] = mapped_column(String(500))
sbom_spdx2_file: Mapped[Optional[str]] = mapped_column(String(500))
md5_sum: Mapped[Optional[str]] = mapped_column(String(200))
filesize: Mapped[int] = mapped_column(Integer, default=0)
download_button: Mapped[bool] = mapped_column(Boolean, default=False)
os: Mapped["OS"] = relationship(back_populates="releases")
release: Mapped["Release"] = relationship(back_populates="files")
__table_args__ = (
UniqueConstraint(
'os_id', 'release_id',
name='uq_one_download_per_os_per_release',
sqlite_where='download_button = true'
),
)
Events Domain¶
# events/models.py
class Calendar(Base, TimestampMixin, CreatorMixin):
__tablename__ = "calendars"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
slug: Mapped[str] = mapped_column(String(100), unique=True)
description: Mapped[Optional[str]] = mapped_column(String(255))
url: Mapped[Optional[str]] = mapped_column(String(500))
rss: Mapped[Optional[str]] = mapped_column(String(500))
embed: Mapped[Optional[str]] = mapped_column(String(500))
twitter: Mapped[Optional[str]] = mapped_column(String(500))
events: Mapped[list["Event"]] = relationship(back_populates="calendar")
class Event(Base, TimestampMixin, CreatorMixin):
__tablename__ = "events"
id: Mapped[int] = mapped_column(primary_key=True)
uid: Mapped[Optional[str]] = mapped_column(String(200))
title: Mapped[str] = mapped_column(String(200))
calendar_id: Mapped[int] = mapped_column(ForeignKey("calendars.id"))
venue_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("event_locations.id", ondelete="SET NULL")
)
description: Mapped[str] = mapped_column(Text)
description_markup_type: Mapped[str] = mapped_column(
String(30),
default="restructuredtext"
)
featured: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
calendar: Mapped["Calendar"] = relationship(back_populates="events")
venue: Mapped[Optional["EventLocation"]] = relationship()
categories: Mapped[list["EventCategory"]] = relationship(
secondary="event_category_associations"
)
Migration from Django¶
Field Mapping:
Django
CharField→ SQLAlchemyStringDjango
TextField→ SQLAlchemyTextDjango
BooleanField→ SQLAlchemyBooleanDjango
DateTimeField→ SQLAlchemyDateTime(timezone=True)Django
ForeignKey→ SQLAlchemyForeignKey+relationship
Default Values:
Django
default=timezone.now→ SQLAlchemyserver_default=func.now()Python defaults →
default=parameter
Indexes:
Django
db_index=True→ SQLAlchemyindex=TrueComposite indexes →
__table_args__
API Design¶
API Architecture¶
The application provides two API versions:
API v1 (Legacy Compatibility): Tastypie-compatible endpoints
API v2 (Modern): RESTful with full OpenAPI documentation
API v2 Design Principles¶
RESTful Resource Design
Consistent Response Format
Pagination Built-in
Rate Limiting
OpenAPI Documentation
Versioning via URL Path
Response Format¶
# Success Response
{
"data": [...],
"meta": {
"total": 100,
"page": 1,
"page_size": 20,
"has_next": true,
"has_previous": false
}
}
# Error Response
{
"detail": "Error message",
"status_code": 400,
"extra": {
"field": ["Field-specific error"]
}
}
Example API Routes¶
# api/v2/downloads.py
from litestar import Controller, get, post
from litestar.params import Parameter
from litestar.pagination import OffsetPagination
class DownloadAPIController(Controller):
path = "/api/v2/downloads"
tags = ["downloads"]
@get("/releases")
async def list_releases(
self,
service: ReleaseService,
version: int | None = Parameter(
query="version",
description="Filter by Python version (1, 2, 3)"
),
is_latest: bool | None = Parameter(
query="is_latest",
description="Show only latest releases"
),
limit: int = 20,
offset: int = 0,
) -> OffsetPagination[ReleaseRead]:
"""List Python releases with filtering."""
releases = await service.list_releases(
version=version,
is_latest=is_latest,
limit=limit,
offset=offset
)
return OffsetPagination(
items=releases,
total=await service.count_releases(version, is_latest),
limit=limit,
offset=offset
)
@get("/releases/{release_slug:str}")
async def get_release(
self,
release_slug: str,
service: ReleaseService
) -> ReleaseRead:
"""Get a specific release by slug."""
release = await service.get_by_slug(release_slug)
if not release:
raise NotFoundException("Release not found")
return release
@get("/releases/{release_slug:str}/files")
async def list_release_files(
self,
release_slug: str,
service: ReleaseService,
os_slug: str | None = None
) -> list[ReleaseFileRead]:
"""List files for a release, optionally filtered by OS."""
return await service.list_files(release_slug, os_slug)
OpenAPI Configuration¶
# config.py
from litestar.openapi import OpenAPIConfig, OpenAPIController
from litestar.openapi.spec import Contact, License
openapi_config = OpenAPIConfig(
title="Python.org API",
version="2.0.0",
description="Official Python.org API for releases, events, jobs, and more",
contact=Contact(
name="Python Software Foundation",
url="https://www.python.org",
email="pydotorg-www@python.org"
),
license=License(
name="Apache 2.0",
url="https://www.apache.org/licenses/LICENSE-2.0.html"
),
use_handler_docstrings=True,
tags=[
{"name": "downloads", "description": "Python release downloads"},
{"name": "events", "description": "Community events"},
{"name": "jobs", "description": "Job board"},
{"name": "users", "description": "User management"},
]
)
Configuration Management¶
Environment-Based Configuration¶
# config.py
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field, PostgresDsn, RedisDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore"
)
# Environment
environment: Literal["development", "staging", "production"] = "development"
debug: bool = False
# Application
site_name: str = "Python.org"
site_description: str = "The official home of the Python Programming Language"
secret_key: str = Field(..., min_length=32)
# Database
database_url: PostgresDsn = Field(
default="postgresql+asyncpg://postgres:postgres@localhost:5432/pythondotorg"
)
database_echo: bool = False
database_pool_size: int = 20
database_max_overflow: int = 10
# Redis
redis_url: RedisDsn = Field(default="redis://localhost:6379/0")
# Cache
cache_ttl: int = 3600
# Session
session_cookie_name: str = "pydotorg_session"
session_lifetime: int = 86400 * 14 # 14 days
# Email
email_backend: str = "smtp"
email_host: str = "localhost"
email_port: int = 587
email_use_tls: bool = True
email_username: str = ""
email_password: str = ""
default_from_email: str = "noreply@python.org"
# Background Tasks
saq_queue_name: str = "pydotorg"
# CDN
fastly_api_key: str | None = None
fastly_service_id: str | None = None
# Static Files
static_url: str = "/static/"
media_url: str = "/media/"
static_root: Path = Path("static-root")
media_root: Path = Path("media")
# Search
meilisearch_url: str = "http://localhost:7700"
meilisearch_api_key: str | None = None
# Jobs
job_threshold_days: int = 90
job_from_email: str = "jobs@python.org"
# Events
events_to_email: str = "events@python.org"
# Sponsors
sponsorship_notification_from_email: str = "sponsors@python.org"
sponsorship_notification_to_email: str = "psf-sponsors@python.org"
# Rate Limiting
rate_limit_anonymous: str = "100/day"
rate_limit_authenticated: str = "3000/day"
# Logging
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
@property
def is_production(self) -> bool:
return self.environment == "production"
@property
def is_development(self) -> bool:
return self.environment == "development"
@lru_cache
def get_settings() -> Settings:
"""Cached settings instance."""
return Settings()
Environment Files¶
# .env.example
ENVIRONMENT=development
DEBUG=true
SECRET_KEY=your-secret-key-min-32-chars-long
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/pythondotorg
REDIS_URL=redis://localhost:6379/0
EMAIL_HOST=smtp.sendgrid.net
EMAIL_USERNAME=apikey
EMAIL_PASSWORD=your-sendgrid-api-key
FASTLY_API_KEY=
FASTLY_SERVICE_ID=
MEILISEARCH_URL=http://localhost:7700
MEILISEARCH_API_KEY=
Background Tasks¶
SAQ Integration¶
# tasks/base.py
from typing import Any
from saq import Queue
from saq.worker import Worker
from redis.asyncio import Redis
from pydotorg.config import get_settings
settings = get_settings()
# Create Redis connection
redis = Redis.from_url(str(settings.redis_url))
# Create queue
queue = Queue(redis, name=settings.saq_queue_name)
# Define worker
worker = Worker(
queue=queue,
functions=[
# Register all task functions here
],
concurrency=10,
)
# tasks/downloads.py
from saq import Job
from pydotorg.domains.downloads.services import ReleaseService
from .base import queue
async def update_download_boxes(ctx: dict[str, Any]) -> None:
"""Update homepage and download page boxes."""
async with get_db_session() as session:
service = ReleaseService(session)
await service.update_download_boxes()
async def purge_download_cache(ctx: dict[str, Any], release_id: int) -> None:
"""Purge Fastly cache for download pages."""
async with get_db_session() as session:
service = ReleaseService(session)
await service.purge_cache(release_id)
# Schedule tasks
async def schedule_update_boxes():
"""Schedule periodic box updates."""
await queue.enqueue(
"update_download_boxes",
scheduled=int(time.time()) + 3600 # 1 hour from now
)
# tasks/events.py
async def import_calendar_events(ctx: dict[str, Any], calendar_id: int) -> None:
"""Import events from iCal feed."""
async with get_db_session() as session:
from pydotorg.domains.events.importer import ICSImporter
result = await session.execute(
select(Calendar).where(Calendar.id == calendar_id)
)
calendar = result.scalar_one()
importer = ICSImporter(calendar, session)
await importer.import_events()
Task Scheduling¶
# main.py - Application startup
from saq.cron import cron
@app.on_startup
async def start_task_worker():
"""Start background task worker."""
# Schedule periodic tasks
await queue.schedule(
"update_download_boxes",
cron="0 */6 * * *", # Every 6 hours
)
await queue.schedule(
"cleanup_expired_jobs",
cron="0 0 * * *", # Daily at midnight
)
Migration Strategy¶
Phase 1: Foundation (Weeks 1-2)¶
Goal: Set up project structure and core infrastructure
Initialize project with uv
Configure Litestar application
Set up SQLAlchemy with async support
Configure Alembic migrations
Implement base models and mixins
Set up testing framework
Configure CI/CD pipeline
Phase 2: Core Domains (Weeks 3-5)¶
Goal: Migrate essential domains
Priority Order:
Users Domain (authentication foundation)
CMS Domain (base mixins and patterns)
Pages Domain (core content)
Downloads Domain (critical functionality)
For each domain:
Create SQLAlchemy models
Write Alembic migrations
Define Pydantic schemas
Implement services layer
Build controllers
Write unit tests
Create integration tests
Phase 3: Feature Domains (Weeks 6-9)¶
Goal: Migrate remaining domains
Events
Jobs
Community
Sponsors
Blogs
Boxes
Banners
Success Stories
Nominations
Minutes
Work Groups
Companies
Code Samples
Mailing
Phase 4: Templates & Frontend (Weeks 10-11)¶
Goal: Port Django templates to Jinja2
Convert base templates
Implement template context processors
Port domain-specific templates
Integrate static asset pipeline
Test responsive layouts
Accessibility audit
Phase 5: Background Tasks & Integration (Week 12)¶
Goal: Implement async tasks and integrations
Set up SAQ task queue
Port Celery tasks to SAQ
Integrate Meilisearch
Configure Fastly cache purging
Email delivery setup
OAuth provider integration
Phase 6: Testing & Optimization (Weeks 13-14)¶
Goal: Ensure quality and performance
End-to-end testing
Performance benchmarking
Load testing
Security audit
Database query optimization
Cache strategy tuning
Phase 7: Deployment & Cutover (Weeks 15-16)¶
Goal: Production deployment
Staging environment deployment
Data migration from Django
Smoke testing in staging
Performance monitoring setup
Production deployment
DNS cutover
Post-deployment monitoring
Data Migration Script¶
# scripts/import_django_data.py
"""
Import data from Django database to Litestar database.
Usage:
uv run python scripts/import_django_data.py --django-db postgresql://... --litestar-db postgresql://...
"""
import asyncio
from sqlalchemy import create_engine, select
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
async def migrate_users(django_conn, litestar_session):
"""Migrate users from Django to Litestar."""
# Read from Django
django_users = django_conn.execute(
"SELECT * FROM auth_user"
).fetchall()
# Transform and insert into Litestar
for du in django_users:
user = User(
id=du.id,
username=du.username,
email=du.email,
password_hash=du.password, # Keep Django password hashes
first_name=du.first_name,
last_name=du.last_name,
is_active=du.is_active,
is_staff=du.is_staff,
is_superuser=du.is_superuser,
created=du.date_joined,
)
litestar_session.add(user)
await litestar_session.commit()
async def main():
# Connect to both databases
django_engine = create_engine("postgresql://...")
litestar_engine = create_async_engine("postgresql+asyncpg://...")
with django_engine.connect() as django_conn:
async with AsyncSession(litestar_engine) as session:
await migrate_users(django_conn, session)
await migrate_pages(django_conn, session)
await migrate_releases(django_conn, session)
# ... continue for all domains
if __name__ == "__main__":
asyncio.run(main())
Performance Considerations¶
Database Optimization¶
Connection Pooling:
engine = create_async_engine( settings.database_url, pool_size=20, max_overflow=10, pool_pre_ping=True, echo=settings.database_echo )
Query Optimization:
Use
selectinload()for relationshipsImplement pagination for large result sets
Add indexes for common query patterns
Use
defer()for large text fields
Caching Strategy:
# core/cache/decorator.py from functools import wraps from typing import Callable def cache_result(ttl: int = 3600): def decorator(func: Callable): @wraps(func) async def wrapper(*args, **kwargs): cache_key = f"{func.__name__}:{args}:{kwargs}" # Try cache first cached = await redis.get(cache_key) if cached: return json.loads(cached) # Execute function result = await func(*args, **kwargs) # Store in cache await redis.setex( cache_key, ttl, json.dumps(result, default=str) ) return result return wrapper return decorator
Response Optimization¶
Compression: Enable gzip compression
ETags: Implement ETags for static content
CDN: Leverage Fastly for static assets and page caching
Async Best Practices¶
Non-blocking I/O: Use
asynciofor all I/O operationsTask Concurrency: Use
asyncio.gather()for parallel tasksConnection Limits: Configure appropriate pool sizes
Security Architecture¶
Security Principles¶
Defense in Depth
Least Privilege
Secure by Default
Input Validation
Output Encoding
Implementation¶
# core/security/middleware.py
from litestar.middleware import DefineMiddleware
from litestar.security.session_auth import SessionAuth
security_config = SessionAuth(
secret=settings.secret_key,
cookie_name=settings.session_cookie_name,
cookie_httponly=True,
cookie_secure=settings.is_production,
cookie_samesite="lax",
)
# CORS configuration
cors_config = CORSConfig(
allow_origins=["https://www.python.org"],
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["*"],
allow_credentials=True,
)
# CSP headers
csp_middleware = DefineMiddleware(
CSPMiddleware,
policy={
"default-src": ["'self'"],
"script-src": ["'self'", "'unsafe-inline'"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:", "https:"],
}
)
Input Validation¶
# All input validated via Pydantic
class JobCreate(BaseModel):
job_title: str = Field(..., min_length=1, max_length=100)
company_name: str = Field(..., min_length=1, max_length=100)
email: EmailStr
url: HttpUrl
description: str = Field(..., min_length=50)
@field_validator('description')
@classmethod
def sanitize_html(cls, v: str) -> str:
"""Strip dangerous HTML tags."""
return bleach.clean(
v,
tags=['p', 'br', 'strong', 'em', 'ul', 'ol', 'li'],
strip=True
)
Rate Limiting¶
# core/middleware/rate_limit.py
from litestar.middleware.rate_limit import RateLimitConfig
rate_limit_config = RateLimitConfig(
rate_limit=("100", "minute"),
exclude=["/health", "/static/*"],
)
Appendices¶
A. Technology Decision Records¶
ADR-001: Litestar over FastAPI¶
Decision: Use Litestar 2.x as the web framework
Rationale:
Superior dependency injection system
Better integration with SQLAlchemy
Advanced Alchemy provides excellent database utilities
Strong type safety enforcement
Excellent OpenAPI documentation
Active development and community
Alternatives Considered:
FastAPI: Good but less integrated ecosystem
Starlette: Too low-level, more boilerplate needed
ADR-002: SAQ over Celery¶
Decision: Use SAQ for background tasks
Rationale:
Native async support
Simpler configuration
Better performance for async tasks
Redis-native (already in stack)
Lighter weight
Trade-offs:
Less mature than Celery
Smaller community
Fewer integrations
ADR-003: Meilisearch over Elasticsearch¶
Decision: Use Meilisearch for search functionality
Rationale:
Simpler deployment
Better out-of-box relevance
Lower resource usage
Easier to maintain
Good Python client
Trade-offs:
Less flexible than Elasticsearch
Smaller feature set
B. File Templates¶
See /docs/architecture/templates/ for:
Model template
Controller template
Service template
Schema template
Test template
C. Development Guidelines¶
See /docs/architecture/DEVELOPMENT.md for:
Code style guide
Testing standards
Git workflow
PR review process
Revision History¶
Version |
Date |
Author |
Changes |
|---|---|---|---|
1.0 |
2025-11-25 |
ARCHITECT Agent |
Initial architecture document |
References¶
Document Path: /Users/coffee/git/public/JacobCoffee/litestar-pydotorg/docs/architecture/ARCHITECTURE.md