flpit 0.2.1.dev7__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.dev7
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.dev7"
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
+ #
@@ -11,12 +11,13 @@ from dataclasses import MISSING, dataclass
11
11
  from enum import StrEnum
12
12
  from functools import lru_cache
13
13
  from hashlib import blake2s
14
- from typing import Annotated, Any, ForwardRef, get_args, get_origin, get_type_hints, Callable
14
+ from typing import Annotated, TYPE_CHECKING, Any, ForwardRef, get_args, get_origin, get_type_hints, Callable
15
15
 
16
16
  import msgspec
17
17
 
18
18
 
19
19
  class JsonNamingPolicy(StrEnum):
20
+ none = "None"
20
21
  lower = "lower" # pyrefly: ignore [bad-override-mutable-attribute]
21
22
  upper = "upper" # pyrefly: ignore [bad-override-mutable-attribute]
22
23
  camel = "camel"
@@ -65,25 +66,52 @@ class JsonSerializerOptions:
65
66
  # Metadata Annotation Markers
66
67
  # ---------------------------------------------------------------------------
67
68
 
68
- class JsonIgnore:
69
- def __class_getitem__[T](cls, item: type[T]) -> Any:
70
- return Annotated[item, cls]
69
+ class JIgnore:
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
+
73
+ class JInclude:
74
+ def __class_getitem__[T](cls, item: type[T]) -> Annotated[type[T], JInclude]:
75
+ return typing.cast(Annotated[type[T], JInclude], Annotated[item, cls])
71
76
 
72
- class JsonInclude:
73
- def __class_getitem__[T](cls, item: type[T]) -> Any:
74
- return Annotated[item, cls]
75
77
 
76
- class JsonProperty:
77
- def __class_getitem__[T](cls, item: tuple[type[T], str]) -> Any:
78
- target_type, json_name = item
79
- return Annotated[target_type, cls(json_name)]
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
80
84
 
81
- def __init__(self, name: str):
82
- self.name = name
85
+ class JProp:
86
+ __slots__ = ("json_name",)
87
+ json_name: str
83
88
 
84
- JIgnore = JsonIgnore
85
- JInclude = JsonInclude
86
- JProp = JsonProperty
89
+ def __init__(self, json_name: str):
90
+ self.json_name = json_name
91
+
92
+ # 1. This handles the standard initialization/metadata if needed
93
+ def __repr__(self):
94
+ return f"JProp(json_name={self.json_name!r})"
95
+
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
+ )
106
+
107
+ return JPropTypeProxy()
108
+
109
+ @dataclass
110
+ class AnnotationModel:
111
+ other_name: Annotated[str, JProp("WorksToo")]
112
+ display_name: JProp[str].meta
113
+ secret: JIgnore[str] = ""
114
+ always_emit: JInclude[int] = 0
87
115
 
88
116
  # ---------------------------------------------------------------------
89
117
  # Generated type metadata
@@ -120,36 +148,44 @@ class _WireType:
120
148
 
121
149
  def _apply_naming_policy(name: str, policy: JsonNamingPolicy) -> str:
122
150
  """
123
- Splits any attribute name (snake_case, PascalCase, UPPER_CASE)
124
- into individual tokens and formats them according to the target policy.
151
+ Tokenizes snake_case, camelCase, PascalCase, UPPER_CASE, and kebab-case
152
+ identifiers, then formats the tokens according to the target policy.
153
+ Preserves leading underscores.
125
154
  """
126
- # 1. Check if there is a leading underscore and save it
127
- prefix = "_" if name.startswith("_") else ""
128
- clean_name = name.lstrip("_")
129
-
130
- # 2. Tokenize only the clean part of the string
131
- tokens = [t for t in re.split(r'_(?=[a-zA-Z])|(?<=[a-z])(?=[A-Z])', clean_name) if t]
132
- if not tokens:
155
+ if policy == JsonNamingPolicy.none:
133
156
  return name
134
157
 
135
- if policy == JsonNamingPolicy.lower:
136
- result = "".join(t.lower() for t in tokens)
158
+ # Preserve all leading underscores.
159
+ prefix = name[: len(name) - len(name.lstrip("_"))]
160
+ clean_name = name[len(prefix):]
137
161
 
138
- if policy == JsonNamingPolicy.upper:
139
- result = "".join(t.upper() for t in tokens)
162
+ # Split on existing separators first, then tokenize each segment.
163
+ # This also correctly handles acronyms such as XMLParser and UPPER_CASE.
164
+ token_pattern = r"[A-Z]+(?=[A-Z][a-z]|\d|$)|[A-Z]?[a-z]+|\d+"
140
165
 
141
- if policy == JsonNamingPolicy.pascal:
142
- result = "".join(t.capitalize() for t in tokens)
166
+ tokens = [
167
+ token
168
+ for part in re.split(r"[_-]+", clean_name)
169
+ for token in re.findall(token_pattern, part)
170
+ ]
143
171
 
144
- if policy == JsonNamingPolicy.kebap:
145
- result = "-".join(t.lower() for t in tokens)
146
-
147
- if policy == JsonNamingPolicy.camel:
148
- # Lowercase the first token completely, capitalize all subsequent tokens
149
- result = tokens[0].lower() + "".join(t.capitalize() for t in tokens[1:])
172
+ if not tokens:
173
+ return name
150
174
 
175
+ if policy == JsonNamingPolicy.lower:
176
+ result = "".join(token.lower() for token in tokens)
177
+ elif policy == JsonNamingPolicy.upper:
178
+ result = "".join(token.upper() for token in tokens)
179
+ elif policy == JsonNamingPolicy.pascal:
180
+ result = "".join(token.capitalize() for token in tokens)
181
+ elif policy == JsonNamingPolicy.camel:
182
+ result = tokens[0].lower() + "".join(
183
+ token.capitalize() for token in tokens[1:]
184
+ )
185
+ elif policy == JsonNamingPolicy.kebap:
186
+ result = "-".join(token.lower() for token in tokens)
151
187
  else:
152
- result = clean_name
188
+ raise ValueError(f"Unsupported naming policy: {policy!r}")
153
189
 
154
190
  return prefix + result
155
191
 
@@ -182,18 +218,18 @@ def _field_directives(
182
218
  args = get_args(annotation)
183
219
  base_type = args[0]
184
220
  metadata = args[1:]
185
- ignored = any(item is JsonIgnore for item in metadata)
186
- included = any(item is JsonInclude for item in metadata)
221
+ ignored = any(item is JIgnore for item in metadata)
222
+ included = any(item is JInclude for item in metadata)
187
223
  json_name = next(
188
- (item.name for item in metadata if isinstance(item, JsonProperty)),
224
+ (item.json_name for item in metadata if isinstance(item, JProp)),
189
225
  None,
190
226
  )
191
227
  remaining = tuple(
192
228
  item
193
229
  for item in metadata
194
- if item is not JsonIgnore
195
- and item is not JsonInclude
196
- and not isinstance(item, JsonProperty)
230
+ if item is not JIgnore
231
+ and item is not JInclude
232
+ and not isinstance(item, JProp)
197
233
  )
198
234
 
199
235
  if remaining:
@@ -550,6 +586,13 @@ def _get_decoder(wire_type: Any):
550
586
  _JSON_ENCODER = msgspec.json.Encoder()
551
587
 
552
588
 
589
+ class JsonSerializationError(Exception):
590
+ pass
591
+
592
+ class JsonDeserializationError(Exception):
593
+ pass
594
+
595
+
553
596
  class JsonConvert:
554
597
 
555
598
  @staticmethod
@@ -558,22 +601,29 @@ class JsonConvert:
558
601
  target: type[T],
559
602
  jso: JsonSerializerOptions | None = None
560
603
  ) -> T:
604
+ if data is None:
605
+ raise JsonDeserializationError("data must not be None")
606
+
561
607
  if jso is None:
562
608
  jso = JsonSerializerOptions()
563
609
 
564
610
  wire_type = wire_type_for(target, jso)
565
- parsed = _get_decoder(wire_type).decode(data)
566
611
 
567
- if jso.native:
568
- return parsed
612
+ try:
613
+ parsed = _get_decoder(wire_type).decode(data)
569
614
 
570
- result = msgspec.convert(
571
- parsed,
572
- target,
573
- from_attributes=True,
574
- )
615
+ if jso.native:
616
+ return parsed
575
617
 
576
- 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
577
627
 
578
628
  @staticmethod
579
629
  def serialize[T](
@@ -581,6 +631,9 @@ class JsonConvert:
581
631
  target: type[T],
582
632
  jso: JsonSerializerOptions | None = None
583
633
  ) -> str:
634
+ if data is None:
635
+ raise JsonSerializationError("data must not be None")
636
+
584
637
  return JsonConvert.serialize_bytes(data, target, jso).decode("utf-8")
585
638
 
586
639
  @staticmethod
@@ -593,8 +646,11 @@ class JsonConvert:
593
646
  jso = JsonSerializerOptions()
594
647
 
595
648
  wire_type = wire_type_for(target, jso)
596
- wire_obj = msgspec.convert(data, wire_type, from_attributes=True)
597
- return _JSON_ENCODER.encode(wire_obj)
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
598
654
 
599
655
  @staticmethod
600
656
  def serialize_to[T](
@@ -1,4 +1,4 @@
1
- """Task: a small asyncio-free Task/awaitable runtime.
1
+ """Task: a small asyncio-free task/awaitable runtime.
2
2
 
3
3
  This is a deliberately small runtime, not an asyncio compatibility layer.
4
4
 
@@ -36,11 +36,17 @@ 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
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
+
48
+
49
+ import flpit
44
50
 
45
51
  T = TypeVar("T")
46
52
  _LOG = logging.getLogger("tinytask")
@@ -48,8 +54,25 @@ _MISSING = object()
48
54
  _WAKE = object()
49
55
 
50
56
 
51
- class OperationCanceledError(BaseException):
52
- """Raised when cancellation is delivered to an operation or coroutine."""
57
+ class OperationCanceledError(Exception):
58
+ """Raised when an operation is canceled.
59
+
60
+ ``token`` records the token associated with cancellation when one exists.
61
+ The exception intentionally derives from ``Exception`` so normal exception
62
+ handling can catch cancellation explicitly or through a broad handler.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ message: str = "The operation was canceled.",
68
+ token: CancellationToken | None = None,
69
+ ) -> None:
70
+ self.token = token
71
+ super().__init__(message)
72
+
73
+
74
+ # Backwards-compatible spelling used by the first TinyTask prototype.
75
+ CancelledError = OperationCanceledError
53
76
 
54
77
 
55
78
  class InvalidStateError(RuntimeError):
@@ -65,10 +88,10 @@ class RuntimeShutdownError(RuntimeError):
65
88
 
66
89
 
67
90
  class AggregateError(Exception):
68
- """Raised by Task.when_all() when multiple child tasks fail.
91
+ """Represents multiple errors, preserving the original exceptions.
69
92
 
70
- The original exceptions are available in the ``exceptions`` tuple. A single
71
- child failure is re-raised directly to keep the common case ergonomic.
93
+ The default message follows the .NET ``AggregateException`` convention.
94
+ ``exceptions`` is stored as an immutable snapshot.
72
95
  """
73
96
 
74
97
  def __init__(
@@ -77,6 +100,10 @@ class AggregateError(Exception):
77
100
  message: str = "One or more errors occurred.",
78
101
  ) -> None:
79
102
  self.exceptions = tuple(exceptions)
103
+ if not self.exceptions:
104
+ raise ValueError("exceptions must contain at least one exception")
105
+ if not all(isinstance(error, Exception) for error in self.exceptions):
106
+ raise TypeError("exceptions must contain Exception instances")
80
107
  super().__init__(message)
81
108
 
82
109
 
@@ -258,11 +285,13 @@ class CancellationTokenSource:
258
285
  self._linked_registrations.clear()
259
286
  if timer is not None:
260
287
  timer.cancel()
261
- cancelled = self._state.cancel()
262
- # A linked source no longer needs to hold registrations after its own
263
- # cancellation has fired. This is safe even when called from a link callback.
264
- for registration in links:
265
- registration.dispose()
288
+ try:
289
+ cancelled = self._state.cancel()
290
+ finally:
291
+ # A linked source no longer needs to hold registrations after its own
292
+ # cancellation has fired. Also clean these up if a callback failed.
293
+ for registration in links:
294
+ registration.dispose()
266
295
  return cancelled
267
296
 
268
297
  def cancel_after(self, seconds: float | None) -> None:
@@ -384,17 +413,17 @@ class _WaitIO(_Operation):
384
413
  self.mode = mode
385
414
 
386
415
 
387
- class _WaitTask(_Operation):
388
- def __init__(self, runtime: "_Runtime", task: "Task[Any]") -> None:
416
+ class _WaitTask[T](_Operation):
417
+ def __init__(self, runtime: "_Runtime", ttw: "Task[T]") -> None:
389
418
  super().__init__(runtime)
390
- self.task = task
419
+ self.task = ttw
391
420
 
392
421
 
393
- class _WaitMany(_Operation):
422
+ class _WaitMany[T](_Operation):
394
423
  """Wait for all or any of a set of tasks to complete."""
395
424
 
396
425
  def __init__(
397
- self, runtime: "_Runtime", tasks: tuple["Task[Any]", ...], mode: str
426
+ self, runtime: "_Runtime", tasks: tuple["Task[T]", ...], mode: str
398
427
  ) -> None:
399
428
  super().__init__(runtime)
400
429
  if mode not in ("all", "any"):
@@ -447,7 +476,7 @@ async def _delay_coroutine(runtime: "_Runtime", delay: float) -> None:
447
476
 
448
477
 
449
478
  class Task(Generic[T]):
450
- """Result-bearing task managed by TinyTask's process-wide scheduler."""
479
+ """Result-bearing task managed by FlpIt's process-wide scheduler."""
451
480
 
452
481
  PENDING = "pending"
453
482
  SUCCEEDED = "succeeded"
@@ -478,6 +507,13 @@ class Task(Generic[T]):
478
507
  self._token = token or CancellationToken.none()
479
508
  self._token_registration: CancellationTokenRegistration | None = None
480
509
  self._pass_token = False
510
+ self._is_promise_task = False
511
+ self.async_state: Any = None
512
+
513
+ @property
514
+ def status(self) -> str:
515
+ """Current task state: pending, succeeded, failed, or cancelled."""
516
+ return self._state
481
517
 
482
518
  @classmethod
483
519
  def configure(
@@ -527,14 +563,14 @@ class Task(Generic[T]):
527
563
  @classmethod
528
564
  def run(
529
565
  cls,
530
- work: Any,
566
+ work: Callable[..., T],
531
567
  /,
532
568
  *args: Any,
533
569
  name: str | None = None,
534
570
  token: CancellationToken | None = None,
535
571
  pass_token: bool = False,
536
572
  **kwargs: Any,
537
- ) -> "Task[Any]":
573
+ ) -> "Task[T]":
538
574
  """Schedule a coroutine/awaitable or ordinary callable.
539
575
 
540
576
  Ordinary functions run in the bounded worker pool. ``token`` controls
@@ -584,6 +620,8 @@ class Task(Generic[T]):
584
620
  normalized = _normalize_tasks(tasks, allow_empty=True)
585
621
  return cast(
586
622
  "Task[list[Any]]",
623
+ # todo: make run type compatible via Generator[Y, S, R]
624
+ # pyrefly: ignore [bad-argument-type]
587
625
  cls.run(_when_all_coroutine, normalized, name=name, token=token),
588
626
  )
589
627
 
@@ -609,6 +647,8 @@ class Task(Generic[T]):
609
647
  normalized = _normalize_tasks(tasks, allow_empty=False)
610
648
  return cast(
611
649
  "Task[Task[Any]]",
650
+ # todo: make run type compatible via Generator[Y, S, R]
651
+ # pyrefly: ignore [bad-argument-type]
612
652
  cls.run(_when_any_coroutine, normalized, name=name, token=token),
613
653
  )
614
654
 
@@ -694,23 +734,26 @@ class Task(Generic[T]):
694
734
  def cancelled(self) -> bool:
695
735
  return self._completed.is_set() and self._state == self.CANCELLED
696
736
 
697
- def result(self, timeout: float | None = None) -> T:
737
+ def result(self) -> T:
698
738
  """Block an external thread until completion and return/raise the result."""
699
739
  if not self.done() and self._runtime.is_runtime_thread():
700
740
  raise RuntimeError("Task.result() would block the runtime thread; use 'await task'")
701
- if not self._completed.wait(timeout):
702
- raise TimeoutError(f"{self.name} did not complete before the timeout")
741
+ self._completed.wait()
703
742
  return self._raise_or_return()
704
743
 
705
- def exception(self, timeout: float | None = None) -> BaseException | None:
744
+ def exception(self) -> BaseException | None:
706
745
  if not self.done() and self._runtime.is_runtime_thread():
707
746
  raise RuntimeError("Task.exception() would block the runtime thread")
708
- if not self._completed.wait(timeout):
709
- raise TimeoutError(f"{self.name} did not complete before the timeout")
747
+ self._completed.wait()
710
748
  if self._state == self.CANCELLED:
711
- raise OperationCanceledError(f"{self.name} was cancelled")
749
+ raise self._cancellation_exception()
712
750
  return self._exception
713
751
 
752
+ def _cancellation_exception(self) -> OperationCanceledError:
753
+ if isinstance(self._exception, OperationCanceledError):
754
+ return self._exception
755
+ return OperationCanceledError(f"{self.name} was cancelled")
756
+
714
757
  def cancel(self) -> bool:
715
758
  """Request cancellation of this Task, independently of other token users."""
716
759
  if self.done():
@@ -721,15 +764,15 @@ class Task(Generic[T]):
721
764
  return False
722
765
  return True
723
766
 
724
- def __await__(self):
767
+ def __await__(self) -> Generator[Any, T, T]:
725
768
  if self.done():
726
769
  return self._raise_or_return()
727
- result = yield _WaitTask(self._runtime, self)
770
+ result = yield _WaitTask[T](self._runtime, self)
728
771
  return result
729
772
 
730
773
  def _raise_or_return(self) -> T:
731
774
  if self._state == self.CANCELLED:
732
- raise OperationCanceledError(f"{self.name} was cancelled")
775
+ raise self._cancellation_exception()
733
776
  if self._state == self.FAILED:
734
777
  assert self._exception is not None
735
778
  raise self._exception
@@ -747,7 +790,12 @@ class Task(Generic[T]):
747
790
  return
748
791
  self._cancel_requested = True
749
792
  self._runtime._detach_wait(self)
750
- if not self._running:
793
+ if self._is_promise_task:
794
+ self._finish(
795
+ self.CANCELLED,
796
+ exception=OperationCanceledError(f"{self.name} cancelled"),
797
+ )
798
+ elif not self._running:
751
799
  self._runtime._schedule(self, exc=OperationCanceledError(f"{self.name} cancelled"))
752
800
 
753
801
  def _finish(
@@ -815,6 +863,165 @@ class Task(Generic[T]):
815
863
  return f"<{self.name} state={self._state}>"
816
864
 
817
865
 
866
+ class TaskCompletionSource(Generic[T]):
867
+ """Externally controlled producer for a stable, awaitable ``Task[T]``.
868
+
869
+ The source can complete its task with a result, exception, cancellation, or
870
+ the terminal outcome of an already-completed task. Completion methods are
871
+ safe to call from arbitrary threads. Exactly one terminal transition wins.
872
+
873
+ ``state`` is carried by the task as ``task.async_state`` for diagnostics and
874
+ callback-to-task adapters. ``set_result()`` without a value is allowed for
875
+ the non-generic-style use case and completes with ``None``.
876
+ """
877
+
878
+ def __init__(self, state: Any = None) -> None:
879
+ runtime = _get_runtime()
880
+ self._runtime = runtime
881
+ self._task: Task[T] = Task(runtime)
882
+ self._task._is_promise_task = True
883
+ self._task.async_state = state
884
+ # Promise tasks are tracked so runtime shutdown can cancel unresolved
885
+ # externally-completed tasks and wake any awaiters.
886
+ runtime._call_sync(lambda: runtime._tasks.add(self._task), internal=False)
887
+
888
+ @property
889
+ def task(self) -> Task[T]:
890
+ """The stable task controlled by this source."""
891
+ return self._task
892
+
893
+ @property
894
+ def Task(self) -> Task[T]: # .NET-spelling alias for source-porting convenience
895
+ return self._task
896
+
897
+ def set_result(self, result: T = cast(T, _MISSING)) -> None:
898
+ if result is _MISSING:
899
+ result = cast(T, None)
900
+ if not self.try_set_result(result):
901
+ raise InvalidStateError("TaskCompletionSource task has already completed")
902
+
903
+ def try_set_result(self, result: T = cast(T, _MISSING)) -> bool:
904
+ if result is _MISSING:
905
+ result = cast(T, None)
906
+ return self._try_complete(Task.SUCCEEDED, result=result)
907
+
908
+ @staticmethod
909
+ def _normalize_exceptions(
910
+ error: Exception | Iterable[Exception],
911
+ ) -> tuple[Exception, ...] | Exception:
912
+ if isinstance(error, Exception):
913
+ return error
914
+ if error is None:
915
+ raise TypeError("exception must not be None")
916
+ if isinstance(error, (str, bytes, bytearray)):
917
+ raise TypeError("exception must be an Exception or iterable of Exceptions")
918
+ try:
919
+ exceptions = tuple(error)
920
+ except TypeError as exc:
921
+ raise TypeError("exception must be an Exception or iterable of Exceptions") from exc
922
+ if not exceptions:
923
+ raise ValueError("exception iterable must not be empty")
924
+ if not all(isinstance(item, Exception) for item in exceptions):
925
+ raise TypeError("exception iterable must contain only Exception instances")
926
+ return exceptions
927
+
928
+ def set_exception(self, error: Exception | Iterable[Exception]) -> None:
929
+ normalized = self._normalize_exceptions(error)
930
+ exception = (
931
+ normalized
932
+ if isinstance(normalized, Exception)
933
+ else AggregateError(normalized)
934
+ )
935
+ if not self._try_complete(Task.FAILED, exception=exception):
936
+ raise InvalidStateError("TaskCompletionSource task has already completed")
937
+
938
+ def try_set_exception(self, error: Exception | Iterable[Exception]) -> bool:
939
+ normalized = self._normalize_exceptions(error)
940
+ exception = (
941
+ normalized
942
+ if isinstance(normalized, Exception)
943
+ else AggregateError(normalized)
944
+ )
945
+ return self._try_complete(Task.FAILED, exception=exception)
946
+
947
+ def set_canceled(self, token: CancellationToken | None = None) -> None:
948
+ self._validate_token(token)
949
+ cancellation = OperationCanceledError(
950
+ "The task was canceled.", token=token
951
+ )
952
+ if not self._try_complete(Task.CANCELLED, exception=cancellation):
953
+ raise InvalidStateError("TaskCompletionSource task has already completed")
954
+
955
+ def try_set_canceled(self, token: CancellationToken | None = None) -> bool:
956
+ self._validate_token(token)
957
+ cancellation = OperationCanceledError(
958
+ "The task was canceled.", token=token
959
+ )
960
+ return self._try_complete(Task.CANCELLED, exception=cancellation)
961
+
962
+ @staticmethod
963
+ def _validate_token(token: CancellationToken | None) -> None:
964
+ if token is not None and not isinstance(token, CancellationToken):
965
+ raise TypeError("token must be a CancellationToken or None")
966
+
967
+ def set_from_task(self, completed_task: flpit.task.Task[Any]) -> None:
968
+ self._validate_completed_task(completed_task)
969
+ if not self._try_copy_task(completed_task):
970
+ raise InvalidStateError("TaskCompletionSource task has already completed")
971
+
972
+ def try_set_from_task(self, completed_task: flpit.task.Task[Any]) -> bool:
973
+ self._validate_completed_task(completed_task)
974
+ return self._try_copy_task(completed_task)
975
+
976
+ def _validate_completed_task(self, source: "flpit.task.Task[Any] | None") -> None:
977
+ if source is None:
978
+ raise TypeError("completed_task must not be None")
979
+ if not isinstance(source, Task):
980
+ raise TypeError("completed_task must be a Task instance")
981
+ if not source.done():
982
+ raise ValueError("completed_task must already be completed")
983
+
984
+ def _try_copy_task(self, source: "flpit.task.Task[Any]") -> bool:
985
+ if source._state == Task.SUCCEEDED:
986
+ return self._try_complete(Task.SUCCEEDED, result=source._result)
987
+ if source._state == Task.FAILED:
988
+ assert source._exception is not None
989
+ return self._try_complete(Task.FAILED, exception=source._exception)
990
+ if source._state == Task.CANCELLED:
991
+ return self._try_complete(
992
+ Task.CANCELLED,
993
+ exception=source._cancellation_exception(),
994
+ )
995
+ # A terminal state not recognized here would indicate a runtime invariant
996
+ # violation rather than a normal rejected completion.
997
+ raise InvalidStateError(f"Cannot copy task in state {source._state!r}")
998
+
999
+ def _try_complete(
1000
+ self,
1001
+ state: str,
1002
+ *,
1003
+ result: Any = None,
1004
+ exception: Exception | BaseException | None = None,
1005
+ ) -> bool:
1006
+ if self._task.done():
1007
+ return False
1008
+
1009
+ def complete_on_runtime() -> bool:
1010
+ if self._task.done():
1011
+ return False
1012
+ self._task._finish(state, result=result, exception=exception)
1013
+ return True
1014
+
1015
+ try:
1016
+ return bool(self._runtime._call_sync(complete_on_runtime, internal=True))
1017
+ except RuntimeShutdownError:
1018
+ # Shutdown may have won the race and completed this promise task as
1019
+ # cancelled. In that case TrySet* follows the standard lost-race path.
1020
+ if self._task.done():
1021
+ return False
1022
+ raise
1023
+
1024
+
818
1025
  def _normalize_tasks(items: tuple[Any, ...], *, allow_empty: bool) -> tuple[Task[Any], ...]:
819
1026
  """Normalize task-combinator inputs, scheduling raw awaitables as Tasks.
820
1027
 
@@ -887,9 +1094,10 @@ def _normalize_tasks(items: tuple[Any, ...], *, allow_empty: bool) -> tuple[Task
887
1094
  return tuple(normalized)
888
1095
 
889
1096
 
890
- async def _when_all_coroutine(tasks: tuple[Task[Any], ...]) -> list[Any]:
1097
+ async def _when_all_coroutine(tasks: tuple[Task[T], ...]) -> list[T]:
891
1098
  await _WaitMany(_get_runtime(), tasks, "all")
892
1099
  failures = tuple(task._exception for task in tasks if task._state == Task.FAILED)
1100
+
893
1101
  if len(failures) == 1:
894
1102
  assert failures[0] is not None
895
1103
  raise failures[0]
@@ -900,7 +1108,7 @@ async def _when_all_coroutine(tasks: tuple[Task[Any], ...]) -> list[Any]:
900
1108
  return [task._result for task in tasks]
901
1109
 
902
1110
 
903
- async def _when_any_coroutine(tasks: tuple[Task[Any], ...]) -> Task[Any]:
1111
+ async def _when_any_coroutine(tasks: tuple[Task[T], ...]) -> Task[T]:
904
1112
  winner = await _WaitMany(_get_runtime(), tasks, "any")
905
1113
  return cast(Task[Any], winner)
906
1114
 
@@ -995,6 +1203,39 @@ class _Runtime:
995
1203
  message = str(context.get("message", "Unexpected TinyTask runtime error"))
996
1204
  _LOG.error("%s", message, exc_info=(type(error), error, error.__traceback__))
997
1205
 
1206
+ def _call_sync(self, callback: Callable[[], T], *, internal: bool = False) -> T:
1207
+ """Run a small state mutation on the scheduler thread and return its value.
1208
+
1209
+ External completion APIs use this so callbacks/awaiters are manipulated
1210
+ only by the scheduler thread while callers retain synchronous Set/TrySet
1211
+ semantics. The operation fails rather than hanging if the runtime stops.
1212
+ """
1213
+ self._ensure_started()
1214
+ if self.is_runtime_thread():
1215
+ return callback()
1216
+
1217
+ completed = threading.Event()
1218
+ outcome: dict[str, Any] = {}
1219
+
1220
+ def invoke() -> None:
1221
+ try:
1222
+ outcome["value"] = callback()
1223
+ except BaseException as error:
1224
+ outcome["error"] = error
1225
+ finally:
1226
+ completed.set()
1227
+
1228
+ self._post(invoke, internal=internal)
1229
+ while not completed.wait(0.05):
1230
+ thread = self._thread
1231
+ if thread is None or not thread.is_alive():
1232
+ raise RuntimeShutdownError(
1233
+ "TinyTask runtime stopped before the scheduled operation ran"
1234
+ )
1235
+ if "error" in outcome:
1236
+ raise outcome["error"]
1237
+ return cast(T, outcome.get("value"))
1238
+
998
1239
  def _post(self, callback: Callable[[], None], *, internal: bool = False) -> None:
999
1240
  self._ensure_started()
1000
1241
  execute_inline = False
@@ -1412,12 +1653,16 @@ class _Runtime:
1412
1653
  self._schedule(task)
1413
1654
 
1414
1655
  def _next_timeout(self) -> float | None:
1656
+ max_selector_wait = 24 * 60 * 60 # 24 hours, in seconds
1657
+
1415
1658
  while self._timers:
1416
1659
  deadline, _, task, operation = self._timers[0]
1417
1660
  if task.done() or task._waiting_on is not operation:
1418
1661
  heapq.heappop(self._timers)
1419
1662
  else:
1420
- return max(0.0, deadline - time.monotonic())
1663
+ remaining = max(0.0, deadline - time.monotonic())
1664
+ return min(remaining, max_selector_wait)
1665
+
1421
1666
  return None
1422
1667
 
1423
1668
  def _begin_shutdown(self) -> None:
@@ -1585,17 +1830,41 @@ def _shutdown_at_exit() -> None:
1585
1830
 
1586
1831
  atexit.register(_shutdown_at_exit)
1587
1832
 
1588
- def task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
1589
- @functools.wraps(async_function)
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]]:
1845
+ """
1846
+ | Wraps sync functions and non-asyncio dependent coroutines to eagerly executed await-able tasks
1847
+ """
1848
+ @wraps(function)
1590
1849
  def wrapper(*args: Any, **kwargs: Any) -> Task[Any]:
1591
- return Task.run(async_function, *args, **kwargs)
1850
+ return Task.run(function, *args, **kwargs)
1592
1851
 
1593
1852
  return wrapper
1594
1853
 
1595
1854
 
1596
- def coro_task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
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
+
1865
+ def asyncio_task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
1597
1866
  """
1598
- | Wraps an async coroutine function to be compatible with Task.run(coro)
1867
+ | Wraps async functions with a dependency on asyncio to be compatible with Task.run(coro)
1599
1868
 
1600
1869
  Use with caution as this transfers execution control to an isolated asyncio loop runner.
1601
1870
  """
@@ -1608,3 +1877,25 @@ def coro_task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
1608
1877
  return runner.run(coro)
1609
1878
 
1610
1879
  return task(wrapper)
1880
+
1881
+
1882
+ class _SyncBridge:
1883
+ def __rshift__(self, ttwa: Task[T]) -> T:
1884
+ frame = inspect.currentframe()
1885
+ try:
1886
+ assert frame is not None
1887
+ caller: Any = frame.f_back
1888
+ if caller and caller.f_code.co_flags & inspect.CO_COROUTINE:
1889
+ raise RuntimeError("twait >> cannot be used inside async def")
1890
+
1891
+ return ttwa.result()
1892
+ finally:
1893
+ del frame
1894
+
1895
+
1896
+ twait: Final = _SyncBridge()
1897
+ """
1898
+ | If you seriously want to await in a sync context make it nice
1899
+
1900
+ twait >> Task.delay()
1901
+ """
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes