flpit 0.2.0.dev2__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/__init__.py +29 -3
- flpit/core/linq.py +130 -37
- flpit/io.py +212 -0
- flpit/json.py +632 -0
- {flpit-0.2.0.dev2.dist-info → flpit-0.2.1.dev4.dist-info}/METADATA +8 -4
- flpit-0.2.1.dev4.dist-info/RECORD +12 -0
- flpit-0.2.0.dev2.dist-info/RECORD +0 -10
- {flpit-0.2.0.dev2.dist-info → flpit-0.2.1.dev4.dist-info}/WHEEL +0 -0
- {flpit-0.2.0.dev2.dist-info → flpit-0.2.1.dev4.dist-info}/licenses/LICENSE +0 -0
flpit/__init__.py
CHANGED
|
@@ -5,33 +5,59 @@ from __future__ import annotations
|
|
|
5
5
|
from importlib.metadata import version
|
|
6
6
|
__version__ = version("flpit")
|
|
7
7
|
|
|
8
|
-
import builtins
|
|
9
|
-
from typing import Iterable as _Iterable, TypeVar
|
|
10
|
-
|
|
11
8
|
from flpit import flp
|
|
12
9
|
from flpit.core.linq import (
|
|
13
10
|
FlpIt,
|
|
14
11
|
FlpList,
|
|
15
12
|
Grouping,
|
|
16
13
|
OrderedIt,
|
|
14
|
+
|
|
15
|
+
#====================
|
|
16
|
+
# InvalidOperationException
|
|
17
|
+
#====================
|
|
18
|
+
InvalidOperationError,
|
|
17
19
|
EmptySequenceError,
|
|
18
20
|
NoMatchError,
|
|
19
21
|
MultipleElementsError,
|
|
20
22
|
MultipleMatchesError,
|
|
23
|
+
|
|
24
|
+
#====================
|
|
25
|
+
# ArgumentExceptions
|
|
26
|
+
#====================
|
|
27
|
+
ArgumentError,
|
|
28
|
+
ArgumentNullError,
|
|
21
29
|
SourceNoneError,
|
|
22
30
|
PredicateNoneError,
|
|
31
|
+
CollectionNoneError,
|
|
32
|
+
SelectorNoneError,
|
|
33
|
+
SecondNoneError,
|
|
34
|
+
ArgumentOutOfRangeError,
|
|
35
|
+
ArgumentNonNegError,
|
|
36
|
+
ArgumentOutOfBoundsError
|
|
23
37
|
)
|
|
24
38
|
|
|
25
39
|
__all__ = [
|
|
40
|
+
"flp",
|
|
26
41
|
"FlpIt",
|
|
27
42
|
"FlpList",
|
|
28
43
|
"Grouping",
|
|
29
44
|
"OrderedIt",
|
|
45
|
+
|
|
46
|
+
"InvalidOperationError",
|
|
30
47
|
"EmptySequenceError",
|
|
31
48
|
"NoMatchError",
|
|
32
49
|
"MultipleElementsError",
|
|
33
50
|
"MultipleMatchesError",
|
|
51
|
+
|
|
52
|
+
"ArgumentError",
|
|
53
|
+
"ArgumentNullError",
|
|
34
54
|
"SourceNoneError",
|
|
35
55
|
"PredicateNoneError",
|
|
56
|
+
"CollectionNoneError",
|
|
57
|
+
"SelectorNoneError",
|
|
58
|
+
"SecondNoneError",
|
|
59
|
+
"ArgumentOutOfRangeError",
|
|
60
|
+
"ArgumentNonNegError",
|
|
61
|
+
"ArgumentOutOfBoundsError",
|
|
36
62
|
]
|
|
37
63
|
|
flpit/core/linq.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
from
|
|
2
|
-
from typing import SupportsIndex
|
|
1
|
+
from flpit.io import StringWriter
|
|
3
2
|
import decimal
|
|
4
3
|
from decimal import Decimal, Overflow, Inexact
|
|
5
4
|
|
|
@@ -15,15 +14,22 @@ from typing import (
|
|
|
15
14
|
Iterable,
|
|
16
15
|
Iterator,
|
|
17
16
|
Collection,
|
|
17
|
+
SupportsIndex,
|
|
18
18
|
List,
|
|
19
19
|
Set,
|
|
20
20
|
Type,
|
|
21
21
|
TypeVar,
|
|
22
|
+
ClassVar,
|
|
22
23
|
Union,
|
|
23
24
|
override,
|
|
24
25
|
overload,
|
|
25
26
|
cast as typing_cast,
|
|
27
|
+
GenericAlias
|
|
26
28
|
)
|
|
29
|
+
|
|
30
|
+
from flpit.io import MemoryByteWriter
|
|
31
|
+
from flpit.json import JsonConvert, JsonSerializerOptions
|
|
32
|
+
|
|
27
33
|
TItem = TypeVar("TItem")
|
|
28
34
|
TResult = TypeVar("TResult")
|
|
29
35
|
TKey = TypeVar("TKey")
|
|
@@ -39,38 +45,70 @@ class _Sentinel:
|
|
|
39
45
|
_SENTINEL = _Sentinel()
|
|
40
46
|
_MISSING = object()
|
|
41
47
|
|
|
48
|
+
# =============================================================================
|
|
49
|
+
# Invalid Operation Errors
|
|
50
|
+
# =============================================================================
|
|
51
|
+
class InvalidOperationError(ValueError):
|
|
52
|
+
pass
|
|
42
53
|
|
|
43
|
-
class EmptySequenceError(
|
|
54
|
+
class EmptySequenceError(InvalidOperationError):
|
|
44
55
|
def __init__(self):
|
|
45
56
|
super().__init__("Sequence contains no elements")
|
|
46
57
|
|
|
47
|
-
class NoMatchError(
|
|
58
|
+
class NoMatchError(InvalidOperationError):
|
|
48
59
|
def __init__(self):
|
|
49
60
|
super().__init__("Sequence contains no matching elements")
|
|
50
61
|
|
|
51
|
-
class MultipleMatchesError(
|
|
62
|
+
class MultipleMatchesError(InvalidOperationError):
|
|
52
63
|
def __init__(self):
|
|
53
64
|
super().__init__("Sequence contains more than one matching element")
|
|
54
65
|
|
|
55
|
-
class MultipleElementsError(
|
|
66
|
+
class MultipleElementsError(InvalidOperationError):
|
|
56
67
|
def __init__(self):
|
|
57
68
|
super().__init__("Sequence contains more than one element")
|
|
58
69
|
|
|
59
|
-
|
|
70
|
+
|
|
71
|
+
# =============================================================================
|
|
72
|
+
# Argument Errors
|
|
73
|
+
# =============================================================================
|
|
74
|
+
class ArgumentError(TypeError):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
class ArgumentNullError(ArgumentError):
|
|
78
|
+
def __init__(self, argument: str):
|
|
79
|
+
super().__init__(f"Value cannot be None. (Argument '{argument}')")
|
|
80
|
+
|
|
81
|
+
class SourceNoneError(ArgumentNullError):
|
|
82
|
+
def __init__(self):
|
|
83
|
+
super().__init__("source")
|
|
84
|
+
|
|
85
|
+
class PredicateNoneError(ArgumentNullError):
|
|
86
|
+
def __init__(self):
|
|
87
|
+
super().__init__("predicate")
|
|
88
|
+
|
|
89
|
+
class CollectionNoneError(ArgumentNullError):
|
|
60
90
|
def __init__(self):
|
|
61
|
-
super().__init__("
|
|
91
|
+
super().__init__("collection")
|
|
62
92
|
|
|
63
|
-
class
|
|
93
|
+
class SelectorNoneError(ArgumentNullError):
|
|
64
94
|
def __init__(self):
|
|
65
|
-
super().__init__("
|
|
95
|
+
super().__init__("keySelector")
|
|
66
96
|
|
|
67
|
-
class
|
|
97
|
+
class SecondNoneError(ArgumentNullError):
|
|
68
98
|
def __init__(self):
|
|
69
|
-
super().__init__("
|
|
99
|
+
super().__init__("second")
|
|
70
100
|
|
|
71
|
-
class
|
|
101
|
+
class ArgumentOutOfRangeError(ArgumentError):
|
|
102
|
+
def __init__(self, argument: str, pattern:str="Index was out of range. Must be non-negative and less than the size of the collection."):
|
|
103
|
+
super().__init__(f"{pattern} (Parameter '{argument}')")
|
|
104
|
+
|
|
105
|
+
class ArgumentNonNegError(ArgumentOutOfRangeError):
|
|
106
|
+
def __init__(self, argument:str):
|
|
107
|
+
super().__init__(argument, "Non-negative number required.")
|
|
108
|
+
|
|
109
|
+
class ArgumentOutOfBoundsError(ArgumentError):
|
|
72
110
|
def __init__(self):
|
|
73
|
-
super().__init__("
|
|
111
|
+
super().__init__("Offset and length were out of bounds for the array or count is greater than the number of elements from index to the end of the source collection.")
|
|
74
112
|
|
|
75
113
|
|
|
76
114
|
def _guard_empty(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
@@ -191,6 +229,9 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
|
|
|
191
229
|
return builtins.all(map(predicate, self))
|
|
192
230
|
|
|
193
231
|
def concat(self, second: Iterable[TItem]) -> "FlpIt[TItem]":
|
|
232
|
+
if second is None:
|
|
233
|
+
raise SecondNoneError()
|
|
234
|
+
|
|
194
235
|
def _generator() -> Iterator[TItem]:
|
|
195
236
|
yield from self
|
|
196
237
|
yield from second
|
|
@@ -211,8 +252,8 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
|
|
|
211
252
|
for item in self:
|
|
212
253
|
if predicate(item):
|
|
213
254
|
yield item
|
|
214
|
-
|
|
215
|
-
return
|
|
255
|
+
# return typing_cast(FlpIt[TItem], FlpIt[TItem](_FactoryIterable(_generator)))
|
|
256
|
+
return FlpIt[TItem](_FactoryIterable(_generator))
|
|
216
257
|
|
|
217
258
|
def select(self, selector: Callable[[TItem], TResult]) -> FlpIt[TResult]:
|
|
218
259
|
"""Projects each element into a new form."""
|
|
@@ -232,6 +273,18 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
|
|
|
232
273
|
|
|
233
274
|
return FlpIt(_FactoryIterable(_generator))
|
|
234
275
|
|
|
276
|
+
def skip(self, count: int) -> "FlpIt[TItem]":
|
|
277
|
+
"""
|
|
278
|
+
|Bypasses a specified number of elements from the start.
|
|
279
|
+
"""
|
|
280
|
+
if count <= 0:
|
|
281
|
+
return self
|
|
282
|
+
|
|
283
|
+
def _generator() -> Iterator[TItem]:
|
|
284
|
+
yield from islice(iter(self), count, None)
|
|
285
|
+
|
|
286
|
+
return FlpIt(_FactoryIterable(_generator))
|
|
287
|
+
|
|
235
288
|
def take(self, count: int) -> "FlpIt[TItem]":
|
|
236
289
|
"""
|
|
237
290
|
|Returns a specified number of contiguous elements from the start.
|
|
@@ -528,11 +581,13 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
|
|
|
528
581
|
def element_at(self, index: int) -> TItem:
|
|
529
582
|
"""Returns the element at a specified index in a sequence."""
|
|
530
583
|
if index < 0:
|
|
531
|
-
raise
|
|
584
|
+
raise ArgumentOutOfRangeError("index")
|
|
585
|
+
|
|
532
586
|
for i, item in enumerate(self):
|
|
533
587
|
if i == index:
|
|
534
588
|
return item
|
|
535
|
-
|
|
589
|
+
|
|
590
|
+
raise ArgumentOutOfRangeError("index")
|
|
536
591
|
|
|
537
592
|
def first(self, predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL) -> TItem:
|
|
538
593
|
"""Returns the first element matching a predicate, or raises ValueError."""
|
|
@@ -568,10 +623,28 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
|
|
|
568
623
|
|
|
569
624
|
raise MultipleMatchesError() if has_predicate else MultipleElementsError()
|
|
570
625
|
|
|
571
|
-
|
|
572
626
|
def to_list(self) -> FlpList[TItem]:
|
|
573
627
|
"""Explicitly materializes the query into a FlpList."""
|
|
574
|
-
return FlpList(self)
|
|
628
|
+
return FlpList[TItem](self)
|
|
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
|
+
|
|
575
648
|
|
|
576
649
|
from functools import partial
|
|
577
650
|
|
|
@@ -682,6 +755,8 @@ class FlpList(Collection[TItem], Generic[TItem]):
|
|
|
682
755
|
| Fluent List
|
|
683
756
|
A materialized list extending UserList that yields lazy FlpIt instances for query operations.
|
|
684
757
|
"""
|
|
758
|
+
__type_args__: ClassVar[tuple[Any, ...]]
|
|
759
|
+
|
|
685
760
|
def __init__(self, source: Iterable[TItem] | _Sentinel=_SENTINEL):
|
|
686
761
|
if source is None:
|
|
687
762
|
raise SourceNoneError()
|
|
@@ -830,7 +905,7 @@ class FlpList(Collection[TItem], Generic[TItem]):
|
|
|
830
905
|
|
|
831
906
|
def insert(self, index: int, item: TItem) -> None:
|
|
832
907
|
if index < 0 or index > len(self.__list.data):
|
|
833
|
-
raise
|
|
908
|
+
raise ArgumentOutOfRangeError("index")
|
|
834
909
|
|
|
835
910
|
self.__list.data.insert(index, item)
|
|
836
911
|
|
|
@@ -839,7 +914,7 @@ class FlpList(Collection[TItem], Generic[TItem]):
|
|
|
839
914
|
raise CollectionNoneError()
|
|
840
915
|
|
|
841
916
|
if index < 0 or index > len(self.__list.data):
|
|
842
|
-
raise
|
|
917
|
+
raise ArgumentOutOfRangeError("index")
|
|
843
918
|
|
|
844
919
|
self.__list.data[index:index] = list(collection)
|
|
845
920
|
|
|
@@ -865,22 +940,22 @@ class FlpList(Collection[TItem], Generic[TItem]):
|
|
|
865
940
|
return original_length - len(self.__list.data)
|
|
866
941
|
|
|
867
942
|
def remove_at(self, index: int) -> None:
|
|
868
|
-
if index < 0 or index
|
|
869
|
-
raise
|
|
943
|
+
if index < 0 or index >= len(self.__list.data):
|
|
944
|
+
raise ArgumentOutOfRangeError("index")
|
|
870
945
|
|
|
871
946
|
del self.__list.data[index]
|
|
872
947
|
|
|
873
948
|
def remove_range(self, index: int, count: int) -> None:
|
|
874
949
|
size = len(self.__list.data)
|
|
875
950
|
|
|
876
|
-
if index < 0
|
|
877
|
-
raise
|
|
951
|
+
if index < 0:
|
|
952
|
+
raise ArgumentNonNegError("index")
|
|
878
953
|
|
|
879
954
|
if count < 0:
|
|
880
|
-
raise
|
|
955
|
+
raise ArgumentNonNegError("count")
|
|
881
956
|
|
|
882
957
|
if count > size - index:
|
|
883
|
-
raise
|
|
958
|
+
raise ArgumentOutOfBoundsError()
|
|
884
959
|
|
|
885
960
|
del self.__list.data[index : index + count]
|
|
886
961
|
|
|
@@ -899,17 +974,13 @@ class FlpList(Collection[TItem], Generic[TItem]):
|
|
|
899
974
|
|
|
900
975
|
# Apply your List<T>-compatible argument validation here.
|
|
901
976
|
if index < 0 or index > size:
|
|
902
|
-
raise
|
|
977
|
+
raise ArgumentNonNegError("index")
|
|
903
978
|
|
|
904
979
|
if count < 0:
|
|
905
|
-
raise
|
|
980
|
+
raise ArgumentNonNegError("count")
|
|
906
981
|
|
|
907
982
|
if index + count > size:
|
|
908
|
-
raise
|
|
909
|
-
"Offset and length were out of bounds for the array or "
|
|
910
|
-
"count is greater than the number of elements from index "
|
|
911
|
-
"to the end of the source collection."
|
|
912
|
-
)
|
|
983
|
+
raise ArgumentOutOfBoundsError()
|
|
913
984
|
|
|
914
985
|
self.__list.data[index:index + count] = reversed(
|
|
915
986
|
self.__list.data[index:index + count]
|
|
@@ -948,12 +1019,15 @@ class FlpList(Collection[TItem], Generic[TItem]):
|
|
|
948
1019
|
"""Prepends an element to the sequence lazily, returning a FlpIt without mutating this list."""
|
|
949
1020
|
return FlpIt(self.__list.data).prepend(element)
|
|
950
1021
|
|
|
1022
|
+
def concat(self, second: Iterable[TItem]) -> FlpIt[TItem]:
|
|
1023
|
+
return FlpIt(self.__list.data).concat(second)
|
|
1024
|
+
|
|
951
1025
|
# noinspection unused-parameter
|
|
952
1026
|
def as_type(self, target_type: Type[TResult]) -> FlpList[TResult]:
|
|
953
1027
|
return self # type: ignore[return-value]
|
|
954
1028
|
|
|
955
1029
|
def where(self, predicate: Callable[[TItem], bool]) -> FlpIt[TItem]:
|
|
956
|
-
return FlpIt(self.__list.data).where(predicate)
|
|
1030
|
+
return FlpIt[TItem](self.__list.data).where(predicate)
|
|
957
1031
|
|
|
958
1032
|
def select(self, selector: Callable[[TItem], TResult]) -> FlpIt[TResult]:
|
|
959
1033
|
return FlpIt(self.__list.data).select(selector)
|
|
@@ -963,6 +1037,9 @@ class FlpList(Collection[TItem], Generic[TItem]):
|
|
|
963
1037
|
) -> FlpIt[TResult]:
|
|
964
1038
|
return FlpIt(self.__list.data).select_many(selector)
|
|
965
1039
|
|
|
1040
|
+
def skip(self, count: int) -> FlpIt[TItem]:
|
|
1041
|
+
return FlpIt(self.__list.data).skip(count)
|
|
1042
|
+
|
|
966
1043
|
def take(self, count: int) -> FlpIt[TItem]:
|
|
967
1044
|
return FlpIt(self.__list.data).take(count)
|
|
968
1045
|
|
|
@@ -1076,7 +1153,7 @@ class FlpList(Collection[TItem], Generic[TItem]):
|
|
|
1076
1153
|
|
|
1077
1154
|
def element_at(self, index: int) -> TItem:
|
|
1078
1155
|
if index < 0 or index >= len(self.__list.data):
|
|
1079
|
-
raise
|
|
1156
|
+
raise ArgumentOutOfRangeError("index")
|
|
1080
1157
|
return self.__list.data[index]
|
|
1081
1158
|
|
|
1082
1159
|
def first(self, predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL) -> TItem:
|
|
@@ -1128,3 +1205,19 @@ class FlpList(Collection[TItem], Generic[TItem]):
|
|
|
1128
1205
|
def to_list(self) -> FlpList[TItem]:
|
|
1129
1206
|
"""Explicitly returns a shallow copy instance to isolate mutations."""
|
|
1130
1207
|
return FlpList(self.__list.data.copy())
|
|
1208
|
+
|
|
1209
|
+
# =====================================================================
|
|
1210
|
+
# 5. json serialization
|
|
1211
|
+
# =====================================================================
|
|
1212
|
+
|
|
1213
|
+
@classmethod
|
|
1214
|
+
def from_json[T](cls, data: str | bytes, item_type: type[T], jso: JsonSerializerOptions|None=None) -> FlpList[T]:
|
|
1215
|
+
return FlpList[T](JsonConvert.deserialize(data, list[item_type], jso))
|
|
1216
|
+
|
|
1217
|
+
def to_json_bytes[T](self, item_type: type[T], jso: JsonSerializerOptions|None=None) -> bytes:
|
|
1218
|
+
return JsonConvert.serialize_bytes(self.__list.data, list[item_type], jso)
|
|
1219
|
+
|
|
1220
|
+
def to_json[T](self, item_type: type[T], jso: JsonSerializerOptions|None=None) -> str:
|
|
1221
|
+
return JsonConvert.serialize(self.__list.data, list[item_type], jso)
|
|
1222
|
+
|
|
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
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
import dataclasses
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import types
|
|
7
|
+
import typing
|
|
8
|
+
from dataclasses import MISSING, dataclass
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from hashlib import blake2s
|
|
12
|
+
from typing import Annotated, Any, ForwardRef, get_args, get_origin, get_type_hints, Callable, cast as typing_cast
|
|
13
|
+
|
|
14
|
+
import msgspec
|
|
15
|
+
|
|
16
|
+
from flpit.io import ByteWriter, TextWriter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JsonNamingPolicy(StrEnum):
|
|
20
|
+
lower = "lower" # pyrefly: ignore [bad-override-mutable-attribute]
|
|
21
|
+
upper = "upper" # pyrefly: ignore [bad-override-mutable-attribute]
|
|
22
|
+
camel = "camel"
|
|
23
|
+
pascal = "pascal"
|
|
24
|
+
kebap = "kebab"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class JsonDefaultHandling(StrEnum):
|
|
28
|
+
include = "include"
|
|
29
|
+
exclude = "exclude"
|
|
30
|
+
|
|
31
|
+
class JsonUnknownFieldHandling(StrEnum):
|
|
32
|
+
allow = "allow"
|
|
33
|
+
disallow = "disallow"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True, frozen=True)
|
|
37
|
+
class JsonSerializerOptions:
|
|
38
|
+
naming_policy: JsonNamingPolicy = JsonNamingPolicy.camel
|
|
39
|
+
default_handling: JsonDefaultHandling = JsonDefaultHandling.exclude
|
|
40
|
+
unknown_field_handling: JsonUnknownFieldHandling = JsonUnknownFieldHandling.allow
|
|
41
|
+
|
|
42
|
+
# write_indented: bool = False # will be supported later
|
|
43
|
+
native: bool = False
|
|
44
|
+
"""
|
|
45
|
+
| Set to true for the native speed of msgspec
|
|
46
|
+
|
|
47
|
+
!! only set to true if you know what you are doing !!
|
|
48
|
+
You'll receive the msgspec.msgstruct object that will try to do it's best to behave like your dataclass.
|
|
49
|
+
But remember !! "It's NOT your dataclass" !!
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def fingerprint(self) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Identifies a unique jso instance for building a struct
|
|
56
|
+
!! All parts of a jso that don't influence the defstruct MUST be ignored!
|
|
57
|
+
"""
|
|
58
|
+
settings = (f"jpn-{self.naming_policy.value}:"
|
|
59
|
+
f"dh-{self.default_handling}:"
|
|
60
|
+
f"ufh-{self.unknown_field_handling}:"
|
|
61
|
+
).encode("utf-8")
|
|
62
|
+
return blake2s(settings, digest_size=6).hexdigest()
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Metadata Annotation Markers
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
class JsonIgnore:
|
|
69
|
+
def __class_getitem__[T](cls, item: type[T]) -> Any:
|
|
70
|
+
return Annotated[item, cls]
|
|
71
|
+
|
|
72
|
+
class JsonInclude:
|
|
73
|
+
def __class_getitem__[T](cls, item: type[T]) -> Any:
|
|
74
|
+
return Annotated[item, cls]
|
|
75
|
+
|
|
76
|
+
class JsonProperty:
|
|
77
|
+
def __class_getitem__[T](cls, item: tuple[type[T], str]) -> Any:
|
|
78
|
+
target_type, json_name = item
|
|
79
|
+
return Annotated[target_type, cls(json_name)]
|
|
80
|
+
|
|
81
|
+
def __init__(self, name: str):
|
|
82
|
+
self.name = name
|
|
83
|
+
|
|
84
|
+
JIgnore = JsonIgnore
|
|
85
|
+
JInclude = JsonInclude
|
|
86
|
+
JProp = JsonProperty
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------
|
|
89
|
+
# Generated type metadata
|
|
90
|
+
# ---------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
@dataclass(slots=True, eq=False)
|
|
93
|
+
class _WireType:
|
|
94
|
+
source: type
|
|
95
|
+
"""The source type to build a msgspec.struct for"""
|
|
96
|
+
hints: dict[str, Any]
|
|
97
|
+
namespace: types.ModuleType
|
|
98
|
+
"""the name space we are targeting"""
|
|
99
|
+
struct: type | None = None
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def module_name(self: _WireType) -> str:
|
|
103
|
+
return self.namespace.__name__
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def class_name(self: _WireType) -> str:
|
|
107
|
+
return self.source.__name__
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def ref_name(self: _WireType) -> str:
|
|
111
|
+
identity = (
|
|
112
|
+
f"{self.source.__module__}_{self.source.__qualname__}"
|
|
113
|
+
)
|
|
114
|
+
return "_ref_" + re.sub(r"\W", "_", identity)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------
|
|
118
|
+
# Never imagined this would be required to have a proper lib behavior
|
|
119
|
+
# ---------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def _apply_naming_policy(name: str, policy: JsonNamingPolicy) -> str:
|
|
122
|
+
"""
|
|
123
|
+
Splits any attribute name (snake_case, PascalCase, UPPER_CASE)
|
|
124
|
+
into individual tokens and formats them according to the target policy.
|
|
125
|
+
"""
|
|
126
|
+
# 1. Check if there is a leading underscore and save it
|
|
127
|
+
prefix = "_" if name.startswith("_") else ""
|
|
128
|
+
clean_name = name.lstrip("_")
|
|
129
|
+
|
|
130
|
+
# 2. Tokenize only the clean part of the string
|
|
131
|
+
tokens = [t for t in re.split(r'_(?=[a-zA-Z])|(?<=[a-z])(?=[A-Z])', clean_name) if t]
|
|
132
|
+
if not tokens:
|
|
133
|
+
return name
|
|
134
|
+
|
|
135
|
+
if policy == JsonNamingPolicy.lower:
|
|
136
|
+
result = "".join(t.lower() for t in tokens)
|
|
137
|
+
|
|
138
|
+
if policy == JsonNamingPolicy.upper:
|
|
139
|
+
result = "".join(t.upper() for t in tokens)
|
|
140
|
+
|
|
141
|
+
if policy == JsonNamingPolicy.pascal:
|
|
142
|
+
result = "".join(t.capitalize() for t in tokens)
|
|
143
|
+
|
|
144
|
+
if policy == JsonNamingPolicy.kebap:
|
|
145
|
+
result = "-".join(t.lower() for t in tokens)
|
|
146
|
+
|
|
147
|
+
if policy == JsonNamingPolicy.camel:
|
|
148
|
+
# Lowercase the first token completely, capitalize all subsequent tokens
|
|
149
|
+
result = tokens[0].lower() + "".join(t.capitalize() for t in tokens[1:])
|
|
150
|
+
|
|
151
|
+
else:
|
|
152
|
+
result = clean_name
|
|
153
|
+
|
|
154
|
+
return prefix + result
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _generated_module(source: type) -> types.ModuleType:
|
|
159
|
+
"""Generates the required module definition for our runtime only dynamic msgspec.struct"""
|
|
160
|
+
name = f"flpit.json._generated.{source.__module__}"
|
|
161
|
+
|
|
162
|
+
module = sys.modules.get(name)
|
|
163
|
+
if module is None:
|
|
164
|
+
module = types.ModuleType(name)
|
|
165
|
+
sys.modules[name] = module
|
|
166
|
+
|
|
167
|
+
return module
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _is_dataclass_type(tp: Any) -> bool:
|
|
171
|
+
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _field_directives(
|
|
175
|
+
annotation: Any,
|
|
176
|
+
) -> tuple[Any, bool, bool, str | None]:
|
|
177
|
+
"""Extract JIgnore, JInclude, and JProp directives from a field annotation."""
|
|
178
|
+
|
|
179
|
+
if get_origin(annotation) is not typing.Annotated:
|
|
180
|
+
return annotation, False, False, None
|
|
181
|
+
|
|
182
|
+
args = get_args(annotation)
|
|
183
|
+
base_type = args[0]
|
|
184
|
+
metadata = args[1:]
|
|
185
|
+
ignored = any(item is JsonIgnore for item in metadata)
|
|
186
|
+
included = any(item is JsonInclude for item in metadata)
|
|
187
|
+
json_name = next(
|
|
188
|
+
(item.name for item in metadata if isinstance(item, JsonProperty)),
|
|
189
|
+
None,
|
|
190
|
+
)
|
|
191
|
+
remaining = tuple(
|
|
192
|
+
item
|
|
193
|
+
for item in metadata
|
|
194
|
+
if item is not JsonIgnore
|
|
195
|
+
and item is not JsonInclude
|
|
196
|
+
and not isinstance(item, JsonProperty)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if remaining:
|
|
200
|
+
base_type = Annotated[(base_type, *remaining)]
|
|
201
|
+
|
|
202
|
+
return base_type, ignored, included, json_name
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _get_property_directive(prop_func: Callable[[Any, Any], None] | Callable[[Any], Any] | None):
|
|
206
|
+
if prop_func is not None:
|
|
207
|
+
getter_hints = get_type_hints(prop_func, include_extras=True)
|
|
208
|
+
source_annotation = getter_hints.get("return", Any)
|
|
209
|
+
else:
|
|
210
|
+
source_annotation = Any
|
|
211
|
+
|
|
212
|
+
return _field_directives(source_annotation)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _nested_dataclasses(tp: Any):
|
|
216
|
+
"""Yield dataclass types referenced by an annotation."""
|
|
217
|
+
if _is_dataclass_type(tp):
|
|
218
|
+
yield tp
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
if isinstance(tp, (list, tuple)):
|
|
222
|
+
for item in tp:
|
|
223
|
+
yield from _nested_dataclasses(item)
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
origin = get_origin(tp)
|
|
227
|
+
|
|
228
|
+
if origin is typing.Annotated:
|
|
229
|
+
yield from _nested_dataclasses(get_args(tp)[0])
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
if origin is typing.Literal:
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
for arg in get_args(tp):
|
|
236
|
+
yield from _nested_dataclasses(arg)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _rewrite_type(
|
|
240
|
+
tp: Any,
|
|
241
|
+
owner: _WireType,
|
|
242
|
+
graph: dict[type, _WireType],
|
|
243
|
+
*,
|
|
244
|
+
resolve: bool = False,
|
|
245
|
+
) -> Any:
|
|
246
|
+
"""Replace source dataclasses with generated Structs or ForwardRefs."""
|
|
247
|
+
if tp is getattr(typing, "Self", None):
|
|
248
|
+
info = owner
|
|
249
|
+
return info.struct if resolve else ForwardRef(info.ref_name)
|
|
250
|
+
|
|
251
|
+
if _is_dataclass_type(tp):
|
|
252
|
+
info = graph[tp]
|
|
253
|
+
return info.struct if resolve else ForwardRef(info.ref_name)
|
|
254
|
+
|
|
255
|
+
if isinstance(tp, list):
|
|
256
|
+
return [
|
|
257
|
+
_rewrite_type(item, owner, graph, resolve=resolve)
|
|
258
|
+
for item in tp
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
if isinstance(tp, tuple):
|
|
262
|
+
return tuple(
|
|
263
|
+
_rewrite_type(item, owner, graph, resolve=resolve)
|
|
264
|
+
for item in tp
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
origin = get_origin(tp)
|
|
268
|
+
|
|
269
|
+
if origin is None or origin is typing.Literal:
|
|
270
|
+
return tp
|
|
271
|
+
|
|
272
|
+
args = get_args(tp)
|
|
273
|
+
|
|
274
|
+
if origin is typing.Annotated:
|
|
275
|
+
base = _rewrite_type(
|
|
276
|
+
args[0], owner, graph, resolve=resolve
|
|
277
|
+
)
|
|
278
|
+
return typing.Annotated[(base, *args[1:])]
|
|
279
|
+
|
|
280
|
+
rewritten: tuple[Any, ...] = tuple(
|
|
281
|
+
_rewrite_type(arg, owner, graph, resolve=resolve)
|
|
282
|
+
for arg in args
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if origin in (typing.Union, types.UnionType):
|
|
286
|
+
union: Any = typing.Union # with all the magic here, there's no way to make typing really happy
|
|
287
|
+
return union[rewritten]
|
|
288
|
+
|
|
289
|
+
params = rewritten[0] if len(rewritten) == 1 else rewritten
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
return origin[params]
|
|
293
|
+
except (TypeError, AttributeError) as exc:
|
|
294
|
+
copier = getattr(tp, "copy_with", None)
|
|
295
|
+
if copier is not None:
|
|
296
|
+
return copier(rewritten)
|
|
297
|
+
|
|
298
|
+
raise TypeError(
|
|
299
|
+
f"Cannot rewrite annotation {tp!r}"
|
|
300
|
+
) from exc
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------
|
|
304
|
+
# Graph discovery
|
|
305
|
+
# ---------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _discover_graph(
|
|
309
|
+
roots: tuple[type, ...],
|
|
310
|
+
) -> dict[type, _WireType]:
|
|
311
|
+
"""Discover reachable dataclasses and resolve their annotations."""
|
|
312
|
+
graph: dict[type, _WireType] = {}
|
|
313
|
+
pending = list(roots)
|
|
314
|
+
|
|
315
|
+
while pending:
|
|
316
|
+
source = pending.pop()
|
|
317
|
+
|
|
318
|
+
if source in graph:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
info = _WireType(
|
|
322
|
+
source=source,
|
|
323
|
+
hints=get_type_hints(source, include_extras=True),
|
|
324
|
+
namespace=_generated_module(source),
|
|
325
|
+
)
|
|
326
|
+
graph[source] = info
|
|
327
|
+
|
|
328
|
+
for field in dataclasses.fields(source):
|
|
329
|
+
annotation, ignored, included, _ = _field_directives(
|
|
330
|
+
info.hints[field.name]
|
|
331
|
+
)
|
|
332
|
+
if ignored:
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
pending.extend(_nested_dataclasses(annotation))
|
|
336
|
+
|
|
337
|
+
return graph
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _allows_none(tp: Any) -> bool:
|
|
341
|
+
if get_origin(tp) is typing.Annotated:
|
|
342
|
+
tp = get_args(tp)[0]
|
|
343
|
+
|
|
344
|
+
return type(None) in get_args(tp)
|
|
345
|
+
|
|
346
|
+
# ---------------------------------------------------------------------
|
|
347
|
+
# Struct construction
|
|
348
|
+
# ---------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _struct_fields(
|
|
352
|
+
info: _WireType,
|
|
353
|
+
graph: dict[type, _WireType],
|
|
354
|
+
jso: JsonSerializerOptions,
|
|
355
|
+
) -> list[tuple[Any, ...]]:
|
|
356
|
+
definitions: list[tuple[Any, ...]] = []
|
|
357
|
+
dataclass_fields = {f.name for f in dataclasses.fields(info.source)}
|
|
358
|
+
|
|
359
|
+
# 1. Process regular fields (regardless of init=True or init=False)
|
|
360
|
+
for field in dataclasses.fields(info.source):
|
|
361
|
+
source_annotation = info.hints[field.name]
|
|
362
|
+
annotation, ignored, included, json_name = _field_directives(source_annotation)
|
|
363
|
+
|
|
364
|
+
if ignored:
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
# If the field starts with an underscore, it MUST have JInclude or JProp attached!
|
|
368
|
+
if field.name.startswith("_") and not (included or json_name is not None):
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
annotation = _rewrite_type(annotation, info, graph)
|
|
372
|
+
wire_name = json_name if json_name is not None else _apply_naming_policy(field.name, jso.naming_policy)
|
|
373
|
+
|
|
374
|
+
# Resolve structural field default values cleanly
|
|
375
|
+
if field.default is not MISSING:
|
|
376
|
+
default_val = field.default
|
|
377
|
+
has_default = True
|
|
378
|
+
elif field.default_factory is not MISSING:
|
|
379
|
+
default_val = msgspec.field(default_factory=field.default_factory)
|
|
380
|
+
has_default = True
|
|
381
|
+
elif _allows_none(annotation):
|
|
382
|
+
default_val = None
|
|
383
|
+
has_default = True
|
|
384
|
+
else:
|
|
385
|
+
default_val = MISSING
|
|
386
|
+
has_default = False
|
|
387
|
+
|
|
388
|
+
field_options: dict[str, Any] = {"name": wire_name}
|
|
389
|
+
if has_default:
|
|
390
|
+
if field.default_factory is not MISSING:
|
|
391
|
+
field_options["default_factory"] = field.default_factory
|
|
392
|
+
else:
|
|
393
|
+
field_options["default"] = default_val
|
|
394
|
+
|
|
395
|
+
spec = (field.name, annotation, msgspec.field(**field_options))
|
|
396
|
+
definitions.append(spec)
|
|
397
|
+
|
|
398
|
+
# 2. Process properties automatically
|
|
399
|
+
for name, value in vars(info.source).items():
|
|
400
|
+
if isinstance(value, property) and name not in dataclass_fields:
|
|
401
|
+
# Extract the type annotation directly from the getter function signature
|
|
402
|
+
if value.fset is not None:
|
|
403
|
+
_, _, included, json_name = _get_property_directive(value.fset)
|
|
404
|
+
|
|
405
|
+
if included or json_name is not None:
|
|
406
|
+
raise ValueError(
|
|
407
|
+
f"{info.source.__qualname__}.{name}: "
|
|
408
|
+
"fplit.json with msgspec backend doesn't support "
|
|
409
|
+
"JProp/JInclude on property setters"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
annotation, ignored, included, json_name = _get_property_directive(value.fget)
|
|
413
|
+
|
|
414
|
+
if not included and json_name is None:
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
annotation = _rewrite_type(annotation, info, graph)
|
|
418
|
+
wire_name = json_name if json_name is not None else _apply_naming_policy(name, jso.naming_policy)
|
|
419
|
+
|
|
420
|
+
# Map the field into the C-struct. Default to None so the inbound parser never complains.
|
|
421
|
+
spec = (name, annotation, msgspec.field(name=wire_name, default=None))
|
|
422
|
+
definitions.append(spec)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
return definitions
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _register_forward_refs(
|
|
429
|
+
graph: dict[type, _WireType],
|
|
430
|
+
) -> None:
|
|
431
|
+
"""Expose every graph reference in each participating module."""
|
|
432
|
+
namespaces = []
|
|
433
|
+
|
|
434
|
+
for info in graph.values():
|
|
435
|
+
if not any(info.namespace is ns for ns in namespaces):
|
|
436
|
+
namespaces.append(info.namespace)
|
|
437
|
+
|
|
438
|
+
for namespace in namespaces:
|
|
439
|
+
for info in graph.values():
|
|
440
|
+
setattr(namespace, info.ref_name, info.struct)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _behavior_namespace(source: type) -> dict[str, Any]:
|
|
444
|
+
return {
|
|
445
|
+
name: value
|
|
446
|
+
for name, value in vars(source).items()
|
|
447
|
+
if (
|
|
448
|
+
not name.startswith("__")
|
|
449
|
+
and (callable(value) or isinstance(value, property))
|
|
450
|
+
)
|
|
451
|
+
# pyrefly: ignore [implicit-any-lambda]
|
|
452
|
+
} | { "__class__": property(lambda self, cls=source: cls) }
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _create_structs(
|
|
456
|
+
graph: dict[type, _WireType],
|
|
457
|
+
jso: JsonSerializerOptions,
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Create the generated msgspec structs for every node in the graph."""
|
|
460
|
+
for wt in graph.values():
|
|
461
|
+
fields = _struct_fields(wt, graph, jso)
|
|
462
|
+
wt.struct = msgspec.defstruct(
|
|
463
|
+
wt.class_name,
|
|
464
|
+
fields,
|
|
465
|
+
module=wt.module_name,
|
|
466
|
+
namespace=_behavior_namespace(wt.source),
|
|
467
|
+
rename=None,
|
|
468
|
+
kw_only=False,
|
|
469
|
+
omit_defaults=jso.default_handling.value == JsonDefaultHandling.exclude,
|
|
470
|
+
forbid_unknown_fields=jso.unknown_field_handling.value == JsonUnknownFieldHandling.disallow,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _validate_structs(graph: dict[type, _WireType]) -> None:
|
|
475
|
+
"""Force msgspec to inspect each generated struct after refs are registered."""
|
|
476
|
+
for wt in graph.values():
|
|
477
|
+
msgspec.inspect.type_info(wt.struct)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _publish_structs(graph: dict[type, _WireType]) -> None:
|
|
481
|
+
# Publish the readable generated class names.
|
|
482
|
+
for wt in graph.values():
|
|
483
|
+
setattr(wt.namespace, wt.class_name, wt.struct)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _build_graph(
|
|
487
|
+
roots: tuple[type, ...],
|
|
488
|
+
jso: JsonSerializerOptions,
|
|
489
|
+
) -> dict[type, _WireType]:
|
|
490
|
+
graph = _discover_graph(roots)
|
|
491
|
+
_create_structs(graph, jso)
|
|
492
|
+
_register_forward_refs(graph)
|
|
493
|
+
_validate_structs(graph)
|
|
494
|
+
_publish_structs(graph)
|
|
495
|
+
return graph
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ---------------------------------------------------------------------
|
|
499
|
+
# Cache and public API
|
|
500
|
+
# ---------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
# we must only build new when the
|
|
503
|
+
_jso_registry: dict[str, JsonSerializerOptions] = {}
|
|
504
|
+
_registry_lock = threading.Lock()
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@lru_cache(maxsize=None)
|
|
508
|
+
def _wrapped_lru(roots: tuple[type, ...], jso_id: str):
|
|
509
|
+
with _registry_lock:
|
|
510
|
+
jso = _jso_registry[jso_id]
|
|
511
|
+
|
|
512
|
+
return _build_graph(roots, jso)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@lru_cache(maxsize=None)
|
|
516
|
+
def _cached_wire_type(target_type: object, jso_id: str):
|
|
517
|
+
roots = tuple(_nested_dataclasses(target_type))
|
|
518
|
+
|
|
519
|
+
if not roots:
|
|
520
|
+
raise TypeError(
|
|
521
|
+
"Only @dataclass are supported!, "
|
|
522
|
+
f"got {target_type!r}"
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
graph_roots = tuple(sorted(set(roots), key=id))
|
|
526
|
+
graph = _wrapped_lru(graph_roots, jso_id)
|
|
527
|
+
|
|
528
|
+
return _rewrite_type(
|
|
529
|
+
target_type,
|
|
530
|
+
graph[roots[0]],
|
|
531
|
+
graph,
|
|
532
|
+
resolve=True,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def wire_type_for(target_type: type, jso: JsonSerializerOptions) -> type:
|
|
537
|
+
jso_id = jso.fingerprint
|
|
538
|
+
|
|
539
|
+
with _registry_lock:
|
|
540
|
+
_jso_registry[jso_id] = jso
|
|
541
|
+
|
|
542
|
+
return _cached_wire_type(target_type, jso_id)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
@lru_cache(maxsize=256)
|
|
546
|
+
def _get_decoder(wire_type: Any):
|
|
547
|
+
return msgspec.json.Decoder(type=wire_type)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
_JSON_ENCODER = msgspec.json.Encoder()
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class JsonConvert:
|
|
554
|
+
|
|
555
|
+
@staticmethod
|
|
556
|
+
def deserialize[T](
|
|
557
|
+
data: bytes | str,
|
|
558
|
+
target: type[T],
|
|
559
|
+
jso: JsonSerializerOptions | None = None
|
|
560
|
+
) -> T:
|
|
561
|
+
if jso is None:
|
|
562
|
+
jso = JsonSerializerOptions()
|
|
563
|
+
|
|
564
|
+
wire_type = wire_type_for(target, jso)
|
|
565
|
+
parsed = _get_decoder(wire_type).decode(data)
|
|
566
|
+
|
|
567
|
+
if jso.native:
|
|
568
|
+
return parsed
|
|
569
|
+
|
|
570
|
+
result = msgspec.convert(
|
|
571
|
+
parsed,
|
|
572
|
+
target,
|
|
573
|
+
from_attributes=True,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
return result
|
|
577
|
+
|
|
578
|
+
@staticmethod
|
|
579
|
+
def serialize[T](
|
|
580
|
+
data: Any,
|
|
581
|
+
target: type[T],
|
|
582
|
+
jso: JsonSerializerOptions | None = None
|
|
583
|
+
) -> str:
|
|
584
|
+
return JsonConvert.serialize_bytes(data, target, jso).decode("utf-8")
|
|
585
|
+
|
|
586
|
+
@staticmethod
|
|
587
|
+
def serialize_bytes[T](
|
|
588
|
+
data: Any,
|
|
589
|
+
target: type[T],
|
|
590
|
+
jso: JsonSerializerOptions | None = None
|
|
591
|
+
) -> bytes:
|
|
592
|
+
if jso is None:
|
|
593
|
+
jso = JsonSerializerOptions()
|
|
594
|
+
|
|
595
|
+
wire_type = wire_type_for(target, jso)
|
|
596
|
+
wire_obj = msgspec.convert(data, wire_type, from_attributes=True)
|
|
597
|
+
return _JSON_ENCODER.encode(wire_obj)
|
|
598
|
+
|
|
599
|
+
@staticmethod
|
|
600
|
+
def serialize_to[T](
|
|
601
|
+
it: Iterable[T],
|
|
602
|
+
target: type[T],
|
|
603
|
+
output: ByteWriter | TextWriter,
|
|
604
|
+
jso: JsonSerializerOptions | None = None
|
|
605
|
+
) -> None:
|
|
606
|
+
if jso is None:
|
|
607
|
+
jso = JsonSerializerOptions()
|
|
608
|
+
|
|
609
|
+
if isinstance(output, ByteWriter):
|
|
610
|
+
write = output.write
|
|
611
|
+
elif isinstance(output, TextWriter):
|
|
612
|
+
def write(data: bytes | bytearray) -> int:
|
|
613
|
+
return output.write(data.decode("utf-8"))
|
|
614
|
+
else:
|
|
615
|
+
raise TypeError("output must be ByteWriter or TextWriter")
|
|
616
|
+
|
|
617
|
+
first = True
|
|
618
|
+
wire_type = wire_type_for(target, jso)
|
|
619
|
+
|
|
620
|
+
write(b"[")
|
|
621
|
+
|
|
622
|
+
for item in iter(it):
|
|
623
|
+
if not first:
|
|
624
|
+
write(b",")
|
|
625
|
+
|
|
626
|
+
wire_obj = msgspec.convert(item, wire_type, from_attributes=True)
|
|
627
|
+
write(_JSON_ENCODER.encode(wire_obj))
|
|
628
|
+
first = False
|
|
629
|
+
|
|
630
|
+
write(b']')
|
|
631
|
+
|
|
632
|
+
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: flpit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1.dev4
|
|
4
4
|
Summary: Fluent LINQ for Python — flip your iterables
|
|
5
|
+
Project-URL: Homepage, https://github.com/riemar/flpit
|
|
6
|
+
Project-URL: Repository, https://github.com/riemar/flpit
|
|
5
7
|
License: MIT
|
|
6
8
|
License-File: LICENSE
|
|
7
9
|
Keywords: fluent,generators,iterable,lazy-evaluation,linq,pipeline,pyrefly,type-safe
|
|
@@ -9,13 +11,13 @@ Classifier: Development Status :: 3 - Alpha
|
|
|
9
11
|
Classifier: Intended Audience :: Developers
|
|
10
12
|
Classifier: License :: OSI Approved :: MIT License
|
|
11
13
|
Classifier: Programming Language :: Python :: 3
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
13
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
16
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
18
|
Classifier: Typing :: Typed
|
|
18
|
-
Requires-Python: >=3.
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: msgspec>=0.22.0
|
|
19
21
|
Description-Content-Type: text/markdown
|
|
20
22
|
|
|
21
23
|
# FlpIt
|
|
@@ -369,16 +371,18 @@ Distributed under the MIT License. See `LICENSE` for more information.
|
|
|
369
371
|
| ├── test_all.py | 80 |
|
|
370
372
|
| ├── test_any.py | 90 |
|
|
371
373
|
| ├── test_average.py | 572 |
|
|
374
|
+
| ├── test_concat.py | 64 |
|
|
372
375
|
| ├── test_count.py | 37 |
|
|
373
376
|
| ├── test_generic_list.py | 95 |
|
|
374
377
|
| ├── test_last.py | 29 |
|
|
375
378
|
| ├── test_order_by.py | 100 |
|
|
376
379
|
| ├── test_order_descending_by.py | 45 |
|
|
380
|
+
| ├── test_skip.py | 75 |
|
|
377
381
|
| ├── test_sum.py | 134 |
|
|
378
382
|
| ├── test_take.py | 39 |
|
|
379
383
|
| ├── test_then_by.py | 40 |
|
|
380
384
|
| └── test_then_descending_by.py | 34 |
|
|
381
385
|
----------------------------------------------------------------
|
|
382
|
-
| GRAND TOTALS |
|
|
386
|
+
| GRAND TOTALS | 1434 |
|
|
383
387
|
================================================================
|
|
384
388
|
```
|
|
@@ -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,10 +0,0 @@
|
|
|
1
|
-
flp/__init__.py,sha256=reAHhZtYt0f2LHeIYJbklta6Vatr6yeQCL1R-9TTyY0,688
|
|
2
|
-
flpit/__init__.py,sha256=T8tdJmfJiYiBuUszbACGan0U1meuBQa7IQpfIncADLA,705
|
|
3
|
-
flpit/flp.py,sha256=i-FbnMeO92cxNRssYsgdlm2UGjILpveXgYUT468fWvY,999
|
|
4
|
-
flpit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
flpit/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
flpit/core/linq.py,sha256=S4rbqA4UTIze_NOeSKLJMm1gWcWWS_voHdHhEVmlZ8U,37560
|
|
7
|
-
flpit-0.2.0.dev2.dist-info/METADATA,sha256=IgRIlar-JXbxGK2Dt1Jcj8Sbf_GZNmp6X3yl9JjLJaU,12813
|
|
8
|
-
flpit-0.2.0.dev2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
-
flpit-0.2.0.dev2.dist-info/licenses/LICENSE,sha256=bi5Vg_YP3Oq3jaOMQT4b7WZu0NfBcZr-nRyOExUKJrQ,1063
|
|
10
|
-
flpit-0.2.0.dev2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|