flpit 0.2.0__tar.gz → 0.2.1.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flpit
3
- Version: 0.2.0
3
+ Version: 0.2.1.dev3
4
4
  Summary: Fluent LINQ for Python — flip your iterables
5
5
  Project-URL: Homepage, https://github.com/riemar/flpit
6
6
  License: MIT
@@ -16,7 +16,8 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Classifier: Typing :: Typed
19
- Requires-Python: >=3.11
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: msgspec>=0.22.0
20
21
  Description-Content-Type: text/markdown
21
22
 
22
23
  # FlpIt
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "flpit"
3
- version = "0.2.0"
3
+ version = "0.2.1.dev3"
4
4
  description = "Fluent LINQ for Python — flip your iterables"
5
5
  readme = "README.md"
6
- requires-python = ">=3.11"
6
+ requires-python = ">=3.12"
7
7
  license = { text = "MIT" }
8
8
  keywords = ["linq", "fluent", "iterable", "pipeline", "lazy-evaluation", "pyrefly", "type-safe", "generators"]
9
9
  classifiers = [
@@ -18,7 +18,9 @@ classifiers = [
18
18
  "Topic :: Software Development :: Libraries :: Python Modules",
19
19
  "Typing :: Typed"
20
20
  ]
21
- dependencies = []
21
+ dependencies = [
22
+ "msgspec>=0.22.0",
23
+ ]
22
24
 
23
25
  [project.urls]
24
26
  Homepage = "https://github.com/riemar/flpit"
@@ -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")
@@ -246,8 +250,8 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
246
250
  for item in self:
247
251
  if predicate(item):
248
252
  yield item
249
-
250
- return typing_cast(FlpIt[TItem], FlpIt(_FactoryIterable(_generator)))
253
+ # return typing_cast(FlpIt[TItem], FlpIt[TItem](_FactoryIterable(_generator)))
254
+ return FlpIt[TItem](_FactoryIterable(_generator))
251
255
 
252
256
  def select(self, selector: Callable[[TItem], TResult]) -> FlpIt[TResult]:
253
257
  """Projects each element into a new form."""
@@ -617,10 +621,10 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
617
621
 
618
622
  raise MultipleMatchesError() if has_predicate else MultipleElementsError()
619
623
 
620
-
621
624
  def to_list(self) -> FlpList[TItem]:
622
625
  """Explicitly materializes the query into a FlpList."""
623
- return FlpList(self)
626
+ return FlpList[TItem](self)
627
+
624
628
 
625
629
  from functools import partial
626
630
 
@@ -731,6 +735,8 @@ class FlpList(Collection[TItem], Generic[TItem]):
731
735
  | Fluent List
732
736
  A materialized list extending UserList that yields lazy FlpIt instances for query operations.
733
737
  """
738
+ __type_args__: ClassVar[tuple[Any, ...]]
739
+
734
740
  def __init__(self, source: Iterable[TItem] | _Sentinel=_SENTINEL):
735
741
  if source is None:
736
742
  raise SourceNoneError()
@@ -1001,7 +1007,7 @@ class FlpList(Collection[TItem], Generic[TItem]):
1001
1007
  return self # type: ignore[return-value]
1002
1008
 
1003
1009
  def where(self, predicate: Callable[[TItem], bool]) -> FlpIt[TItem]:
1004
- return FlpIt(self.__list.data).where(predicate)
1010
+ return FlpIt[TItem](self.__list.data).where(predicate)
1005
1011
 
1006
1012
  def select(self, selector: Callable[[TItem], TResult]) -> FlpIt[TResult]:
1007
1013
  return FlpIt(self.__list.data).select(selector)
@@ -1179,3 +1185,19 @@ class FlpList(Collection[TItem], Generic[TItem]):
1179
1185
  def to_list(self) -> FlpList[TItem]:
1180
1186
  """Explicitly returns a shallow copy instance to isolate mutations."""
1181
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
+
@@ -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
+
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes