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.
- {flpit-0.2.1.dev8 → flpit-0.2.3}/PKG-INFO +63 -1
- {flpit-0.2.1.dev8 → flpit-0.2.3}/README.md +61 -0
- {flpit-0.2.1.dev8 → flpit-0.2.3}/pyproject.toml +3 -2
- {flpit-0.2.1.dev8 → flpit-0.2.3}/src/flpit/core/linq.py +47 -39
- flpit-0.2.3/src/flpit/csv.py +333 -0
- {flpit-0.2.1.dev8 → flpit-0.2.3}/src/flpit/json.py +64 -32
- {flpit-0.2.1.dev8 → flpit-0.2.3}/src/flpit/task.py +50 -22
- {flpit-0.2.1.dev8 → flpit-0.2.3}/.gitignore +0 -0
- {flpit-0.2.1.dev8 → flpit-0.2.3}/LICENSE +0 -0
- {flpit-0.2.1.dev8 → flpit-0.2.3}/src/flp/__init__.py +0 -0
- {flpit-0.2.1.dev8 → flpit-0.2.3}/src/flpit/__init__.py +0 -0
- {flpit-0.2.1.dev8 → flpit-0.2.3}/src/flpit/core/__init__.py +0 -0
- {flpit-0.2.1.dev8 → flpit-0.2.3}/src/flpit/flp.py +0 -0
- {flpit-0.2.1.dev8 → flpit-0.2.3}/src/flpit/py.typed +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: flpit
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
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,
|
|
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
|
|
117
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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[
|
|
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
|
-
|
|
1035
|
-
|
|
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]) ->
|
|
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]) ->
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
return Annotated[target_type, cls(json_name)]
|
|
86
|
+
__slots__ = ("json_name",)
|
|
87
|
+
json_name: str
|
|
81
88
|
|
|
82
|
-
def __init__(self,
|
|
83
|
-
self.
|
|
89
|
+
def __init__(self, json_name: str):
|
|
90
|
+
self.json_name = json_name
|
|
84
91
|
|
|
85
|
-
|
|
86
|
-
def
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
J = Annotated
|
|
107
|
+
return JPropTypeProxy()
|
|
95
108
|
|
|
96
109
|
@dataclass
|
|
97
110
|
class AnnotationModel:
|
|
98
|
-
|
|
99
|
-
display_name:
|
|
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.
|
|
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
|
-
|
|
592
|
-
|
|
612
|
+
try:
|
|
613
|
+
parsed = _get_decoder(wire_type).decode(data)
|
|
593
614
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
target,
|
|
597
|
-
from_attributes=True,
|
|
598
|
-
)
|
|
615
|
+
if jso.native:
|
|
616
|
+
return parsed
|
|
599
617
|
|
|
600
|
-
|
|
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
|
-
|
|
621
|
-
|
|
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,
|
|
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",
|
|
416
|
+
class _WaitTask[T](_Operation):
|
|
417
|
+
def __init__(self, runtime: "_Runtime", ttw: "Task[T]") -> None:
|
|
414
418
|
super().__init__(runtime)
|
|
415
|
-
self.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[
|
|
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:
|
|
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[
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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[
|
|
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[
|
|
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
|
-
|
|
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
|
-
@
|
|
1848
|
+
@wraps(function)
|
|
1831
1849
|
def wrapper(*args: Any, **kwargs: Any) -> Task[Any]:
|
|
1832
|
-
return Task.run(
|
|
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]) ->
|
|
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
|
|
File without changes
|