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.
- drf_fastserializers/__init__.py +79 -0
- drf_fastserializers/_compat.py +21 -0
- drf_fastserializers/_errors.py +17 -0
- drf_fastserializers/_payload.py +109 -0
- drf_fastserializers/accelerator.py +127 -0
- drf_fastserializers/migrate.py +218 -0
- drf_fastserializers/models.py +153 -0
- drf_fastserializers/parser.py +58 -0
- drf_fastserializers/py.typed +0 -0
- drf_fastserializers/renderer.py +61 -0
- drf_fastserializers/serializer.py +210 -0
- drf_fastserializers/spectacular.py +59 -0
- drf_fastserializers-0.2.0.dist-info/METADATA +513 -0
- drf_fastserializers-0.2.0.dist-info/RECORD +16 -0
- drf_fastserializers-0.2.0.dist-info/WHEEL +4 -0
- drf_fastserializers-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|