yandex-drf-mixins
Typed reusable components for Django REST Framework and ADRF.
The library provides several independent groups of mixins:
- DRF and ADRF
ModelViewSetbase classes, action-specific serializer selection, and create/update/upsert responses rendered with a retrieve serializer; - query-parameter and request-body serialization and validation;
- synchronous and asynchronous limit/offset pagination with
?all=truesupport; - URL and API request test mixins that check status codes, JSON contracts, error codes, and SQL query counts.
Components can be used independently or as a coherent set through
BaseModelViewSet and ABaseModelViewSet.
Installation
pip install yandex-drf-mixins
ADRF is an optional dependency:
pip install 'yandex-drf-mixins[adrf]'
Supported versions:
- Python 3.10–3.13;
- Django 4.0–5.1;
- Django REST Framework 3.14–3.15;
- ADRF 0.1.7+ within the 0.1.x series.
The package includes a py.typed marker, so its public API types are available to
mypy and other PEP 561-compatible type checkers.
Internationalization
The package provides English source messages and Russian translations for its user-facing API errors and schema descriptions. Add the package to Django's application registry so Django can discover the bundled translation catalog:
INSTALLED_APPS = [
# ...
"yandex_drf_mixins",
]
The active translation follows Django's standard LANGUAGE_CODE, locale
middleware, and translation.override() behavior. English remains the fallback
when no supported translation is active.
Quick start
Add two mixins to a regular APITestCase:
from rest_framework.test import APITestCase
from yandex_drf_mixins.testing import GenericRequestMixin, UrlNameMixin
class CourseListTestCase(UrlNameMixin, GenericRequestMixin, APITestCase):
URL_NAME = "api:course-list"
The route is then resolved by name, while the request and routine assertions fit into a single method call.
LMS test examples
User setup, authentication, and factory data are omitted because they are application-specific and identical with or without this library.
LMS: test a paginated list
This example is adapted from LMS tag tests. The domain-specific
build_expected() helper is omitted because it is unchanged by the library.
class TagListTestCase(UrlNameMixin, GenericRequestMixin, APITestCase):
URL_NAME = "api:tag-list"
def test_list(self):
expected = self.build_expected(self.tags)
self.list_request(
url=self.get_url(),
expected=expected,
num_queries=4,
)
list_request() checks:
- HTTP 200;
- the standard DRF pagination structure:
count,next,previous,results; - the value of
count; - object IDs and their order;
- the complete JSON representation of every object;
- exactly four SQL queries.
Without the library: the same list test written manually
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
class TagListTestCase(APITestCase):
def test_list(self):
expected = self.build_expected(self.tags)
url = reverse("api:tag-list")
with self.assertNumQueries(4):
response = self.client.get(url, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data.keys(),
{"count", "next", "previous", "results"},
)
self.assertEqual(response.data["count"], len(expected))
self.assertEqual(
[item["id"] for item in response.data["results"]],
[item["id"] for item in expected],
)
self.assertListEqual(response.data["results"], expected)
LMS: test creation and a serializer error code
expected may be a function of the response, which is useful for generated IDs
or timestamps. DRF errors can be checked by their stable ErrorDetail.code
values instead of localized messages. Implementations of build_expected() and
build_expected_errors() are omitted.
class TagCreateTestCase(UrlNameMixin, GenericRequestMixin, APITestCase):
URL_NAME = "labapi:tag-create"
def test_create(self):
tag = TagFactory.build()
self.create_request(
url=self.get_url(),
data={"name": tag.name},
expected=lambda response: self.build_expected(tag, response),
num_queries=5,
)
def test_duplicate_name(self):
self.create_request(
url=self.get_url(),
data={"name": "existing-tag"},
status_code=400,
check_errors=True,
expected=self.build_expected_errors(),
num_queries=2,
)
Without the library: creation and error checks written manually
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
class TagCreateTestCase(APITestCase):
def test_create(self):
tag = TagFactory.build()
url = reverse("labapi:tag-create")
with self.assertNumQueries(5):
response = self.client.post(
url,
data={"name": tag.name},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertDictEqual(
response.data,
self.build_expected(tag, response),
)
def test_duplicate_name(self):
url = reverse("labapi:tag-create")
with self.assertNumQueries(2):
response = self.client.post(
url,
data={"name": "existing-tag"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
expected_errors = self.build_expected_errors()
self.assertEqual(response.data.keys(), expected_errors.keys())
self.assertEqual(
response.data["name"][0].code,
expected_errors["name"][0],
)
update_request(), partial_update_request(), and delete_request() work the
same way. Tests contain only endpoint-specific data, while requests and repeated
assertions remain in the library.
API structure
Public components are separated by execution model:
from yandex_drf_mixins.drf import BaseModelViewSet
from yandex_drf_mixins.adrf import ABaseModelViewSet
from yandex_drf_mixins.testing import GenericRequestMixin, UrlNameMixin
yandex_drf_mixins.drfcontains synchronous DRF components;yandex_drf_mixins.adrfcontains ADRF components, with asynchronous classes prefixed byA;yandex_drf_mixins.testingcontains API test mixins;yandex_drf_mixins.baseis internal and must not be imported by applications.
Testing API
UrlNameMixin
Set URL_NAME and use:
get_url(*args, **kwargs)to call Djangoreverse();assertURLNameEqual(url, base_url=None, args=None, kwargs=None)to check that a named route matches the expected URL.
class CourseDetailTestCase(UrlNameMixin, APITestCase):
URL_NAME = "api:course-detail"
def test_url(self):
self.assertURLNameEqual(
"courses/{}/",
args=(self.course.id,),
base_url="api/v1/",
)
GenericRequestMixin
Common request helper arguments:
expected: expected JSON or aresponse -> expectedcallable;status_code: expected status, defaulting to 200, 201 for create, and 204 for delete;num_queries: expected SQL query count;Nonedisables the check;check_errors=True: recursively compare DRF error codes;data: JSON request body.
Available methods:
| Method | Default HTTP | Purpose |
|---|---|---|
list_request() |
GET | Check a list and optional DRF pagination |
detail_request() |
GET | Check one object |
create_request() |
POST | Check creation, expecting 201 by default |
update_request() |
PUT | Check a full update |
partial_update_request() |
PATCH | Check a partial update |
delete_request() |
DELETE | Check deletion, expecting 204 by default |
make_request() |
configurable | Perform any supported request scenario |
assert_errors() |
— | Recursively compare ErrorDetail structures and codes |
Additional list_request() arguments:
pagination=True: expectcount/next/previous/results;count: expected total size, including a correctcount=0check;check_ids=True: compare object IDs;only_ids=True: limit comparison to IDs;check_order=False: compare without considering order.
In synchronous tests, an explicit num_queries uses Django's native
assertNumQueries(). The check is unreliable inside an event loop, so it is
ignored there with a RuntimeWarning. Pass num_queries=None when SQL budgets
are intentionally not checked.
Action-specific serializers
ActionSerializerMixin
Select a serializer by self.action:
from yandex_drf_mixins.drf import ActionSerializerMixin
class CourseViewSet(ActionSerializerMixin, ModelViewSet):
serializer_class = CourseSerializer
serializer_classes = {
"list": CourseListSerializer,
"retrieve": CourseDetailSerializer,
"create": CourseWriteSerializer,
}
get_serializer_class() returns the action-specific class or falls back to
serializer_class. get_retrieve_serializer() always selects the retrieve
serializer and adds the regular viewset serializer context. The same
ActionSerializerMixin is exported from both drf and adrf.
Write serializer input, retrieve serializer output
Synchronous mixins
CreateWithRetrieveModelMixinvalidates the input serializer, callsperform_create(), and renders the retrieve serializer;UpdateWithRetrieveModelMixinsupports PUT/PATCH, callsperform_update(), clears the prefetch cache, and renders the retrieve serializer;UpdateOrCreateWithRetrieveModelMixinupdates an existing object or switches the action tocreateafterHttp404and creates a new one;- Django
ValidationErroris converted to DRFValidationErrorwith exception chaining preserved.
from yandex_drf_mixins.drf import BaseModelViewSet
class CourseViewSet(BaseModelViewSet):
queryset = Course.objects.all()
serializer_class = CourseSerializer
serializer_classes = {
"create": CourseWriteSerializer,
"update": CourseWriteSerializer,
"partial_update": CourseWriteSerializer,
"retrieve": CourseDetailSerializer,
}
Asynchronous counterparts
ACreateWithRetrieveModelMixin:acreate(),acreate_with_retrieve(), andaperform_create();AUpdateWithRetrieveModelMixin:aupdate(),apartial_update(),aupdate_with_retrieve(),apartial_update_with_retrieve(), andaperform_update();AUpdateOrCreateWithRetrieveModelMixin:aupdate_or_create().
The default aperform_create() and aperform_update() implementations call
await serializer.asave(). After aperform_create(), the serializer must have
an instance, otherwise the library raises ImproperlyConfigured. Asynchronous
serializer data is read through the native adrf.mixins.get_data() helper.
Protected deletion
DeleteProtectedModelMixin
The synchronous mixin converts Django ValidationError and ProtectedError
into DRF errors. Protected-relation text is configured through class attributes:
class CourseViewSet(BaseModelViewSet):
protected_error_message = "Course cannot be deleted: %(object_name)s exists"
protected_object_default_name = "related data"
protected_objects_limit = 1
protected_objects_limit limits the number of protecting object names in the
message. It defaults to 1; None includes all names.
protected_object_default_name is used when ProtectedError has no objects.
These settings are shared by synchronous and asynchronous mixins.
ProtectedError is returned as one ErrorDetail, not a one-element list.
ADeleteProtectedModelMixin
The asynchronous version provides adestroy() and aperform_destroy(). Its
default implementation calls await instance.adelete() and handles
ValidationError and ProtectedError in the same way.
Query-parameter and request-body validation
DRF
from rest_framework.views import APIView
from yandex_drf_mixins.drf import SerializeGetParamsViewMixin
class CourseSearchView(SerializeGetParamsViewMixin, APIView):
params_serializer_class = CourseSearchParamsSerializer
def get(self, request):
params = self.validated_params
return Response(search_courses(**params))
SerializeGetParamsViewMixinserializesrequest.query_params;SerializePostParamsViewMixinserializesrequest.data;params_serializer_classselects the serializer;params_raise_exceptioncontrolsis_valid(raise_exception=...);params_serializer_many=Truecollects query parameters into a list of objects;validated_paramsis evaluated lazily and cached.
ADRF
ASerializeGetParamsViewMixin and ASerializePostParamsViewMixin use the same
configuration contract but expose the result asynchronously:
class AsyncCourseSearchView(ASerializeGetParamsViewMixin, APIView):
params_serializer_class = CourseSearchParamsSerializer
async def get(self, request):
params = await self.avalidated_params()
return Response(await search_courses(**params))
Lists with additional data
ListWithAdditionalDataMixin serializes an object containing a queryset and
additional fields:
def list(self, request, *args, **kwargs):
return self.list_with_additional_data(
request,
additional_data={"facets": build_facets()},
objects_field_name="courses",
)
The asynchronous AListWithAdditionalDataMixin provides
await alist_with_additional_data(...). Both variants apply
filter_queryset() and keep the queryset lazy until serialization.
Pagination
LimitOffsetAllPagination
Extends DRF LimitOffsetPagination with ?all=true:
from yandex_drf_mixins.drf import LimitOffsetAllPagination
class CourseViewSet(BaseModelViewSet):
pagination_class = LimitOffsetAllPagination
- without
all, standard limit/offset pagination is used; ?all=truereturns all items as one lazy queryset slice;- an invalid boolean value is treated as
False; allis added to OpenAPI operation parameters;- the legacy CoreAPI schema is not supported.
Asynchronous pagination
ALimitOffsetPaginationis the asynchronous equivalent of standard limit/offset pagination;ALimitOffsetAllPaginationadditionally supports?all=true;aget_count()usesQuerySet.acount()and falls back tolen()for ordinary sequences;apaginate_queryset()returns a lazy slice without converting it tolist.
Base viewsets
BaseModelViewSet
Combines:
ActionSerializerMixin;- create/update with retrieve serialization;
- update-or-create;
- protected deletion;
- lists with additional data;
- the standard DRF
ModelViewSet.
ABaseModelViewSet
The asynchronous base class includes:
AListModelMixinwithalist();- asynchronous create/update/update-or-create/delete mixins;
AListWithAdditionalDataMixin;aget_object()throughadrf.shortcuts.aget_object_or_404;acheck_object_permissions()with mixed sync/async permission support;apaginate_queryset()adapters for asynchronous and regular paginators.
get_queryset() may be synchronous or declared with async def; the library
detects the variant with iscoroutinefunction. filter_queryset() intentionally
remains synchronous.
ADRF example:
from yandex_drf_mixins.adrf import ABaseModelViewSet, ALimitOffsetAllPagination
class StaffOccupationViewSet(ABaseModelViewSet):
queryset = StaffOccupation.objects.select_related("direction")
serializer_class = StaffOccupationSerializer
pagination_class = ALimitOffsetAllPagination
async def list(self, request, *args, **kwargs):
return await self.alist(request, *args, **kwargs)
Every coroutine method owned by the library has an a prefix. Synchronous
methods are not duplicated in ADRF classes.
Canonical courses example
The sanitized LMS courses application is available under examples/courses.
It demonstrates realistic viewsets, serializers, filters, pagination, CRUD,
upsert, protected deletion, and compact tests built with GenericRequestMixin.
See examples/README.md for setup and test commands.
Running library tests
Run commands from the repository root. For the complete suite, including the ADRF contract, create an isolated environment and install development dependencies:
python3.13 -m venv venv
source venv/bin/activate
python3 -m pip install -e '.[dev]'
python3 -m pytest
DJANGO_SETTINGS_MODULE=tests.settings and the test directory are configured in
pyproject.toml, so they do not need to be passed on the command line.
Run contract groups independently:
# Synchronous DRF components
python3 -m pytest tests/test_drf.py
# Asynchronous ADRF components
python3 -m pytest tests/test_adrf.py
# Public imports, testing mixins, and release skill
python3 -m pytest \
tests/test_public_api.py \
tests/test_testing.py \
tests/test_release_skill.py
Repeat the full suite on Python 3.10 to check the minimum supported version. If ADRF is not installed, ADRF-specific tests are skipped while DRF and testing contracts must still pass.
Development
python3 -m pip install -e '.[dev]'
python3 -m pytest
python3 -m black --check src tests
python3 -m isort --check-only src tests
python3 -m flake8 src tests
python3 -m mypy --explicit-package-bases src/yandex_drf_mixins
PYTHONPATH=.:src python3 -m pylint src/yandex_drf_mixins
python3 -m bandit -q -r src
python3 -m build
Build and test the wheel locally in LMS before publishing. Publication is a separate explicitly confirmed step.
Use .agents/yandex-drf-mixins-release/skill.md to prepare a release. The skill
performs reversible preparation and verification automatically, but never runs
the irreversible twine upload command.
Metadata
Release files for yandex-drf-mixins 0.1.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| yandex_drf_mixins-0.1.2.tar.gz | 29.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| yandex_drf_mixins-0.1.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 53.7 kB
Release files / yandex_drf_mixins-0.1.2.tar.gz
| Download URL | yandex_drf_mixins-0.1.2.tar.gz |
|---|---|
| Size | 29.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9290cf6fc84d56ddba093e5ce682937cbbed529ab065b42e2e74d30d9ea4ef6e
|
|
BLAKE2b-256 checksum How to use checksums |
075bcafca6886894552840479985a652aec61a8ffcd9391cd9de25638eddda2a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.14
|
Release files / yandex_drf_mixins-0.1.2-py3-none-any.whl
| Download URL | yandex_drf_mixins-0.1.2-py3-none-any.whl |
|---|---|
| Size | 23.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
839ed118749a954f6b55db3bdb5ea588a54def4951842f291a5cb5d637d41f9f
|
|
BLAKE2b-256 checksum How to use checksums |
2b1ec93e714ab37ed931be2aabb38c7717770722064db95c99f5a8089b1e14e2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.14
|