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 +24 -4
- flpit/io.py +212 -0
- flpit/json.py +301 -96
- {flpit-0.2.1.dev3.dist-info → flpit-0.2.1.dev4.dist-info}/METADATA +2 -2
- flpit-0.2.1.dev4.dist-info/RECORD +12 -0
- flpit-0.2.1.dev3.dist-info/RECORD +0 -11
- {flpit-0.2.1.dev3.dist-info → flpit-0.2.1.dev4.dist-info}/WHEEL +0 -0
- {flpit-0.2.1.dev3.dist-info → flpit-0.2.1.dev4.dist-info}/licenses/LICENSE +0 -0
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.
|
|
30
|
+
from flpit.io import MemoryByteWriter
|
|
31
|
+
from flpit.json import JsonConvert, JsonSerializerOptions
|
|
30
32
|
|
|
31
33
|
TItem = TypeVar("TItem")
|
|
32
34
|
TResult = TypeVar("TResult")
|
|
@@ -625,6 +627,24 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
|
|
|
625
627
|
"""Explicitly materializes the query into a FlpList."""
|
|
626
628
|
return FlpList[TItem](self)
|
|
627
629
|
|
|
630
|
+
# =====================================================================
|
|
631
|
+
# 5. json serialization
|
|
632
|
+
# =====================================================================
|
|
633
|
+
|
|
634
|
+
@classmethod
|
|
635
|
+
def from_json[T](cls, data: str | bytes, item_type: type[T], jso: JsonSerializerOptions|None=None) -> FlpIt[T]:
|
|
636
|
+
return FlpIt[T](JsonConvert.deserialize(data, list[item_type], jso))
|
|
637
|
+
|
|
638
|
+
def to_json_bytes[T](self, item_type: type[T], jso: JsonSerializerOptions|None=None) -> bytes:
|
|
639
|
+
writer = MemoryByteWriter()
|
|
640
|
+
JsonConvert.serialize_to(self, item_type, writer, jso)
|
|
641
|
+
return writer.getvalue()
|
|
642
|
+
|
|
643
|
+
def to_json[T](self, item_type: type[T], jso: JsonSerializerOptions|None=None) -> str:
|
|
644
|
+
writer = StringWriter()
|
|
645
|
+
JsonConvert.serialize_to(self, item_type, writer, jso)
|
|
646
|
+
return writer.getvalue()
|
|
647
|
+
|
|
628
648
|
|
|
629
649
|
from functools import partial
|
|
630
650
|
|
|
@@ -1192,12 +1212,12 @@ class FlpList(Collection[TItem], Generic[TItem]):
|
|
|
1192
1212
|
|
|
1193
1213
|
@classmethod
|
|
1194
1214
|
def from_json[T](cls, data: str | bytes, item_type: type[T], jso: JsonSerializerOptions|None=None) -> FlpList[T]:
|
|
1195
|
-
return FlpList[T](
|
|
1215
|
+
return FlpList[T](JsonConvert.deserialize(data, list[item_type], jso))
|
|
1196
1216
|
|
|
1197
1217
|
def to_json_bytes[T](self, item_type: type[T], jso: JsonSerializerOptions|None=None) -> bytes:
|
|
1198
|
-
return
|
|
1218
|
+
return JsonConvert.serialize_bytes(self.__list.data, list[item_type], jso)
|
|
1199
1219
|
|
|
1200
1220
|
def to_json[T](self, item_type: type[T], jso: JsonSerializerOptions|None=None) -> str:
|
|
1201
|
-
return self.
|
|
1221
|
+
return JsonConvert.serialize(self.__list.data, list[item_type], jso)
|
|
1202
1222
|
|
|
1203
1223
|
|
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
|
|
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
|
|
16
|
+
from flpit.io import ByteWriter, TextWriter
|
|
17
|
+
|
|
17
18
|
|
|
18
19
|
class JsonNamingPolicy(StrEnum):
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
464
|
+
fields,
|
|
328
465
|
module=wt.module_name,
|
|
329
466
|
namespace=_behavior_namespace(wt.source),
|
|
330
|
-
rename=
|
|
331
|
-
kw_only=
|
|
332
|
-
omit_defaults=
|
|
333
|
-
forbid_unknown_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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
|
397
|
-
|
|
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
|
-
|
|
405
|
-
|
|
539
|
+
with _registry_lock:
|
|
540
|
+
_jso_registry[jso_id] = jso
|
|
406
541
|
|
|
407
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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.
|
|
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,,
|
|
File without changes
|
|
File without changes
|