drf-fastserializers 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ """drf-fastserializers: faster DRF serializers, one line at a time.
2
+
3
+ Quick start (existing DRF serializers):
4
+
5
+ from drf_fastserializers import FastSerializerMixin, FastJSONRenderer
6
+ from rest_framework import serializers
7
+
8
+ class TxnSerializer(FastSerializerMixin, serializers.ModelSerializer):
9
+ class Meta:
10
+ model = Txn
11
+ fields = ["id", "name", "amount"]
12
+
13
+ class TxnListView(ListAPIView):
14
+ serializer_class = TxnSerializer # unchanged
15
+ renderer_classes = [FastJSONRenderer] # add this
16
+ queryset = Txn.objects.all()
17
+
18
+ The mixin auto-translates the serializer at first .data access (cached
19
+ per-class) and switches the read path to pydantic-core's Rust JSON
20
+ encoder. Falls back to standard DRF .data with a warning if any field
21
+ can't be mechanically translated (typically SerializerMethodField).
22
+
23
+ New code (define schemas natively in pydantic):
24
+
25
+ from drf_fastserializers import FastSerializer
26
+
27
+ class TxnOut(FastSerializer):
28
+ id: int
29
+ name: str
30
+ amount: Decimal | None = None
31
+
32
+ class TxnListView(ListAPIView):
33
+ serializer_class = TxnOut.drf
34
+ renderer_classes = [FastJSONRenderer]
35
+
36
+ Public surface:
37
+
38
+ - `FastSerializerMixin`: mix into an existing DRF Serializer.
39
+ - `FastJSONRenderer`: Rust-encoded output path; add to `renderer_classes`.
40
+ - `FastJSONParser`: Rust-encoded input path; add to `parser_classes`.
41
+ - `FastSerializer`: pydantic-backed schema base for new code.
42
+ - `from_drf(SerializerCls)`: explicit DRF to FastSerializer translation.
43
+ - `from_model(DjangoModel)`: derive a FastSerializer from a Django model.
44
+ - `drf_serializer(SchemaCls)`: functional alternative to `.drf`.
45
+ """
46
+
47
+ from ._compat import PYD_V3
48
+ from ._payload import FastPayload, RawJSONBytes
49
+ from .accelerator import FastListSerializer, FastSerializerMixin
50
+ from .migrate import MigrationError, from_drf
51
+ from .models import ModelMappingError, from_model
52
+ from .parser import FastJSONParser
53
+ from .renderer import FastJSONRenderer
54
+ from .serializer import DRFAdapter, FastSerializer, drf_serializer
55
+
56
+ # Ordered to match README narrative: migration story first, native second.
57
+ __all__ = [
58
+ # Entry path: drop into existing DRF serializers.
59
+ "FastSerializerMixin",
60
+ "FastJSONRenderer",
61
+ "FastJSONParser",
62
+ "FastListSerializer",
63
+ # Migration helpers.
64
+ "from_drf",
65
+ "from_model",
66
+ "MigrationError",
67
+ "ModelMappingError",
68
+ # Native pydantic-first path for new code.
69
+ "FastSerializer",
70
+ "DRFAdapter",
71
+ "drf_serializer",
72
+ # Marker payloads (mostly internal but useful for type hints).
73
+ "FastPayload",
74
+ "RawJSONBytes",
75
+ # Version compatibility flag.
76
+ "PYD_V3",
77
+ ]
78
+
79
+ __version__ = "0.2.0.dev0"
@@ -0,0 +1,21 @@
1
+ """Pydantic version compatibility gate.
2
+
3
+ Supports pydantic v2.7+ and v3.x. Both share the surface this library uses
4
+ (BaseModel, TypeAdapter, ConfigDict, ValidationError, model_dump_json).
5
+ """
6
+
7
+ from typing import Final
8
+
9
+ import pydantic
10
+
11
+ _parts = pydantic.VERSION.split(".")
12
+ PYD_MAJOR: Final[int] = int(_parts[0])
13
+ PYD_MINOR: Final[int] = int(_parts[1])
14
+
15
+ if PYD_MAJOR < 2 or (PYD_MAJOR == 2 and PYD_MINOR < 7):
16
+ raise RuntimeError(
17
+ f"drf-fastserializers requires pydantic>=2.7, got {pydantic.VERSION}. "
18
+ f"Upgrade with: pip install -U 'pydantic>=2.7'"
19
+ )
20
+
21
+ PYD_V3: Final[bool] = PYD_MAJOR >= 3
@@ -0,0 +1,17 @@
1
+ """Coerce pydantic ValidationError into DRF's `{field: [msg, ...]}` shape."""
2
+
3
+ from pydantic import ValidationError
4
+
5
+
6
+ def pydantic_errors_to_drf(exc: ValidationError) -> dict[str, list[str]]:
7
+ """Flatten pydantic loc paths to dotted keys. Aggregate messages per key.
8
+
9
+ Pydantic loc is a tuple like ('user', 'addresses', 0, 'zip'); DRF expects
10
+ a flat dotted field name. Empty loc maps to 'non_field_errors' to match
11
+ DRF's idiom for object-level validation failures.
12
+ """
13
+ out: dict[str, list[str]] = {}
14
+ for err in exc.errors():
15
+ loc = ".".join(str(part) for part in err["loc"]) or "non_field_errors"
16
+ out.setdefault(loc, []).append(err["msg"])
17
+ return out
@@ -0,0 +1,109 @@
1
+ """Marker payloads exchanged between subsystems.
2
+
3
+ `FastPayload` (output side) carries validated pydantic instances + the
4
+ TypeAdapter needed to encode them. The renderer recognizes the type and
5
+ routes encoding to `dump_json` (Rust). Anything that treats it as a
6
+ dict/list lazy-materializes via `dump_python`.
7
+
8
+ `RawJSONBytes` (input side) wraps raw request body bytes so that the
9
+ matching `DRFAdapter.is_valid` can hand them straight to `validate_json`
10
+ (Rust). Anything else reading `request.data` sees a dict-like proxy
11
+ that decodes on demand.
12
+ """
13
+
14
+ import json
15
+ from typing import Any
16
+
17
+ from pydantic import TypeAdapter
18
+
19
+
20
+ class FastPayload:
21
+ """Lazily-materialized JSON payload.
22
+
23
+ Renderers detect this type and emit Rust-encoded bytes via
24
+ `adapter.dump_json`. Anything that subscripts or iterates falls back
25
+ to a one-time Python materialization for compatibility.
26
+ """
27
+
28
+ __slots__ = ("adapter", "instances", "many", "_materialized")
29
+
30
+ def __init__(self, adapter: TypeAdapter, instances: Any, many: bool) -> None:
31
+ self.adapter = adapter
32
+ self.instances = instances
33
+ self.many = many
34
+ self._materialized: Any = None
35
+
36
+ def _materialize(self) -> Any:
37
+ if self._materialized is None:
38
+ self._materialized = self.adapter.dump_python(self.instances, mode="json")
39
+ return self._materialized
40
+
41
+ def __getitem__(self, key: Any) -> Any:
42
+ return self._materialize()[key]
43
+
44
+ def __iter__(self):
45
+ return iter(self._materialize())
46
+
47
+ def __len__(self) -> int:
48
+ return len(self._materialize())
49
+
50
+ def __contains__(self, item: Any) -> bool:
51
+ return item in self._materialize()
52
+
53
+ def __eq__(self, other: object) -> bool:
54
+ if isinstance(other, FastPayload):
55
+ return self._materialize() == other._materialize()
56
+ return self._materialize() == other
57
+
58
+ def __repr__(self) -> str:
59
+ return f"FastPayload(many={self.many})"
60
+
61
+
62
+ class RawJSONBytes:
63
+ """Lazy-decoded JSON payload from a `FastJSONParser`.
64
+
65
+ Holds the raw request body for the fast input path (validate_json
66
+ runs in Rust over bytes). Falls back to a one-time `json.loads`
67
+ when something else accesses the payload as a Python container.
68
+ """
69
+
70
+ __slots__ = ("raw", "_parsed")
71
+
72
+ def __init__(self, raw: bytes) -> None:
73
+ self.raw = raw
74
+ self._parsed: Any = None
75
+
76
+ def _decode(self) -> Any:
77
+ if self._parsed is None:
78
+ self._parsed = json.loads(self.raw or b"null")
79
+ return self._parsed
80
+
81
+ def __getitem__(self, key: Any) -> Any:
82
+ return self._decode()[key]
83
+
84
+ def __iter__(self):
85
+ return iter(self._decode())
86
+
87
+ def __len__(self) -> int:
88
+ return len(self._decode())
89
+
90
+ def __contains__(self, item: Any) -> bool:
91
+ return item in self._decode()
92
+
93
+ def get(self, key: Any, default: Any = None) -> Any:
94
+ decoded = self._decode()
95
+ if isinstance(decoded, dict):
96
+ return decoded.get(key, default)
97
+ return default
98
+
99
+ def keys(self):
100
+ return self._decode().keys()
101
+
102
+ def values(self):
103
+ return self._decode().values()
104
+
105
+ def items(self):
106
+ return self._decode().items()
107
+
108
+ def __repr__(self) -> str:
109
+ return f"RawJSONBytes({len(self.raw)} bytes)"
@@ -0,0 +1,127 @@
1
+ """In-place acceleration of existing DRF Serializer subclasses.
2
+
3
+ Add `FastSerializerMixin` to the inheritance list of an existing
4
+ serializer and `.data` switches to the pydantic-core Rust path:
5
+
6
+ from drf_fastserializers import FastSerializerMixin, FastJSONRenderer
7
+ from rest_framework import serializers
8
+
9
+ class TxnSerializer(FastSerializerMixin, serializers.ModelSerializer):
10
+ class Meta:
11
+ model = Txn
12
+ fields = ["id", "name", "amount"]
13
+
14
+ class TxnListView(ListAPIView):
15
+ serializer_class = TxnSerializer
16
+ renderer_classes = [FastJSONRenderer]
17
+ queryset = Txn.objects.all()
18
+
19
+ The mixin translates the serializer's fields to a `FastSerializer`
20
+ schema at first `.data` access (cached per class). If translation fails
21
+ (most commonly because of `SerializerMethodField`), it emits a one-time
22
+ warning and falls back to DRF's standard `.data`. The mixin is safe to
23
+ leave on; it never breaks the response.
24
+
25
+ Set `Meta.fast = False` to disable the fast path explicitly. Useful for
26
+ opt-out per serializer without removing the mixin.
27
+ """
28
+
29
+ import warnings
30
+ from typing import Any, ClassVar
31
+
32
+ from rest_framework import serializers as drf
33
+
34
+ from .migrate import MigrationError, from_drf
35
+ from .serializer import FastSerializer
36
+
37
+
38
+ class FastListSerializer(drf.ListSerializer):
39
+ """ListSerializer with a Rust-encoded `.data` property.
40
+
41
+ Constructed automatically by `FastSerializerMixin.many_init`. Defers
42
+ to the child's translated `FastSerializer` schema; falls back to
43
+ `ListSerializer.data` if translation is unavailable.
44
+ """
45
+
46
+ @property
47
+ def data(self) -> Any:
48
+ child_cls = type(self.child)
49
+ schema = _resolve_schema(child_cls)
50
+ if schema is None or self.instance is None:
51
+ return super().data
52
+ return schema.drf(instance=self.instance, many=True).data
53
+
54
+
55
+ class FastSerializerMixin:
56
+ """Mix into an existing DRF Serializer to accelerate `.data`.
57
+
58
+ Override order matters: place `FastSerializerMixin` **first** in the
59
+ MRO so its `data` property wins:
60
+
61
+ class TxnSerializer(FastSerializerMixin, serializers.ModelSerializer):
62
+ ...
63
+ """
64
+
65
+ _fast_schema_cache: ClassVar[type[FastSerializer] | None] = None
66
+ _fast_schema_resolved: ClassVar[bool] = False
67
+
68
+ @classmethod
69
+ def many_init(cls, *args: Any, **kwargs: Any) -> drf.ListSerializer:
70
+ # Mirror DRF's many_init logic, swapping ListSerializer → FastListSerializer
71
+ # unless the user explicitly set Meta.list_serializer_class.
72
+ meta = getattr(cls, "Meta", None)
73
+ user_list_cls = getattr(meta, "list_serializer_class", None)
74
+ if user_list_cls is not None and user_list_cls is not drf.ListSerializer:
75
+ return super().many_init(*args, **kwargs) # type: ignore[misc]
76
+
77
+ allow_empty = kwargs.pop("allow_empty", None)
78
+ max_length = kwargs.pop("max_length", None)
79
+ min_length = kwargs.pop("min_length", None)
80
+ child_kwargs = {k: v for k, v in kwargs.items() if k not in drf.LIST_SERIALIZER_KWARGS}
81
+ list_kwargs: dict[str, Any] = {"child": cls(**child_kwargs)}
82
+ if allow_empty is not None:
83
+ list_kwargs["allow_empty"] = allow_empty
84
+ if max_length is not None:
85
+ list_kwargs["max_length"] = max_length
86
+ if min_length is not None:
87
+ list_kwargs["min_length"] = min_length
88
+ list_kwargs.update({k: v for k, v in kwargs.items() if k in drf.LIST_SERIALIZER_KWARGS})
89
+ return FastListSerializer(*args, **list_kwargs)
90
+
91
+ @property
92
+ def data(self) -> Any:
93
+ schema = _resolve_schema(type(self))
94
+ if schema is None or self.instance is None:
95
+ return super().data # type: ignore[misc]
96
+ return schema.drf(instance=self.instance, many=False).data
97
+
98
+
99
+ def _resolve_schema(cls: type) -> type[FastSerializer] | None:
100
+ """Memoized translation of a DRF Serializer class into a FastSerializer.
101
+
102
+ Honors `Meta.fast = False` as an explicit opt-out. Caches the result
103
+ (success or failure) on the serializer class itself to keep subsequent
104
+ lookups O(1).
105
+ """
106
+ if not cls.__dict__.get("_fast_schema_resolved"):
107
+ meta = getattr(cls, "Meta", None)
108
+ if meta is not None and getattr(meta, "fast", True) is False:
109
+ cls._fast_schema_cache = None
110
+ else:
111
+ cls._fast_schema_cache = _try_translate(cls)
112
+ cls._fast_schema_resolved = True
113
+ return cls._fast_schema_cache
114
+
115
+
116
+ def _try_translate(cls: type) -> type[FastSerializer] | None:
117
+ try:
118
+ return from_drf(cls)
119
+ except MigrationError as exc:
120
+ warnings.warn(
121
+ f"{cls.__name__}: cannot auto-translate to FastSerializer ({exc}). "
122
+ f"Falling back to standard DRF `.data` (no Rust speedup). "
123
+ f"Pass `exclude=(...)` via from_drf manually, or set `Meta.fast = False` "
124
+ f"to silence this warning.",
125
+ stacklevel=3,
126
+ )
127
+ return None
@@ -0,0 +1,218 @@
1
+ """Translate `rest_framework.serializers.Serializer` classes into `FastSerializer`.
2
+
3
+ `from_drf(MyExistingSerializer)` walks `serializer.fields` and builds an
4
+ equivalent pydantic-backed schema at runtime. Use the result as you would
5
+ any other `FastSerializer`:
6
+
7
+ FastTxnOut = from_drf(TxnSerializer)
8
+
9
+ class MyView(ListAPIView):
10
+ serializer_class = FastTxnOut.drf
11
+ renderer_classes = [FastJSONRenderer]
12
+
13
+ Limits: `SerializerMethodField` and DRF fields with overridden
14
+ `to_representation` have no mechanical equivalent. The helper raises
15
+ `MigrationError` naming the offending field; convert those manually
16
+ using pydantic's `@computed_field`, then exclude them from `from_drf`.
17
+ """
18
+
19
+ from collections.abc import Callable
20
+ from datetime import date, datetime, time, timedelta
21
+ from decimal import Decimal
22
+ from typing import Any
23
+ from uuid import UUID
24
+
25
+ from pydantic import AliasPath, Field, computed_field, create_model
26
+ from rest_framework import serializers as drf
27
+
28
+ from .serializer import FastSerializer
29
+
30
+ # Exact-type DRF field → pydantic type mapping. Subclasses are handled by
31
+ # walking MRO below. ListSerializer / Serializer / ListField / SerializerMethodField
32
+ # are special-cased before this table is consulted.
33
+ _SCALAR_MAP: dict[type[drf.Field], type] = {
34
+ drf.CharField: str,
35
+ drf.EmailField: str,
36
+ drf.URLField: str,
37
+ drf.SlugField: str,
38
+ drf.RegexField: str,
39
+ drf.IntegerField: int,
40
+ drf.FloatField: float,
41
+ drf.DecimalField: Decimal,
42
+ drf.BooleanField: bool,
43
+ drf.DateField: date,
44
+ drf.DateTimeField: datetime,
45
+ drf.TimeField: time,
46
+ drf.DurationField: timedelta,
47
+ drf.UUIDField: UUID,
48
+ drf.IPAddressField: str,
49
+ drf.FileField: str,
50
+ drf.ImageField: str,
51
+ drf.ChoiceField: str,
52
+ drf.JSONField: Any, # type: ignore[dict-item]
53
+ drf.DictField: dict,
54
+ drf.HStoreField: dict,
55
+ drf.PrimaryKeyRelatedField: int,
56
+ drf.StringRelatedField: str,
57
+ drf.HyperlinkedIdentityField: str,
58
+ drf.HyperlinkedRelatedField: str,
59
+ drf.SlugRelatedField: str,
60
+ }
61
+
62
+
63
+ class MigrationError(NotImplementedError):
64
+ """Raised when a DRF field cannot be mechanically translated."""
65
+
66
+
67
+ def from_drf(
68
+ serializer_cls: type[drf.Serializer],
69
+ *,
70
+ name: str | None = None,
71
+ exclude: tuple[str, ...] = (),
72
+ computed: dict[str, tuple[Callable[[Any], Any], type]] | None = None,
73
+ ) -> type[FastSerializer]:
74
+ """Build a `FastSerializer` subclass equivalent to `serializer_cls`.
75
+
76
+ Args:
77
+ serializer_cls: An existing DRF `Serializer` or `ModelSerializer` class.
78
+ name: Override the generated class name; defaults to `"<Cls>Fast"`.
79
+ exclude: Field names to skip (use for fields you intend to drop or
80
+ redeclare manually).
81
+ computed: Replace `SerializerMethodField`s with inline
82
+ `@computed_field`s. Map of ``field_name -> (callable, return_type)``;
83
+ the callable receives the pydantic instance as its sole arg.
84
+ Fields named here are skipped during DRF translation, then
85
+ attached as computed properties on the result.
86
+
87
+ Returns:
88
+ A `FastSerializer` subclass ready for `.drf` + `FastJSONRenderer`.
89
+
90
+ Raises:
91
+ MigrationError: if any non-excluded, non-computed field has no
92
+ clean mapping. The exception names the field and its DRF type.
93
+ """
94
+ computed = computed or {}
95
+ skip = set(exclude) | set(computed)
96
+
97
+ instance = serializer_cls()
98
+ fields: dict[str, tuple[Any, Any]] = {}
99
+ for field_name, field in instance.fields.items():
100
+ if field_name in skip:
101
+ continue
102
+ py_type, py_field = _map_field(field_name, field)
103
+ fields[field_name] = (py_type, py_field)
104
+
105
+ cls_name = name or f"{serializer_cls.__name__}Fast"
106
+ base = create_model(cls_name, __base__=FastSerializer, **fields)
107
+ if not computed:
108
+ return base
109
+ return _attach_computed(base, computed, cls_name)
110
+
111
+
112
+ def _attach_computed(
113
+ base: type[FastSerializer],
114
+ computed: dict[str, tuple[Callable[[Any], Any], type]],
115
+ cls_name: str,
116
+ ) -> type[FastSerializer]:
117
+ """Subclass `base` with `@computed_field` properties attached.
118
+
119
+ Each entry's callable becomes a property getter; the second element of
120
+ the tuple is the return annotation pydantic uses to type the field in
121
+ the JSON schema and for output coercion.
122
+ """
123
+ namespace: dict[str, Any] = {}
124
+ for cname, (fn, return_type) in computed.items():
125
+ # Build a typed wrapper so pydantic can read the return annotation.
126
+ def _make_getter(_fn: Callable[[Any], Any], _ret: type) -> Callable[[Any], Any]:
127
+ def getter(self: Any) -> Any:
128
+ return _fn(self)
129
+
130
+ getter.__annotations__ = {"self": Any, "return": _ret}
131
+ return getter
132
+
133
+ namespace[cname] = computed_field(property(_make_getter(fn, return_type)))
134
+ return type(cls_name, (base,), namespace)
135
+
136
+
137
+ def _map_field(field_name: str, field: drf.Field) -> tuple[Any, Any]:
138
+ """Map one DRF field to (python_type, pydantic Field info)."""
139
+ if isinstance(field, drf.SerializerMethodField):
140
+ raise MigrationError(
141
+ f"Field {field_name!r} is a SerializerMethodField; cannot be "
142
+ f"auto-translated. Pass exclude=({field_name!r},) to from_drf() "
143
+ f"and redeclare it on the result with @computed_field."
144
+ )
145
+
146
+ if isinstance(field, drf.ListSerializer):
147
+ child_type = from_drf(type(field.child))
148
+ return _wrap_optional(list[child_type], field), _field_info(field, field_name)
149
+
150
+ if isinstance(field, drf.Serializer):
151
+ child_type = from_drf(type(field))
152
+ return _wrap_optional(child_type, field), _field_info(field, field_name)
153
+
154
+ if isinstance(field, drf.ListField):
155
+ child_type, _ = _map_field(f"{field_name}[]", field.child)
156
+ return _wrap_optional(list[child_type], field), _field_info(
157
+ field, field_name, empty_default=list
158
+ )
159
+
160
+ py_type = _resolve_scalar(field)
161
+ if py_type is None:
162
+ raise MigrationError(
163
+ f"Field {field_name!r} of type {type(field).__name__!r} has no "
164
+ f"mapping in from_drf. Add it to exclude=(...) and declare it "
165
+ f"manually on the resulting FastSerializer."
166
+ )
167
+ return _wrap_optional(py_type, field), _field_info(field, field_name)
168
+
169
+
170
+ def _resolve_scalar(field: drf.Field) -> type | None:
171
+ """Walk MRO to find the closest mapped DRF field type."""
172
+ for cls in type(field).__mro__:
173
+ if cls in _SCALAR_MAP:
174
+ return _SCALAR_MAP[cls]
175
+ return None
176
+
177
+
178
+ def _wrap_optional(py_type: Any, field: drf.Field) -> Any:
179
+ """Wrap as `T | None` when the DRF field is nullable or not required."""
180
+ if field.allow_null or not field.required:
181
+ return py_type | None
182
+ return py_type
183
+
184
+
185
+ def _field_info(
186
+ field: drf.Field,
187
+ field_name: str,
188
+ *,
189
+ empty_default: Any = None,
190
+ ) -> Any:
191
+ """Build the pydantic Field() carrying default + validation_alias.
192
+
193
+ `empty_default` is the factory (e.g. `list`, `dict`) used when the DRF
194
+ field is `required=False` with no explicit default; mirrors DRF's
195
+ behavior of treating those as empty containers rather than null.
196
+ """
197
+ kwargs: dict[str, Any] = {}
198
+
199
+ # source="a.b.c" → AliasPath(a, b, c). source=="*" means "the whole object";
200
+ # leave it alone, pydantic's from_attributes handles it.
201
+ source = getattr(field, "source", None)
202
+ if source and source != field_name and source != "*":
203
+ parts = source.split(".")
204
+ kwargs["validation_alias"] = parts[0] if len(parts) == 1 else AliasPath(*parts)
205
+
206
+ default = getattr(field, "default", drf.empty)
207
+ if field.required:
208
+ if not kwargs:
209
+ return ... # plain required field
210
+ return Field(..., **kwargs)
211
+ if default is drf.empty:
212
+ if empty_default is not None:
213
+ kwargs["default_factory"] = empty_default
214
+ else:
215
+ kwargs["default"] = None
216
+ else:
217
+ kwargs["default"] = default
218
+ return Field(**kwargs)