flpit 0.2.0.dev2__py3-none-any.whl → 0.2.1.dev3__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 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,3 @@
1
- from __future__ import annotations
2
- from typing import SupportsIndex
3
1
  import decimal
4
2
  from decimal import Decimal, Overflow, Inexact
5
3
 
@@ -15,15 +13,21 @@ from typing import (
15
13
  Iterable,
16
14
  Iterator,
17
15
  Collection,
16
+ SupportsIndex,
18
17
  List,
19
18
  Set,
20
19
  Type,
21
20
  TypeVar,
21
+ ClassVar,
22
22
  Union,
23
23
  override,
24
24
  overload,
25
25
  cast as typing_cast,
26
+ GenericAlias
26
27
  )
28
+
29
+ from flpit.json import JsonSerializerOptions, decode_dataclass, encode_dataclass
30
+
27
31
  TItem = TypeVar("TItem")
28
32
  TResult = TypeVar("TResult")
29
33
  TKey = TypeVar("TKey")
@@ -39,38 +43,70 @@ class _Sentinel:
39
43
  _SENTINEL = _Sentinel()
40
44
  _MISSING = object()
41
45
 
46
+ # =============================================================================
47
+ # Invalid Operation Errors
48
+ # =============================================================================
49
+ class InvalidOperationError(ValueError):
50
+ pass
42
51
 
43
- class EmptySequenceError(ValueError):
52
+ class EmptySequenceError(InvalidOperationError):
44
53
  def __init__(self):
45
54
  super().__init__("Sequence contains no elements")
46
55
 
47
- class NoMatchError(ValueError):
56
+ class NoMatchError(InvalidOperationError):
48
57
  def __init__(self):
49
58
  super().__init__("Sequence contains no matching elements")
50
59
 
51
- class MultipleMatchesError(ValueError):
60
+ class MultipleMatchesError(InvalidOperationError):
52
61
  def __init__(self):
53
62
  super().__init__("Sequence contains more than one matching element")
54
63
 
55
- class MultipleElementsError(ValueError):
64
+ class MultipleElementsError(InvalidOperationError):
56
65
  def __init__(self):
57
66
  super().__init__("Sequence contains more than one element")
58
67
 
59
- class SourceNoneError(TypeError):
68
+
69
+ # =============================================================================
70
+ # Argument Errors
71
+ # =============================================================================
72
+ class ArgumentError(TypeError):
73
+ pass
74
+
75
+ class ArgumentNullError(ArgumentError):
76
+ def __init__(self, argument: str):
77
+ super().__init__(f"Value cannot be None. (Argument '{argument}')")
78
+
79
+ class SourceNoneError(ArgumentNullError):
60
80
  def __init__(self):
61
- super().__init__("Value cannot be None. (Argument 'source')")
81
+ super().__init__("source")
62
82
 
63
- class PredicateNoneError(TypeError):
83
+ class PredicateNoneError(ArgumentNullError):
64
84
  def __init__(self):
65
- super().__init__("Value cannot be None. (Argument 'predicate')")
85
+ super().__init__("predicate")
66
86
 
67
- class CollectionNoneError(TypeError):
87
+ class CollectionNoneError(ArgumentNullError):
68
88
  def __init__(self):
69
- super().__init__("Value cannot be None. (Argument 'collection')")
89
+ super().__init__("collection")
90
+
91
+ class SelectorNoneError(ArgumentNullError):
92
+ def __init__(self):
93
+ super().__init__("keySelector")
94
+
95
+ class SecondNoneError(ArgumentNullError):
96
+ def __init__(self):
97
+ super().__init__("second")
98
+
99
+ class ArgumentOutOfRangeError(ArgumentError):
100
+ def __init__(self, argument: str, pattern:str="Index was out of range. Must be non-negative and less than the size of the collection."):
101
+ super().__init__(f"{pattern} (Parameter '{argument}')")
102
+
103
+ class ArgumentNonNegError(ArgumentOutOfRangeError):
104
+ def __init__(self, argument:str):
105
+ super().__init__(argument, "Non-negative number required.")
70
106
 
71
- class SelectorNoneError(TypeError):
107
+ class ArgumentOutOfBoundsError(ArgumentError):
72
108
  def __init__(self):
73
- super().__init__("Value cannot be None. (Parameter 'keySelector')")
109
+ 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
110
 
75
111
 
76
112
  def _guard_empty(func: Callable[..., Any]) -> Callable[..., Any]:
@@ -191,6 +227,9 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
191
227
  return builtins.all(map(predicate, self))
192
228
 
193
229
  def concat(self, second: Iterable[TItem]) -> "FlpIt[TItem]":
230
+ if second is None:
231
+ raise SecondNoneError()
232
+
194
233
  def _generator() -> Iterator[TItem]:
195
234
  yield from self
196
235
  yield from second
@@ -211,8 +250,8 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
211
250
  for item in self:
212
251
  if predicate(item):
213
252
  yield item
214
-
215
- return typing_cast(FlpIt[TItem], FlpIt(_FactoryIterable(_generator)))
253
+ # return typing_cast(FlpIt[TItem], FlpIt[TItem](_FactoryIterable(_generator)))
254
+ return FlpIt[TItem](_FactoryIterable(_generator))
216
255
 
217
256
  def select(self, selector: Callable[[TItem], TResult]) -> FlpIt[TResult]:
218
257
  """Projects each element into a new form."""
@@ -232,6 +271,18 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
232
271
 
233
272
  return FlpIt(_FactoryIterable(_generator))
234
273
 
274
+ def skip(self, count: int) -> "FlpIt[TItem]":
275
+ """
276
+ |Bypasses a specified number of elements from the start.
277
+ """
278
+ if count <= 0:
279
+ return self
280
+
281
+ def _generator() -> Iterator[TItem]:
282
+ yield from islice(iter(self), count, None)
283
+
284
+ return FlpIt(_FactoryIterable(_generator))
285
+
235
286
  def take(self, count: int) -> "FlpIt[TItem]":
236
287
  """
237
288
  |Returns a specified number of contiguous elements from the start.
@@ -528,11 +579,13 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
528
579
  def element_at(self, index: int) -> TItem:
529
580
  """Returns the element at a specified index in a sequence."""
530
581
  if index < 0:
531
- raise IndexError("Index out of range")
582
+ raise ArgumentOutOfRangeError("index")
583
+
532
584
  for i, item in enumerate(self):
533
585
  if i == index:
534
586
  return item
535
- raise IndexError("Index out of range")
587
+
588
+ raise ArgumentOutOfRangeError("index")
536
589
 
537
590
  def first(self, predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL) -> TItem:
538
591
  """Returns the first element matching a predicate, or raises ValueError."""
@@ -568,10 +621,10 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
568
621
 
569
622
  raise MultipleMatchesError() if has_predicate else MultipleElementsError()
570
623
 
571
-
572
624
  def to_list(self) -> FlpList[TItem]:
573
625
  """Explicitly materializes the query into a FlpList."""
574
- return FlpList(self)
626
+ return FlpList[TItem](self)
627
+
575
628
 
576
629
  from functools import partial
577
630
 
@@ -682,6 +735,8 @@ class FlpList(Collection[TItem], Generic[TItem]):
682
735
  | Fluent List
683
736
  A materialized list extending UserList that yields lazy FlpIt instances for query operations.
684
737
  """
738
+ __type_args__: ClassVar[tuple[Any, ...]]
739
+
685
740
  def __init__(self, source: Iterable[TItem] | _Sentinel=_SENTINEL):
686
741
  if source is None:
687
742
  raise SourceNoneError()
@@ -830,7 +885,7 @@ class FlpList(Collection[TItem], Generic[TItem]):
830
885
 
831
886
  def insert(self, index: int, item: TItem) -> None:
832
887
  if index < 0 or index > len(self.__list.data):
833
- raise IndexError(f"Index {index} is out of range.")
888
+ raise ArgumentOutOfRangeError("index")
834
889
 
835
890
  self.__list.data.insert(index, item)
836
891
 
@@ -839,7 +894,7 @@ class FlpList(Collection[TItem], Generic[TItem]):
839
894
  raise CollectionNoneError()
840
895
 
841
896
  if index < 0 or index > len(self.__list.data):
842
- raise IndexError(f"Index {index} is out of range.")
897
+ raise ArgumentOutOfRangeError("index")
843
898
 
844
899
  self.__list.data[index:index] = list(collection)
845
900
 
@@ -865,22 +920,22 @@ class FlpList(Collection[TItem], Generic[TItem]):
865
920
  return original_length - len(self.__list.data)
866
921
 
867
922
  def remove_at(self, index: int) -> None:
868
- if index < 0 or index > len(self.__list.data):
869
- raise IndexError(f"Index {index} is out of range.")
923
+ if index < 0 or index >= len(self.__list.data):
924
+ raise ArgumentOutOfRangeError("index")
870
925
 
871
926
  del self.__list.data[index]
872
927
 
873
928
  def remove_range(self, index: int, count: int) -> None:
874
929
  size = len(self.__list.data)
875
930
 
876
- if index < 0 or index > size:
877
- raise IndexError("index")
931
+ if index < 0:
932
+ raise ArgumentNonNegError("index")
878
933
 
879
934
  if count < 0:
880
- raise IndexError("count")
935
+ raise ArgumentNonNegError("count")
881
936
 
882
937
  if count > size - index:
883
- raise IndexError("The range is invalid.")
938
+ raise ArgumentOutOfBoundsError()
884
939
 
885
940
  del self.__list.data[index : index + count]
886
941
 
@@ -899,17 +954,13 @@ class FlpList(Collection[TItem], Generic[TItem]):
899
954
 
900
955
  # Apply your List<T>-compatible argument validation here.
901
956
  if index < 0 or index > size:
902
- raise IndexError("Non-negative number required.")
957
+ raise ArgumentNonNegError("index")
903
958
 
904
959
  if count < 0:
905
- raise IndexError("Non-negative number required.")
960
+ raise ArgumentNonNegError("count")
906
961
 
907
962
  if index + count > size:
908
- raise IndexError(
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
- )
963
+ raise ArgumentOutOfBoundsError()
913
964
 
914
965
  self.__list.data[index:index + count] = reversed(
915
966
  self.__list.data[index:index + count]
@@ -948,12 +999,15 @@ class FlpList(Collection[TItem], Generic[TItem]):
948
999
  """Prepends an element to the sequence lazily, returning a FlpIt without mutating this list."""
949
1000
  return FlpIt(self.__list.data).prepend(element)
950
1001
 
1002
+ def concat(self, second: Iterable[TItem]) -> FlpIt[TItem]:
1003
+ return FlpIt(self.__list.data).concat(second)
1004
+
951
1005
  # noinspection unused-parameter
952
1006
  def as_type(self, target_type: Type[TResult]) -> FlpList[TResult]:
953
1007
  return self # type: ignore[return-value]
954
1008
 
955
1009
  def where(self, predicate: Callable[[TItem], bool]) -> FlpIt[TItem]:
956
- return FlpIt(self.__list.data).where(predicate)
1010
+ return FlpIt[TItem](self.__list.data).where(predicate)
957
1011
 
958
1012
  def select(self, selector: Callable[[TItem], TResult]) -> FlpIt[TResult]:
959
1013
  return FlpIt(self.__list.data).select(selector)
@@ -963,6 +1017,9 @@ class FlpList(Collection[TItem], Generic[TItem]):
963
1017
  ) -> FlpIt[TResult]:
964
1018
  return FlpIt(self.__list.data).select_many(selector)
965
1019
 
1020
+ def skip(self, count: int) -> FlpIt[TItem]:
1021
+ return FlpIt(self.__list.data).skip(count)
1022
+
966
1023
  def take(self, count: int) -> FlpIt[TItem]:
967
1024
  return FlpIt(self.__list.data).take(count)
968
1025
 
@@ -1076,7 +1133,7 @@ class FlpList(Collection[TItem], Generic[TItem]):
1076
1133
 
1077
1134
  def element_at(self, index: int) -> TItem:
1078
1135
  if index < 0 or index >= len(self.__list.data):
1079
- raise IndexError("Index out of range")
1136
+ raise ArgumentOutOfRangeError("index")
1080
1137
  return self.__list.data[index]
1081
1138
 
1082
1139
  def first(self, predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL) -> TItem:
@@ -1128,3 +1185,19 @@ class FlpList(Collection[TItem], Generic[TItem]):
1128
1185
  def to_list(self) -> FlpList[TItem]:
1129
1186
  """Explicitly returns a shallow copy instance to isolate mutations."""
1130
1187
  return FlpList(self.__list.data.copy())
1188
+
1189
+ # =====================================================================
1190
+ # 5. json serialization
1191
+ # =====================================================================
1192
+
1193
+ @classmethod
1194
+ 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))
1196
+
1197
+ 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)
1199
+
1200
+ def to_json[T](self, item_type: type[T], jso: JsonSerializerOptions|None=None) -> str:
1201
+ return self.to_json_bytes(item_type, jso).decode(encoding="utf-8")
1202
+
1203
+
flpit/json.py ADDED
@@ -0,0 +1,427 @@
1
+ from typing import Annotated
2
+ import dataclasses
3
+ import re
4
+ import sys
5
+ import types
6
+ import typing
7
+
8
+ import threading
9
+ from dataclasses import MISSING, dataclass
10
+ from functools import lru_cache
11
+ from typing import Any, ForwardRef, get_args, get_origin, get_type_hints
12
+ from hashlib import blake2s
13
+
14
+ import msgspec
15
+
16
+ from enum import StrEnum
17
+
18
+ class JsonNamingPolicy(StrEnum):
19
+ lower_case = "lower"
20
+ upper_case = "upper"
21
+ camel_case = "camel"
22
+ pascal_case = "pascal"
23
+ kebap_case = "kebab"
24
+
25
+
26
+ @dataclass(slots=True, frozen=True)
27
+ class JsonSerializerOptions:
28
+ naming_policy: JsonNamingPolicy = JsonNamingPolicy.camel_case
29
+ write_indented: bool = False
30
+ native:bool=False
31
+ """
32
+ | Set to true for the native speed of msgspec
33
+
34
+ !! only set to true if you know what you are doing !!
35
+ You'll receive the msgspec.msgstruct object that will try to do it's best to behave like your dataclass.
36
+ But remember !! "It's NOT your dataclass" !!
37
+ """
38
+
39
+ @property
40
+ def fingerprint(self) -> str:
41
+ """
42
+ Identifies a unique jso instance for building a struct
43
+ !! All parts of a jso that don't influence the defstruct MUST be ignored!
44
+ """
45
+ settings = self.naming_policy.value
46
+ return blake2s(settings.encode(), digest_size=6).hexdigest()
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Metadata Annotation Markers
50
+ # ---------------------------------------------------------------------------
51
+
52
+ class JsonIgnore:
53
+ def __class_getitem__[T](cls, item: type[T]) -> Any:
54
+ return Annotated[item, cls]
55
+
56
+ class JsonInclude:
57
+ def __class_getitem__[T](cls, item: type[T]) -> Any:
58
+ return Annotated[item, cls]
59
+
60
+ class JsonProperty:
61
+ def __class_getitem__[T](cls, item: tuple[type[T], str]) -> Any:
62
+ target_type, json_name = item
63
+ return Annotated[target_type, cls(json_name)]
64
+
65
+ def __init__(self, name: str):
66
+ self.name = name
67
+
68
+ JIgnore = JsonIgnore
69
+ JInclude = JsonInclude
70
+ JProp = JsonProperty
71
+
72
+ # ---------------------------------------------------------------------
73
+ # Generated type metadata
74
+ # ---------------------------------------------------------------------
75
+
76
+ @dataclass(slots=True, eq=False)
77
+ class _WireType:
78
+ source: type
79
+ """The source type to build a msgspec.struct for"""
80
+ hints: dict[str, Any]
81
+ namespace: types.ModuleType
82
+ """the name space we are targeting"""
83
+ struct: type | None = None
84
+
85
+ @property
86
+ def module_name(self: _WireType) -> str:
87
+ return self.namespace.__name__
88
+
89
+ @property
90
+ def class_name(self: _WireType) -> str:
91
+ return self.source.__name__
92
+
93
+ @property
94
+ def ref_name(self: _WireType) -> str:
95
+ identity = (
96
+ f"{self.source.__module__}_{self.source.__qualname__}"
97
+ )
98
+ return "_ref_" + re.sub(r"\W", "_", identity)
99
+
100
+
101
+ # ---------------------------------------------------------------------
102
+ # Module and annotation utilities
103
+ # ---------------------------------------------------------------------
104
+
105
+ def _generated_module(source: type) -> types.ModuleType:
106
+ """Generates the required module definition for our runtime only dynamic msgspec.struct"""
107
+ name = f"flpit.json._generated.{source.__module__}"
108
+
109
+ module = sys.modules.get(name)
110
+ if module is None:
111
+ module = types.ModuleType(name)
112
+ sys.modules[name] = module
113
+
114
+ return module
115
+
116
+
117
+ def _is_dataclass_type(tp: Any) -> bool:
118
+ return isinstance(tp, type) and dataclasses.is_dataclass(tp)
119
+
120
+
121
+ def _nested_dataclasses(tp: Any):
122
+ """Yield dataclass types referenced by an annotation."""
123
+ if _is_dataclass_type(tp):
124
+ yield tp
125
+ return
126
+
127
+ if isinstance(tp, (list, tuple)):
128
+ for item in tp:
129
+ yield from _nested_dataclasses(item)
130
+ return
131
+
132
+ origin = get_origin(tp)
133
+
134
+ if origin is typing.Annotated:
135
+ yield from _nested_dataclasses(get_args(tp)[0])
136
+ return
137
+
138
+ if origin is typing.Literal:
139
+ return
140
+
141
+ for arg in get_args(tp):
142
+ yield from _nested_dataclasses(arg)
143
+
144
+
145
+ def _rewrite_type(
146
+ tp: Any,
147
+ owner: _WireType,
148
+ graph: dict[type, _WireType],
149
+ *,
150
+ resolve: bool = False,
151
+ ) -> Any:
152
+ """Replace source dataclasses with generated Structs or ForwardRefs."""
153
+ if tp is getattr(typing, "Self", None):
154
+ info = owner
155
+ return info.struct if resolve else ForwardRef(info.ref_name)
156
+
157
+ if _is_dataclass_type(tp):
158
+ info = graph[tp]
159
+ return info.struct if resolve else ForwardRef(info.ref_name)
160
+
161
+ if isinstance(tp, list):
162
+ return [
163
+ _rewrite_type(item, owner, graph, resolve=resolve)
164
+ for item in tp
165
+ ]
166
+
167
+ if isinstance(tp, tuple):
168
+ return tuple(
169
+ _rewrite_type(item, owner, graph, resolve=resolve)
170
+ for item in tp
171
+ )
172
+
173
+ origin = get_origin(tp)
174
+
175
+ if origin is None or origin is typing.Literal:
176
+ return tp
177
+
178
+ args = get_args(tp)
179
+
180
+ if origin is typing.Annotated:
181
+ base = _rewrite_type(
182
+ args[0], owner, graph, resolve=resolve
183
+ )
184
+ return typing.Annotated[(base, *args[1:])]
185
+
186
+ rewritten: tuple[Any, ...] = tuple(
187
+ _rewrite_type(arg, owner, graph, resolve=resolve)
188
+ for arg in args
189
+ )
190
+
191
+ if origin in (typing.Union, types.UnionType):
192
+ union: Any = typing.Union # with all the magic here, there's no way to make typing really happy
193
+ return union[rewritten]
194
+
195
+ params = rewritten[0] if len(rewritten) == 1 else rewritten
196
+
197
+ try:
198
+ return origin[params]
199
+ except (TypeError, AttributeError) as exc:
200
+ copier = getattr(tp, "copy_with", None)
201
+ if copier is not None:
202
+ return copier(rewritten)
203
+
204
+ raise TypeError(
205
+ f"Cannot rewrite annotation {tp!r}"
206
+ ) from exc
207
+
208
+
209
+ # ---------------------------------------------------------------------
210
+ # Graph discovery
211
+ # ---------------------------------------------------------------------
212
+
213
+ def _discover_graph(
214
+ roots: tuple[type, ...],
215
+ ) -> dict[type, _WireType]:
216
+ """Discover reachable dataclasses and resolve their annotations."""
217
+ graph: dict[type, _WireType] = {}
218
+ pending = list(roots)
219
+
220
+ while pending:
221
+ source = pending.pop()
222
+
223
+ if source in graph:
224
+ continue
225
+
226
+ info = _WireType(
227
+ source=source,
228
+ hints=get_type_hints(source, include_extras=True),
229
+ namespace=_generated_module(source),
230
+ )
231
+ graph[source] = info
232
+
233
+ for field in dataclasses.fields(source):
234
+ if not field.init:
235
+ continue
236
+
237
+ pending.extend(
238
+ _nested_dataclasses(info.hints[field.name])
239
+ )
240
+
241
+ return graph
242
+
243
+ def _allows_none(tp: Any) -> bool:
244
+ if get_origin(tp) is typing.Annotated:
245
+ tp = get_args(tp)[0]
246
+
247
+ return type(None) in get_args(tp)
248
+
249
+ # ---------------------------------------------------------------------
250
+ # Struct construction
251
+ # ---------------------------------------------------------------------
252
+
253
+ def _struct_fields(
254
+ info: _WireType,
255
+ graph: dict[type, _WireType],
256
+ ) -> list[tuple[Any, ...]]:
257
+ definitions: list[tuple[Any, ...]] = []
258
+
259
+ for field in dataclasses.fields(info.source):
260
+ if not field.init:
261
+ continue
262
+
263
+ annotation = _rewrite_type(
264
+ info.hints[field.name],
265
+ info,
266
+ graph,
267
+ )
268
+
269
+ if field.default is not MISSING:
270
+ spec = (field.name, annotation, field.default)
271
+
272
+ elif field.default_factory is not MISSING:
273
+ spec = (
274
+ field.name,
275
+ annotation,
276
+ msgspec.field(
277
+ default_factory=field.default_factory
278
+ ),
279
+ )
280
+
281
+ # for now... no enforcing of explicit null in json
282
+ elif _allows_none(info.hints[field.name]):
283
+ spec = (field.name, annotation, None)
284
+
285
+ else:
286
+ spec = (field.name, annotation)
287
+
288
+ definitions.append(spec)
289
+
290
+ return definitions
291
+
292
+
293
+ def _register_forward_refs(
294
+ graph: dict[type, _WireType],
295
+ ) -> None:
296
+ """Expose every graph reference in each participating module."""
297
+ namespaces = []
298
+
299
+ for info in graph.values():
300
+ if not any(info.namespace is ns for ns in namespaces):
301
+ namespaces.append(info.namespace)
302
+
303
+ for namespace in namespaces:
304
+ for info in graph.values():
305
+ setattr(namespace, info.ref_name, info.struct)
306
+
307
+ def _behavior_namespace(source: type) -> dict[str, Any]:
308
+ return {
309
+ name: value
310
+ for name, value in vars(source).items()
311
+ if (
312
+ not name.startswith("__")
313
+ and callable(value)
314
+ )
315
+ # pyrefly: ignore [implicit-any-lambda]
316
+ } | { "__class__": property(lambda self, cls=source: cls) }
317
+
318
+ def _build_graph(
319
+ roots: tuple[type, ...],
320
+ jso: JsonSerializerOptions,
321
+ ) -> dict[type, _WireType]:
322
+ graph = _discover_graph(roots)
323
+
324
+ for wt in graph.values():
325
+ wt.struct = msgspec.defstruct(
326
+ wt.class_name,
327
+ _struct_fields(wt, graph),
328
+ module=wt.module_name,
329
+ namespace=_behavior_namespace(wt.source),
330
+ rename=jso.naming_policy.value,
331
+ kw_only=True,
332
+ omit_defaults=True, # don't render nulls
333
+ forbid_unknown_fields=False, # ensure ignoring unknow fields!
334
+ )
335
+
336
+ _register_forward_refs(graph)
337
+
338
+ for wt in graph.values():
339
+ msgspec.inspect.type_info(wt.struct)
340
+
341
+ # Publish the readable generated class names.
342
+ for wt in graph.values():
343
+ setattr(wt.namespace, wt.class_name, wt.struct)
344
+
345
+ return graph
346
+
347
+
348
+ # ---------------------------------------------------------------------
349
+ # Cache and public API
350
+ # ---------------------------------------------------------------------
351
+
352
+ # we must only build new when the
353
+ _jso_registry: dict[str, JsonSerializerOptions] = {}
354
+ _registry_lock = threading.Lock()
355
+
356
+ @lru_cache(maxsize=None)
357
+ def _wrapped_lru(roots: tuple[type, ...], jso_id: str):
358
+ with _registry_lock:
359
+ jso = _jso_registry[jso_id]
360
+ return _build_graph(roots, jso)
361
+
362
+ def _cached_graph(roots:tuple[object, ...], jso: JsonSerializerOptions):
363
+ with _registry_lock:
364
+ _jso_registry[jso.fingerprint] = jso
365
+
366
+ return _wrapped_lru(roots, jso.fingerprint)
367
+
368
+ def wire_type_for(
369
+ target_type: type,
370
+ jso: JsonSerializerOptions,
371
+ ):
372
+ roots = tuple(_nested_dataclasses(target_type))
373
+
374
+ if not roots:
375
+ raise TypeError(
376
+ "Only @dataclass are supported!, "
377
+ f"got {target_type!r}"
378
+ )
379
+
380
+ # Normalize the root set so User and list[User] share the graph.
381
+ graph_roots = tuple(sorted(set(roots), key=id))
382
+
383
+ graph = _cached_graph(
384
+ graph_roots,
385
+ jso,
386
+ )
387
+
388
+ return _rewrite_type(
389
+ target_type,
390
+ graph[roots[0]],
391
+ graph,
392
+ resolve=True,
393
+ )
394
+
395
+
396
+ def decode_dataclass(
397
+ data: bytes | str,
398
+ target: type,
399
+ jso: JsonSerializerOptions|None=None
400
+ ):
401
+ if jso is None:
402
+ jso = JsonSerializerOptions()
403
+
404
+ wire_type = wire_type_for(target, jso)
405
+ parsed = msgspec.json.decode(data, type=wire_type)
406
+
407
+ if jso.native:
408
+ return parsed
409
+
410
+ return msgspec.convert(
411
+ parsed,
412
+ target,
413
+ from_attributes=True,
414
+ )
415
+
416
+ def encode_dataclass[T](
417
+ data: Any,
418
+ target: type[T],
419
+ jso: JsonSerializerOptions|None=None
420
+ ) -> bytes:
421
+ if jso is None:
422
+ jso = JsonSerializerOptions()
423
+
424
+ wire_type = wire_type_for(target, jso)
425
+ wire_obj = msgspec.convert(data, wire_type, from_attributes=True)
426
+ return msgspec.json.encode(wire_obj)
427
+
@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flpit
3
- Version: 0.2.0.dev2
3
+ Version: 0.2.1.dev3
4
4
  Summary: Fluent LINQ for Python — flip your iterables
5
+ Project-URL: Homepage, https://github.com/riemar/flpit
5
6
  License: MIT
6
7
  License-File: LICENSE
7
8
  Keywords: fluent,generators,iterable,lazy-evaluation,linq,pipeline,pyrefly,type-safe
@@ -15,7 +16,8 @@ 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.11
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 | 1295 |
386
+ | GRAND TOTALS | 1434 |
383
387
  ================================================================
384
388
  ```
@@ -0,0 +1,11 @@
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,,
@@ -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,,