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.
- {flpit-0.2.1.dev7 → flpit-0.2.2}/PKG-INFO +63 -1
- {flpit-0.2.1.dev7 → flpit-0.2.2}/README.md +61 -0
- {flpit-0.2.1.dev7 → flpit-0.2.2}/pyproject.toml +2 -1
- {flpit-0.2.1.dev7 → flpit-0.2.2}/src/flpit/core/linq.py +2 -1
- flpit-0.2.2/src/flpit/csv.py +333 -0
- {flpit-0.2.1.dev7 → flpit-0.2.2}/src/flpit/json.py +111 -55
- {flpit-0.2.1.dev7 → flpit-0.2.2}/src/flpit/task.py +330 -39
- {flpit-0.2.1.dev7 → flpit-0.2.2}/.gitignore +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.2}/LICENSE +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.2}/src/flp/__init__.py +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.2}/src/flpit/__init__.py +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.2}/src/flpit/core/__init__.py +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.2}/src/flpit/flp.py +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.2}/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.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.
|
|
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
|
|
69
|
-
def __class_getitem__[T](cls, item: type[T]) ->
|
|
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
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
85
|
+
class JProp:
|
|
86
|
+
__slots__ = ("json_name",)
|
|
87
|
+
json_name: str
|
|
83
88
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
158
|
+
# Preserve all leading underscores.
|
|
159
|
+
prefix = name[: len(name) - len(name.lstrip("_"))]
|
|
160
|
+
clean_name = name[len(prefix):]
|
|
137
161
|
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
186
|
-
included = any(item is
|
|
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.
|
|
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
|
|
195
|
-
and item is not
|
|
196
|
-
and not isinstance(item,
|
|
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
|
-
|
|
568
|
-
|
|
612
|
+
try:
|
|
613
|
+
parsed = _get_decoder(wire_type).decode(data)
|
|
569
614
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
target,
|
|
573
|
-
from_attributes=True,
|
|
574
|
-
)
|
|
615
|
+
if jso.native:
|
|
616
|
+
return parsed
|
|
575
617
|
|
|
576
|
-
|
|
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
|
-
|
|
597
|
-
|
|
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
|
|
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,
|
|
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(
|
|
52
|
-
"""Raised when
|
|
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
|
-
"""
|
|
91
|
+
"""Represents multiple errors, preserving the original exceptions.
|
|
69
92
|
|
|
70
|
-
The
|
|
71
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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",
|
|
416
|
+
class _WaitTask[T](_Operation):
|
|
417
|
+
def __init__(self, runtime: "_Runtime", ttw: "Task[T]") -> None:
|
|
389
418
|
super().__init__(runtime)
|
|
390
|
-
self.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[
|
|
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
|
|
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:
|
|
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[
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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[
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
1589
|
-
|
|
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(
|
|
1850
|
+
return Task.run(function, *args, **kwargs)
|
|
1592
1851
|
|
|
1593
1852
|
return wrapper
|
|
1594
1853
|
|
|
1595
1854
|
|
|
1596
|
-
|
|
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
|
|
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
|
|
File without changes
|