Monkey-patching and extensions for django-stubs providing runtime type support for Django generic classes
—
Type-safe containers and annotations for Django model annotations, enabling proper typing of models with computed fields, aggregations, and custom annotations. This system provides compile-time type safety for dynamically computed model attributes.
A generic container class for TypedDict-style model annotations that provides type safety for annotated model instances.
class Annotations(Generic[_Annotations]):
"""
Generic container for TypedDict-style model annotations.
Use as `Annotations[MyTypedDict]` where MyTypedDict defines the
structure of annotations added to model instances.
"""
passA convenient type alias that combines Django models with their annotation types using Python's Annotated type.
WithAnnotations = Annotated[_T, Annotations[_Annotations]]
"""
Annotated type combining Django models with their annotation types.
Use as:
- `WithAnnotations[MyModel]` - Model with Any annotations
- `WithAnnotations[MyModel, MyTypedDict]` - Model with typed annotations
This enables type-safe access to both model fields and computed annotations.
"""Basic model annotations with TypedDict:
from django_stubs_ext import WithAnnotations, Annotations
from django.db import models
from django.db.models import Count, Avg, Case, When, QuerySet
from django.utils import timezone
from datetime import timedelta
from typing import TypedDict
class User(models.Model):
name = models.CharField(max_length=100)
email = models.EmailField()
created_at = models.DateTimeField(auto_now_add=True)
class UserStats(TypedDict):
post_count: int
avg_rating: float
is_active: bool
# Type-annotated QuerySet with custom annotations
def get_user_stats() -> QuerySet[WithAnnotations[User, UserStats]]:
return User.objects.annotate(
post_count=Count('posts'),
avg_rating=Avg('posts__rating'),
is_active=Case(
When(last_login__gte=timezone.now() - timedelta(days=30), then=True),
default=False
)
)
# Type-safe usage
users_with_stats = get_user_stats()
for user in users_with_stats:
# Both model fields and annotations are properly typed
print(f"{user.name} has {user.post_count} posts") # type: ignore
print(f"Average rating: {user.avg_rating}") # type: ignore
print(f"Active: {user.is_active}") # type: ignoreGeneric annotations without specific TypedDict:
from django_stubs_ext import WithAnnotations
from django.db import models
from django.db.models import F
class Product(models.Model):
name = models.CharField(max_length=100)
base_price = models.DecimalField(max_digits=10, decimal_places=2)
tax_rate = models.DecimalField(max_digits=5, decimal_places=4)
# Using WithAnnotations without specific TypedDict (annotations are Any)
def get_products_with_total() -> QuerySet[WithAnnotations[Product]]:
return Product.objects.annotate(
total_price=F('base_price') * (1 + F('tax_rate'))
)
products = get_products_with_total()
for product in products:
print(f"{product.name}: ${product.total_price}") # type: ignoreComplex aggregations with multiple annotation types:
from django_stubs_ext import WithAnnotations, Annotations
from django.db import models
from django.db.models import Count, Sum, Q, QuerySet
from typing import TypedDict
class Customer(models.Model):
name = models.CharField(max_length=100)
class Order(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
total = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=20)
created_at = models.DateTimeField(auto_now_add=True)
class CustomerStats(TypedDict):
total_orders: int
total_spent: float
pending_orders: int
completed_orders: int
def get_customer_analytics() -> QuerySet[WithAnnotations[Customer, CustomerStats]]:
return Customer.objects.annotate(
total_orders=Count('orders'),
total_spent=Sum('orders__total'),
pending_orders=Count('orders', filter=Q(orders__status='pending')),
completed_orders=Count('orders', filter=Q(orders__status='completed'))
)
# Type-safe analytics
customers = get_customer_analytics()
for customer in customers:
stats = {
'name': customer.name,
'total_orders': customer.total_orders, # type: ignore
'total_spent': customer.total_spent, # type: ignore
'pending': customer.pending_orders, # type: ignore
'completed': customer.completed_orders # type: ignore
}
print(stats)The annotation types work seamlessly with Django QuerySet operations:
from django_stubs_ext import WithAnnotations
from django.db.models import Count, Q
from typing import TypedDict
class PostStats(TypedDict):
comment_count: int
published_comment_count: int
def get_popular_posts() -> QuerySet[WithAnnotations[Post, PostStats]]:
return Post.objects.annotate(
comment_count=Count('comments'),
published_comment_count=Count('comments', filter=Q(comments__published=True))
).filter(
comment_count__gte=10
).order_by('-published_comment_count')
# Chainable operations maintain type information
popular_posts = get_popular_posts()
top_posts = popular_posts[:5] # Still properly typed
recent_popular = popular_posts.filter(created_at__gte=last_week)from typing import Any, Generic, Mapping, TypeVar
from django.db.models.base import Model
from typing_extensions import Annotated
# Type variables for generic annotation support
_Annotations = TypeVar("_Annotations", covariant=True, bound=Mapping[str, Any])
_T = TypeVar("_T", bound=Model)
class Annotations(Generic[_Annotations]):
"""Use as `Annotations[MyTypedDict]`"""
pass
WithAnnotations = Annotated[_T, Annotations[_Annotations]]
"""Alias to make it easy to annotate the model `_T` as having annotations
`_Annotations` (a `TypedDict` or `Any` if not provided).
Use as `WithAnnotations[MyModel]` or `WithAnnotations[MyModel, MyTypedDict]`.
"""Install with Tessl CLI
npx tessl i tessl/pypi-django-stubs-ext