From a29b057fd682b342f599ac23c80a3da3183bc5f5 Mon Sep 17 00:00:00 2001 From: Slava Nabatchikov Date: Thu, 16 Jul 2026 21:31:44 +0300 Subject: [PATCH] Rewrite API camelCase layer and drop drf-orjson-renderer Replace djangorestframework-camel-case and drf-orjson-renderer with an own case_converters.py boundary layer plus orjson used directly in the parser/renderer. Frees the project from two third-party dependencies and gives full control over key conversion policy (round-trip rules for digits, lazy iterables, error types). --- {{ cookiecutter.name }}/pyproject.toml | 5 +- .../src/app/api/case_converters.py | 49 +++++ .../src/app/api/parsers.py | 34 ++-- .../src/app/api/renderers.py | 33 +++- .../src/app/api/spectacular.py | 32 ++++ {{ cookiecutter.name }}/src/app/conf/api.py | 6 +- .../src/app/tests/api/__init__.py | 0 .../app/tests/api/test_api_camel_casing.py | 170 ++++++++++++++++++ .../src/app/tests/api/test_case_converters.py | 61 +++++++ .../src/users/tests/test_whoami.py | 2 + {{ cookiecutter.name }}/uv.lock | 62 +++---- 11 files changed, 384 insertions(+), 70 deletions(-) create mode 100644 {{ cookiecutter.name }}/src/app/api/case_converters.py create mode 100644 {{ cookiecutter.name }}/src/app/api/spectacular.py create mode 100644 {{ cookiecutter.name }}/src/app/tests/api/__init__.py create mode 100644 {{ cookiecutter.name }}/src/app/tests/api/test_api_camel_casing.py create mode 100644 {{ cookiecutter.name }}/src/app/tests/api/test_case_converters.py diff --git a/{{ cookiecutter.name }}/pyproject.toml b/{{ cookiecutter.name }}/pyproject.toml index 52f7d507..5a163cc4 100644 --- a/{{ cookiecutter.name }}/pyproject.toml +++ b/{{ cookiecutter.name }}/pyproject.toml @@ -15,11 +15,10 @@ dependencies = [ "django-split-settings>=1.3.2", "django-storages>=1.14.6", "django>=5.2,<6.0", - "djangorestframework-camel-case>=1.4.2", "djangorestframework-simplejwt[crypto]>=5.5.0", "djangorestframework>=3.15.2", - "drf-orjson-renderer>=1.7.3", "drf-spectacular[sidecar]>=0.28.0", + "orjson>=3.11.9", "pillow>=11.2.1", "psycopg[binary]>=3.2.6", "redis>=5.2.1", @@ -228,8 +227,6 @@ module = [ "axes.*", "celery.*", "django_filters.*", - "djangorestframework_camel_case.*", - "drf_orjson_renderer.*", "ipware.*", ] ignore_missing_imports = true diff --git a/{{ cookiecutter.name }}/src/app/api/case_converters.py b/{{ cookiecutter.name }}/src/app/api/case_converters.py new file mode 100644 index 00000000..07aab424 --- /dev/null +++ b/{{ cookiecutter.name }}/src/app/api/case_converters.py @@ -0,0 +1,49 @@ +"""Conversion between Python snake_case and wire camelCase API keys. + +Policy: +- Python keys are words of [a-z0-9] joined by single underscores, wire keys are lowerCamelCase. +- A digit belongs to the preceding word: field2_name <-> field2Name. + +Round-trip is guaranteed only for keys that follow this convention; +keys with double or leading underscores, capitalized abbreviations or digit-only words are out of scope. +""" + +import re + +from django.http import QueryDict +from django.utils.datastructures import MultiValueDict + + +camel_boundary_re = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") + + +def snake_to_camel(key: str) -> str: + first, *rest = key.split("_") + return first + "".join(word.capitalize() for word in rest) + + +def camel_to_snake(key: str) -> str: + return camel_boundary_re.sub("_", key).lower() + + +def camelize_keys(data: object) -> object: + if isinstance(data, dict): + return {snake_to_camel(key) if isinstance(key, str) else key: camelize_keys(value) for key, value in data.items()} + if isinstance(data, list | tuple): + return [camelize_keys(item) for item in data] + return data + + +def snakeize_keys(data: object) -> object: + if isinstance(data, dict): + return {camel_to_snake(key) if isinstance(key, str) else key: snakeize_keys(value) for key, value in data.items()} + if isinstance(data, list | tuple): + return [snakeize_keys(item) for item in data] + return data + + +def snakeize_query_dict(data: MultiValueDict) -> MultiValueDict: + result = QueryDict(mutable=True) if isinstance(data, QueryDict) else MultiValueDict() + for key in data: + result.setlist(camel_to_snake(key), data.getlist(key)) + return result diff --git a/{{ cookiecutter.name }}/src/app/api/parsers.py b/{{ cookiecutter.name }}/src/app/api/parsers.py index 83d74de2..2c766f5c 100644 --- a/{{ cookiecutter.name }}/src/app/api/parsers.py +++ b/{{ cookiecutter.name }}/src/app/api/parsers.py @@ -1,21 +1,29 @@ +from collections.abc import Mapping from typing import IO, Any -from djangorestframework_camel_case.settings import api_settings -from djangorestframework_camel_case.util import underscoreize -from drf_orjson_renderer.parsers import ORJSONParser +import orjson from rest_framework.exceptions import ParseError +from rest_framework.parsers import BaseParser, DataAndFiles, FormParser, MultiPartParser +from app.api.case_converters import snakeize_keys, snakeize_query_dict -class AppJSONParser(ORJSONParser): - """Combination of ORJSONParser and CamelCaseJSONParser""" - # djangorestframework_camel_case parameter - # details: https://github.com/vbabiy/djangorestframework-camel-case?tab=readme-ov-file#underscoreize-options - json_underscoreize = api_settings.JSON_UNDERSCOREIZE +class AppJSONParser(BaseParser): + media_type = "application/json" - def parse(self, stream: IO[Any], media_type: Any = None, parser_context: Any = None) -> Any: + def parse(self, stream: IO[Any], media_type: str | None = None, parser_context: Mapping[str, Any] | None = None) -> Any: try: - data = super().parse(stream, media_type, parser_context) - return underscoreize(data, **self.json_underscoreize) - except ValueError as exc: - raise ParseError(f"JSON parse error - {exc}") + return snakeize_keys(orjson.loads(stream.read())) + except orjson.JSONDecodeError as exc: + raise ParseError(f"JSON parse error - {exc}") from exc + + +class AppFormParser(FormParser): + def parse(self, stream: IO[Any], media_type: str | None = None, parser_context: Mapping[str, Any] | None = None) -> Any: + return snakeize_query_dict(super().parse(stream, media_type, parser_context)) + + +class AppMultiPartParser(MultiPartParser): + def parse(self, stream: IO[Any], media_type: str | None = None, parser_context: Mapping[str, Any] | None = None) -> DataAndFiles: + result = super().parse(stream, media_type, parser_context) + return DataAndFiles(snakeize_query_dict(result.data), snakeize_query_dict(result.files)) diff --git a/{{ cookiecutter.name }}/src/app/api/renderers.py b/{{ cookiecutter.name }}/src/app/api/renderers.py index 4b61c9ce..a8889b29 100644 --- a/{{ cookiecutter.name }}/src/app/api/renderers.py +++ b/{{ cookiecutter.name }}/src/app/api/renderers.py @@ -1,14 +1,31 @@ +from collections.abc import Iterable, Mapping +from decimal import Decimal from typing import Any +from uuid import UUID -from djangorestframework_camel_case.util import camelize -from drf_orjson_renderer.renderers import ORJSONRenderer +import orjson +from django.utils.functional import Promise +from rest_framework.renderers import BaseRenderer +from app.api.case_converters import camelize_keys -class AppJSONRenderer(ORJSONRenderer): - """Combination of CamelCaseJSONRenderer and ORJSONRenderer""" - charset = "utf-8" # force DRF to add charset header to the content-type - json_underscoreize = {"no_underscore_before_number": True} # https://github.com/vbabiy/djangorestframework-camel-case#underscoreize-options +def to_serializable(value: object) -> object: + if isinstance(value, Promise | UUID | Decimal): + return str(value) + if isinstance(value, bytes | bytearray): + raise TypeError(f"Type is not JSON serializable: {type(value).__name__}") + if isinstance(value, Iterable): + return camelize_keys(list(value)) + raise TypeError(f"Type is not JSON serializable: {type(value).__name__}") - def render(self, data: Any, *args: Any, **kwargs: Any) -> bytes: - return super().render(camelize(data, **self.json_underscoreize), *args, **kwargs) + +class AppJSONRenderer(BaseRenderer): + media_type = "application/json" + format = "json" + charset = "utf-8" # force DRF to add charset to the content-type header + + def render(self, data: Any, accepted_media_type: str | None = None, renderer_context: Mapping[str, Any] | None = None) -> bytes: + if data is None: + return b"" + return orjson.dumps(camelize_keys(data), default=to_serializable) diff --git a/{{ cookiecutter.name }}/src/app/api/spectacular.py b/{{ cookiecutter.name }}/src/app/api/spectacular.py new file mode 100644 index 00000000..432ce168 --- /dev/null +++ b/{{ cookiecutter.name }}/src/app/api/spectacular.py @@ -0,0 +1,32 @@ +from typing import Any + +from app.api.case_converters import snake_to_camel + + +def camelize_serializer_fields(result: dict[str, Any], generator: Any, **kwargs: Any) -> dict[str, Any]: # noqa: ARG001 + for schema in result.get("components", {}).get("schemas", {}).values(): + camelize_schema(schema) + return result + + +def camelize_schema_properties(schema: dict[str, Any]) -> dict[str, Any]: + if isinstance(schema.get("properties"), dict): + schema["properties"] = {snake_to_camel(name): camelize_schema(prop) for name, prop in schema["properties"].items()} + if isinstance(schema.get("required"), list): + schema["required"] = [snake_to_camel(name) for name in schema["required"]] + for keyword in ("items", "additionalProperties"): + if isinstance(schema.get(keyword), dict): + schema[keyword] = camelize_schema(schema[keyword]) + return schema + + +def camelize_schema_variants(schema: dict[str, Any]) -> dict[str, Any]: + for keyword in ("allOf", "anyOf", "oneOf"): + if keyword in schema: + schema[keyword] = [camelize_schema(item) for item in schema[keyword]] + return schema + + +def camelize_schema(schema: dict[str, Any]) -> dict[str, Any]: + schema = camelize_schema_properties(schema) + return camelize_schema_variants(schema) diff --git a/{{ cookiecutter.name }}/src/app/conf/api.py b/{{ cookiecutter.name }}/src/app/conf/api.py index f83e9f26..01ecf27d 100644 --- a/{{ cookiecutter.name }}/src/app/conf/api.py +++ b/{{ cookiecutter.name }}/src/app/conf/api.py @@ -19,8 +19,8 @@ ], "DEFAULT_PARSER_CLASSES": [ "app.api.parsers.AppJSONParser", - "djangorestframework_camel_case.parser.CamelCaseMultiPartParser", - "djangorestframework_camel_case.parser.CamelCaseFormParser", + "app.api.parsers.AppMultiPartParser", + "app.api.parsers.AppFormParser", ], "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning", "DEFAULT_PAGINATION_CLASS": "app.api.pagination.AppPagination", @@ -43,6 +43,6 @@ "CAMELIZE_NAMES": True, "POSTPROCESSING_HOOKS": [ "drf_spectacular.hooks.postprocess_schema_enums", - "drf_spectacular.contrib.djangorestframework_camel_case.camelize_serializer_fields", + "app.api.spectacular.camelize_serializer_fields", ], } diff --git a/{{ cookiecutter.name }}/src/app/tests/api/__init__.py b/{{ cookiecutter.name }}/src/app/tests/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/{{ cookiecutter.name }}/src/app/tests/api/test_api_camel_casing.py b/{{ cookiecutter.name }}/src/app/tests/api/test_api_camel_casing.py new file mode 100644 index 00000000..b3b138df --- /dev/null +++ b/{{ cookiecutter.name }}/src/app/tests/api/test_api_camel_casing.py @@ -0,0 +1,170 @@ +import io +import json +from datetime import datetime +from decimal import Decimal +from urllib.parse import urlencode +from uuid import UUID + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils.translation import gettext_lazy +from rest_framework.exceptions import ParseError +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory + +from app.api.parsers import AppFormParser, AppJSONParser, AppMultiPartParser +from app.api.renderers import AppJSONRenderer +from app.api.spectacular import camelize_serializer_fields + + +pytestmark = [ + pytest.mark.django_db, +] + + +def test_renderer_camelizes_keys(): + rendered = AppJSONRenderer().render({"first_name": "Ann", "home_address": {"zip_code": "12345"}, "field2_name": "Ann"}) + + assert json.loads(rendered) == {"firstName": "Ann", "homeAddress": {"zipCode": "12345"}, "field2Name": "Ann"} + + +def test_renderer_returns_empty_bytes_for_none(): + assert AppJSONRenderer().render(None) == b"" + + +def test_renderer_serializes_django_types(): + data = { + "id": UUID("0195b1e0-6f0e-7000-8000-000000000000"), + "price": Decimal("9.90"), + "created": datetime(2026, 1, 2, 3, 4, 5), + "label": gettext_lazy("Lazy Text"), + "letters": (letter for letter in "ab"), + } + + rendered = json.loads(AppJSONRenderer().render(data)) + + assert rendered == { + "id": "0195b1e0-6f0e-7000-8000-000000000000", + "price": "9.90", + "created": "2026-01-02T03:04:05", + "label": "Lazy Text", + "letters": ["a", "b"], + } + + +def test_renderer_rejects_unknown_types(): + with pytest.raises(TypeError): + AppJSONRenderer().render({"value": object()}) + + +def test_renderer_camelizes_keys_inside_lazy_iterables(): + data = {"items": ({"tag_name": str(index)} for index in range(2))} + + rendered = json.loads(AppJSONRenderer().render(data)) + + assert rendered == {"items": [{"tagName": "0"}, {"tagName": "1"}]} + + +def test_renderer_rejects_bytes(): + with pytest.raises(TypeError): + AppJSONRenderer().render({"value": b"raw"}) + + +def test_json_parser_snakeizes_keys(): + parsed = AppJSONParser().parse(io.BytesIO(b'{"firstName":"Ann","homeAddress":{"zipCode":"12345"},"field2Name":"Ann"}')) + + assert parsed == {"first_name": "Ann", "home_address": {"zip_code": "12345"}, "field2_name": "Ann"} + + +def test_json_parser_rejects_invalid_json(): + with pytest.raises(ParseError): + AppJSONParser().parse(io.BytesIO(b"{broken")) + + +def test_json_parser_rejects_non_utf8_body(): + with pytest.raises(ParseError): + AppJSONParser().parse(io.BytesIO(b'{"a":"\xff"}')) + + +def test_form_parser_snakeizes_keys(): + request = APIRequestFactory().post("/", urlencode({"firstName": "Ann"}), content_type="application/x-www-form-urlencoded") + + data = Request(request, parsers=[AppFormParser()]).data + + assert data["first_name"] == "Ann" + + +def test_multipart_parser_snakeizes_data_and_file_keys(): + upload = SimpleUploadedFile("avatar.png", b"image-bytes", content_type="image/png") + request = APIRequestFactory().post("/", {"firstName": "Ann", "avatarFile": upload}, format="multipart") + + data = Request(request, parsers=[AppMultiPartParser()]).data + + assert data["first_name"] == "Ann" + assert data["avatar_file"].name == "avatar.png" + + +def test_schema_camel_casing(as_anon): + response = as_anon.get("/api/v1/docs/schema/", as_response=True) + + content = response.content.decode() + + assert "User:" in content + assert "firstName:" in content + assert "remoteAddr:" in content + + +def test_spectacular_camelize_serializer_fields(): + result = { + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "first_name": {"type": "string"}, + "home_address": { + "type": "object", + "properties": {"zip_code": {"type": "string"}}, + "required": ["zip_code"], + }, + }, + "required": ["first_name"], + } + } + } + } + + camelized = camelize_serializer_fields(result, generator=None) + + assert camelized["components"]["schemas"]["User"]["properties"]["firstName"] == {"type": "string"} + assert camelized["components"]["schemas"]["User"]["properties"]["homeAddress"]["properties"]["zipCode"] == {"type": "string"} + assert camelized["components"]["schemas"]["User"]["properties"]["homeAddress"]["required"] == ["zipCode"] + assert camelized["components"]["schemas"]["User"]["required"] == ["firstName"] + + +def test_spectacular_camelize_schema_variants(): + result = { + "components": { + "schemas": { + "Payload": { + "type": "object", + "properties": { + "entries": {"type": "array", "items": {"type": "object", "properties": {"zip_code": {"type": "string"}}}}, + "extras": {"type": "object", "additionalProperties": {"type": "object", "properties": {"street_name": {"type": "string"}}}}, + }, + "allOf": [{"type": "object", "properties": {"first_name": {"type": "string"}}}], + "anyOf": [{"type": "object", "properties": {"last_name": {"type": "string"}}}], + "oneOf": [{"type": "object", "properties": {"middle_name": {"type": "string"}}}], + } + } + } + } + + camelized = camelize_serializer_fields(result, generator=None) + + schema = camelized["components"]["schemas"]["Payload"] + assert schema["properties"]["entries"]["items"]["properties"] == {"zipCode": {"type": "string"}} + assert schema["properties"]["extras"]["additionalProperties"]["properties"] == {"streetName": {"type": "string"}} + assert schema["allOf"] == [{"type": "object", "properties": {"firstName": {"type": "string"}}}] + assert schema["anyOf"] == [{"type": "object", "properties": {"lastName": {"type": "string"}}}] + assert schema["oneOf"] == [{"type": "object", "properties": {"middleName": {"type": "string"}}}] diff --git a/{{ cookiecutter.name }}/src/app/tests/api/test_case_converters.py b/{{ cookiecutter.name }}/src/app/tests/api/test_case_converters.py new file mode 100644 index 00000000..2c9bab8c --- /dev/null +++ b/{{ cookiecutter.name }}/src/app/tests/api/test_case_converters.py @@ -0,0 +1,61 @@ +import pytest +from django.http import QueryDict +from django.utils.datastructures import MultiValueDict + +from app.api.case_converters import camel_to_snake, camelize_keys, snake_to_camel, snakeize_keys, snakeize_query_dict + + +@pytest.mark.parametrize( + ("snake", "camel"), + [ + ("first_name", "firstName"), + ("home_address_zip", "homeAddressZip"), + ("field2_name", "field2Name"), + ("plain", "plain"), + ("child_uuid", "childUuid"), + ], +) +def test_key_conversion_round_trip(snake, camel): + assert snake_to_camel(snake) == camel + assert camel_to_snake(camel) == snake + + +def test_camelize_keys_nested(): + data = {"user_profile": {"first_name": "John", "home_address": {"zip_code": "12345"}}, "tags": [{"tag_name": "a"}]} + + assert camelize_keys(data) == {"userProfile": {"firstName": "John", "homeAddress": {"zipCode": "12345"}}, "tags": [{"tagName": "a"}]} + + +def test_snakeize_keys_nested(): + data = {"userProfile": {"firstName": "John"}, "items": [{"zipCode": "1"}]} + + assert snakeize_keys(data) == {"user_profile": {"first_name": "John"}, "items": [{"zip_code": "1"}]} + + +def test_camelize_keys_leaves_non_string_keys(): + assert camelize_keys({1: "a", "b_c": "d"}) == {1: "a", "bC": "d"} + + +def test_snakeize_query_dict_returns_query_dict(): + source = QueryDict("firstName=John&firstName=Jane&lastName=Doe") + + result = snakeize_query_dict(source) + + assert isinstance(result, QueryDict) + assert result.getlist("first_name") == ["John", "Jane"] + assert result["last_name"] == "Doe" + + +def test_snakeize_query_dict_returns_multi_value_dict(): + source = MultiValueDict({"fieldName": ["value1", "value2"]}) + + result = snakeize_query_dict(source) + + assert isinstance(result, MultiValueDict) + assert not isinstance(result, QueryDict) + assert result.getlist("field_name") == ["value1", "value2"] + + +def test_snake_to_camel_digit_word_is_not_reversible(): + assert snake_to_camel("address_2") == "address2" + assert camel_to_snake("address2") == "address2" diff --git a/{{ cookiecutter.name }}/src/users/tests/test_whoami.py b/{{ cookiecutter.name }}/src/users/tests/test_whoami.py index 07fc27c3..f42a61c5 100644 --- a/{{ cookiecutter.name }}/src/users/tests/test_whoami.py +++ b/{{ cookiecutter.name }}/src/users/tests/test_whoami.py @@ -9,6 +9,8 @@ def test_ok(as_user, user): assert result["id"] == user.pk assert result["username"] == user.username + assert result["firstName"] == user.first_name + assert result["lastName"] == user.last_name def test_anon(as_anon): diff --git a/{{ cookiecutter.name }}/uv.lock b/{{ cookiecutter.name }}/uv.lock index cd52991e..59311733 100644 --- a/{{ cookiecutter.name }}/uv.lock +++ b/{{ cookiecutter.name }}/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = "==3.12.*" [[package]] @@ -436,12 +436,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/3e/2448e93f4f87fc9a9f35e73e3c05669e0edd0c2526834686e949bb1fd303/djangorestframework-3.16.0-py3-none-any.whl", hash = "sha256:bea7e9f6b96a8584c5224bfb2e4348dfb3f8b5e34edbecb98da258e892089361", size = 1067305, upload-time = "2025-03-28T14:18:39.489Z" }, ] -[[package]] -name = "djangorestframework-camel-case" -version = "1.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/87/647ce93053cb5e35e07bded676340774fe43190388b885c54aff47d8557b/djangorestframework-camel-case-1.4.2.tar.gz", hash = "sha256:cdae75846648abb6585c7470639a1d2fb064dc45f8e8b62aaa50be7f1a7a61f4", size = 8839, upload-time = "2023-02-13T15:28:11.941Z" } - [[package]] name = "djangorestframework-simplejwt" version = "5.5.0" @@ -493,20 +487,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/5e/e26881b8d6bd6498c1a7225fba8ead3626a9f4b2d7d29dd272a875753d0d/dotenv_linter-0.7.0-py3-none-any.whl", hash = "sha256:0ffdf0c7435bd638aba5ff6cc9ea53bf093488bf1c722e363e902008659bb1fb", size = 19806, upload-time = "2025-04-28T17:39:58.395Z" }, ] -[[package]] -name = "drf-orjson-renderer" -version = "1.7.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, - { name = "djangorestframework" }, - { name = "orjson" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/3e/5cc16467d91087bf883cdbeb52a06f3a6fa59563ad09b9b84bac72d68716/drf_orjson_renderer-1.7.3.tar.gz", hash = "sha256:0c49760fc415df8096c1ef05f029802f2e5862d4e15fe96066289b8c526835f1", size = 8492, upload-time = "2024-08-22T02:44:27.694Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/87/53e1a0e13f1d0247fe97e53619cff72181888a5bac8fd0401e5607c101b6/drf_orjson_renderer-1.7.3-py3-none-any.whl", hash = "sha256:9c3fe521b0e8c641b334c40bb81ecadb14519a27599a495d360385abe193a4b4", size = 7138, upload-time = "2024-08-22T02:44:26.124Z" }, -] - [[package]] name = "drf-spectacular" version = "0.28.0" @@ -759,25 +739,25 @@ wheels = [ [[package]] name = "orjson" -version = "3.10.18" +version = "3.11.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/0b/fea456a3ffe74e70ba30e01ec183a9b26bec4d497f61dcfce1b601059c60/orjson-3.10.18.tar.gz", hash = "sha256:e8da3947d92123eda795b68228cafe2724815621fe35e8e320a9e9593a4bcd53", size = 5422810, upload-time = "2025-04-29T23:30:08.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/1a/67236da0916c1a192d5f4ccbe10ec495367a726996ceb7614eaa687112f2/orjson-3.10.18-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:50c15557afb7f6d63bc6d6348e0337a880a04eaa9cd7c9d569bcb4e760a24753", size = 249184, upload-time = "2025-04-29T23:28:53.612Z" }, - { url = "https://files.pythonhosted.org/packages/b3/bc/c7f1db3b1d094dc0c6c83ed16b161a16c214aaa77f311118a93f647b32dc/orjson-3.10.18-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:356b076f1662c9813d5fa56db7d63ccceef4c271b1fb3dd522aca291375fcf17", size = 133279, upload-time = "2025-04-29T23:28:55.055Z" }, - { url = "https://files.pythonhosted.org/packages/af/84/664657cd14cc11f0d81e80e64766c7ba5c9b7fc1ec304117878cc1b4659c/orjson-3.10.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:559eb40a70a7494cd5beab2d73657262a74a2c59aff2068fdba8f0424ec5b39d", size = 136799, upload-time = "2025-04-29T23:28:56.828Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bb/f50039c5bb05a7ab024ed43ba25d0319e8722a0ac3babb0807e543349978/orjson-3.10.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3c29eb9a81e2fbc6fd7ddcfba3e101ba92eaff455b8d602bf7511088bbc0eae", size = 132791, upload-time = "2025-04-29T23:28:58.751Z" }, - { url = "https://files.pythonhosted.org/packages/93/8c/ee74709fc072c3ee219784173ddfe46f699598a1723d9d49cbc78d66df65/orjson-3.10.18-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6612787e5b0756a171c7d81ba245ef63a3533a637c335aa7fcb8e665f4a0966f", size = 137059, upload-time = "2025-04-29T23:29:00.129Z" }, - { url = "https://files.pythonhosted.org/packages/6a/37/e6d3109ee004296c80426b5a62b47bcadd96a3deab7443e56507823588c5/orjson-3.10.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ac6bd7be0dcab5b702c9d43d25e70eb456dfd2e119d512447468f6405b4a69c", size = 138359, upload-time = "2025-04-29T23:29:01.704Z" }, - { url = "https://files.pythonhosted.org/packages/4f/5d/387dafae0e4691857c62bd02839a3bf3fa648eebd26185adfac58d09f207/orjson-3.10.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f72f100cee8dde70100406d5c1abba515a7df926d4ed81e20a9730c062fe9ad", size = 142853, upload-time = "2025-04-29T23:29:03.576Z" }, - { url = "https://files.pythonhosted.org/packages/27/6f/875e8e282105350b9a5341c0222a13419758545ae32ad6e0fcf5f64d76aa/orjson-3.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dca85398d6d093dd41dc0983cbf54ab8e6afd1c547b6b8a311643917fbf4e0c", size = 133131, upload-time = "2025-04-29T23:29:05.753Z" }, - { url = "https://files.pythonhosted.org/packages/48/b2/73a1f0b4790dcb1e5a45f058f4f5dcadc8a85d90137b50d6bbc6afd0ae50/orjson-3.10.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22748de2a07fcc8781a70edb887abf801bb6142e6236123ff93d12d92db3d406", size = 134834, upload-time = "2025-04-29T23:29:07.35Z" }, - { url = "https://files.pythonhosted.org/packages/56/f5/7ed133a5525add9c14dbdf17d011dd82206ca6840811d32ac52a35935d19/orjson-3.10.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3a83c9954a4107b9acd10291b7f12a6b29e35e8d43a414799906ea10e75438e6", size = 413368, upload-time = "2025-04-29T23:29:09.301Z" }, - { url = "https://files.pythonhosted.org/packages/11/7c/439654221ed9c3324bbac7bdf94cf06a971206b7b62327f11a52544e4982/orjson-3.10.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:303565c67a6c7b1f194c94632a4a39918e067bd6176a48bec697393865ce4f06", size = 153359, upload-time = "2025-04-29T23:29:10.813Z" }, - { url = "https://files.pythonhosted.org/packages/48/e7/d58074fa0cc9dd29a8fa2a6c8d5deebdfd82c6cfef72b0e4277c4017563a/orjson-3.10.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:86314fdb5053a2f5a5d881f03fca0219bfdf832912aa88d18676a5175c6916b5", size = 137466, upload-time = "2025-04-29T23:29:12.26Z" }, - { url = "https://files.pythonhosted.org/packages/57/4d/fe17581cf81fb70dfcef44e966aa4003360e4194d15a3f38cbffe873333a/orjson-3.10.18-cp312-cp312-win32.whl", hash = "sha256:187ec33bbec58c76dbd4066340067d9ece6e10067bb0cc074a21ae3300caa84e", size = 142683, upload-time = "2025-04-29T23:29:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/e6/22/469f62d25ab5f0f3aee256ea732e72dc3aab6d73bac777bd6277955bceef/orjson-3.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:f9f94cf6d3f9cd720d641f8399e390e7411487e493962213390d1ae45c7814fc", size = 134754, upload-time = "2025-04-29T23:29:15.338Z" }, - { url = "https://files.pythonhosted.org/packages/10/b0/1040c447fac5b91bc1e9c004b69ee50abb0c1ffd0d24406e1350c58a7fcb/orjson-3.10.18-cp312-cp312-win_arm64.whl", hash = "sha256:3d600be83fe4514944500fa8c2a0a77099025ec6482e8087d7659e891f23058a", size = 131218, upload-time = "2025-04-29T23:29:17.324Z" }, + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, ] [[package]] @@ -1229,10 +1209,9 @@ dependencies = [ { name = "django-split-settings" }, { name = "django-storages" }, { name = "djangorestframework" }, - { name = "djangorestframework-camel-case" }, { name = "djangorestframework-simplejwt", extra = ["crypto"] }, - { name = "drf-orjson-renderer" }, { name = "drf-spectacular", extra = ["sidecar"] }, + { name = "orjson" }, { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "redis" }, @@ -1278,10 +1257,9 @@ requires-dist = [ { name = "django-split-settings", specifier = ">=1.3.2" }, { name = "django-storages", specifier = ">=1.14.6" }, { name = "djangorestframework", specifier = ">=3.15.2" }, - { name = "djangorestframework-camel-case", specifier = ">=1.4.2" }, { name = "djangorestframework-simplejwt", extras = ["crypto"], specifier = ">=5.5.0" }, - { name = "drf-orjson-renderer", specifier = ">=1.7.3" }, { name = "drf-spectacular", extras = ["sidecar"], specifier = ">=0.28.0" }, + { name = "orjson", specifier = ">=3.11.9" }, { name = "pillow", specifier = ">=11.2.1" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.6" }, { name = "redis", specifier = ">=5.2.1" },