flpit 0.2.1.dev3__py3-none-any.whl → 0.2.1.dev4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flpit/core/linq.py CHANGED
@@ -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
 
flpit/io.py ADDED
@@ -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"]")
flpit/json.py CHANGED
@@ -1,33 +1,46 @@
1
- from typing import Annotated
1
+ from collections.abc import Iterable
2
2
  import dataclasses
3
3
  import re
4
4
  import sys
5
+ import threading
5
6
  import types
6
7
  import typing
7
-
8
- import threading
9
8
  from dataclasses import MISSING, dataclass
9
+ from enum import StrEnum
10
10
  from functools import lru_cache
11
- from typing import Any, ForwardRef, get_args, get_origin, get_type_hints
12
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
13
 
14
14
  import msgspec
15
15
 
16
- from enum import StrEnum
16
+ from flpit.io import ByteWriter, TextWriter
17
+
17
18
 
18
19
  class JsonNamingPolicy(StrEnum):
19
- lower_case = "lower"
20
- upper_case = "upper"
21
- camel_case = "camel"
22
- pascal_case = "pascal"
23
- kebap_case = "kebab"
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"
24
34
 
25
35
 
26
36
  @dataclass(slots=True, frozen=True)
27
37
  class JsonSerializerOptions:
28
- naming_policy: JsonNamingPolicy = JsonNamingPolicy.camel_case
29
- write_indented: bool = False
30
- native:bool=False
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
31
44
  """
32
45
  | Set to true for the native speed of msgspec
33
46
 
@@ -42,8 +55,11 @@ class JsonSerializerOptions:
42
55
  Identifies a unique jso instance for building a struct
43
56
  !! All parts of a jso that don't influence the defstruct MUST be ignored!
44
57
  """
45
- settings = self.naming_policy.value
46
- return blake2s(settings.encode(), digest_size=6).hexdigest()
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()
47
63
 
48
64
  # ---------------------------------------------------------------------------
49
65
  # Metadata Annotation Markers
@@ -99,9 +115,46 @@ class _WireType:
99
115
 
100
116
 
101
117
  # ---------------------------------------------------------------------
102
- # Module and annotation utilities
118
+ # Never imagined this would be required to have a proper lib behavior
103
119
  # ---------------------------------------------------------------------
104
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
+
105
158
  def _generated_module(source: type) -> types.ModuleType:
106
159
  """Generates the required module definition for our runtime only dynamic msgspec.struct"""
107
160
  name = f"flpit.json._generated.{source.__module__}"
@@ -118,6 +171,47 @@ def _is_dataclass_type(tp: Any) -> bool:
118
171
  return isinstance(tp, type) and dataclasses.is_dataclass(tp)
119
172
 
120
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
+
121
215
  def _nested_dataclasses(tp: Any):
122
216
  """Yield dataclass types referenced by an annotation."""
123
217
  if _is_dataclass_type(tp):
@@ -210,6 +304,7 @@ def _rewrite_type(
210
304
  # Graph discovery
211
305
  # ---------------------------------------------------------------------
212
306
 
307
+
213
308
  def _discover_graph(
214
309
  roots: tuple[type, ...],
215
310
  ) -> dict[type, _WireType]:
@@ -231,15 +326,17 @@ def _discover_graph(
231
326
  graph[source] = info
232
327
 
233
328
  for field in dataclasses.fields(source):
234
- if not field.init:
329
+ annotation, ignored, included, _ = _field_directives(
330
+ info.hints[field.name]
331
+ )
332
+ if ignored:
235
333
  continue
236
334
 
237
- pending.extend(
238
- _nested_dataclasses(info.hints[field.name])
239
- )
335
+ pending.extend(_nested_dataclasses(annotation))
240
336
 
241
337
  return graph
242
338
 
339
+
243
340
  def _allows_none(tp: Any) -> bool:
244
341
  if get_origin(tp) is typing.Annotated:
245
342
  tp = get_args(tp)[0]
@@ -250,43 +347,81 @@ def _allows_none(tp: Any) -> bool:
250
347
  # Struct construction
251
348
  # ---------------------------------------------------------------------
252
349
 
350
+
253
351
  def _struct_fields(
254
352
  info: _WireType,
255
353
  graph: dict[type, _WireType],
354
+ jso: JsonSerializerOptions,
256
355
  ) -> list[tuple[Any, ...]]:
257
356
  definitions: list[tuple[Any, ...]] = []
357
+ dataclass_fields = {f.name for f in dataclasses.fields(info.source)}
258
358
 
359
+ # 1. Process regular fields (regardless of init=True or init=False)
259
360
  for field in dataclasses.fields(info.source):
260
- if not field.init:
361
+ source_annotation = info.hints[field.name]
362
+ annotation, ignored, included, json_name = _field_directives(source_annotation)
363
+
364
+ if ignored:
261
365
  continue
262
366
 
263
- annotation = _rewrite_type(
264
- info.hints[field.name],
265
- info,
266
- graph,
267
- )
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
268
370
 
269
- if field.default is not MISSING:
270
- spec = (field.name, annotation, field.default)
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)
271
373
 
374
+ # Resolve structural field default values cleanly
375
+ if field.default is not MISSING:
376
+ default_val = field.default
377
+ has_default = True
272
378
  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
-
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
285
384
  else:
286
- spec = (field.name, annotation)
385
+ default_val = MISSING
386
+ has_default = False
287
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))
288
396
  definitions.append(spec)
289
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
+
290
425
  return definitions
291
426
 
292
427
 
@@ -304,44 +439,59 @@ def _register_forward_refs(
304
439
  for info in graph.values():
305
440
  setattr(namespace, info.ref_name, info.struct)
306
441
 
442
+
307
443
  def _behavior_namespace(source: type) -> dict[str, Any]:
308
444
  return {
309
445
  name: value
310
446
  for name, value in vars(source).items()
311
447
  if (
312
448
  not name.startswith("__")
313
- and callable(value)
449
+ and (callable(value) or isinstance(value, property))
314
450
  )
315
- # pyrefly: ignore [implicit-any-lambda]
451
+ # pyrefly: ignore [implicit-any-lambda]
316
452
  } | { "__class__": property(lambda self, cls=source: cls) }
317
453
 
318
- def _build_graph(
319
- roots: tuple[type, ...],
320
- jso: JsonSerializerOptions,
321
- ) -> dict[type, _WireType]:
322
- graph = _discover_graph(roots)
323
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."""
324
460
  for wt in graph.values():
461
+ fields = _struct_fields(wt, graph, jso)
325
462
  wt.struct = msgspec.defstruct(
326
463
  wt.class_name,
327
- _struct_fields(wt, graph),
464
+ fields,
328
465
  module=wt.module_name,
329
466
  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!
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,
334
471
  )
335
472
 
336
- _register_forward_refs(graph)
337
473
 
474
+ def _validate_structs(graph: dict[type, _WireType]) -> None:
475
+ """Force msgspec to inspect each generated struct after refs are registered."""
338
476
  for wt in graph.values():
339
477
  msgspec.inspect.type_info(wt.struct)
340
478
 
479
+
480
+ def _publish_structs(graph: dict[type, _WireType]) -> None:
341
481
  # Publish the readable generated class names.
342
482
  for wt in graph.values():
343
483
  setattr(wt.namespace, wt.class_name, wt.struct)
344
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)
345
495
  return graph
346
496
 
347
497
 
@@ -353,22 +503,17 @@ def _build_graph(
353
503
  _jso_registry: dict[str, JsonSerializerOptions] = {}
354
504
  _registry_lock = threading.Lock()
355
505
 
506
+
356
507
  @lru_cache(maxsize=None)
357
508
  def _wrapped_lru(roots: tuple[type, ...], jso_id: str):
358
509
  with _registry_lock:
359
510
  jso = _jso_registry[jso_id]
360
- return _build_graph(roots, jso)
361
511
 
362
- def _cached_graph(roots:tuple[object, ...], jso: JsonSerializerOptions):
363
- with _registry_lock:
364
- _jso_registry[jso.fingerprint] = jso
512
+ return _build_graph(roots, jso)
365
513
 
366
- return _wrapped_lru(roots, jso.fingerprint)
367
514
 
368
- def wire_type_for(
369
- target_type: type,
370
- jso: JsonSerializerOptions,
371
- ):
515
+ @lru_cache(maxsize=None)
516
+ def _cached_wire_type(target_type: object, jso_id: str):
372
517
  roots = tuple(_nested_dataclasses(target_type))
373
518
 
374
519
  if not roots:
@@ -377,13 +522,8 @@ def wire_type_for(
377
522
  f"got {target_type!r}"
378
523
  )
379
524
 
380
- # Normalize the root set so User and list[User] share the graph.
381
525
  graph_roots = tuple(sorted(set(roots), key=id))
382
-
383
- graph = _cached_graph(
384
- graph_roots,
385
- jso,
386
- )
526
+ graph = _wrapped_lru(graph_roots, jso_id)
387
527
 
388
528
  return _rewrite_type(
389
529
  target_type,
@@ -393,35 +533,100 @@ def wire_type_for(
393
533
  )
394
534
 
395
535
 
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()
536
+ def wire_type_for(target_type: type, jso: JsonSerializerOptions) -> type:
537
+ jso_id = jso.fingerprint
403
538
 
404
- wire_type = wire_type_for(target, jso)
405
- parsed = msgspec.json.decode(data, type=wire_type)
539
+ with _registry_lock:
540
+ _jso_registry[jso_id] = jso
406
541
 
407
- if jso.native:
408
- return parsed
542
+ return _cached_wire_type(target_type, jso_id)
409
543
 
410
- return msgspec.convert(
411
- parsed,
412
- target,
413
- from_attributes=True,
414
- )
415
544
 
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)
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
+
427
632
 
@@ -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
@@ -0,0 +1,12 @@
1
+ flp/__init__.py,sha256=reAHhZtYt0f2LHeIYJbklta6Vatr6yeQCL1R-9TTyY0,688
2
+ flpit/__init__.py,sha256=tx4tkpLxoKQTP7wu2PyUtQtLs9_pWHbx1jHh4Vdsrus,1275
3
+ flpit/flp.py,sha256=i-FbnMeO92cxNRssYsgdlm2UGjILpveXgYUT468fWvY,999
4
+ flpit/io.py,sha256=ATXt0H7DpKt9aC7-td5FhR601alVDsAPZ1tFJ9tVYvM,5259
5
+ flpit/json.py,sha256=Ujlk8jo6kCIaVwhYRwDV17PQ6rmCVa8G9IjPz_Tvfsw,19115
6
+ flpit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ flpit/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ flpit/core/linq.py,sha256=h2KKK9wP77cGFKsqDtCRlulSlhKj40DCVZj4D9tpK2s,41049
9
+ flpit-0.2.1.dev4.dist-info/METADATA,sha256=1O1rnsiWRbdz8f1bJbgKMrgCyDK9wlbsNmwz-IF9epk,13047
10
+ flpit-0.2.1.dev4.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ flpit-0.2.1.dev4.dist-info/licenses/LICENSE,sha256=bi5Vg_YP3Oq3jaOMQT4b7WZu0NfBcZr-nRyOExUKJrQ,1063
12
+ flpit-0.2.1.dev4.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- flp/__init__.py,sha256=reAHhZtYt0f2LHeIYJbklta6Vatr6yeQCL1R-9TTyY0,688
2
- flpit/__init__.py,sha256=tx4tkpLxoKQTP7wu2PyUtQtLs9_pWHbx1jHh4Vdsrus,1275
3
- flpit/flp.py,sha256=i-FbnMeO92cxNRssYsgdlm2UGjILpveXgYUT468fWvY,999
4
- flpit/json.py,sha256=KHACbdvunJyyuVt18AZjC_L-CGh0aptkem0wiJtJx0s,11687
5
- flpit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- flpit/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- flpit/core/linq.py,sha256=ekihrga5XEhWUyG5pIRl3LJBrhr3V0cv498jFfyZhKg,40136
8
- flpit-0.2.1.dev3.dist-info/METADATA,sha256=KqNxb2w0XNQsTcNVNVyGCX4nqnWHhCtH6-RvRFn5XXg,13041
9
- flpit-0.2.1.dev3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
- flpit-0.2.1.dev3.dist-info/licenses/LICENSE,sha256=bi5Vg_YP3Oq3jaOMQT4b7WZu0NfBcZr-nRyOExUKJrQ,1063
11
- flpit-0.2.1.dev3.dist-info/RECORD,,