flpit 0.2.1.dev3__tar.gz → 0.2.1.dev4__tar.gz
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.
- {flpit-0.2.1.dev3 → flpit-0.2.1.dev4}/PKG-INFO +2 -2
- {flpit-0.2.1.dev3 → flpit-0.2.1.dev4}/pyproject.toml +2 -2
- {flpit-0.2.1.dev3 → flpit-0.2.1.dev4}/src/flpit/core/linq.py +24 -4
- flpit-0.2.1.dev4/src/flpit/io.py +212 -0
- flpit-0.2.1.dev4/src/flpit/json.py +632 -0
- flpit-0.2.1.dev3/src/flpit/json.py +0 -427
- {flpit-0.2.1.dev3 → flpit-0.2.1.dev4}/.gitignore +0 -0
- {flpit-0.2.1.dev3 → flpit-0.2.1.dev4}/LICENSE +0 -0
- {flpit-0.2.1.dev3 → flpit-0.2.1.dev4}/README.md +0 -0
- {flpit-0.2.1.dev3 → flpit-0.2.1.dev4}/src/flp/__init__.py +0 -0
- {flpit-0.2.1.dev3 → flpit-0.2.1.dev4}/src/flpit/__init__.py +0 -0
- {flpit-0.2.1.dev3 → flpit-0.2.1.dev4}/src/flpit/core/__init__.py +0 -0
- {flpit-0.2.1.dev3 → flpit-0.2.1.dev4}/src/flpit/flp.py +0 -0
- {flpit-0.2.1.dev3 → flpit-0.2.1.dev4}/src/flpit/py.typed +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: flpit
|
|
3
|
-
Version: 0.2.1.
|
|
3
|
+
Version: 0.2.1.dev4
|
|
4
4
|
Summary: Fluent LINQ for Python — flip your iterables
|
|
5
5
|
Project-URL: Homepage, https://github.com/riemar/flpit
|
|
6
|
+
Project-URL: Repository, https://github.com/riemar/flpit
|
|
6
7
|
License: MIT
|
|
7
8
|
License-File: LICENSE
|
|
8
9
|
Keywords: fluent,generators,iterable,lazy-evaluation,linq,pipeline,pyrefly,type-safe
|
|
@@ -10,7 +11,6 @@ Classifier: Development Status :: 3 - Alpha
|
|
|
10
11
|
Classifier: Intended Audience :: Developers
|
|
11
12
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
13
|
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "flpit"
|
|
3
|
-
version = "0.2.1.
|
|
3
|
+
version = "0.2.1.dev4"
|
|
4
4
|
description = "Fluent LINQ for Python — flip your iterables"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -11,7 +11,6 @@ classifiers = [
|
|
|
11
11
|
"Intended Audience :: Developers",
|
|
12
12
|
"License :: OSI Approved :: MIT License",
|
|
13
13
|
"Programming Language :: Python :: 3",
|
|
14
|
-
"Programming Language :: Python :: 3.11",
|
|
15
14
|
"Programming Language :: Python :: 3.12",
|
|
16
15
|
"Programming Language :: Python :: 3.13",
|
|
17
16
|
"Programming Language :: Python :: 3.14",
|
|
@@ -24,6 +23,7 @@ dependencies = [
|
|
|
24
23
|
|
|
25
24
|
[project.urls]
|
|
26
25
|
Homepage = "https://github.com/riemar/flpit"
|
|
26
|
+
Repository = "https://github.com/riemar/flpit"
|
|
27
27
|
|
|
28
28
|
[dependency-groups]
|
|
29
29
|
dev = [
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from flpit.io import StringWriter
|
|
1
2
|
import decimal
|
|
2
3
|
from decimal import Decimal, Overflow, Inexact
|
|
3
4
|
|
|
@@ -26,7 +27,8 @@ from typing import (
|
|
|
26
27
|
GenericAlias
|
|
27
28
|
)
|
|
28
29
|
|
|
29
|
-
from flpit.
|
|
30
|
+
from flpit.io import MemoryByteWriter
|
|
31
|
+
from flpit.json import JsonConvert, JsonSerializerOptions
|
|
30
32
|
|
|
31
33
|
TItem = TypeVar("TItem")
|
|
32
34
|
TResult = TypeVar("TResult")
|
|
@@ -625,6 +627,24 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
|
|
|
625
627
|
"""Explicitly materializes the query into a FlpList."""
|
|
626
628
|
return FlpList[TItem](self)
|
|
627
629
|
|
|
630
|
+
# =====================================================================
|
|
631
|
+
# 5. json serialization
|
|
632
|
+
# =====================================================================
|
|
633
|
+
|
|
634
|
+
@classmethod
|
|
635
|
+
def from_json[T](cls, data: str | bytes, item_type: type[T], jso: JsonSerializerOptions|None=None) -> FlpIt[T]:
|
|
636
|
+
return FlpIt[T](JsonConvert.deserialize(data, list[item_type], jso))
|
|
637
|
+
|
|
638
|
+
def to_json_bytes[T](self, item_type: type[T], jso: JsonSerializerOptions|None=None) -> bytes:
|
|
639
|
+
writer = MemoryByteWriter()
|
|
640
|
+
JsonConvert.serialize_to(self, item_type, writer, jso)
|
|
641
|
+
return writer.getvalue()
|
|
642
|
+
|
|
643
|
+
def to_json[T](self, item_type: type[T], jso: JsonSerializerOptions|None=None) -> str:
|
|
644
|
+
writer = StringWriter()
|
|
645
|
+
JsonConvert.serialize_to(self, item_type, writer, jso)
|
|
646
|
+
return writer.getvalue()
|
|
647
|
+
|
|
628
648
|
|
|
629
649
|
from functools import partial
|
|
630
650
|
|
|
@@ -1192,12 +1212,12 @@ class FlpList(Collection[TItem], Generic[TItem]):
|
|
|
1192
1212
|
|
|
1193
1213
|
@classmethod
|
|
1194
1214
|
def from_json[T](cls, data: str | bytes, item_type: type[T], jso: JsonSerializerOptions|None=None) -> FlpList[T]:
|
|
1195
|
-
return FlpList[T](
|
|
1215
|
+
return FlpList[T](JsonConvert.deserialize(data, list[item_type], jso))
|
|
1196
1216
|
|
|
1197
1217
|
def to_json_bytes[T](self, item_type: type[T], jso: JsonSerializerOptions|None=None) -> bytes:
|
|
1198
|
-
return
|
|
1218
|
+
return JsonConvert.serialize_bytes(self.__list.data, list[item_type], jso)
|
|
1199
1219
|
|
|
1200
1220
|
def to_json[T](self, item_type: type[T], jso: JsonSerializerOptions|None=None) -> str:
|
|
1201
|
-
return self.
|
|
1221
|
+
return JsonConvert.serialize(self.__list.data, list[item_type], jso)
|
|
1202
1222
|
|
|
1203
1223
|
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from types import TracebackType
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from io import StringIO, TextIOWrapper
|
|
4
|
+
from typing import Any, BinaryIO, Iterable
|
|
5
|
+
|
|
6
|
+
import msgspec
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ---------- Text layer ----------
|
|
10
|
+
|
|
11
|
+
class StringBuilder:
|
|
12
|
+
__slots__ = ("_buffer", "_length")
|
|
13
|
+
|
|
14
|
+
def __init__(self, initial_value: str = ""):
|
|
15
|
+
self._buffer = StringIO()
|
|
16
|
+
self._length = 0
|
|
17
|
+
self.append(initial_value)
|
|
18
|
+
|
|
19
|
+
def append(self, text: str) -> "StringBuilder":
|
|
20
|
+
self._buffer.write(text)
|
|
21
|
+
self._length += len(text)
|
|
22
|
+
return self
|
|
23
|
+
|
|
24
|
+
def append_line(self, text: str = "") -> "StringBuilder":
|
|
25
|
+
return self.append(text).append("\n")
|
|
26
|
+
|
|
27
|
+
def clear(self) -> None:
|
|
28
|
+
self._buffer.seek(0)
|
|
29
|
+
self._buffer.truncate(0)
|
|
30
|
+
self._length = 0
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def length(self) -> int:
|
|
34
|
+
return self._length
|
|
35
|
+
|
|
36
|
+
def to_string(self) -> str:
|
|
37
|
+
return self._buffer.getvalue()
|
|
38
|
+
|
|
39
|
+
def __str__(self) -> str:
|
|
40
|
+
return self.to_string()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TextWriter(ABC):
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def write(self, text: str) -> int:
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
def write_line(self, text: str = "") -> None:
|
|
49
|
+
self.write(text)
|
|
50
|
+
self.write("\n")
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def flush(self) -> None:
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def __enter__(self):
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(
|
|
64
|
+
self,
|
|
65
|
+
exc_type: type[BaseException] | None,
|
|
66
|
+
exc_val: BaseException | None,
|
|
67
|
+
exc_tb: TracebackType | None,
|
|
68
|
+
) -> bool | None:
|
|
69
|
+
self.close()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class StringWriter(TextWriter):
|
|
73
|
+
def __init__(self):
|
|
74
|
+
self._builder = StringBuilder()
|
|
75
|
+
self._closed = False
|
|
76
|
+
|
|
77
|
+
def write(self, text: str) -> int:
|
|
78
|
+
if self._closed:
|
|
79
|
+
raise ValueError("Writer is closed")
|
|
80
|
+
self._builder.append(text)
|
|
81
|
+
return len(text)
|
|
82
|
+
|
|
83
|
+
def flush(self) -> None:
|
|
84
|
+
if self._closed:
|
|
85
|
+
raise ValueError("Writer is closed")
|
|
86
|
+
|
|
87
|
+
def close(self) -> None:
|
|
88
|
+
self._closed = True
|
|
89
|
+
|
|
90
|
+
def getvalue(self) -> str:
|
|
91
|
+
return self._builder.to_string()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class StreamWriter(TextWriter):
|
|
95
|
+
"""Text writer over a binary stream; closing it closes the stream."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, stream: BinaryIO, encoding: str = "utf-8"):
|
|
98
|
+
self._stream = TextIOWrapper(stream, encoding=encoding)
|
|
99
|
+
|
|
100
|
+
def write(self, text: str) -> int:
|
|
101
|
+
return self._stream.write(text)
|
|
102
|
+
|
|
103
|
+
def flush(self) -> None:
|
|
104
|
+
self._stream.flush()
|
|
105
|
+
|
|
106
|
+
def close(self) -> None:
|
|
107
|
+
self._stream.close()
|
|
108
|
+
|
|
109
|
+
#===================================
|
|
110
|
+
# ---------- Binary layer ----------
|
|
111
|
+
#===================================
|
|
112
|
+
|
|
113
|
+
class ByteWriter(ABC):
|
|
114
|
+
"""write() must consume or copy the supplied bytes before returning."""
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
def write(self, data: bytes | bytearray | memoryview) -> None:
|
|
118
|
+
...
|
|
119
|
+
|
|
120
|
+
@abstractmethod
|
|
121
|
+
def flush(self) -> None:
|
|
122
|
+
...
|
|
123
|
+
|
|
124
|
+
class MemoryByteWriter(ByteWriter):
|
|
125
|
+
def __init__(self):
|
|
126
|
+
self._buffer = bytearray()
|
|
127
|
+
|
|
128
|
+
def write(self, data: bytes | bytearray | memoryview) -> None:
|
|
129
|
+
self._buffer.extend(data)
|
|
130
|
+
|
|
131
|
+
def flush(self) -> None:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
def getvalue(self) -> bytes:
|
|
135
|
+
return bytes(self._buffer)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class StreamByteWriter(ByteWriter):
|
|
139
|
+
"""Adapter for a Python binary stream. The caller owns the stream."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, stream: BinaryIO):
|
|
142
|
+
self._stream = stream
|
|
143
|
+
|
|
144
|
+
def write(self, data: bytes | bytearray | memoryview) -> None:
|
|
145
|
+
view = memoryview(data)
|
|
146
|
+
|
|
147
|
+
while view:
|
|
148
|
+
count = self._stream.write(view)
|
|
149
|
+
if count is None or count <= 0:
|
|
150
|
+
raise OSError("Stream failed to write data")
|
|
151
|
+
view = view[count:]
|
|
152
|
+
|
|
153
|
+
def flush(self) -> None:
|
|
154
|
+
self._stream.flush()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class BufferedByteWriter(ByteWriter):
|
|
158
|
+
"""Adds explicit, bounded-size output buffering to another writer."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, destination: ByteWriter, chunk_size: int = 1 << 20):
|
|
161
|
+
if chunk_size < 1:
|
|
162
|
+
raise ValueError("chunk_size must be positive")
|
|
163
|
+
|
|
164
|
+
self._destination = destination
|
|
165
|
+
self._chunk_size = chunk_size
|
|
166
|
+
self._buffer = bytearray()
|
|
167
|
+
|
|
168
|
+
def write(self, data: bytes | bytearray | memoryview) -> None:
|
|
169
|
+
view = memoryview(data)
|
|
170
|
+
|
|
171
|
+
while view:
|
|
172
|
+
count = min(
|
|
173
|
+
self._chunk_size - len(self._buffer),
|
|
174
|
+
len(view),
|
|
175
|
+
)
|
|
176
|
+
self._buffer.extend(view[:count])
|
|
177
|
+
view = view[count:]
|
|
178
|
+
|
|
179
|
+
if len(self._buffer) == self._chunk_size:
|
|
180
|
+
self._flush_buffer()
|
|
181
|
+
|
|
182
|
+
def _flush_buffer(self) -> None:
|
|
183
|
+
if self._buffer:
|
|
184
|
+
self._destination.write(self._buffer)
|
|
185
|
+
self._buffer.clear()
|
|
186
|
+
|
|
187
|
+
def flush(self) -> None:
|
|
188
|
+
self._flush_buffer()
|
|
189
|
+
self._destination.flush()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------- JSON serialization policy ----------
|
|
193
|
+
|
|
194
|
+
def write_json_array(
|
|
195
|
+
items: Iterable[Any],
|
|
196
|
+
output: ByteWriter,
|
|
197
|
+
) -> None:
|
|
198
|
+
encoder = msgspec.json.Encoder()
|
|
199
|
+
scratch = bytearray()
|
|
200
|
+
first = True
|
|
201
|
+
|
|
202
|
+
output.write(b"[")
|
|
203
|
+
|
|
204
|
+
for item in items:
|
|
205
|
+
if not first:
|
|
206
|
+
output.write(b",")
|
|
207
|
+
|
|
208
|
+
encoder.encode_into(item, scratch)
|
|
209
|
+
output.write(scratch)
|
|
210
|
+
first = False
|
|
211
|
+
|
|
212
|
+
output.write(b"]")
|
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
import dataclasses
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import types
|
|
7
|
+
import typing
|
|
8
|
+
from dataclasses import MISSING, dataclass
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from hashlib import blake2s
|
|
12
|
+
from typing import Annotated, Any, ForwardRef, get_args, get_origin, get_type_hints, Callable, cast as typing_cast
|
|
13
|
+
|
|
14
|
+
import msgspec
|
|
15
|
+
|
|
16
|
+
from flpit.io import ByteWriter, TextWriter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JsonNamingPolicy(StrEnum):
|
|
20
|
+
lower = "lower" # pyrefly: ignore [bad-override-mutable-attribute]
|
|
21
|
+
upper = "upper" # pyrefly: ignore [bad-override-mutable-attribute]
|
|
22
|
+
camel = "camel"
|
|
23
|
+
pascal = "pascal"
|
|
24
|
+
kebap = "kebab"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class JsonDefaultHandling(StrEnum):
|
|
28
|
+
include = "include"
|
|
29
|
+
exclude = "exclude"
|
|
30
|
+
|
|
31
|
+
class JsonUnknownFieldHandling(StrEnum):
|
|
32
|
+
allow = "allow"
|
|
33
|
+
disallow = "disallow"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True, frozen=True)
|
|
37
|
+
class JsonSerializerOptions:
|
|
38
|
+
naming_policy: JsonNamingPolicy = JsonNamingPolicy.camel
|
|
39
|
+
default_handling: JsonDefaultHandling = JsonDefaultHandling.exclude
|
|
40
|
+
unknown_field_handling: JsonUnknownFieldHandling = JsonUnknownFieldHandling.allow
|
|
41
|
+
|
|
42
|
+
# write_indented: bool = False # will be supported later
|
|
43
|
+
native: bool = False
|
|
44
|
+
"""
|
|
45
|
+
| Set to true for the native speed of msgspec
|
|
46
|
+
|
|
47
|
+
!! only set to true if you know what you are doing !!
|
|
48
|
+
You'll receive the msgspec.msgstruct object that will try to do it's best to behave like your dataclass.
|
|
49
|
+
But remember !! "It's NOT your dataclass" !!
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def fingerprint(self) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Identifies a unique jso instance for building a struct
|
|
56
|
+
!! All parts of a jso that don't influence the defstruct MUST be ignored!
|
|
57
|
+
"""
|
|
58
|
+
settings = (f"jpn-{self.naming_policy.value}:"
|
|
59
|
+
f"dh-{self.default_handling}:"
|
|
60
|
+
f"ufh-{self.unknown_field_handling}:"
|
|
61
|
+
).encode("utf-8")
|
|
62
|
+
return blake2s(settings, digest_size=6).hexdigest()
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Metadata Annotation Markers
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
class JsonIgnore:
|
|
69
|
+
def __class_getitem__[T](cls, item: type[T]) -> Any:
|
|
70
|
+
return Annotated[item, cls]
|
|
71
|
+
|
|
72
|
+
class JsonInclude:
|
|
73
|
+
def __class_getitem__[T](cls, item: type[T]) -> Any:
|
|
74
|
+
return Annotated[item, cls]
|
|
75
|
+
|
|
76
|
+
class JsonProperty:
|
|
77
|
+
def __class_getitem__[T](cls, item: tuple[type[T], str]) -> Any:
|
|
78
|
+
target_type, json_name = item
|
|
79
|
+
return Annotated[target_type, cls(json_name)]
|
|
80
|
+
|
|
81
|
+
def __init__(self, name: str):
|
|
82
|
+
self.name = name
|
|
83
|
+
|
|
84
|
+
JIgnore = JsonIgnore
|
|
85
|
+
JInclude = JsonInclude
|
|
86
|
+
JProp = JsonProperty
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------
|
|
89
|
+
# Generated type metadata
|
|
90
|
+
# ---------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
@dataclass(slots=True, eq=False)
|
|
93
|
+
class _WireType:
|
|
94
|
+
source: type
|
|
95
|
+
"""The source type to build a msgspec.struct for"""
|
|
96
|
+
hints: dict[str, Any]
|
|
97
|
+
namespace: types.ModuleType
|
|
98
|
+
"""the name space we are targeting"""
|
|
99
|
+
struct: type | None = None
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def module_name(self: _WireType) -> str:
|
|
103
|
+
return self.namespace.__name__
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def class_name(self: _WireType) -> str:
|
|
107
|
+
return self.source.__name__
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def ref_name(self: _WireType) -> str:
|
|
111
|
+
identity = (
|
|
112
|
+
f"{self.source.__module__}_{self.source.__qualname__}"
|
|
113
|
+
)
|
|
114
|
+
return "_ref_" + re.sub(r"\W", "_", identity)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------
|
|
118
|
+
# Never imagined this would be required to have a proper lib behavior
|
|
119
|
+
# ---------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def _apply_naming_policy(name: str, policy: JsonNamingPolicy) -> str:
|
|
122
|
+
"""
|
|
123
|
+
Splits any attribute name (snake_case, PascalCase, UPPER_CASE)
|
|
124
|
+
into individual tokens and formats them according to the target policy.
|
|
125
|
+
"""
|
|
126
|
+
# 1. Check if there is a leading underscore and save it
|
|
127
|
+
prefix = "_" if name.startswith("_") else ""
|
|
128
|
+
clean_name = name.lstrip("_")
|
|
129
|
+
|
|
130
|
+
# 2. Tokenize only the clean part of the string
|
|
131
|
+
tokens = [t for t in re.split(r'_(?=[a-zA-Z])|(?<=[a-z])(?=[A-Z])', clean_name) if t]
|
|
132
|
+
if not tokens:
|
|
133
|
+
return name
|
|
134
|
+
|
|
135
|
+
if policy == JsonNamingPolicy.lower:
|
|
136
|
+
result = "".join(t.lower() for t in tokens)
|
|
137
|
+
|
|
138
|
+
if policy == JsonNamingPolicy.upper:
|
|
139
|
+
result = "".join(t.upper() for t in tokens)
|
|
140
|
+
|
|
141
|
+
if policy == JsonNamingPolicy.pascal:
|
|
142
|
+
result = "".join(t.capitalize() for t in tokens)
|
|
143
|
+
|
|
144
|
+
if policy == JsonNamingPolicy.kebap:
|
|
145
|
+
result = "-".join(t.lower() for t in tokens)
|
|
146
|
+
|
|
147
|
+
if policy == JsonNamingPolicy.camel:
|
|
148
|
+
# Lowercase the first token completely, capitalize all subsequent tokens
|
|
149
|
+
result = tokens[0].lower() + "".join(t.capitalize() for t in tokens[1:])
|
|
150
|
+
|
|
151
|
+
else:
|
|
152
|
+
result = clean_name
|
|
153
|
+
|
|
154
|
+
return prefix + result
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _generated_module(source: type) -> types.ModuleType:
|
|
159
|
+
"""Generates the required module definition for our runtime only dynamic msgspec.struct"""
|
|
160
|
+
name = f"flpit.json._generated.{source.__module__}"
|
|
161
|
+
|
|
162
|
+
module = sys.modules.get(name)
|
|
163
|
+
if module is None:
|
|
164
|
+
module = types.ModuleType(name)
|
|
165
|
+
sys.modules[name] = module
|
|
166
|
+
|
|
167
|
+
return module
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _is_dataclass_type(tp: Any) -> bool:
|
|
171
|
+
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _field_directives(
|
|
175
|
+
annotation: Any,
|
|
176
|
+
) -> tuple[Any, bool, bool, str | None]:
|
|
177
|
+
"""Extract JIgnore, JInclude, and JProp directives from a field annotation."""
|
|
178
|
+
|
|
179
|
+
if get_origin(annotation) is not typing.Annotated:
|
|
180
|
+
return annotation, False, False, None
|
|
181
|
+
|
|
182
|
+
args = get_args(annotation)
|
|
183
|
+
base_type = args[0]
|
|
184
|
+
metadata = args[1:]
|
|
185
|
+
ignored = any(item is JsonIgnore for item in metadata)
|
|
186
|
+
included = any(item is JsonInclude for item in metadata)
|
|
187
|
+
json_name = next(
|
|
188
|
+
(item.name for item in metadata if isinstance(item, JsonProperty)),
|
|
189
|
+
None,
|
|
190
|
+
)
|
|
191
|
+
remaining = tuple(
|
|
192
|
+
item
|
|
193
|
+
for item in metadata
|
|
194
|
+
if item is not JsonIgnore
|
|
195
|
+
and item is not JsonInclude
|
|
196
|
+
and not isinstance(item, JsonProperty)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if remaining:
|
|
200
|
+
base_type = Annotated[(base_type, *remaining)]
|
|
201
|
+
|
|
202
|
+
return base_type, ignored, included, json_name
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _get_property_directive(prop_func: Callable[[Any, Any], None] | Callable[[Any], Any] | None):
|
|
206
|
+
if prop_func is not None:
|
|
207
|
+
getter_hints = get_type_hints(prop_func, include_extras=True)
|
|
208
|
+
source_annotation = getter_hints.get("return", Any)
|
|
209
|
+
else:
|
|
210
|
+
source_annotation = Any
|
|
211
|
+
|
|
212
|
+
return _field_directives(source_annotation)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _nested_dataclasses(tp: Any):
|
|
216
|
+
"""Yield dataclass types referenced by an annotation."""
|
|
217
|
+
if _is_dataclass_type(tp):
|
|
218
|
+
yield tp
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
if isinstance(tp, (list, tuple)):
|
|
222
|
+
for item in tp:
|
|
223
|
+
yield from _nested_dataclasses(item)
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
origin = get_origin(tp)
|
|
227
|
+
|
|
228
|
+
if origin is typing.Annotated:
|
|
229
|
+
yield from _nested_dataclasses(get_args(tp)[0])
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
if origin is typing.Literal:
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
for arg in get_args(tp):
|
|
236
|
+
yield from _nested_dataclasses(arg)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _rewrite_type(
|
|
240
|
+
tp: Any,
|
|
241
|
+
owner: _WireType,
|
|
242
|
+
graph: dict[type, _WireType],
|
|
243
|
+
*,
|
|
244
|
+
resolve: bool = False,
|
|
245
|
+
) -> Any:
|
|
246
|
+
"""Replace source dataclasses with generated Structs or ForwardRefs."""
|
|
247
|
+
if tp is getattr(typing, "Self", None):
|
|
248
|
+
info = owner
|
|
249
|
+
return info.struct if resolve else ForwardRef(info.ref_name)
|
|
250
|
+
|
|
251
|
+
if _is_dataclass_type(tp):
|
|
252
|
+
info = graph[tp]
|
|
253
|
+
return info.struct if resolve else ForwardRef(info.ref_name)
|
|
254
|
+
|
|
255
|
+
if isinstance(tp, list):
|
|
256
|
+
return [
|
|
257
|
+
_rewrite_type(item, owner, graph, resolve=resolve)
|
|
258
|
+
for item in tp
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
if isinstance(tp, tuple):
|
|
262
|
+
return tuple(
|
|
263
|
+
_rewrite_type(item, owner, graph, resolve=resolve)
|
|
264
|
+
for item in tp
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
origin = get_origin(tp)
|
|
268
|
+
|
|
269
|
+
if origin is None or origin is typing.Literal:
|
|
270
|
+
return tp
|
|
271
|
+
|
|
272
|
+
args = get_args(tp)
|
|
273
|
+
|
|
274
|
+
if origin is typing.Annotated:
|
|
275
|
+
base = _rewrite_type(
|
|
276
|
+
args[0], owner, graph, resolve=resolve
|
|
277
|
+
)
|
|
278
|
+
return typing.Annotated[(base, *args[1:])]
|
|
279
|
+
|
|
280
|
+
rewritten: tuple[Any, ...] = tuple(
|
|
281
|
+
_rewrite_type(arg, owner, graph, resolve=resolve)
|
|
282
|
+
for arg in args
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if origin in (typing.Union, types.UnionType):
|
|
286
|
+
union: Any = typing.Union # with all the magic here, there's no way to make typing really happy
|
|
287
|
+
return union[rewritten]
|
|
288
|
+
|
|
289
|
+
params = rewritten[0] if len(rewritten) == 1 else rewritten
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
return origin[params]
|
|
293
|
+
except (TypeError, AttributeError) as exc:
|
|
294
|
+
copier = getattr(tp, "copy_with", None)
|
|
295
|
+
if copier is not None:
|
|
296
|
+
return copier(rewritten)
|
|
297
|
+
|
|
298
|
+
raise TypeError(
|
|
299
|
+
f"Cannot rewrite annotation {tp!r}"
|
|
300
|
+
) from exc
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------
|
|
304
|
+
# Graph discovery
|
|
305
|
+
# ---------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _discover_graph(
|
|
309
|
+
roots: tuple[type, ...],
|
|
310
|
+
) -> dict[type, _WireType]:
|
|
311
|
+
"""Discover reachable dataclasses and resolve their annotations."""
|
|
312
|
+
graph: dict[type, _WireType] = {}
|
|
313
|
+
pending = list(roots)
|
|
314
|
+
|
|
315
|
+
while pending:
|
|
316
|
+
source = pending.pop()
|
|
317
|
+
|
|
318
|
+
if source in graph:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
info = _WireType(
|
|
322
|
+
source=source,
|
|
323
|
+
hints=get_type_hints(source, include_extras=True),
|
|
324
|
+
namespace=_generated_module(source),
|
|
325
|
+
)
|
|
326
|
+
graph[source] = info
|
|
327
|
+
|
|
328
|
+
for field in dataclasses.fields(source):
|
|
329
|
+
annotation, ignored, included, _ = _field_directives(
|
|
330
|
+
info.hints[field.name]
|
|
331
|
+
)
|
|
332
|
+
if ignored:
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
pending.extend(_nested_dataclasses(annotation))
|
|
336
|
+
|
|
337
|
+
return graph
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _allows_none(tp: Any) -> bool:
|
|
341
|
+
if get_origin(tp) is typing.Annotated:
|
|
342
|
+
tp = get_args(tp)[0]
|
|
343
|
+
|
|
344
|
+
return type(None) in get_args(tp)
|
|
345
|
+
|
|
346
|
+
# ---------------------------------------------------------------------
|
|
347
|
+
# Struct construction
|
|
348
|
+
# ---------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _struct_fields(
|
|
352
|
+
info: _WireType,
|
|
353
|
+
graph: dict[type, _WireType],
|
|
354
|
+
jso: JsonSerializerOptions,
|
|
355
|
+
) -> list[tuple[Any, ...]]:
|
|
356
|
+
definitions: list[tuple[Any, ...]] = []
|
|
357
|
+
dataclass_fields = {f.name for f in dataclasses.fields(info.source)}
|
|
358
|
+
|
|
359
|
+
# 1. Process regular fields (regardless of init=True or init=False)
|
|
360
|
+
for field in dataclasses.fields(info.source):
|
|
361
|
+
source_annotation = info.hints[field.name]
|
|
362
|
+
annotation, ignored, included, json_name = _field_directives(source_annotation)
|
|
363
|
+
|
|
364
|
+
if ignored:
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
# If the field starts with an underscore, it MUST have JInclude or JProp attached!
|
|
368
|
+
if field.name.startswith("_") and not (included or json_name is not None):
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
annotation = _rewrite_type(annotation, info, graph)
|
|
372
|
+
wire_name = json_name if json_name is not None else _apply_naming_policy(field.name, jso.naming_policy)
|
|
373
|
+
|
|
374
|
+
# Resolve structural field default values cleanly
|
|
375
|
+
if field.default is not MISSING:
|
|
376
|
+
default_val = field.default
|
|
377
|
+
has_default = True
|
|
378
|
+
elif field.default_factory is not MISSING:
|
|
379
|
+
default_val = msgspec.field(default_factory=field.default_factory)
|
|
380
|
+
has_default = True
|
|
381
|
+
elif _allows_none(annotation):
|
|
382
|
+
default_val = None
|
|
383
|
+
has_default = True
|
|
384
|
+
else:
|
|
385
|
+
default_val = MISSING
|
|
386
|
+
has_default = False
|
|
387
|
+
|
|
388
|
+
field_options: dict[str, Any] = {"name": wire_name}
|
|
389
|
+
if has_default:
|
|
390
|
+
if field.default_factory is not MISSING:
|
|
391
|
+
field_options["default_factory"] = field.default_factory
|
|
392
|
+
else:
|
|
393
|
+
field_options["default"] = default_val
|
|
394
|
+
|
|
395
|
+
spec = (field.name, annotation, msgspec.field(**field_options))
|
|
396
|
+
definitions.append(spec)
|
|
397
|
+
|
|
398
|
+
# 2. Process properties automatically
|
|
399
|
+
for name, value in vars(info.source).items():
|
|
400
|
+
if isinstance(value, property) and name not in dataclass_fields:
|
|
401
|
+
# Extract the type annotation directly from the getter function signature
|
|
402
|
+
if value.fset is not None:
|
|
403
|
+
_, _, included, json_name = _get_property_directive(value.fset)
|
|
404
|
+
|
|
405
|
+
if included or json_name is not None:
|
|
406
|
+
raise ValueError(
|
|
407
|
+
f"{info.source.__qualname__}.{name}: "
|
|
408
|
+
"fplit.json with msgspec backend doesn't support "
|
|
409
|
+
"JProp/JInclude on property setters"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
annotation, ignored, included, json_name = _get_property_directive(value.fget)
|
|
413
|
+
|
|
414
|
+
if not included and json_name is None:
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
annotation = _rewrite_type(annotation, info, graph)
|
|
418
|
+
wire_name = json_name if json_name is not None else _apply_naming_policy(name, jso.naming_policy)
|
|
419
|
+
|
|
420
|
+
# Map the field into the C-struct. Default to None so the inbound parser never complains.
|
|
421
|
+
spec = (name, annotation, msgspec.field(name=wire_name, default=None))
|
|
422
|
+
definitions.append(spec)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
return definitions
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _register_forward_refs(
|
|
429
|
+
graph: dict[type, _WireType],
|
|
430
|
+
) -> None:
|
|
431
|
+
"""Expose every graph reference in each participating module."""
|
|
432
|
+
namespaces = []
|
|
433
|
+
|
|
434
|
+
for info in graph.values():
|
|
435
|
+
if not any(info.namespace is ns for ns in namespaces):
|
|
436
|
+
namespaces.append(info.namespace)
|
|
437
|
+
|
|
438
|
+
for namespace in namespaces:
|
|
439
|
+
for info in graph.values():
|
|
440
|
+
setattr(namespace, info.ref_name, info.struct)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _behavior_namespace(source: type) -> dict[str, Any]:
|
|
444
|
+
return {
|
|
445
|
+
name: value
|
|
446
|
+
for name, value in vars(source).items()
|
|
447
|
+
if (
|
|
448
|
+
not name.startswith("__")
|
|
449
|
+
and (callable(value) or isinstance(value, property))
|
|
450
|
+
)
|
|
451
|
+
# pyrefly: ignore [implicit-any-lambda]
|
|
452
|
+
} | { "__class__": property(lambda self, cls=source: cls) }
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _create_structs(
|
|
456
|
+
graph: dict[type, _WireType],
|
|
457
|
+
jso: JsonSerializerOptions,
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Create the generated msgspec structs for every node in the graph."""
|
|
460
|
+
for wt in graph.values():
|
|
461
|
+
fields = _struct_fields(wt, graph, jso)
|
|
462
|
+
wt.struct = msgspec.defstruct(
|
|
463
|
+
wt.class_name,
|
|
464
|
+
fields,
|
|
465
|
+
module=wt.module_name,
|
|
466
|
+
namespace=_behavior_namespace(wt.source),
|
|
467
|
+
rename=None,
|
|
468
|
+
kw_only=False,
|
|
469
|
+
omit_defaults=jso.default_handling.value == JsonDefaultHandling.exclude,
|
|
470
|
+
forbid_unknown_fields=jso.unknown_field_handling.value == JsonUnknownFieldHandling.disallow,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _validate_structs(graph: dict[type, _WireType]) -> None:
|
|
475
|
+
"""Force msgspec to inspect each generated struct after refs are registered."""
|
|
476
|
+
for wt in graph.values():
|
|
477
|
+
msgspec.inspect.type_info(wt.struct)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _publish_structs(graph: dict[type, _WireType]) -> None:
|
|
481
|
+
# Publish the readable generated class names.
|
|
482
|
+
for wt in graph.values():
|
|
483
|
+
setattr(wt.namespace, wt.class_name, wt.struct)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _build_graph(
|
|
487
|
+
roots: tuple[type, ...],
|
|
488
|
+
jso: JsonSerializerOptions,
|
|
489
|
+
) -> dict[type, _WireType]:
|
|
490
|
+
graph = _discover_graph(roots)
|
|
491
|
+
_create_structs(graph, jso)
|
|
492
|
+
_register_forward_refs(graph)
|
|
493
|
+
_validate_structs(graph)
|
|
494
|
+
_publish_structs(graph)
|
|
495
|
+
return graph
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ---------------------------------------------------------------------
|
|
499
|
+
# Cache and public API
|
|
500
|
+
# ---------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
# we must only build new when the
|
|
503
|
+
_jso_registry: dict[str, JsonSerializerOptions] = {}
|
|
504
|
+
_registry_lock = threading.Lock()
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@lru_cache(maxsize=None)
|
|
508
|
+
def _wrapped_lru(roots: tuple[type, ...], jso_id: str):
|
|
509
|
+
with _registry_lock:
|
|
510
|
+
jso = _jso_registry[jso_id]
|
|
511
|
+
|
|
512
|
+
return _build_graph(roots, jso)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@lru_cache(maxsize=None)
|
|
516
|
+
def _cached_wire_type(target_type: object, jso_id: str):
|
|
517
|
+
roots = tuple(_nested_dataclasses(target_type))
|
|
518
|
+
|
|
519
|
+
if not roots:
|
|
520
|
+
raise TypeError(
|
|
521
|
+
"Only @dataclass are supported!, "
|
|
522
|
+
f"got {target_type!r}"
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
graph_roots = tuple(sorted(set(roots), key=id))
|
|
526
|
+
graph = _wrapped_lru(graph_roots, jso_id)
|
|
527
|
+
|
|
528
|
+
return _rewrite_type(
|
|
529
|
+
target_type,
|
|
530
|
+
graph[roots[0]],
|
|
531
|
+
graph,
|
|
532
|
+
resolve=True,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def wire_type_for(target_type: type, jso: JsonSerializerOptions) -> type:
|
|
537
|
+
jso_id = jso.fingerprint
|
|
538
|
+
|
|
539
|
+
with _registry_lock:
|
|
540
|
+
_jso_registry[jso_id] = jso
|
|
541
|
+
|
|
542
|
+
return _cached_wire_type(target_type, jso_id)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
@lru_cache(maxsize=256)
|
|
546
|
+
def _get_decoder(wire_type: Any):
|
|
547
|
+
return msgspec.json.Decoder(type=wire_type)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
_JSON_ENCODER = msgspec.json.Encoder()
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class JsonConvert:
|
|
554
|
+
|
|
555
|
+
@staticmethod
|
|
556
|
+
def deserialize[T](
|
|
557
|
+
data: bytes | str,
|
|
558
|
+
target: type[T],
|
|
559
|
+
jso: JsonSerializerOptions | None = None
|
|
560
|
+
) -> T:
|
|
561
|
+
if jso is None:
|
|
562
|
+
jso = JsonSerializerOptions()
|
|
563
|
+
|
|
564
|
+
wire_type = wire_type_for(target, jso)
|
|
565
|
+
parsed = _get_decoder(wire_type).decode(data)
|
|
566
|
+
|
|
567
|
+
if jso.native:
|
|
568
|
+
return parsed
|
|
569
|
+
|
|
570
|
+
result = msgspec.convert(
|
|
571
|
+
parsed,
|
|
572
|
+
target,
|
|
573
|
+
from_attributes=True,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
return result
|
|
577
|
+
|
|
578
|
+
@staticmethod
|
|
579
|
+
def serialize[T](
|
|
580
|
+
data: Any,
|
|
581
|
+
target: type[T],
|
|
582
|
+
jso: JsonSerializerOptions | None = None
|
|
583
|
+
) -> str:
|
|
584
|
+
return JsonConvert.serialize_bytes(data, target, jso).decode("utf-8")
|
|
585
|
+
|
|
586
|
+
@staticmethod
|
|
587
|
+
def serialize_bytes[T](
|
|
588
|
+
data: Any,
|
|
589
|
+
target: type[T],
|
|
590
|
+
jso: JsonSerializerOptions | None = None
|
|
591
|
+
) -> bytes:
|
|
592
|
+
if jso is None:
|
|
593
|
+
jso = JsonSerializerOptions()
|
|
594
|
+
|
|
595
|
+
wire_type = wire_type_for(target, jso)
|
|
596
|
+
wire_obj = msgspec.convert(data, wire_type, from_attributes=True)
|
|
597
|
+
return _JSON_ENCODER.encode(wire_obj)
|
|
598
|
+
|
|
599
|
+
@staticmethod
|
|
600
|
+
def serialize_to[T](
|
|
601
|
+
it: Iterable[T],
|
|
602
|
+
target: type[T],
|
|
603
|
+
output: ByteWriter | TextWriter,
|
|
604
|
+
jso: JsonSerializerOptions | None = None
|
|
605
|
+
) -> None:
|
|
606
|
+
if jso is None:
|
|
607
|
+
jso = JsonSerializerOptions()
|
|
608
|
+
|
|
609
|
+
if isinstance(output, ByteWriter):
|
|
610
|
+
write = output.write
|
|
611
|
+
elif isinstance(output, TextWriter):
|
|
612
|
+
def write(data: bytes | bytearray) -> int:
|
|
613
|
+
return output.write(data.decode("utf-8"))
|
|
614
|
+
else:
|
|
615
|
+
raise TypeError("output must be ByteWriter or TextWriter")
|
|
616
|
+
|
|
617
|
+
first = True
|
|
618
|
+
wire_type = wire_type_for(target, jso)
|
|
619
|
+
|
|
620
|
+
write(b"[")
|
|
621
|
+
|
|
622
|
+
for item in iter(it):
|
|
623
|
+
if not first:
|
|
624
|
+
write(b",")
|
|
625
|
+
|
|
626
|
+
wire_obj = msgspec.convert(item, wire_type, from_attributes=True)
|
|
627
|
+
write(_JSON_ENCODER.encode(wire_obj))
|
|
628
|
+
first = False
|
|
629
|
+
|
|
630
|
+
write(b']')
|
|
631
|
+
|
|
632
|
+
|
|
@@ -1,427 +0,0 @@
|
|
|
1
|
-
from typing import Annotated
|
|
2
|
-
import dataclasses
|
|
3
|
-
import re
|
|
4
|
-
import sys
|
|
5
|
-
import types
|
|
6
|
-
import typing
|
|
7
|
-
|
|
8
|
-
import threading
|
|
9
|
-
from dataclasses import MISSING, dataclass
|
|
10
|
-
from functools import lru_cache
|
|
11
|
-
from typing import Any, ForwardRef, get_args, get_origin, get_type_hints
|
|
12
|
-
from hashlib import blake2s
|
|
13
|
-
|
|
14
|
-
import msgspec
|
|
15
|
-
|
|
16
|
-
from enum import StrEnum
|
|
17
|
-
|
|
18
|
-
class JsonNamingPolicy(StrEnum):
|
|
19
|
-
lower_case = "lower"
|
|
20
|
-
upper_case = "upper"
|
|
21
|
-
camel_case = "camel"
|
|
22
|
-
pascal_case = "pascal"
|
|
23
|
-
kebap_case = "kebab"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@dataclass(slots=True, frozen=True)
|
|
27
|
-
class JsonSerializerOptions:
|
|
28
|
-
naming_policy: JsonNamingPolicy = JsonNamingPolicy.camel_case
|
|
29
|
-
write_indented: bool = False
|
|
30
|
-
native:bool=False
|
|
31
|
-
"""
|
|
32
|
-
| Set to true for the native speed of msgspec
|
|
33
|
-
|
|
34
|
-
!! only set to true if you know what you are doing !!
|
|
35
|
-
You'll receive the msgspec.msgstruct object that will try to do it's best to behave like your dataclass.
|
|
36
|
-
But remember !! "It's NOT your dataclass" !!
|
|
37
|
-
"""
|
|
38
|
-
|
|
39
|
-
@property
|
|
40
|
-
def fingerprint(self) -> str:
|
|
41
|
-
"""
|
|
42
|
-
Identifies a unique jso instance for building a struct
|
|
43
|
-
!! All parts of a jso that don't influence the defstruct MUST be ignored!
|
|
44
|
-
"""
|
|
45
|
-
settings = self.naming_policy.value
|
|
46
|
-
return blake2s(settings.encode(), digest_size=6).hexdigest()
|
|
47
|
-
|
|
48
|
-
# ---------------------------------------------------------------------------
|
|
49
|
-
# Metadata Annotation Markers
|
|
50
|
-
# ---------------------------------------------------------------------------
|
|
51
|
-
|
|
52
|
-
class JsonIgnore:
|
|
53
|
-
def __class_getitem__[T](cls, item: type[T]) -> Any:
|
|
54
|
-
return Annotated[item, cls]
|
|
55
|
-
|
|
56
|
-
class JsonInclude:
|
|
57
|
-
def __class_getitem__[T](cls, item: type[T]) -> Any:
|
|
58
|
-
return Annotated[item, cls]
|
|
59
|
-
|
|
60
|
-
class JsonProperty:
|
|
61
|
-
def __class_getitem__[T](cls, item: tuple[type[T], str]) -> Any:
|
|
62
|
-
target_type, json_name = item
|
|
63
|
-
return Annotated[target_type, cls(json_name)]
|
|
64
|
-
|
|
65
|
-
def __init__(self, name: str):
|
|
66
|
-
self.name = name
|
|
67
|
-
|
|
68
|
-
JIgnore = JsonIgnore
|
|
69
|
-
JInclude = JsonInclude
|
|
70
|
-
JProp = JsonProperty
|
|
71
|
-
|
|
72
|
-
# ---------------------------------------------------------------------
|
|
73
|
-
# Generated type metadata
|
|
74
|
-
# ---------------------------------------------------------------------
|
|
75
|
-
|
|
76
|
-
@dataclass(slots=True, eq=False)
|
|
77
|
-
class _WireType:
|
|
78
|
-
source: type
|
|
79
|
-
"""The source type to build a msgspec.struct for"""
|
|
80
|
-
hints: dict[str, Any]
|
|
81
|
-
namespace: types.ModuleType
|
|
82
|
-
"""the name space we are targeting"""
|
|
83
|
-
struct: type | None = None
|
|
84
|
-
|
|
85
|
-
@property
|
|
86
|
-
def module_name(self: _WireType) -> str:
|
|
87
|
-
return self.namespace.__name__
|
|
88
|
-
|
|
89
|
-
@property
|
|
90
|
-
def class_name(self: _WireType) -> str:
|
|
91
|
-
return self.source.__name__
|
|
92
|
-
|
|
93
|
-
@property
|
|
94
|
-
def ref_name(self: _WireType) -> str:
|
|
95
|
-
identity = (
|
|
96
|
-
f"{self.source.__module__}_{self.source.__qualname__}"
|
|
97
|
-
)
|
|
98
|
-
return "_ref_" + re.sub(r"\W", "_", identity)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# ---------------------------------------------------------------------
|
|
102
|
-
# Module and annotation utilities
|
|
103
|
-
# ---------------------------------------------------------------------
|
|
104
|
-
|
|
105
|
-
def _generated_module(source: type) -> types.ModuleType:
|
|
106
|
-
"""Generates the required module definition for our runtime only dynamic msgspec.struct"""
|
|
107
|
-
name = f"flpit.json._generated.{source.__module__}"
|
|
108
|
-
|
|
109
|
-
module = sys.modules.get(name)
|
|
110
|
-
if module is None:
|
|
111
|
-
module = types.ModuleType(name)
|
|
112
|
-
sys.modules[name] = module
|
|
113
|
-
|
|
114
|
-
return module
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _is_dataclass_type(tp: Any) -> bool:
|
|
118
|
-
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def _nested_dataclasses(tp: Any):
|
|
122
|
-
"""Yield dataclass types referenced by an annotation."""
|
|
123
|
-
if _is_dataclass_type(tp):
|
|
124
|
-
yield tp
|
|
125
|
-
return
|
|
126
|
-
|
|
127
|
-
if isinstance(tp, (list, tuple)):
|
|
128
|
-
for item in tp:
|
|
129
|
-
yield from _nested_dataclasses(item)
|
|
130
|
-
return
|
|
131
|
-
|
|
132
|
-
origin = get_origin(tp)
|
|
133
|
-
|
|
134
|
-
if origin is typing.Annotated:
|
|
135
|
-
yield from _nested_dataclasses(get_args(tp)[0])
|
|
136
|
-
return
|
|
137
|
-
|
|
138
|
-
if origin is typing.Literal:
|
|
139
|
-
return
|
|
140
|
-
|
|
141
|
-
for arg in get_args(tp):
|
|
142
|
-
yield from _nested_dataclasses(arg)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _rewrite_type(
|
|
146
|
-
tp: Any,
|
|
147
|
-
owner: _WireType,
|
|
148
|
-
graph: dict[type, _WireType],
|
|
149
|
-
*,
|
|
150
|
-
resolve: bool = False,
|
|
151
|
-
) -> Any:
|
|
152
|
-
"""Replace source dataclasses with generated Structs or ForwardRefs."""
|
|
153
|
-
if tp is getattr(typing, "Self", None):
|
|
154
|
-
info = owner
|
|
155
|
-
return info.struct if resolve else ForwardRef(info.ref_name)
|
|
156
|
-
|
|
157
|
-
if _is_dataclass_type(tp):
|
|
158
|
-
info = graph[tp]
|
|
159
|
-
return info.struct if resolve else ForwardRef(info.ref_name)
|
|
160
|
-
|
|
161
|
-
if isinstance(tp, list):
|
|
162
|
-
return [
|
|
163
|
-
_rewrite_type(item, owner, graph, resolve=resolve)
|
|
164
|
-
for item in tp
|
|
165
|
-
]
|
|
166
|
-
|
|
167
|
-
if isinstance(tp, tuple):
|
|
168
|
-
return tuple(
|
|
169
|
-
_rewrite_type(item, owner, graph, resolve=resolve)
|
|
170
|
-
for item in tp
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
origin = get_origin(tp)
|
|
174
|
-
|
|
175
|
-
if origin is None or origin is typing.Literal:
|
|
176
|
-
return tp
|
|
177
|
-
|
|
178
|
-
args = get_args(tp)
|
|
179
|
-
|
|
180
|
-
if origin is typing.Annotated:
|
|
181
|
-
base = _rewrite_type(
|
|
182
|
-
args[0], owner, graph, resolve=resolve
|
|
183
|
-
)
|
|
184
|
-
return typing.Annotated[(base, *args[1:])]
|
|
185
|
-
|
|
186
|
-
rewritten: tuple[Any, ...] = tuple(
|
|
187
|
-
_rewrite_type(arg, owner, graph, resolve=resolve)
|
|
188
|
-
for arg in args
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
if origin in (typing.Union, types.UnionType):
|
|
192
|
-
union: Any = typing.Union # with all the magic here, there's no way to make typing really happy
|
|
193
|
-
return union[rewritten]
|
|
194
|
-
|
|
195
|
-
params = rewritten[0] if len(rewritten) == 1 else rewritten
|
|
196
|
-
|
|
197
|
-
try:
|
|
198
|
-
return origin[params]
|
|
199
|
-
except (TypeError, AttributeError) as exc:
|
|
200
|
-
copier = getattr(tp, "copy_with", None)
|
|
201
|
-
if copier is not None:
|
|
202
|
-
return copier(rewritten)
|
|
203
|
-
|
|
204
|
-
raise TypeError(
|
|
205
|
-
f"Cannot rewrite annotation {tp!r}"
|
|
206
|
-
) from exc
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
# ---------------------------------------------------------------------
|
|
210
|
-
# Graph discovery
|
|
211
|
-
# ---------------------------------------------------------------------
|
|
212
|
-
|
|
213
|
-
def _discover_graph(
|
|
214
|
-
roots: tuple[type, ...],
|
|
215
|
-
) -> dict[type, _WireType]:
|
|
216
|
-
"""Discover reachable dataclasses and resolve their annotations."""
|
|
217
|
-
graph: dict[type, _WireType] = {}
|
|
218
|
-
pending = list(roots)
|
|
219
|
-
|
|
220
|
-
while pending:
|
|
221
|
-
source = pending.pop()
|
|
222
|
-
|
|
223
|
-
if source in graph:
|
|
224
|
-
continue
|
|
225
|
-
|
|
226
|
-
info = _WireType(
|
|
227
|
-
source=source,
|
|
228
|
-
hints=get_type_hints(source, include_extras=True),
|
|
229
|
-
namespace=_generated_module(source),
|
|
230
|
-
)
|
|
231
|
-
graph[source] = info
|
|
232
|
-
|
|
233
|
-
for field in dataclasses.fields(source):
|
|
234
|
-
if not field.init:
|
|
235
|
-
continue
|
|
236
|
-
|
|
237
|
-
pending.extend(
|
|
238
|
-
_nested_dataclasses(info.hints[field.name])
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
return graph
|
|
242
|
-
|
|
243
|
-
def _allows_none(tp: Any) -> bool:
|
|
244
|
-
if get_origin(tp) is typing.Annotated:
|
|
245
|
-
tp = get_args(tp)[0]
|
|
246
|
-
|
|
247
|
-
return type(None) in get_args(tp)
|
|
248
|
-
|
|
249
|
-
# ---------------------------------------------------------------------
|
|
250
|
-
# Struct construction
|
|
251
|
-
# ---------------------------------------------------------------------
|
|
252
|
-
|
|
253
|
-
def _struct_fields(
|
|
254
|
-
info: _WireType,
|
|
255
|
-
graph: dict[type, _WireType],
|
|
256
|
-
) -> list[tuple[Any, ...]]:
|
|
257
|
-
definitions: list[tuple[Any, ...]] = []
|
|
258
|
-
|
|
259
|
-
for field in dataclasses.fields(info.source):
|
|
260
|
-
if not field.init:
|
|
261
|
-
continue
|
|
262
|
-
|
|
263
|
-
annotation = _rewrite_type(
|
|
264
|
-
info.hints[field.name],
|
|
265
|
-
info,
|
|
266
|
-
graph,
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
if field.default is not MISSING:
|
|
270
|
-
spec = (field.name, annotation, field.default)
|
|
271
|
-
|
|
272
|
-
elif field.default_factory is not MISSING:
|
|
273
|
-
spec = (
|
|
274
|
-
field.name,
|
|
275
|
-
annotation,
|
|
276
|
-
msgspec.field(
|
|
277
|
-
default_factory=field.default_factory
|
|
278
|
-
),
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
# for now... no enforcing of explicit null in json
|
|
282
|
-
elif _allows_none(info.hints[field.name]):
|
|
283
|
-
spec = (field.name, annotation, None)
|
|
284
|
-
|
|
285
|
-
else:
|
|
286
|
-
spec = (field.name, annotation)
|
|
287
|
-
|
|
288
|
-
definitions.append(spec)
|
|
289
|
-
|
|
290
|
-
return definitions
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def _register_forward_refs(
|
|
294
|
-
graph: dict[type, _WireType],
|
|
295
|
-
) -> None:
|
|
296
|
-
"""Expose every graph reference in each participating module."""
|
|
297
|
-
namespaces = []
|
|
298
|
-
|
|
299
|
-
for info in graph.values():
|
|
300
|
-
if not any(info.namespace is ns for ns in namespaces):
|
|
301
|
-
namespaces.append(info.namespace)
|
|
302
|
-
|
|
303
|
-
for namespace in namespaces:
|
|
304
|
-
for info in graph.values():
|
|
305
|
-
setattr(namespace, info.ref_name, info.struct)
|
|
306
|
-
|
|
307
|
-
def _behavior_namespace(source: type) -> dict[str, Any]:
|
|
308
|
-
return {
|
|
309
|
-
name: value
|
|
310
|
-
for name, value in vars(source).items()
|
|
311
|
-
if (
|
|
312
|
-
not name.startswith("__")
|
|
313
|
-
and callable(value)
|
|
314
|
-
)
|
|
315
|
-
# pyrefly: ignore [implicit-any-lambda]
|
|
316
|
-
} | { "__class__": property(lambda self, cls=source: cls) }
|
|
317
|
-
|
|
318
|
-
def _build_graph(
|
|
319
|
-
roots: tuple[type, ...],
|
|
320
|
-
jso: JsonSerializerOptions,
|
|
321
|
-
) -> dict[type, _WireType]:
|
|
322
|
-
graph = _discover_graph(roots)
|
|
323
|
-
|
|
324
|
-
for wt in graph.values():
|
|
325
|
-
wt.struct = msgspec.defstruct(
|
|
326
|
-
wt.class_name,
|
|
327
|
-
_struct_fields(wt, graph),
|
|
328
|
-
module=wt.module_name,
|
|
329
|
-
namespace=_behavior_namespace(wt.source),
|
|
330
|
-
rename=jso.naming_policy.value,
|
|
331
|
-
kw_only=True,
|
|
332
|
-
omit_defaults=True, # don't render nulls
|
|
333
|
-
forbid_unknown_fields=False, # ensure ignoring unknow fields!
|
|
334
|
-
)
|
|
335
|
-
|
|
336
|
-
_register_forward_refs(graph)
|
|
337
|
-
|
|
338
|
-
for wt in graph.values():
|
|
339
|
-
msgspec.inspect.type_info(wt.struct)
|
|
340
|
-
|
|
341
|
-
# Publish the readable generated class names.
|
|
342
|
-
for wt in graph.values():
|
|
343
|
-
setattr(wt.namespace, wt.class_name, wt.struct)
|
|
344
|
-
|
|
345
|
-
return graph
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
# ---------------------------------------------------------------------
|
|
349
|
-
# Cache and public API
|
|
350
|
-
# ---------------------------------------------------------------------
|
|
351
|
-
|
|
352
|
-
# we must only build new when the
|
|
353
|
-
_jso_registry: dict[str, JsonSerializerOptions] = {}
|
|
354
|
-
_registry_lock = threading.Lock()
|
|
355
|
-
|
|
356
|
-
@lru_cache(maxsize=None)
|
|
357
|
-
def _wrapped_lru(roots: tuple[type, ...], jso_id: str):
|
|
358
|
-
with _registry_lock:
|
|
359
|
-
jso = _jso_registry[jso_id]
|
|
360
|
-
return _build_graph(roots, jso)
|
|
361
|
-
|
|
362
|
-
def _cached_graph(roots:tuple[object, ...], jso: JsonSerializerOptions):
|
|
363
|
-
with _registry_lock:
|
|
364
|
-
_jso_registry[jso.fingerprint] = jso
|
|
365
|
-
|
|
366
|
-
return _wrapped_lru(roots, jso.fingerprint)
|
|
367
|
-
|
|
368
|
-
def wire_type_for(
|
|
369
|
-
target_type: type,
|
|
370
|
-
jso: JsonSerializerOptions,
|
|
371
|
-
):
|
|
372
|
-
roots = tuple(_nested_dataclasses(target_type))
|
|
373
|
-
|
|
374
|
-
if not roots:
|
|
375
|
-
raise TypeError(
|
|
376
|
-
"Only @dataclass are supported!, "
|
|
377
|
-
f"got {target_type!r}"
|
|
378
|
-
)
|
|
379
|
-
|
|
380
|
-
# Normalize the root set so User and list[User] share the graph.
|
|
381
|
-
graph_roots = tuple(sorted(set(roots), key=id))
|
|
382
|
-
|
|
383
|
-
graph = _cached_graph(
|
|
384
|
-
graph_roots,
|
|
385
|
-
jso,
|
|
386
|
-
)
|
|
387
|
-
|
|
388
|
-
return _rewrite_type(
|
|
389
|
-
target_type,
|
|
390
|
-
graph[roots[0]],
|
|
391
|
-
graph,
|
|
392
|
-
resolve=True,
|
|
393
|
-
)
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
def decode_dataclass(
|
|
397
|
-
data: bytes | str,
|
|
398
|
-
target: type,
|
|
399
|
-
jso: JsonSerializerOptions|None=None
|
|
400
|
-
):
|
|
401
|
-
if jso is None:
|
|
402
|
-
jso = JsonSerializerOptions()
|
|
403
|
-
|
|
404
|
-
wire_type = wire_type_for(target, jso)
|
|
405
|
-
parsed = msgspec.json.decode(data, type=wire_type)
|
|
406
|
-
|
|
407
|
-
if jso.native:
|
|
408
|
-
return parsed
|
|
409
|
-
|
|
410
|
-
return msgspec.convert(
|
|
411
|
-
parsed,
|
|
412
|
-
target,
|
|
413
|
-
from_attributes=True,
|
|
414
|
-
)
|
|
415
|
-
|
|
416
|
-
def encode_dataclass[T](
|
|
417
|
-
data: Any,
|
|
418
|
-
target: type[T],
|
|
419
|
-
jso: JsonSerializerOptions|None=None
|
|
420
|
-
) -> bytes:
|
|
421
|
-
if jso is None:
|
|
422
|
-
jso = JsonSerializerOptions()
|
|
423
|
-
|
|
424
|
-
wire_type = wire_type_for(target, jso)
|
|
425
|
-
wire_obj = msgspec.convert(data, wire_type, from_attributes=True)
|
|
426
|
-
return msgspec.json.encode(wire_obj)
|
|
427
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|