flpit 0.2.1.dev6__tar.gz → 0.2.1.dev8__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.dev6 → flpit-0.2.1.dev8}/PKG-INFO +1 -1
- {flpit-0.2.1.dev6 → flpit-0.2.1.dev8}/pyproject.toml +2 -1
- {flpit-0.2.1.dev6 → flpit-0.2.1.dev8}/src/flpit/json.py +59 -35
- {flpit-0.2.1.dev6 → flpit-0.2.1.dev8}/src/flpit/task.py +315 -19
- {flpit-0.2.1.dev6 → flpit-0.2.1.dev8}/.gitignore +0 -0
- {flpit-0.2.1.dev6 → flpit-0.2.1.dev8}/LICENSE +0 -0
- {flpit-0.2.1.dev6 → flpit-0.2.1.dev8}/README.md +0 -0
- {flpit-0.2.1.dev6 → flpit-0.2.1.dev8}/src/flp/__init__.py +0 -0
- {flpit-0.2.1.dev6 → flpit-0.2.1.dev8}/src/flpit/__init__.py +0 -0
- {flpit-0.2.1.dev6 → flpit-0.2.1.dev8}/src/flpit/core/__init__.py +0 -0
- {flpit-0.2.1.dev6 → flpit-0.2.1.dev8}/src/flpit/core/linq.py +0 -0
- {flpit-0.2.1.dev6 → flpit-0.2.1.dev8}/src/flpit/flp.py +0 -0
- {flpit-0.2.1.dev6 → flpit-0.2.1.dev8}/src/flpit/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "flpit"
|
|
3
|
-
version = "0.2.1.
|
|
3
|
+
version = "0.2.1.dev8"
|
|
4
4
|
description = "Fluent LINQ for Python — flip your iterables"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -34,6 +34,7 @@ dev = [
|
|
|
34
34
|
"pyright>=1.1.414",
|
|
35
35
|
"pytest-benchmark>=5.3.0",
|
|
36
36
|
"mypy>=2.4.0",
|
|
37
|
+
"httpx>=0.28.1",
|
|
37
38
|
]
|
|
38
39
|
|
|
39
40
|
|
|
@@ -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,15 +66,15 @@ class JsonSerializerOptions:
|
|
|
65
66
|
# Metadata Annotation Markers
|
|
66
67
|
# ---------------------------------------------------------------------------
|
|
67
68
|
|
|
68
|
-
class
|
|
69
|
+
class JIgnore:
|
|
69
70
|
def __class_getitem__[T](cls, item: type[T]) -> Any:
|
|
70
71
|
return Annotated[item, cls]
|
|
71
72
|
|
|
72
|
-
class
|
|
73
|
+
class JInclude:
|
|
73
74
|
def __class_getitem__[T](cls, item: type[T]) -> Any:
|
|
74
75
|
return Annotated[item, cls]
|
|
75
76
|
|
|
76
|
-
class
|
|
77
|
+
class JProp:
|
|
77
78
|
def __class_getitem__[T](cls, item: tuple[type[T], str]) -> Any:
|
|
78
79
|
target_type, json_name = item
|
|
79
80
|
return Annotated[target_type, cls(json_name)]
|
|
@@ -81,9 +82,24 @@ class JsonProperty:
|
|
|
81
82
|
def __init__(self, name: str):
|
|
82
83
|
self.name = name
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
class Dummy:
|
|
86
|
+
def __getitem__[T](self, item: tuple[type[T], str]) -> Any:
|
|
87
|
+
target_type, json_name = item
|
|
88
|
+
return Annotated[target_type, self]
|
|
89
|
+
|
|
90
|
+
def __init__(self, name: str):
|
|
91
|
+
self.name = name
|
|
92
|
+
|
|
93
|
+
# just alias it
|
|
94
|
+
J = Annotated
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class AnnotationModel:
|
|
98
|
+
internal_id: JProp[int, "any_arbitrary_string_you_want"]
|
|
99
|
+
display_name: J[str, JProp("")]
|
|
100
|
+
display_name_2: Dummy("name")[str]
|
|
101
|
+
secret: JIgnore[str] = ""
|
|
102
|
+
always_emit: JInclude[int] = 0
|
|
87
103
|
|
|
88
104
|
# ---------------------------------------------------------------------
|
|
89
105
|
# Generated type metadata
|
|
@@ -120,36 +136,44 @@ class _WireType:
|
|
|
120
136
|
|
|
121
137
|
def _apply_naming_policy(name: str, policy: JsonNamingPolicy) -> str:
|
|
122
138
|
"""
|
|
123
|
-
|
|
124
|
-
|
|
139
|
+
Tokenizes snake_case, camelCase, PascalCase, UPPER_CASE, and kebab-case
|
|
140
|
+
identifiers, then formats the tokens according to the target policy.
|
|
141
|
+
Preserves leading underscores.
|
|
125
142
|
"""
|
|
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:
|
|
143
|
+
if policy == JsonNamingPolicy.none:
|
|
133
144
|
return name
|
|
134
145
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if policy == JsonNamingPolicy.upper:
|
|
139
|
-
result = "".join(t.upper() for t in tokens)
|
|
146
|
+
# Preserve all leading underscores.
|
|
147
|
+
prefix = name[: len(name) - len(name.lstrip("_"))]
|
|
148
|
+
clean_name = name[len(prefix):]
|
|
140
149
|
|
|
141
|
-
|
|
142
|
-
|
|
150
|
+
# Split on existing separators first, then tokenize each segment.
|
|
151
|
+
# This also correctly handles acronyms such as XMLParser and UPPER_CASE.
|
|
152
|
+
token_pattern = r"[A-Z]+(?=[A-Z][a-z]|\d|$)|[A-Z]?[a-z]+|\d+"
|
|
143
153
|
|
|
144
|
-
|
|
145
|
-
|
|
154
|
+
tokens = [
|
|
155
|
+
token
|
|
156
|
+
for part in re.split(r"[_-]+", clean_name)
|
|
157
|
+
for token in re.findall(token_pattern, part)
|
|
158
|
+
]
|
|
146
159
|
|
|
147
|
-
if
|
|
148
|
-
|
|
149
|
-
result = tokens[0].lower() + "".join(t.capitalize() for t in tokens[1:])
|
|
160
|
+
if not tokens:
|
|
161
|
+
return name
|
|
150
162
|
|
|
163
|
+
if policy == JsonNamingPolicy.lower:
|
|
164
|
+
result = "".join(token.lower() for token in tokens)
|
|
165
|
+
elif policy == JsonNamingPolicy.upper:
|
|
166
|
+
result = "".join(token.upper() for token in tokens)
|
|
167
|
+
elif policy == JsonNamingPolicy.pascal:
|
|
168
|
+
result = "".join(token.capitalize() for token in tokens)
|
|
169
|
+
elif policy == JsonNamingPolicy.camel:
|
|
170
|
+
result = tokens[0].lower() + "".join(
|
|
171
|
+
token.capitalize() for token in tokens[1:]
|
|
172
|
+
)
|
|
173
|
+
elif policy == JsonNamingPolicy.kebap:
|
|
174
|
+
result = "-".join(token.lower() for token in tokens)
|
|
151
175
|
else:
|
|
152
|
-
|
|
176
|
+
raise ValueError(f"Unsupported naming policy: {policy!r}")
|
|
153
177
|
|
|
154
178
|
return prefix + result
|
|
155
179
|
|
|
@@ -182,18 +206,18 @@ def _field_directives(
|
|
|
182
206
|
args = get_args(annotation)
|
|
183
207
|
base_type = args[0]
|
|
184
208
|
metadata = args[1:]
|
|
185
|
-
ignored = any(item is
|
|
186
|
-
included = any(item is
|
|
209
|
+
ignored = any(item is JIgnore for item in metadata)
|
|
210
|
+
included = any(item is JInclude for item in metadata)
|
|
187
211
|
json_name = next(
|
|
188
|
-
(item.name for item in metadata if isinstance(item,
|
|
212
|
+
(item.name for item in metadata if isinstance(item, JProp)),
|
|
189
213
|
None,
|
|
190
214
|
)
|
|
191
215
|
remaining = tuple(
|
|
192
216
|
item
|
|
193
217
|
for item in metadata
|
|
194
|
-
if item is not
|
|
195
|
-
and item is not
|
|
196
|
-
and not isinstance(item,
|
|
218
|
+
if item is not JIgnore
|
|
219
|
+
and item is not JInclude
|
|
220
|
+
and not isinstance(item, JProp)
|
|
197
221
|
)
|
|
198
222
|
|
|
199
223
|
if remaining:
|
|
@@ -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
|
|
|
@@ -39,17 +39,36 @@ from collections import deque
|
|
|
39
39
|
from concurrent.futures import Future, ThreadPoolExecutor
|
|
40
40
|
from dataclasses import dataclass
|
|
41
41
|
from itertools import count
|
|
42
|
-
from typing import Any, Callable, Coroutine, Generic, TypeVar, cast, Iterable
|
|
42
|
+
from typing import Any, Callable, Coroutine, Generic, TypeVar, cast, Iterable, Final
|
|
43
43
|
import functools
|
|
44
44
|
|
|
45
|
+
import flpit
|
|
46
|
+
|
|
45
47
|
T = TypeVar("T")
|
|
46
48
|
_LOG = logging.getLogger("tinytask")
|
|
47
49
|
_MISSING = object()
|
|
48
50
|
_WAKE = object()
|
|
49
51
|
|
|
50
52
|
|
|
51
|
-
class OperationCanceledError(
|
|
52
|
-
"""Raised when
|
|
53
|
+
class OperationCanceledError(Exception):
|
|
54
|
+
"""Raised when an operation is canceled.
|
|
55
|
+
|
|
56
|
+
``token`` records the token associated with cancellation when one exists.
|
|
57
|
+
The exception intentionally derives from ``Exception`` so normal exception
|
|
58
|
+
handling can catch cancellation explicitly or through a broad handler.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
message: str = "The operation was canceled.",
|
|
64
|
+
token: CancellationToken | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.token = token
|
|
67
|
+
super().__init__(message)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Backwards-compatible spelling used by the first TinyTask prototype.
|
|
71
|
+
CancelledError = OperationCanceledError
|
|
53
72
|
|
|
54
73
|
|
|
55
74
|
class InvalidStateError(RuntimeError):
|
|
@@ -65,10 +84,10 @@ class RuntimeShutdownError(RuntimeError):
|
|
|
65
84
|
|
|
66
85
|
|
|
67
86
|
class AggregateError(Exception):
|
|
68
|
-
"""
|
|
87
|
+
"""Represents multiple errors, preserving the original exceptions.
|
|
69
88
|
|
|
70
|
-
The
|
|
71
|
-
|
|
89
|
+
The default message follows the .NET ``AggregateException`` convention.
|
|
90
|
+
``exceptions`` is stored as an immutable snapshot.
|
|
72
91
|
"""
|
|
73
92
|
|
|
74
93
|
def __init__(
|
|
@@ -77,6 +96,10 @@ class AggregateError(Exception):
|
|
|
77
96
|
message: str = "One or more errors occurred.",
|
|
78
97
|
) -> None:
|
|
79
98
|
self.exceptions = tuple(exceptions)
|
|
99
|
+
if not self.exceptions:
|
|
100
|
+
raise ValueError("exceptions must contain at least one exception")
|
|
101
|
+
if not all(isinstance(error, Exception) for error in self.exceptions):
|
|
102
|
+
raise TypeError("exceptions must contain Exception instances")
|
|
80
103
|
super().__init__(message)
|
|
81
104
|
|
|
82
105
|
|
|
@@ -258,11 +281,13 @@ class CancellationTokenSource:
|
|
|
258
281
|
self._linked_registrations.clear()
|
|
259
282
|
if timer is not None:
|
|
260
283
|
timer.cancel()
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
284
|
+
try:
|
|
285
|
+
cancelled = self._state.cancel()
|
|
286
|
+
finally:
|
|
287
|
+
# A linked source no longer needs to hold registrations after its own
|
|
288
|
+
# cancellation has fired. Also clean these up if a callback failed.
|
|
289
|
+
for registration in links:
|
|
290
|
+
registration.dispose()
|
|
266
291
|
return cancelled
|
|
267
292
|
|
|
268
293
|
def cancel_after(self, seconds: float | None) -> None:
|
|
@@ -447,7 +472,7 @@ async def _delay_coroutine(runtime: "_Runtime", delay: float) -> None:
|
|
|
447
472
|
|
|
448
473
|
|
|
449
474
|
class Task(Generic[T]):
|
|
450
|
-
"""Result-bearing task managed by
|
|
475
|
+
"""Result-bearing task managed by FlpIt's process-wide scheduler."""
|
|
451
476
|
|
|
452
477
|
PENDING = "pending"
|
|
453
478
|
SUCCEEDED = "succeeded"
|
|
@@ -478,6 +503,13 @@ class Task(Generic[T]):
|
|
|
478
503
|
self._token = token or CancellationToken.none()
|
|
479
504
|
self._token_registration: CancellationTokenRegistration | None = None
|
|
480
505
|
self._pass_token = False
|
|
506
|
+
self._is_promise_task = False
|
|
507
|
+
self.async_state: Any = None
|
|
508
|
+
|
|
509
|
+
@property
|
|
510
|
+
def status(self) -> str:
|
|
511
|
+
"""Current task state: pending, succeeded, failed, or cancelled."""
|
|
512
|
+
return self._state
|
|
481
513
|
|
|
482
514
|
@classmethod
|
|
483
515
|
def configure(
|
|
@@ -599,7 +631,7 @@ class Task(Generic[T]):
|
|
|
599
631
|
Accepts Task instances and raw awaitables (including coroutine objects)
|
|
600
632
|
positionally or in one iterable. Raw awaitables are scheduled
|
|
601
633
|
automatically. The winning task is returned even if it failed or was
|
|
602
|
-
|
|
634
|
+
canceled; inspect/await that task to observe
|
|
603
635
|
its outcome. Losing tasks continue running. An empty input is invalid.
|
|
604
636
|
If multiple tasks are already complete at observation time, input order
|
|
605
637
|
breaks the tie deterministically.
|
|
@@ -612,6 +644,22 @@ class Task(Generic[T]):
|
|
|
612
644
|
cls.run(_when_any_coroutine, normalized, name=name, token=token),
|
|
613
645
|
)
|
|
614
646
|
|
|
647
|
+
@staticmethod
|
|
648
|
+
async def collect(*tasks: Task[Any] | list[Task[Any]]):
|
|
649
|
+
"""
|
|
650
|
+
Yields tasks as they complete. Accepts either a list or loose positional tasks.
|
|
651
|
+
"""
|
|
652
|
+
# FIX: If it's a list or tuple, extract and cast the FIRST element to a mutable list
|
|
653
|
+
if len(tasks) == 1 and isinstance(tasks[0], (list, tuple)):
|
|
654
|
+
pending = list(tasks[0]) # Converts the inner container into a fresh list
|
|
655
|
+
else:
|
|
656
|
+
pending = list(tasks) # Converts the *tasks tuple into a fresh list
|
|
657
|
+
|
|
658
|
+
while pending:
|
|
659
|
+
finished_task = await Task.when_any(pending)
|
|
660
|
+
pending.remove(finished_task) # Safe to call, because pending is ALWAYS a list!
|
|
661
|
+
yield finished_task
|
|
662
|
+
|
|
615
663
|
@classmethod
|
|
616
664
|
def delay(
|
|
617
665
|
cls,
|
|
@@ -692,9 +740,14 @@ class Task(Generic[T]):
|
|
|
692
740
|
if not self._completed.wait(timeout):
|
|
693
741
|
raise TimeoutError(f"{self.name} did not complete before the timeout")
|
|
694
742
|
if self._state == self.CANCELLED:
|
|
695
|
-
raise
|
|
743
|
+
raise self._cancellation_exception()
|
|
696
744
|
return self._exception
|
|
697
745
|
|
|
746
|
+
def _cancellation_exception(self) -> OperationCanceledError:
|
|
747
|
+
if isinstance(self._exception, OperationCanceledError):
|
|
748
|
+
return self._exception
|
|
749
|
+
return OperationCanceledError(f"{self.name} was cancelled")
|
|
750
|
+
|
|
698
751
|
def cancel(self) -> bool:
|
|
699
752
|
"""Request cancellation of this Task, independently of other token users."""
|
|
700
753
|
if self.done():
|
|
@@ -713,7 +766,7 @@ class Task(Generic[T]):
|
|
|
713
766
|
|
|
714
767
|
def _raise_or_return(self) -> T:
|
|
715
768
|
if self._state == self.CANCELLED:
|
|
716
|
-
raise
|
|
769
|
+
raise self._cancellation_exception()
|
|
717
770
|
if self._state == self.FAILED:
|
|
718
771
|
assert self._exception is not None
|
|
719
772
|
raise self._exception
|
|
@@ -731,7 +784,12 @@ class Task(Generic[T]):
|
|
|
731
784
|
return
|
|
732
785
|
self._cancel_requested = True
|
|
733
786
|
self._runtime._detach_wait(self)
|
|
734
|
-
if
|
|
787
|
+
if self._is_promise_task:
|
|
788
|
+
self._finish(
|
|
789
|
+
self.CANCELLED,
|
|
790
|
+
exception=OperationCanceledError(f"{self.name} cancelled"),
|
|
791
|
+
)
|
|
792
|
+
elif not self._running:
|
|
735
793
|
self._runtime._schedule(self, exc=OperationCanceledError(f"{self.name} cancelled"))
|
|
736
794
|
|
|
737
795
|
def _finish(
|
|
@@ -799,6 +857,165 @@ class Task(Generic[T]):
|
|
|
799
857
|
return f"<{self.name} state={self._state}>"
|
|
800
858
|
|
|
801
859
|
|
|
860
|
+
class TaskCompletionSource(Generic[T]):
|
|
861
|
+
"""Externally controlled producer for a stable, awaitable ``Task[T]``.
|
|
862
|
+
|
|
863
|
+
The source can complete its task with a result, exception, cancellation, or
|
|
864
|
+
the terminal outcome of an already-completed task. Completion methods are
|
|
865
|
+
safe to call from arbitrary threads. Exactly one terminal transition wins.
|
|
866
|
+
|
|
867
|
+
``state`` is carried by the task as ``task.async_state`` for diagnostics and
|
|
868
|
+
callback-to-task adapters. ``set_result()`` without a value is allowed for
|
|
869
|
+
the non-generic-style use case and completes with ``None``.
|
|
870
|
+
"""
|
|
871
|
+
|
|
872
|
+
def __init__(self, state: Any = None) -> None:
|
|
873
|
+
runtime = _get_runtime()
|
|
874
|
+
self._runtime = runtime
|
|
875
|
+
self._task: Task[T] = Task(runtime)
|
|
876
|
+
self._task._is_promise_task = True
|
|
877
|
+
self._task.async_state = state
|
|
878
|
+
# Promise tasks are tracked so runtime shutdown can cancel unresolved
|
|
879
|
+
# externally-completed tasks and wake any awaiters.
|
|
880
|
+
runtime._call_sync(lambda: runtime._tasks.add(self._task), internal=False)
|
|
881
|
+
|
|
882
|
+
@property
|
|
883
|
+
def task(self) -> Task[T]:
|
|
884
|
+
"""The stable task controlled by this source."""
|
|
885
|
+
return self._task
|
|
886
|
+
|
|
887
|
+
@property
|
|
888
|
+
def Task(self) -> Task[T]: # .NET-spelling alias for source-porting convenience
|
|
889
|
+
return self._task
|
|
890
|
+
|
|
891
|
+
def set_result(self, result: T = cast(T, _MISSING)) -> None:
|
|
892
|
+
if result is _MISSING:
|
|
893
|
+
result = cast(T, None)
|
|
894
|
+
if not self.try_set_result(result):
|
|
895
|
+
raise InvalidStateError("TaskCompletionSource task has already completed")
|
|
896
|
+
|
|
897
|
+
def try_set_result(self, result: T = cast(T, _MISSING)) -> bool:
|
|
898
|
+
if result is _MISSING:
|
|
899
|
+
result = cast(T, None)
|
|
900
|
+
return self._try_complete(Task.SUCCEEDED, result=result)
|
|
901
|
+
|
|
902
|
+
@staticmethod
|
|
903
|
+
def _normalize_exceptions(
|
|
904
|
+
error: Exception | Iterable[Exception],
|
|
905
|
+
) -> tuple[Exception, ...] | Exception:
|
|
906
|
+
if isinstance(error, Exception):
|
|
907
|
+
return error
|
|
908
|
+
if error is None:
|
|
909
|
+
raise TypeError("exception must not be None")
|
|
910
|
+
if isinstance(error, (str, bytes, bytearray)):
|
|
911
|
+
raise TypeError("exception must be an Exception or iterable of Exceptions")
|
|
912
|
+
try:
|
|
913
|
+
exceptions = tuple(error)
|
|
914
|
+
except TypeError as exc:
|
|
915
|
+
raise TypeError("exception must be an Exception or iterable of Exceptions") from exc
|
|
916
|
+
if not exceptions:
|
|
917
|
+
raise ValueError("exception iterable must not be empty")
|
|
918
|
+
if not all(isinstance(item, Exception) for item in exceptions):
|
|
919
|
+
raise TypeError("exception iterable must contain only Exception instances")
|
|
920
|
+
return exceptions
|
|
921
|
+
|
|
922
|
+
def set_exception(self, error: Exception | Iterable[Exception]) -> None:
|
|
923
|
+
normalized = self._normalize_exceptions(error)
|
|
924
|
+
exception = (
|
|
925
|
+
normalized
|
|
926
|
+
if isinstance(normalized, Exception)
|
|
927
|
+
else AggregateError(normalized)
|
|
928
|
+
)
|
|
929
|
+
if not self._try_complete(Task.FAILED, exception=exception):
|
|
930
|
+
raise InvalidStateError("TaskCompletionSource task has already completed")
|
|
931
|
+
|
|
932
|
+
def try_set_exception(self, error: Exception | Iterable[Exception]) -> bool:
|
|
933
|
+
normalized = self._normalize_exceptions(error)
|
|
934
|
+
exception = (
|
|
935
|
+
normalized
|
|
936
|
+
if isinstance(normalized, Exception)
|
|
937
|
+
else AggregateError(normalized)
|
|
938
|
+
)
|
|
939
|
+
return self._try_complete(Task.FAILED, exception=exception)
|
|
940
|
+
|
|
941
|
+
def set_canceled(self, token: CancellationToken | None = None) -> None:
|
|
942
|
+
self._validate_token(token)
|
|
943
|
+
cancellation = OperationCanceledError(
|
|
944
|
+
"The task was canceled.", token=token
|
|
945
|
+
)
|
|
946
|
+
if not self._try_complete(Task.CANCELLED, exception=cancellation):
|
|
947
|
+
raise InvalidStateError("TaskCompletionSource task has already completed")
|
|
948
|
+
|
|
949
|
+
def try_set_canceled(self, token: CancellationToken | None = None) -> bool:
|
|
950
|
+
self._validate_token(token)
|
|
951
|
+
cancellation = OperationCanceledError(
|
|
952
|
+
"The task was canceled.", token=token
|
|
953
|
+
)
|
|
954
|
+
return self._try_complete(Task.CANCELLED, exception=cancellation)
|
|
955
|
+
|
|
956
|
+
@staticmethod
|
|
957
|
+
def _validate_token(token: CancellationToken | None) -> None:
|
|
958
|
+
if token is not None and not isinstance(token, CancellationToken):
|
|
959
|
+
raise TypeError("token must be a CancellationToken or None")
|
|
960
|
+
|
|
961
|
+
def set_from_task(self, completed_task: flpit.task.Task[Any]) -> None:
|
|
962
|
+
self._validate_completed_task(completed_task)
|
|
963
|
+
if not self._try_copy_task(completed_task):
|
|
964
|
+
raise InvalidStateError("TaskCompletionSource task has already completed")
|
|
965
|
+
|
|
966
|
+
def try_set_from_task(self, completed_task: flpit.task.Task[Any]) -> bool:
|
|
967
|
+
self._validate_completed_task(completed_task)
|
|
968
|
+
return self._try_copy_task(completed_task)
|
|
969
|
+
|
|
970
|
+
def _validate_completed_task(self, source: "flpit.task.Task[Any] | None") -> None:
|
|
971
|
+
if source is None:
|
|
972
|
+
raise TypeError("completed_task must not be None")
|
|
973
|
+
if not isinstance(source, Task):
|
|
974
|
+
raise TypeError("completed_task must be a Task instance")
|
|
975
|
+
if not source.done():
|
|
976
|
+
raise ValueError("completed_task must already be completed")
|
|
977
|
+
|
|
978
|
+
def _try_copy_task(self, source: "flpit.task.Task[Any]") -> bool:
|
|
979
|
+
if source._state == Task.SUCCEEDED:
|
|
980
|
+
return self._try_complete(Task.SUCCEEDED, result=source._result)
|
|
981
|
+
if source._state == Task.FAILED:
|
|
982
|
+
assert source._exception is not None
|
|
983
|
+
return self._try_complete(Task.FAILED, exception=source._exception)
|
|
984
|
+
if source._state == Task.CANCELLED:
|
|
985
|
+
return self._try_complete(
|
|
986
|
+
Task.CANCELLED,
|
|
987
|
+
exception=source._cancellation_exception(),
|
|
988
|
+
)
|
|
989
|
+
# A terminal state not recognized here would indicate a runtime invariant
|
|
990
|
+
# violation rather than a normal rejected completion.
|
|
991
|
+
raise InvalidStateError(f"Cannot copy task in state {source._state!r}")
|
|
992
|
+
|
|
993
|
+
def _try_complete(
|
|
994
|
+
self,
|
|
995
|
+
state: str,
|
|
996
|
+
*,
|
|
997
|
+
result: Any = None,
|
|
998
|
+
exception: Exception | BaseException | None = None,
|
|
999
|
+
) -> bool:
|
|
1000
|
+
if self._task.done():
|
|
1001
|
+
return False
|
|
1002
|
+
|
|
1003
|
+
def complete_on_runtime() -> bool:
|
|
1004
|
+
if self._task.done():
|
|
1005
|
+
return False
|
|
1006
|
+
self._task._finish(state, result=result, exception=exception)
|
|
1007
|
+
return True
|
|
1008
|
+
|
|
1009
|
+
try:
|
|
1010
|
+
return bool(self._runtime._call_sync(complete_on_runtime, internal=True))
|
|
1011
|
+
except RuntimeShutdownError:
|
|
1012
|
+
# Shutdown may have won the race and completed this promise task as
|
|
1013
|
+
# cancelled. In that case TrySet* follows the standard lost-race path.
|
|
1014
|
+
if self._task.done():
|
|
1015
|
+
return False
|
|
1016
|
+
raise
|
|
1017
|
+
|
|
1018
|
+
|
|
802
1019
|
def _normalize_tasks(items: tuple[Any, ...], *, allow_empty: bool) -> tuple[Task[Any], ...]:
|
|
803
1020
|
"""Normalize task-combinator inputs, scheduling raw awaitables as Tasks.
|
|
804
1021
|
|
|
@@ -979,6 +1196,39 @@ class _Runtime:
|
|
|
979
1196
|
message = str(context.get("message", "Unexpected TinyTask runtime error"))
|
|
980
1197
|
_LOG.error("%s", message, exc_info=(type(error), error, error.__traceback__))
|
|
981
1198
|
|
|
1199
|
+
def _call_sync(self, callback: Callable[[], T], *, internal: bool = False) -> T:
|
|
1200
|
+
"""Run a small state mutation on the scheduler thread and return its value.
|
|
1201
|
+
|
|
1202
|
+
External completion APIs use this so callbacks/awaiters are manipulated
|
|
1203
|
+
only by the scheduler thread while callers retain synchronous Set/TrySet
|
|
1204
|
+
semantics. The operation fails rather than hanging if the runtime stops.
|
|
1205
|
+
"""
|
|
1206
|
+
self._ensure_started()
|
|
1207
|
+
if self.is_runtime_thread():
|
|
1208
|
+
return callback()
|
|
1209
|
+
|
|
1210
|
+
completed = threading.Event()
|
|
1211
|
+
outcome: dict[str, Any] = {}
|
|
1212
|
+
|
|
1213
|
+
def invoke() -> None:
|
|
1214
|
+
try:
|
|
1215
|
+
outcome["value"] = callback()
|
|
1216
|
+
except BaseException as error:
|
|
1217
|
+
outcome["error"] = error
|
|
1218
|
+
finally:
|
|
1219
|
+
completed.set()
|
|
1220
|
+
|
|
1221
|
+
self._post(invoke, internal=internal)
|
|
1222
|
+
while not completed.wait(0.05):
|
|
1223
|
+
thread = self._thread
|
|
1224
|
+
if thread is None or not thread.is_alive():
|
|
1225
|
+
raise RuntimeShutdownError(
|
|
1226
|
+
"TinyTask runtime stopped before the scheduled operation ran"
|
|
1227
|
+
)
|
|
1228
|
+
if "error" in outcome:
|
|
1229
|
+
raise outcome["error"]
|
|
1230
|
+
return cast(T, outcome.get("value"))
|
|
1231
|
+
|
|
982
1232
|
def _post(self, callback: Callable[[], None], *, internal: bool = False) -> None:
|
|
983
1233
|
self._ensure_started()
|
|
984
1234
|
execute_inline = False
|
|
@@ -1396,12 +1646,16 @@ class _Runtime:
|
|
|
1396
1646
|
self._schedule(task)
|
|
1397
1647
|
|
|
1398
1648
|
def _next_timeout(self) -> float | None:
|
|
1649
|
+
max_selector_wait = 24 * 60 * 60 # 24 hours, in seconds
|
|
1650
|
+
|
|
1399
1651
|
while self._timers:
|
|
1400
1652
|
deadline, _, task, operation = self._timers[0]
|
|
1401
1653
|
if task.done() or task._waiting_on is not operation:
|
|
1402
1654
|
heapq.heappop(self._timers)
|
|
1403
1655
|
else:
|
|
1404
|
-
|
|
1656
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
1657
|
+
return min(remaining, max_selector_wait)
|
|
1658
|
+
|
|
1405
1659
|
return None
|
|
1406
1660
|
|
|
1407
1661
|
def _begin_shutdown(self) -> None:
|
|
@@ -1570,8 +1824,50 @@ def _shutdown_at_exit() -> None:
|
|
|
1570
1824
|
atexit.register(_shutdown_at_exit)
|
|
1571
1825
|
|
|
1572
1826
|
def task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
|
|
1827
|
+
"""
|
|
1828
|
+
| Wraps sync functions and non-asyncio dependent coroutines to eagerly executed await-able tasks
|
|
1829
|
+
"""
|
|
1573
1830
|
@functools.wraps(async_function)
|
|
1574
1831
|
def wrapper(*args: Any, **kwargs: Any) -> Task[Any]:
|
|
1575
1832
|
return Task.run(async_function, *args, **kwargs)
|
|
1576
1833
|
|
|
1577
|
-
return wrapper
|
|
1834
|
+
return wrapper
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
def asyncio_task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
|
|
1838
|
+
"""
|
|
1839
|
+
| Wraps async functions with a dependency on asyncio to be compatible with Task.run(coro)
|
|
1840
|
+
|
|
1841
|
+
Use with caution as this transfers execution control to an isolated asyncio loop runner.
|
|
1842
|
+
"""
|
|
1843
|
+
import asyncio
|
|
1844
|
+
@functools.wraps(async_function)
|
|
1845
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
1846
|
+
with asyncio.Runner() as runner:
|
|
1847
|
+
# Call the function with its arguments to generate the Coroutine object instance
|
|
1848
|
+
coro = async_function(*args, **kwargs)
|
|
1849
|
+
return runner.run(coro)
|
|
1850
|
+
|
|
1851
|
+
return task(wrapper)
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
class _SyncBridge:
|
|
1855
|
+
def __rshift__(self, ttwa: Task[T]) -> Any:
|
|
1856
|
+
frame = inspect.currentframe()
|
|
1857
|
+
try:
|
|
1858
|
+
assert frame is not None
|
|
1859
|
+
caller: Any = frame.f_back
|
|
1860
|
+
if caller and caller.f_code.co_flags & inspect.CO_COROUTINE:
|
|
1861
|
+
raise RuntimeError("twait >> cannot be used inside async def")
|
|
1862
|
+
|
|
1863
|
+
return ttwa.result()
|
|
1864
|
+
finally:
|
|
1865
|
+
del frame
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
twait: Final = _SyncBridge()
|
|
1869
|
+
"""
|
|
1870
|
+
| If you seriously want to await in a sync context make it nice
|
|
1871
|
+
|
|
1872
|
+
twait >> Task.delay()
|
|
1873
|
+
"""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|