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.
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flpit
3
- Version: 0.2.1.dev3
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.dev3"
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.json import JsonSerializerOptions, decode_dataclass, encode_dataclass
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](decode_dataclass(data, list[item_type], jso))
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 encode_dataclass(self.__list.data, list[item_type], jso)
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.to_json_bytes(item_type, jso).decode(encoding="utf-8")
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