flpit 0.2.1.dev7__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.dev7 → flpit-0.2.1.dev8}/PKG-INFO +1 -1
- {flpit-0.2.1.dev7 → flpit-0.2.1.dev8}/pyproject.toml +1 -1
- {flpit-0.2.1.dev7 → flpit-0.2.1.dev8}/src/flpit/json.py +59 -35
- {flpit-0.2.1.dev7 → flpit-0.2.1.dev8}/src/flpit/task.py +282 -19
- {flpit-0.2.1.dev7 → flpit-0.2.1.dev8}/.gitignore +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.1.dev8}/LICENSE +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.1.dev8}/README.md +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.1.dev8}/src/flp/__init__.py +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.1.dev8}/src/flpit/__init__.py +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.1.dev8}/src/flpit/core/__init__.py +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.1.dev8}/src/flpit/core/linq.py +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.1.dev8}/src/flpit/flp.py +0 -0
- {flpit-0.2.1.dev7 → flpit-0.2.1.dev8}/src/flpit/py.typed +0 -0
|
@@ -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(
|
|
@@ -708,9 +740,14 @@ class Task(Generic[T]):
|
|
|
708
740
|
if not self._completed.wait(timeout):
|
|
709
741
|
raise TimeoutError(f"{self.name} did not complete before the timeout")
|
|
710
742
|
if self._state == self.CANCELLED:
|
|
711
|
-
raise
|
|
743
|
+
raise self._cancellation_exception()
|
|
712
744
|
return self._exception
|
|
713
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
|
+
|
|
714
751
|
def cancel(self) -> bool:
|
|
715
752
|
"""Request cancellation of this Task, independently of other token users."""
|
|
716
753
|
if self.done():
|
|
@@ -729,7 +766,7 @@ class Task(Generic[T]):
|
|
|
729
766
|
|
|
730
767
|
def _raise_or_return(self) -> T:
|
|
731
768
|
if self._state == self.CANCELLED:
|
|
732
|
-
raise
|
|
769
|
+
raise self._cancellation_exception()
|
|
733
770
|
if self._state == self.FAILED:
|
|
734
771
|
assert self._exception is not None
|
|
735
772
|
raise self._exception
|
|
@@ -747,7 +784,12 @@ class Task(Generic[T]):
|
|
|
747
784
|
return
|
|
748
785
|
self._cancel_requested = True
|
|
749
786
|
self._runtime._detach_wait(self)
|
|
750
|
-
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:
|
|
751
793
|
self._runtime._schedule(self, exc=OperationCanceledError(f"{self.name} cancelled"))
|
|
752
794
|
|
|
753
795
|
def _finish(
|
|
@@ -815,6 +857,165 @@ class Task(Generic[T]):
|
|
|
815
857
|
return f"<{self.name} state={self._state}>"
|
|
816
858
|
|
|
817
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
|
+
|
|
818
1019
|
def _normalize_tasks(items: tuple[Any, ...], *, allow_empty: bool) -> tuple[Task[Any], ...]:
|
|
819
1020
|
"""Normalize task-combinator inputs, scheduling raw awaitables as Tasks.
|
|
820
1021
|
|
|
@@ -995,6 +1196,39 @@ class _Runtime:
|
|
|
995
1196
|
message = str(context.get("message", "Unexpected TinyTask runtime error"))
|
|
996
1197
|
_LOG.error("%s", message, exc_info=(type(error), error, error.__traceback__))
|
|
997
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
|
+
|
|
998
1232
|
def _post(self, callback: Callable[[], None], *, internal: bool = False) -> None:
|
|
999
1233
|
self._ensure_started()
|
|
1000
1234
|
execute_inline = False
|
|
@@ -1412,12 +1646,16 @@ class _Runtime:
|
|
|
1412
1646
|
self._schedule(task)
|
|
1413
1647
|
|
|
1414
1648
|
def _next_timeout(self) -> float | None:
|
|
1649
|
+
max_selector_wait = 24 * 60 * 60 # 24 hours, in seconds
|
|
1650
|
+
|
|
1415
1651
|
while self._timers:
|
|
1416
1652
|
deadline, _, task, operation = self._timers[0]
|
|
1417
1653
|
if task.done() or task._waiting_on is not operation:
|
|
1418
1654
|
heapq.heappop(self._timers)
|
|
1419
1655
|
else:
|
|
1420
|
-
|
|
1656
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
1657
|
+
return min(remaining, max_selector_wait)
|
|
1658
|
+
|
|
1421
1659
|
return None
|
|
1422
1660
|
|
|
1423
1661
|
def _begin_shutdown(self) -> None:
|
|
@@ -1586,6 +1824,9 @@ def _shutdown_at_exit() -> None:
|
|
|
1586
1824
|
atexit.register(_shutdown_at_exit)
|
|
1587
1825
|
|
|
1588
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
|
+
"""
|
|
1589
1830
|
@functools.wraps(async_function)
|
|
1590
1831
|
def wrapper(*args: Any, **kwargs: Any) -> Task[Any]:
|
|
1591
1832
|
return Task.run(async_function, *args, **kwargs)
|
|
@@ -1593,9 +1834,9 @@ def task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
|
|
|
1593
1834
|
return wrapper
|
|
1594
1835
|
|
|
1595
1836
|
|
|
1596
|
-
def
|
|
1837
|
+
def asyncio_task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
|
|
1597
1838
|
"""
|
|
1598
|
-
| Wraps
|
|
1839
|
+
| Wraps async functions with a dependency on asyncio to be compatible with Task.run(coro)
|
|
1599
1840
|
|
|
1600
1841
|
Use with caution as this transfers execution control to an isolated asyncio loop runner.
|
|
1601
1842
|
"""
|
|
@@ -1608,3 +1849,25 @@ def coro_task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
|
|
|
1608
1849
|
return runner.run(coro)
|
|
1609
1850
|
|
|
1610
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
|