flpit 0.2.1.dev8__tar.gz → 0.2.3__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,9 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flpit
3
- Version: 0.2.1.dev8
3
+ Version: 0.2.3
4
4
  Summary: Fluent LINQ for Python — flip your iterables
5
5
  Project-URL: Homepage, https://github.com/riemar/flpit
6
6
  Project-URL: Repository, https://github.com/riemar/flpit
7
+ Project-URL: Issues, https://github.com/riemar/flpit/issues
7
8
  License: MIT
8
9
  License-File: LICENSE
9
10
  Keywords: fluent,generators,iterable,lazy-evaluation,linq,pipeline,pyrefly,type-safe
@@ -153,6 +154,52 @@ print(list(backend_engineers))
153
154
  # ['Alice']
154
155
  ```
155
156
 
157
+ ## JSON Serialization
158
+
159
+ FlpIt provides typed JSON serialization and deserialization through `flpit.json.JsonConvert`,
160
+ backed by [`msgspec`](https://github.com/jcrist/msgspec).
161
+
162
+ It supports dataclasses, nested models, generic containers, configurable property naming, and explicit field metadata.
163
+
164
+ ```python
165
+ from dataclasses import dataclass
166
+ from typing import Annotated
167
+
168
+ from flpit import FlpList
169
+ from flpit.json import JProp, JIgnore, JsonConvert
170
+
171
+ @dataclass
172
+ class Person:
173
+ first_name: str
174
+ internal_id: Annotated[str, JProp("id")]
175
+ password: Annotated[str, JIgnore] = ""
176
+
177
+
178
+ person = Person("Ada", "usr-42", "secret")
179
+ raw = JsonConvert.serialize(person, Person)
180
+ # {"firstName":"Ada","id":"usr-42"}
181
+
182
+ people_json = """
183
+ [
184
+ {"firstName": "Ada", "id": "usr-42"},
185
+ {"firstName": "Grace", "id": "usr-43"}
186
+ ]
187
+ """
188
+
189
+ people: FlpList[Person] = FlpList.from_json(people_json, Person)
190
+ ```
191
+
192
+ ### Mapping and conversion
193
+
194
+ - **Naming policies:** Configure automatic property naming with `JsonSerializerOptions`. The default is camel case.
195
+ - **Explicit metadata:** `JProp` assigns a specific JSON property name, `JIgnore` excludes a field, and `JInclude` explicitly includes otherwise excluded underscore-prefixed fields.
196
+ - **Serialization options:** Control default-value omission and whether unknown JSON fields are accepted or rejected.
197
+ - **Typed collections:** Pass the complete target type, such as `list[Person]`, so nested and generic values can be decoded against the intended schema.
198
+
199
+ For large outputs, `JsonConvert.serialize_to()` writes a JSON array to a file-like stream without first materializing the entire serialized document.
200
+
201
+ By default, deserialization returns instances of the requested model type. Setting `native=True` opts into the generated `msgspec.Struct` representation instead, trading the original model type for the native representation and its performance characteristics.
202
+
156
203
  ## 🔬 LINQ Semantics, Python Runtime
157
204
 
158
205
  `flpit` deliberately separates the two:
@@ -348,6 +395,21 @@ to_list ← terminal materialization
348
395
 
349
396
  Operations such as `order_by()` necessarily materialize their input internally to establish ordering.
350
397
 
398
+ ## ⚙️ Task Runtime
399
+
400
+ FlpIt includes a lightweight, `asyncio`-free task runtime for scheduling and coordinating concurrent work, with synchronous callables executed through a bounded worker pool.
401
+
402
+ Key features:
403
+
404
+ - **Task scheduling:** `Task.run()` schedules coroutines, awaitables, and synchronous callables.
405
+ - **Task coordination:** `Task.when_all()` waits for all tasks while preserving input order; `Task.when_any()` returns the first completed task; `Task.collect()` yields tasks as they complete.
406
+ - **Cancellation:** `CancellationToken` and `CancellationTokenSource` support cooperative cancellation, cancellation callbacks, timeouts, and linked cancellation tokens.
407
+ - **Resource management:** Configure worker capacity and pending background-work limits, handle queue saturation explicitly, and shut down the process-wide runtime with an observable shutdown report.
408
+
409
+ The runtime starts lazily and manages its own lifecycle. It is deliberately not an `asyncio` compatibility layer; cancellation of running synchronous functions is cooperative.
410
+
411
+ See the [Task Runtime examples](https://github.com/riemar/flpit/tree/main/examples/task) for usage examples.
412
+
351
413
  ## ⚙️ Installation
352
414
 
353
415
  ```bash
@@ -130,6 +130,52 @@ print(list(backend_engineers))
130
130
  # ['Alice']
131
131
  ```
132
132
 
133
+ ## JSON Serialization
134
+
135
+ FlpIt provides typed JSON serialization and deserialization through `flpit.json.JsonConvert`,
136
+ backed by [`msgspec`](https://github.com/jcrist/msgspec).
137
+
138
+ It supports dataclasses, nested models, generic containers, configurable property naming, and explicit field metadata.
139
+
140
+ ```python
141
+ from dataclasses import dataclass
142
+ from typing import Annotated
143
+
144
+ from flpit import FlpList
145
+ from flpit.json import JProp, JIgnore, JsonConvert
146
+
147
+ @dataclass
148
+ class Person:
149
+ first_name: str
150
+ internal_id: Annotated[str, JProp("id")]
151
+ password: Annotated[str, JIgnore] = ""
152
+
153
+
154
+ person = Person("Ada", "usr-42", "secret")
155
+ raw = JsonConvert.serialize(person, Person)
156
+ # {"firstName":"Ada","id":"usr-42"}
157
+
158
+ people_json = """
159
+ [
160
+ {"firstName": "Ada", "id": "usr-42"},
161
+ {"firstName": "Grace", "id": "usr-43"}
162
+ ]
163
+ """
164
+
165
+ people: FlpList[Person] = FlpList.from_json(people_json, Person)
166
+ ```
167
+
168
+ ### Mapping and conversion
169
+
170
+ - **Naming policies:** Configure automatic property naming with `JsonSerializerOptions`. The default is camel case.
171
+ - **Explicit metadata:** `JProp` assigns a specific JSON property name, `JIgnore` excludes a field, and `JInclude` explicitly includes otherwise excluded underscore-prefixed fields.
172
+ - **Serialization options:** Control default-value omission and whether unknown JSON fields are accepted or rejected.
173
+ - **Typed collections:** Pass the complete target type, such as `list[Person]`, so nested and generic values can be decoded against the intended schema.
174
+
175
+ For large outputs, `JsonConvert.serialize_to()` writes a JSON array to a file-like stream without first materializing the entire serialized document.
176
+
177
+ By default, deserialization returns instances of the requested model type. Setting `native=True` opts into the generated `msgspec.Struct` representation instead, trading the original model type for the native representation and its performance characteristics.
178
+
133
179
  ## 🔬 LINQ Semantics, Python Runtime
134
180
 
135
181
  `flpit` deliberately separates the two:
@@ -325,6 +371,21 @@ to_list ← terminal materialization
325
371
 
326
372
  Operations such as `order_by()` necessarily materialize their input internally to establish ordering.
327
373
 
374
+ ## ⚙️ Task Runtime
375
+
376
+ FlpIt includes a lightweight, `asyncio`-free task runtime for scheduling and coordinating concurrent work, with synchronous callables executed through a bounded worker pool.
377
+
378
+ Key features:
379
+
380
+ - **Task scheduling:** `Task.run()` schedules coroutines, awaitables, and synchronous callables.
381
+ - **Task coordination:** `Task.when_all()` waits for all tasks while preserving input order; `Task.when_any()` returns the first completed task; `Task.collect()` yields tasks as they complete.
382
+ - **Cancellation:** `CancellationToken` and `CancellationTokenSource` support cooperative cancellation, cancellation callbacks, timeouts, and linked cancellation tokens.
383
+ - **Resource management:** Configure worker capacity and pending background-work limits, handle queue saturation explicitly, and shut down the process-wide runtime with an observable shutdown report.
384
+
385
+ The runtime starts lazily and manages its own lifecycle. It is deliberately not an `asyncio` compatibility layer; cancellation of running synchronous functions is cooperative.
386
+
387
+ See the [Task Runtime examples](https://github.com/riemar/flpit/tree/main/examples/task) for usage examples.
388
+
328
389
  ## ⚙️ Installation
329
390
 
330
391
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flpit"
3
- version = "0.2.1.dev8"
3
+ version = "0.2.3"
4
4
  description = "Fluent LINQ for Python — flip your iterables"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -25,6 +25,7 @@ dependencies = [
25
25
  [project.urls]
26
26
  Homepage = "https://github.com/riemar/flpit"
27
27
  Repository = "https://github.com/riemar/flpit"
28
+ Issues = "https://github.com/riemar/flpit/issues"
28
29
 
29
30
  [dependency-groups]
30
31
  dev = [
@@ -54,7 +55,7 @@ include = ["src/flp", "src/flpit"]
54
55
  [tool.pyrefly]
55
56
  project-includes = ["src"]
56
57
  project-excludes = ["**/__pycache__", "tests"]
57
- search-path = ["src"]
58
+ search-path = ["src", "typing"]
58
59
  preset = "strict"
59
60
 
60
61
  [tool.pytest.ini_options]
@@ -14,7 +14,6 @@ from itertools import islice
14
14
  from typing import (
15
15
  Any,
16
16
  Callable,
17
- Generic,
18
17
  Iterable,
19
18
  Iterator,
20
19
  Collection,
@@ -27,13 +26,14 @@ from typing import (
27
26
  Union,
28
27
  override,
29
28
  overload,
30
- cast as typing_cast, BinaryIO,
29
+ cast as typing_cast,
30
+ BinaryIO,
31
+ Concatenate,
32
+ Sized
31
33
  )
32
34
 
33
35
  from flpit.json import JsonConvert, JsonSerializerOptions
34
36
 
35
- TItem = TypeVar("TItem")
36
- TResult = TypeVar("TResult")
37
37
  TKey = TypeVar("TKey")
38
38
  TOther = TypeVar("TOther")
39
39
  TAccumulate = TypeVar("TAccumulate")
@@ -113,16 +113,24 @@ class ArgumentOutOfBoundsError(ArgumentError):
113
113
  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.")
114
114
 
115
115
 
116
- def _guard_empty(func: Callable[..., Any]) -> Callable[..., Any]:
117
- """Catches Python's native empty-sequence ValueError and re-raises LINQ-compliant error."""
116
+ def _guard_empty[SelfT: Sized, R, **P](
117
+ func: Callable[Concatenate[SelfT, P], R],
118
+ ) -> Callable[Concatenate[SelfT, P], R]:
119
+ """Translate empty-sequence ValueError into EmptySequenceError."""
120
+
118
121
  @wraps(func)
119
- def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
122
+ def wrapper(
123
+ self: SelfT,
124
+ *args: P.args,
125
+ **kwargs: P.kwargs,
126
+ ) -> R:
120
127
  try:
121
128
  return func(self, *args, **kwargs)
122
129
  except ValueError as e:
123
- if not self._FlpList__list.data:
130
+ if len(self) == 0:
124
131
  raise EmptySequenceError() from e
125
132
  raise
133
+
126
134
  return wrapper
127
135
 
128
136
 
@@ -136,7 +144,7 @@ def dotnet_context() -> AbstractContextManager[decimal.Context]:
136
144
  return decimal.localcontext(ctx)
137
145
 
138
146
 
139
- class _FactoryIterable(Iterable[TItem], Generic[TItem]):
147
+ class _FactoryIterable[TItem](Iterable[TItem]):
140
148
  """
141
149
  Wraps a generator factory function into a clean Iterable[T].
142
150
  Ensures __iter__ produces a brand-new iterator instance on every call
@@ -151,7 +159,7 @@ class _FactoryIterable(Iterable[TItem], Generic[TItem]):
151
159
  return self._factory()
152
160
 
153
161
 
154
- class FlpIt(Iterable[TItem], Generic[TItem]):
162
+ class FlpIt[TItem](Iterable[TItem]):
155
163
  """
156
164
  | Fluent Iterable
157
165
  Lazy evaluation wrapper around an iterable, inspired by .NET LINQ's
@@ -172,7 +180,7 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
172
180
  return iter(self._iterable)
173
181
 
174
182
  # noinspection unused-parameter
175
- def as_type(self, target_type: Type[TResult]) -> FlpIt[TResult]:
183
+ def as_type[TResult](self, target_type: Type[TResult]) -> FlpIt[TResult]:
176
184
  """Zero-cost static type hint update returning self directly without checks or allocations."""
177
185
  return self # type: ignore[return-value]
178
186
 
@@ -256,7 +264,7 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
256
264
  # return typing_cast(FlpIt[TItem], FlpIt[TItem](_FactoryIterable(_generator)))
257
265
  return FlpIt[TItem](_FactoryIterable(_generator))
258
266
 
259
- def select(self, selector: Callable[[TItem], TResult]) -> FlpIt[TResult]:
267
+ def select[TResult](self, selector: Callable[[TItem], TResult]) -> FlpIt[TResult]:
260
268
  """Projects each element into a new form."""
261
269
  def _generator() -> Iterator[TResult]:
262
270
  for item in self:
@@ -264,7 +272,7 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
264
272
 
265
273
  return FlpIt(_FactoryIterable(_generator))
266
274
 
267
- def select_many(
275
+ def select_many[TResult](
268
276
  self, selector: Callable[[TItem], Iterable[TResult]]
269
277
  ) -> FlpIt[TResult]:
270
278
  """Flattens sequence projections."""
@@ -309,7 +317,7 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
309
317
 
310
318
  return FlpIt(_FactoryIterable(_generator))
311
319
 
312
- def cast(self, target_type: Type[TResult]) -> FlpIt[TResult]:
320
+ def cast[TResult](self, target_type: Type[TResult]) -> FlpIt[TResult]:
313
321
  """Casts elements to a specified type or raises TypeError if cast fails."""
314
322
  def _generator() -> Iterator[TResult]:
315
323
  for item in self:
@@ -321,7 +329,7 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
321
329
 
322
330
  return FlpIt(_FactoryIterable(_generator))
323
331
 
324
- def of_type(self, target_type: Type[TResult]) -> FlpIt[TResult]:
332
+ def of_type[TResult](self, target_type: Type[TResult]) -> FlpIt[TResult]:
325
333
  """Filters the elements of an Iterable based on a specified type."""
326
334
  def _generator() -> Iterator[TResult]:
327
335
  for item in self:
@@ -359,7 +367,7 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
359
367
  def zip(self, second: Iterable[TOther]) -> FlpIt[tuple[TItem, TOther]]: ...
360
368
 
361
369
  @overload
362
- def zip(
370
+ def zip[TResult, TOther](
363
371
  self,
364
372
  second: Iterable[TOther],
365
373
  result_selector: Callable[[TItem, TOther], TResult],
@@ -597,7 +605,7 @@ class FlpIt(Iterable[TItem], Generic[TItem]):
597
605
  return item
598
606
  raise EmptySequenceError() if predicate is _SENTINEL else NoMatchError()
599
607
 
600
- def first_or_default(
608
+ def first_or_default[TResult](
601
609
  self, default: TResult, predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL
602
610
  ) -> Union[TItem, TResult]:
603
611
  """Returns the first element matching a predicate, or a default value."""
@@ -672,14 +680,7 @@ class _NoneOrderKey:
672
680
 
673
681
  _NONE_ORDER_KEY = _NoneOrderKey()
674
682
 
675
- def _none_aware_key(
676
- selector: Callable[[TItem], Any],
677
- item: TItem,
678
- ) -> Any:
679
- key = selector(item)
680
- return _NONE_ORDER_KEY if key is None else key
681
-
682
- class OrderedIt(FlpIt[TItem]):
683
+ class OrderedIt[TItem](FlpIt[TItem]):
683
684
  __slots__ = ("_criteria",)
684
685
 
685
686
  def __init__(
@@ -700,13 +701,21 @@ class OrderedIt(FlpIt[TItem]):
700
701
  else:
701
702
  self._criteria = _criteria + ((selector, descending),)
702
703
 
704
+ @staticmethod
705
+ def __none_aware_key(
706
+ selector: Callable[[TItem], Any],
707
+ item: TItem,
708
+ ) -> Any:
709
+ key = selector(item)
710
+ return _NONE_ORDER_KEY if key is None else key
711
+
703
712
  @override
704
713
  def __iter__(self) -> Iterator[TItem]:
705
714
  items = list(self._iterable)
706
715
 
707
716
  for selector, descending in reversed(self._criteria):
708
717
  items.sort(
709
- key=partial(_none_aware_key, selector),
718
+ key=partial(self.__none_aware_key, selector),
710
719
  reverse=descending,
711
720
  )
712
721
 
@@ -735,7 +744,7 @@ class OrderedIt(FlpIt[TItem]):
735
744
  )
736
745
 
737
746
 
738
- class Grouping(FlpIt[TItem], Generic[TKey, TItem]):
747
+ class Grouping[TKey, TItem](FlpIt[TItem]):
739
748
  """Represents a collection of elements sharing a common key (.NET IGrouping<TKey, TElement>)."""
740
749
  __slots__ = ("_key", )
741
750
 
@@ -759,7 +768,7 @@ class Grouping(FlpIt[TItem], Generic[TKey, TItem]):
759
768
  return self.key == other.key and self.to_list() == other.to_list()
760
769
 
761
770
 
762
- class FlpList(Collection[TItem], Generic[TItem]):
771
+ class FlpList[TItem](Collection[TItem]):
763
772
  """
764
773
  | Fluent List
765
774
  A materialized list extending UserList that yields lazy FlpIt instances for query operations.
@@ -780,7 +789,7 @@ class FlpList(Collection[TItem], Generic[TItem]):
780
789
  # =====================================================================
781
790
  # 1. THE ABC CONTRACT
782
791
  # =====================================================================
783
- def __iter__(self) -> Iterator[Any]:
792
+ def __iter__(self) -> Iterator[TItem]:
784
793
  return self.__list.__iter__()
785
794
 
786
795
  def __len__(self) -> int:
@@ -1031,17 +1040,16 @@ class FlpList(Collection[TItem], Generic[TItem]):
1031
1040
  def concat(self, second: Iterable[TItem]) -> FlpIt[TItem]:
1032
1041
  return FlpIt(self.__list.data).concat(second)
1033
1042
 
1034
- # noinspection unused-parameter
1035
- def as_type(self, target_type: Type[TResult]) -> FlpList[TResult]:
1036
- return self # type: ignore[return-value]
1043
+ def as_type[TResult](self, target_type: Type[TResult]) -> FlpList[TResult]:
1044
+ return typing_cast(FlpList[TResult], self)
1037
1045
 
1038
1046
  def where(self, predicate: Callable[[TItem], bool]) -> FlpIt[TItem]:
1039
1047
  return FlpIt[TItem](self.__list.data).where(predicate)
1040
1048
 
1041
- def select(self, selector: Callable[[TItem], TResult]) -> FlpIt[TResult]:
1049
+ def select[TResult](self, selector: Callable[[TItem], TResult]) -> FlpIt[TResult]:
1042
1050
  return FlpIt(self.__list.data).select(selector)
1043
1051
 
1044
- def select_many(
1052
+ def select_many[TResult](
1045
1053
  self, selector: Callable[[TItem], Iterable[TResult]]
1046
1054
  ) -> FlpIt[TResult]:
1047
1055
  return FlpIt(self.__list.data).select_many(selector)
@@ -1052,10 +1060,10 @@ class FlpList(Collection[TItem], Generic[TItem]):
1052
1060
  def take(self, count: int) -> FlpIt[TItem]:
1053
1061
  return FlpIt(self.__list.data).take(count)
1054
1062
 
1055
- def cast(self, target_type: Type[TResult]) -> FlpIt[TResult]:
1063
+ def cast[TResult](self, target_type: Type[TResult]) -> FlpIt[TResult]:
1056
1064
  return FlpIt(self.__list.data).cast(target_type)
1057
1065
 
1058
- def of_type(self, target_type: Type[TResult]) -> FlpIt[TResult]:
1066
+ def of_type[TResult](self, target_type: Type[TResult]) -> FlpIt[TResult]:
1059
1067
  return FlpIt(self.__list.data).of_type(target_type)
1060
1068
 
1061
1069
  def distinct(self) -> FlpIt[TItem]:
@@ -1070,7 +1078,7 @@ class FlpList(Collection[TItem], Generic[TItem]):
1070
1078
  def zip(self, second: Iterable[TOther]) -> FlpIt[tuple[TItem, TOther]]: ...
1071
1079
 
1072
1080
  @overload
1073
- def zip(
1081
+ def zip[TResult, TOther](
1074
1082
  self,
1075
1083
  second: Iterable[TOther],
1076
1084
  result_selector: Callable[[TItem, TOther], TResult],
@@ -1203,7 +1211,7 @@ class FlpList(Collection[TItem], Generic[TItem]):
1203
1211
 
1204
1212
  raise EmptySequenceError() if predicate is _SENTINEL else NoMatchError()
1205
1213
 
1206
- def first_or_default(
1214
+ def first_or_default[TResult](
1207
1215
  self, default: TResult, predicate: Callable[[TItem], bool] | _Sentinel = _SENTINEL
1208
1216
  ) -> TItem | TResult:
1209
1217
  return FlpIt(self.__list.data).first_or_default(default, predicate)
@@ -1235,4 +1243,4 @@ class FlpList(Collection[TItem], Generic[TItem]):
1235
1243
  output: BinaryIO,
1236
1244
  jso: JsonSerializerOptions | None = None,
1237
1245
  ) -> None:
1238
- JsonConvert.serialize_to(self, item_type, output, jso)
1246
+ JsonConvert.serialize_to(self, item_type, output, jso)
@@ -0,0 +1,333 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import io
5
+ from csv import DictReader
6
+ from dataclasses import dataclass
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+ from typing import Any, Iterator, TextIO
10
+
11
+ import msgspec
12
+
13
+ from flpit import FlpIt, FlpList
14
+ from flpit.json import (
15
+ JProp,
16
+ JsonNamingPolicy,
17
+ JsonSerializerOptions,
18
+ wire_type_for,
19
+ )
20
+
21
+
22
+ class CsvCol(JProp):
23
+ """Explicit CSV column name.
24
+
25
+ Inherits JProp so the existing wire-schema machinery recognizes
26
+ the metadata without a separate conversion implementation.
27
+ """
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class CsvOptions:
32
+ """CSV parsing and property-mapping options.
33
+
34
+ Naming defaults to none: headers match declared property names unless
35
+ CsvCol specifies an explicit column name.
36
+ """
37
+
38
+ delimiter: str = ","
39
+ quotechar: str = '"'
40
+ escapechar: str | None = None
41
+ doublequote: bool = True
42
+ skipinitialspace: bool = False
43
+ strict: bool = False
44
+ encoding: str = "utf-8-sig"
45
+ naming_policy: JsonNamingPolicy = JsonNamingPolicy.none
46
+
47
+ def __post_init__(self) -> None:
48
+ if len(self.delimiter) != 1:
49
+ raise ValueError("delimiter must be exactly one character")
50
+ if len(self.quotechar) != 1:
51
+ raise ValueError("quotechar must be exactly one character")
52
+ if self.escapechar is not None and len(self.escapechar) != 1:
53
+ raise ValueError("escapechar must be exactly one character")
54
+
55
+
56
+ @lru_cache(maxsize=None)
57
+ def _json_options_for_csv(
58
+ naming_policy: JsonNamingPolicy,
59
+ ) -> JsonSerializerOptions:
60
+ """Reuse options identity because the wire-type cache keys by options ID."""
61
+ return JsonSerializerOptions(naming_policy=naming_policy)
62
+
63
+
64
+ class CsvLoader:
65
+ """Load typed records from CSV without adding format methods to FlpList/FlpIt."""
66
+
67
+ @staticmethod
68
+ def load_list[T](
69
+ data: str | Path,
70
+ item_type: type[T],
71
+ options: CsvOptions | None = None,
72
+ ) -> FlpList[T]:
73
+ """Load CSV rows eagerly using bulk conversion.
74
+
75
+ Args:
76
+ data: CSV text, or a Path to a CSV file.
77
+ item_type: Type of each resulting model.
78
+ options: CSV dialect, encoding, and naming policy.
79
+
80
+ Returns:
81
+ An eagerly populated FlpList of item_type instances.
82
+ """
83
+ opts = options or CsvOptions()
84
+ jso = _json_options_for_csv(opts.naming_policy)
85
+ target: type[list[T]] = list[item_type]
86
+ wire_type = wire_type_for(target, jso)
87
+
88
+ with CsvLoader._open_text(data, opts) as stream:
89
+ reader: DictReader[Any] = CsvLoader._reader(stream, opts)
90
+ rows = CsvLoader._read_rows(reader)
91
+
92
+ output = msgspec.convert(
93
+ rows,
94
+ wire_type,
95
+ from_attributes=True,
96
+ strict=False,
97
+ )
98
+ del rows
99
+
100
+ return FlpList[T](output)
101
+
102
+ @staticmethod
103
+ def load_it[T](
104
+ data: str | Path,
105
+ item_type: type[T],
106
+ options: CsvOptions | None = None,
107
+ ) -> FlpIt[T]:
108
+ """Load CSV rows lazily, converting one row when it is consumed.
109
+
110
+ For Path input, the file is opened on iteration and closed when
111
+ the iterator is exhausted or closed. CSV parsing and conversion
112
+ errors therefore occur during iteration, not at this call.
113
+ """
114
+ opts = options or CsvOptions()
115
+ jso = _json_options_for_csv(opts.naming_policy)
116
+ wire_item_type = wire_type_for(item_type, jso)
117
+
118
+ return FlpIt[T](
119
+ CsvLoader._iter_items(data, item_type, wire_item_type, opts)
120
+ )
121
+
122
+ @staticmethod
123
+ def _open_text(data: str | Path, options: CsvOptions) -> TextIO:
124
+ if isinstance(data, Path):
125
+ return data.open("r", newline="", encoding=options.encoding)
126
+ return io.StringIO(data.removeprefix("\ufeff"), newline="")
127
+
128
+ @staticmethod
129
+ def _reader(stream: TextIO, options: CsvOptions) -> csv.DictReader[Any]:
130
+ reader = csv.DictReader(
131
+ stream,
132
+ delimiter=options.delimiter,
133
+ quotechar=options.quotechar,
134
+ escapechar=options.escapechar,
135
+ doublequote=options.doublequote,
136
+ skipinitialspace=options.skipinitialspace,
137
+ strict=options.strict,
138
+ )
139
+
140
+ headers = reader.fieldnames
141
+ if headers is None:
142
+ raise ValueError("CSV input must contain a header row")
143
+
144
+ if any(header == "" for header in headers):
145
+ raise ValueError("CSV column names must not be empty")
146
+
147
+ if len(headers) != len(set(headers)):
148
+ raise ValueError("CSV column names must be unique")
149
+
150
+ return reader
151
+
152
+ @staticmethod
153
+ def _read_rows(reader: csv.DictReader[Any]) -> list[dict[str, Any]]:
154
+ rows: list[dict[str, Any]] = []
155
+
156
+ for row in reader:
157
+ if None in row:
158
+ raise ValueError(
159
+ f"CSV row ending at line {reader.line_num} "
160
+ "contains more values than the header"
161
+ )
162
+ rows.append(row)
163
+
164
+ return rows
165
+
166
+ @staticmethod
167
+ def _iter_items[T](
168
+ data: str | Path,
169
+ item_type: type[T],
170
+ wire_item_type: Any,
171
+ options: CsvOptions,
172
+ ) -> Iterator[T]:
173
+ with CsvLoader._open_text(data, options) as stream:
174
+ reader = CsvLoader._reader(stream, options)
175
+
176
+ for row in reader:
177
+ if None in row:
178
+ raise ValueError(
179
+ f"CSV row ending at line {reader.line_num} "
180
+ "contains more values than the header"
181
+ )
182
+
183
+ yield msgspec.convert(
184
+ row,
185
+ wire_item_type,
186
+ from_attributes=True,
187
+ strict=False,
188
+ )
189
+
190
+ # import csv
191
+ # import io
192
+ # from dataclasses import dataclass
193
+ # from pathlib import Path
194
+ # from typing import TextIO
195
+ #
196
+ # import msgspec
197
+ #
198
+ # from flpit.core.linq import FlpList
199
+ # from flpit.json import (
200
+ # JProp,
201
+ # JsonNamingPolicy,
202
+ # JsonSerializerOptions,
203
+ # wire_type_for,
204
+ # )
205
+ #
206
+ #
207
+ # class CsvCol(JProp):
208
+ # """Explicit CSV column name using the existing property metadata."""
209
+ #
210
+ #
211
+ # @dataclass(frozen=True, slots=True)
212
+ # class CsvOptions:
213
+ # """CSV parsing and field-mapping options.
214
+ #
215
+ # The naming policy defaults to none, so CSV headers match Python field
216
+ # names unless an explicit CsvCol/JProp name is supplied.
217
+ # """
218
+ #
219
+ # delimiter: str = ","
220
+ # quotechar: str = '"'
221
+ # escapechar: str | None = None
222
+ # doublequote: bool = True
223
+ # skipinitialspace: bool = False
224
+ # strict: bool = False
225
+ # encoding: str = "utf-8-sig"
226
+ # naming_policy: JsonNamingPolicy = JsonNamingPolicy.none
227
+ #
228
+ # def __post_init__(self) -> None:
229
+ # if len(self.delimiter) != 1:
230
+ # raise ValueError("delimiter must be exactly one character")
231
+ # if len(self.quotechar) != 1:
232
+ # raise ValueError("quotechar must be exactly one character")
233
+ # if self.escapechar is not None and len(self.escapechar) != 1:
234
+ # raise ValueError("escapechar must be exactly one character")
235
+ #
236
+ # def to_json_options(self) -> JsonSerializerOptions:
237
+ # """Map CSV field naming to the shared wire-schema builder."""
238
+ # return JsonSerializerOptions(naming_policy=self.naming_policy)
239
+ #
240
+ #
241
+ # class CsvLoader:
242
+ # @staticmethod
243
+ # def load[T](
244
+ # data: str | Path,
245
+ # item_type: type[T],
246
+ # options: CsvOptions | None = None,
247
+ # ) -> FlpList[T]:
248
+ # """Load CSV content or a CSV file into a FlpList.
249
+ #
250
+ # CSV headers are matched using the configured naming policy and
251
+ # explicit property metadata. The naming policy is passed to the
252
+ # existing JSON wire-schema builder so annotation handling stays
253
+ # consistent with JsonConvert.
254
+ #
255
+ # CSV values are converted in lax mode because CSV supplies text
256
+ # values, including numeric values represented as strings.
257
+ #
258
+ # Args:
259
+ # data: CSV content as a string, or a Path to a CSV file.
260
+ # item_type: Model type to construct for each row.
261
+ # options: CSV dialect, encoding and naming options.
262
+ #
263
+ # Returns:
264
+ # A FlpList containing instances of item_type.
265
+ # """
266
+ # csv_options = options or CsvOptions()
267
+ # json_options = csv_options.to_json_options()
268
+ # wire_type = wire_type_for(item_type, json_options)
269
+ #
270
+ # if isinstance(data, Path):
271
+ # with data.open(
272
+ # "r",
273
+ # newline="",
274
+ # encoding=csv_options.encoding,
275
+ # ) as stream:
276
+ # return CsvLoader._load_stream(
277
+ # stream, item_type, wire_type, csv_options
278
+ # )
279
+ #
280
+ # with io.StringIO(data, newline="") as stream:
281
+ # return CsvLoader._load_stream(
282
+ # stream, item_type, wire_type, csv_options
283
+ # )
284
+ #
285
+ # @staticmethod
286
+ # def _load_stream[T](
287
+ # stream: TextIO,
288
+ # item_type: type[T],
289
+ # wire_type: type,
290
+ # options: CsvOptions,
291
+ # ) -> FlpList[T]:
292
+ # reader = csv.DictReader(
293
+ # stream,
294
+ # delimiter=options.delimiter,
295
+ # quotechar=options.quotechar,
296
+ # escapechar=options.escapechar,
297
+ # doublequote=options.doublequote,
298
+ # skipinitialspace=options.skipinitialspace,
299
+ # strict=options.strict,
300
+ # )
301
+ #
302
+ # if reader.fieldnames is None:
303
+ # raise ValueError("CSV input must contain a header row")
304
+ #
305
+ # if len(reader.fieldnames) != len(set(reader.fieldnames)):
306
+ # raise ValueError("CSV column names must be unique")
307
+ #
308
+ # def convert_rows():
309
+ # for row in reader:
310
+ # if None in row:
311
+ # raise ValueError(
312
+ # f"CSV row ending at line {reader.line_num} "
313
+ # "contains more values than the header"
314
+ # )
315
+ #
316
+ # wire_obj = msgspec.convert(
317
+ # row,
318
+ # wire_type,
319
+ # from_attributes=True,
320
+ # strict=False,
321
+ # )
322
+ #
323
+ # # Reconstruct the declared model rather than leaking the
324
+ # # dynamically generated wire type to the caller.
325
+ # yield msgspec.convert(
326
+ # wire_obj,
327
+ # item_type,
328
+ # from_attributes=True,
329
+ # strict=False,
330
+ # )
331
+ #
332
+ # return FlpList[T](convert_rows())
333
+ #
@@ -67,37 +67,49 @@ class JsonSerializerOptions:
67
67
  # ---------------------------------------------------------------------------
68
68
 
69
69
  class JIgnore:
70
- def __class_getitem__[T](cls, item: type[T]) -> Any:
71
- return Annotated[item, cls]
70
+ def __class_getitem__[T](cls, item: type[T]) -> Annotated[type[T], JIgnore]:
71
+ return typing.cast(Annotated[type[T], JIgnore], Annotated[item, cls])
72
72
 
73
73
  class JInclude:
74
- def __class_getitem__[T](cls, item: type[T]) -> Any:
75
- return Annotated[item, cls]
74
+ def __class_getitem__[T](cls, item: type[T]) -> Annotated[type[T], JInclude]:
75
+ return typing.cast(Annotated[type[T], JInclude], Annotated[item, cls])
76
+
77
+
78
+ class JPropMeta(type):
79
+ def __getattr__(cls, name: str) -> Any:
80
+ class SpecializedJProp:
81
+ def __class_getitem__[T](sub_cls, item: type[T]) -> Annotated[type[T], JProp]:
82
+ return typing.cast(Annotated[type[T], JProp], Annotated[item, JProp(json_name=name)])
83
+ return SpecializedJProp
76
84
 
77
85
  class JProp:
78
- def __class_getitem__[T](cls, item: tuple[type[T], str]) -> Any:
79
- target_type, json_name = item
80
- return Annotated[target_type, cls(json_name)]
86
+ __slots__ = ("json_name",)
87
+ json_name: str
81
88
 
82
- def __init__(self, name: str):
83
- self.name = name
89
+ def __init__(self, json_name: str):
90
+ self.json_name = json_name
84
91
 
85
- class Dummy:
86
- def __getitem__[T](self, item: tuple[type[T], str]) -> Any:
87
- target_type, json_name = item
88
- return Annotated[target_type, self]
92
+ # 1. This handles the standard initialization/metadata if needed
93
+ def __repr__(self):
94
+ return f"JProp(json_name={self.json_name!r})"
89
95
 
90
- def __init__(self, name: str):
91
- self.name = name
96
+ # 2. This intercepts JProp[str] (the type subscription first)
97
+ def __class_getitem__[T](cls, item: type[T]) -> Any:
98
+ # We return a proxy class that remembers the type 'item' (e.g., str | None)
99
+ class JPropTypeProxy:
100
+ def __getattr__(self, name: str) -> Annotated[type[T], JProp]:
101
+ # 3. This intercepts .JsonPropNameToUse and returns the Annotated type
102
+ return typing.cast(
103
+ Annotated[type[T], JProp],
104
+ Annotated[item, JProp(json_name=name)]
105
+ )
92
106
 
93
- # just alias it
94
- J = Annotated
107
+ return JPropTypeProxy()
95
108
 
96
109
  @dataclass
97
110
  class AnnotationModel:
98
- internal_id: JProp[int, "any_arbitrary_string_you_want"]
99
- display_name: J[str, JProp("")]
100
- display_name_2: Dummy("name")[str]
111
+ other_name: Annotated[str, JProp("WorksToo")]
112
+ display_name: JProp[str].meta
101
113
  secret: JIgnore[str] = ""
102
114
  always_emit: JInclude[int] = 0
103
115
 
@@ -209,7 +221,7 @@ def _field_directives(
209
221
  ignored = any(item is JIgnore for item in metadata)
210
222
  included = any(item is JInclude for item in metadata)
211
223
  json_name = next(
212
- (item.name for item in metadata if isinstance(item, JProp)),
224
+ (item.json_name for item in metadata if isinstance(item, JProp)),
213
225
  None,
214
226
  )
215
227
  remaining = tuple(
@@ -574,6 +586,13 @@ def _get_decoder(wire_type: Any):
574
586
  _JSON_ENCODER = msgspec.json.Encoder()
575
587
 
576
588
 
589
+ class JsonSerializationError(Exception):
590
+ pass
591
+
592
+ class JsonDeserializationError(Exception):
593
+ pass
594
+
595
+
577
596
  class JsonConvert:
578
597
 
579
598
  @staticmethod
@@ -582,22 +601,29 @@ class JsonConvert:
582
601
  target: type[T],
583
602
  jso: JsonSerializerOptions | None = None
584
603
  ) -> T:
604
+ if data is None:
605
+ raise JsonDeserializationError("data must not be None")
606
+
585
607
  if jso is None:
586
608
  jso = JsonSerializerOptions()
587
609
 
588
610
  wire_type = wire_type_for(target, jso)
589
- parsed = _get_decoder(wire_type).decode(data)
590
611
 
591
- if jso.native:
592
- return parsed
612
+ try:
613
+ parsed = _get_decoder(wire_type).decode(data)
593
614
 
594
- result = msgspec.convert(
595
- parsed,
596
- target,
597
- from_attributes=True,
598
- )
615
+ if jso.native:
616
+ return parsed
599
617
 
600
- return result
618
+ result = msgspec.convert(
619
+ parsed,
620
+ target,
621
+ from_attributes=True,
622
+ )
623
+
624
+ return result
625
+ except msgspec.MsgspecError as exc:
626
+ raise JsonDeserializationError(str(exc)) from exc
601
627
 
602
628
  @staticmethod
603
629
  def serialize[T](
@@ -605,6 +631,9 @@ class JsonConvert:
605
631
  target: type[T],
606
632
  jso: JsonSerializerOptions | None = None
607
633
  ) -> str:
634
+ if data is None:
635
+ raise JsonSerializationError("data must not be None")
636
+
608
637
  return JsonConvert.serialize_bytes(data, target, jso).decode("utf-8")
609
638
 
610
639
  @staticmethod
@@ -617,8 +646,11 @@ class JsonConvert:
617
646
  jso = JsonSerializerOptions()
618
647
 
619
648
  wire_type = wire_type_for(target, jso)
620
- wire_obj = msgspec.convert(data, wire_type, from_attributes=True)
621
- return _JSON_ENCODER.encode(wire_obj)
649
+ try:
650
+ wire_obj = msgspec.convert(data, wire_type, from_attributes=True)
651
+ return _JSON_ENCODER.encode(wire_obj)
652
+ except msgspec.MsgspecError as exc:
653
+ raise JsonSerializationError(str(exc)) from exc
622
654
 
623
655
  @staticmethod
624
656
  def serialize_to[T](
@@ -36,11 +36,15 @@ import socket
36
36
  import threading
37
37
  import time
38
38
  from collections import deque
39
+ from collections.abc import Generator
39
40
  from concurrent.futures import Future, ThreadPoolExecutor
40
41
  from dataclasses import dataclass
41
42
  from itertools import count
42
- from typing import Any, Callable, Coroutine, Generic, TypeVar, cast, Iterable, Final
43
+ from typing import Any, Generic, TypeVar, cast, Iterable, Final, overload
43
44
  import functools
45
+ from functools import wraps
46
+ from collections.abc import Awaitable, Callable, Coroutine
47
+
44
48
 
45
49
  import flpit
46
50
 
@@ -409,17 +413,17 @@ class _WaitIO(_Operation):
409
413
  self.mode = mode
410
414
 
411
415
 
412
- class _WaitTask(_Operation):
413
- def __init__(self, runtime: "_Runtime", task: "Task[Any]") -> None:
416
+ class _WaitTask[T](_Operation):
417
+ def __init__(self, runtime: "_Runtime", ttw: "Task[T]") -> None:
414
418
  super().__init__(runtime)
415
- self.task = task
419
+ self.task = ttw
416
420
 
417
421
 
418
- class _WaitMany(_Operation):
422
+ class _WaitMany[T](_Operation):
419
423
  """Wait for all or any of a set of tasks to complete."""
420
424
 
421
425
  def __init__(
422
- self, runtime: "_Runtime", tasks: tuple["Task[Any]", ...], mode: str
426
+ self, runtime: "_Runtime", tasks: tuple["Task[T]", ...], mode: str
423
427
  ) -> None:
424
428
  super().__init__(runtime)
425
429
  if mode not in ("all", "any"):
@@ -559,14 +563,14 @@ class Task(Generic[T]):
559
563
  @classmethod
560
564
  def run(
561
565
  cls,
562
- work: Any,
566
+ work: Callable[..., T],
563
567
  /,
564
568
  *args: Any,
565
569
  name: str | None = None,
566
570
  token: CancellationToken | None = None,
567
571
  pass_token: bool = False,
568
572
  **kwargs: Any,
569
- ) -> "Task[Any]":
573
+ ) -> "Task[T]":
570
574
  """Schedule a coroutine/awaitable or ordinary callable.
571
575
 
572
576
  Ordinary functions run in the bounded worker pool. ``token`` controls
@@ -616,6 +620,8 @@ class Task(Generic[T]):
616
620
  normalized = _normalize_tasks(tasks, allow_empty=True)
617
621
  return cast(
618
622
  "Task[list[Any]]",
623
+ # todo: make run type compatible via Generator[Y, S, R]
624
+ # pyrefly: ignore [bad-argument-type]
619
625
  cls.run(_when_all_coroutine, normalized, name=name, token=token),
620
626
  )
621
627
 
@@ -641,6 +647,8 @@ class Task(Generic[T]):
641
647
  normalized = _normalize_tasks(tasks, allow_empty=False)
642
648
  return cast(
643
649
  "Task[Task[Any]]",
650
+ # todo: make run type compatible via Generator[Y, S, R]
651
+ # pyrefly: ignore [bad-argument-type]
644
652
  cls.run(_when_any_coroutine, normalized, name=name, token=token),
645
653
  )
646
654
 
@@ -726,19 +734,17 @@ class Task(Generic[T]):
726
734
  def cancelled(self) -> bool:
727
735
  return self._completed.is_set() and self._state == self.CANCELLED
728
736
 
729
- def result(self, timeout: float | None = None) -> T:
737
+ def result(self) -> T:
730
738
  """Block an external thread until completion and return/raise the result."""
731
739
  if not self.done() and self._runtime.is_runtime_thread():
732
740
  raise RuntimeError("Task.result() would block the runtime thread; use 'await task'")
733
- if not self._completed.wait(timeout):
734
- raise TimeoutError(f"{self.name} did not complete before the timeout")
741
+ self._completed.wait()
735
742
  return self._raise_or_return()
736
743
 
737
- def exception(self, timeout: float | None = None) -> BaseException | None:
744
+ def exception(self) -> BaseException | None:
738
745
  if not self.done() and self._runtime.is_runtime_thread():
739
746
  raise RuntimeError("Task.exception() would block the runtime thread")
740
- if not self._completed.wait(timeout):
741
- raise TimeoutError(f"{self.name} did not complete before the timeout")
747
+ self._completed.wait()
742
748
  if self._state == self.CANCELLED:
743
749
  raise self._cancellation_exception()
744
750
  return self._exception
@@ -758,10 +764,10 @@ class Task(Generic[T]):
758
764
  return False
759
765
  return True
760
766
 
761
- def __await__(self):
767
+ def __await__(self) -> Generator[Any, T, T]:
762
768
  if self.done():
763
769
  return self._raise_or_return()
764
- result = yield _WaitTask(self._runtime, self)
770
+ result = yield _WaitTask[T](self._runtime, self)
765
771
  return result
766
772
 
767
773
  def _raise_or_return(self) -> T:
@@ -1088,9 +1094,10 @@ def _normalize_tasks(items: tuple[Any, ...], *, allow_empty: bool) -> tuple[Task
1088
1094
  return tuple(normalized)
1089
1095
 
1090
1096
 
1091
- async def _when_all_coroutine(tasks: tuple[Task[Any], ...]) -> list[Any]:
1097
+ async def _when_all_coroutine(tasks: tuple[Task[T], ...]) -> list[T]:
1092
1098
  await _WaitMany(_get_runtime(), tasks, "all")
1093
1099
  failures = tuple(task._exception for task in tasks if task._state == Task.FAILED)
1100
+
1094
1101
  if len(failures) == 1:
1095
1102
  assert failures[0] is not None
1096
1103
  raise failures[0]
@@ -1101,7 +1108,7 @@ async def _when_all_coroutine(tasks: tuple[Task[Any], ...]) -> list[Any]:
1101
1108
  return [task._result for task in tasks]
1102
1109
 
1103
1110
 
1104
- async def _when_any_coroutine(tasks: tuple[Task[Any], ...]) -> Task[Any]:
1111
+ async def _when_any_coroutine(tasks: tuple[Task[T], ...]) -> Task[T]:
1105
1112
  winner = await _WaitMany(_get_runtime(), tasks, "any")
1106
1113
  return cast(Task[Any], winner)
1107
1114
 
@@ -1823,17 +1830,38 @@ def _shutdown_at_exit() -> None:
1823
1830
 
1824
1831
  atexit.register(_shutdown_at_exit)
1825
1832
 
1826
- def task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
1833
+
1834
+ @overload
1835
+ def task[**P, T](
1836
+ function: Callable[P, Awaitable[T]]
1837
+ ) -> Callable[P, Task[T]]: ...
1838
+
1839
+ @overload
1840
+ def task[**P, T](
1841
+ function: Callable[P, T]
1842
+ ) -> Callable[P, Task[T]]: ...
1843
+
1844
+ def task(function: Callable[..., Any]) -> Callable[..., Task[Any]]:
1827
1845
  """
1828
1846
  | Wraps sync functions and non-asyncio dependent coroutines to eagerly executed await-able tasks
1829
1847
  """
1830
- @functools.wraps(async_function)
1848
+ @wraps(function)
1831
1849
  def wrapper(*args: Any, **kwargs: Any) -> Task[Any]:
1832
- return Task.run(async_function, *args, **kwargs)
1850
+ return Task.run(function, *args, **kwargs)
1833
1851
 
1834
1852
  return wrapper
1835
1853
 
1836
1854
 
1855
+ @overload
1856
+ def asyncio_task[**P, T](
1857
+ async_function: Callable[P, Awaitable[T]]
1858
+ ) -> Callable[P, Task[T]]: ...
1859
+
1860
+ @overload
1861
+ def asyncio_task[**P, T](
1862
+ async_function: Callable[P, T], /
1863
+ ) -> Callable[P, Task[T]]: ...
1864
+
1837
1865
  def asyncio_task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
1838
1866
  """
1839
1867
  | Wraps async functions with a dependency on asyncio to be compatible with Task.run(coro)
@@ -1852,7 +1880,7 @@ def asyncio_task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]
1852
1880
 
1853
1881
 
1854
1882
  class _SyncBridge:
1855
- def __rshift__(self, ttwa: Task[T]) -> Any:
1883
+ def __rshift__(self, ttwa: Task[T]) -> T:
1856
1884
  frame = inspect.currentframe()
1857
1885
  try:
1858
1886
  assert frame is not None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes