flpit 0.2.1.dev8__tar.gz → 0.2.2__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.2
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.2"
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 = [
@@ -30,6 +30,7 @@ from typing import (
30
30
  cast as typing_cast, BinaryIO,
31
31
  )
32
32
 
33
+ import flpit.json
33
34
  from flpit.json import JsonConvert, JsonSerializerOptions
34
35
 
35
36
  TItem = TypeVar("TItem")
@@ -1235,4 +1236,4 @@ class FlpList(Collection[TItem], Generic[TItem]):
1235
1236
  output: BinaryIO,
1236
1237
  jso: JsonSerializerOptions | None = None,
1237
1238
  ) -> None:
1238
- JsonConvert.serialize_to(self, item_type, output, jso)
1239
+ 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