flpit 0.2.1.dev3__tar.gz → 0.2.1.dev5__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.dev5
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,10 +11,10 @@ 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
17
+ Classifier: Programming Language :: Python :: 3.15
17
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
19
  Classifier: Typing :: Typed
19
20
  Requires-Python: >=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flpit"
3
- version = "0.2.1.dev3"
3
+ version = "0.2.1.dev5"
4
4
  description = "Fluent LINQ for Python — flip your iterables"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -11,10 +11,10 @@ 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",
17
+ "Programming Language :: Python :: 3.15",
18
18
  "Topic :: Software Development :: Libraries :: Python Modules",
19
19
  "Typing :: Typed"
20
20
  ]
@@ -24,6 +24,7 @@ dependencies = [
24
24
 
25
25
  [project.urls]
26
26
  Homepage = "https://github.com/riemar/flpit"
27
+ Repository = "https://github.com/riemar/flpit"
27
28
 
28
29
  [dependency-groups]
29
30
  dev = [
@@ -1,5 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from io import BytesIO, StringIO
4
+
1
5
  import decimal
2
6
  from decimal import Decimal, Overflow, Inexact
7
+ from contextlib import AbstractContextManager
3
8
 
4
9
  import builtins
5
10
  from collections import UserList
@@ -22,11 +27,10 @@ from typing import (
22
27
  Union,
23
28
  override,
24
29
  overload,
25
- cast as typing_cast,
26
- GenericAlias
30
+ cast as typing_cast, BinaryIO,
27
31
  )
28
32
 
29
- from flpit.json import JsonSerializerOptions, decode_dataclass, encode_dataclass
33
+ from flpit.json import JsonConvert, JsonSerializerOptions
30
34
 
31
35
  TItem = TypeVar("TItem")
32
36
  TResult = TypeVar("TResult")
@@ -122,8 +126,7 @@ def _guard_empty(func: Callable[..., Any]) -> Callable[..., Any]:
122
126
  return wrapper
123
127
 
124
128
 
125
- # noinspection unresolved-references,protected-member
126
- def dotnet_context() -> decimal._ContextManager: # awesome that type was not intended
129
+ def dotnet_context() -> AbstractContextManager[decimal.Context]:
127
130
  # 1. Create and configure pure Context object
128
131
  ctx = decimal.getcontext().copy()
129
132
  ctx.prec = max(ctx.prec, 29)
@@ -625,6 +628,32 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
625
628
  """Explicitly materializes the query into a FlpList."""
626
629
  return FlpList[TItem](self)
627
630
 
631
+ # =====================================================================
632
+ # 5. json serialization
633
+ # =====================================================================
634
+
635
+ @classmethod
636
+ def from_json[T](cls, data: str | bytes, item_type: type[T], jso: JsonSerializerOptions|None=None) -> FlpIt[T]:
637
+ return FlpIt[T](JsonConvert.deserialize(data, list[item_type], jso))
638
+
639
+ def to_json_bytes[T](self, item_type: type[T], jso: JsonSerializerOptions|None=None) -> bytes:
640
+ writer = BytesIO()
641
+ JsonConvert.serialize_to(self, item_type, writer, jso)
642
+ return writer.getvalue()
643
+
644
+ def to_json[T](self, item_type: type[T], jso: JsonSerializerOptions|None=None) -> str:
645
+ writer = StringIO()
646
+ JsonConvert.serialize_to(self, item_type, writer, jso)
647
+ return writer.getvalue()
648
+
649
+ def write_json[T](
650
+ self,
651
+ item_type: type[T],
652
+ output: BinaryIO,
653
+ jso: JsonSerializerOptions | None = None,
654
+ ) -> None:
655
+ JsonConvert.serialize_to(self, item_type, output, jso)
656
+
628
657
 
629
658
  from functools import partial
630
659
 
@@ -1192,12 +1221,18 @@ class FlpList(Collection[TItem], Generic[TItem]):
1192
1221
 
1193
1222
  @classmethod
1194
1223
  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))
1224
+ return FlpList[T](JsonConvert.deserialize(data, list[item_type], jso))
1196
1225
 
1197
1226
  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)
1227
+ return JsonConvert.serialize_bytes(self.__list.data, list[item_type], jso)
1199
1228
 
1200
1229
  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")
1202
-
1230
+ return JsonConvert.serialize(self.__list.data, list[item_type], jso)
1203
1231
 
1232
+ def write_json[T](
1233
+ self,
1234
+ item_type: type[T],
1235
+ output: BinaryIO,
1236
+ jso: JsonSerializerOptions | None = None,
1237
+ ) -> None:
1238
+ JsonConvert.serialize_to(self, item_type, output, jso)
@@ -0,0 +1,638 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ import dataclasses
5
+ import re
6
+ import sys
7
+ import threading
8
+ import types
9
+ import typing
10
+ from dataclasses import MISSING, dataclass
11
+ from enum import StrEnum
12
+ from functools import lru_cache
13
+ from hashlib import blake2s
14
+ from typing import Annotated, Any, ForwardRef, get_args, get_origin, get_type_hints, Callable
15
+
16
+ import msgspec
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: typing.BinaryIO | typing.TextIO,
604
+ jso: JsonSerializerOptions | None = None,
605
+ ) -> None:
606
+ import io
607
+ if jso is None:
608
+ jso = JsonSerializerOptions()
609
+
610
+ if isinstance(output, io.TextIOBase):
611
+ def write(data: bytes) -> None:
612
+ output.write(data.decode("utf-8"))
613
+
614
+ elif isinstance(
615
+ output,
616
+ (io.BufferedIOBase, io.RawIOBase),
617
+ ):
618
+ def write(data: bytes) -> None:
619
+ output.write(data)
620
+ else:
621
+ raise TypeError("output must be ByteWriter or TextWriter")
622
+
623
+ first = True
624
+ wire_type = wire_type_for(target, jso)
625
+
626
+ write(b"[")
627
+
628
+ for item in iter(it):
629
+ if not first:
630
+ write(b",")
631
+
632
+ wire_obj = msgspec.convert(item, wire_type, from_attributes=True)
633
+ write(_JSON_ENCODER.encode(wire_obj))
634
+ first = False
635
+
636
+ write(b']')
637
+
638
+
@@ -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