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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flpit
3
- Version: 0.2.1.dev7
3
+ Version: 0.2.1.dev8
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flpit"
3
- version = "0.2.1.dev7"
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"
@@ -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 JsonIgnore:
69
+ class JIgnore:
69
70
  def __class_getitem__[T](cls, item: type[T]) -> Any:
70
71
  return Annotated[item, cls]
71
72
 
72
- class JsonInclude:
73
+ class JInclude:
73
74
  def __class_getitem__[T](cls, item: type[T]) -> Any:
74
75
  return Annotated[item, cls]
75
76
 
76
- class JsonProperty:
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
- JIgnore = JsonIgnore
85
- JInclude = JsonInclude
86
- JProp = JsonProperty
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
- Splits any attribute name (snake_case, PascalCase, UPPER_CASE)
124
- into individual tokens and formats them according to the target policy.
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
- # 1. Check if there is a leading underscore and save it
127
- prefix = "_" if name.startswith("_") else ""
128
- clean_name = name.lstrip("_")
129
-
130
- # 2. Tokenize only the clean part of the string
131
- tokens = [t for t in re.split(r'_(?=[a-zA-Z])|(?<=[a-z])(?=[A-Z])', clean_name) if t]
132
- if not tokens:
143
+ if policy == JsonNamingPolicy.none:
133
144
  return name
134
145
 
135
- if policy == JsonNamingPolicy.lower:
136
- result = "".join(t.lower() for t in tokens)
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
- if policy == JsonNamingPolicy.pascal:
142
- result = "".join(t.capitalize() for t in tokens)
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
- if policy == JsonNamingPolicy.kebap:
145
- result = "-".join(t.lower() for t in tokens)
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 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:])
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
- result = clean_name
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 JsonIgnore for item in metadata)
186
- included = any(item is JsonInclude for item in metadata)
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, JsonProperty)),
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 JsonIgnore
195
- and item is not JsonInclude
196
- and not isinstance(item, JsonProperty)
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 Task/awaitable runtime.
1
+ """Task: a small asyncio-free task/awaitable runtime.
2
2
 
3
3
  This is a deliberately small runtime, not an asyncio compatibility layer.
4
4
 
@@ -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(BaseException):
52
- """Raised when cancellation is delivered to an operation or coroutine."""
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
- """Raised by Task.when_all() when multiple child tasks fail.
87
+ """Represents multiple errors, preserving the original exceptions.
69
88
 
70
- The original exceptions are available in the ``exceptions`` tuple. A single
71
- child failure is re-raised directly to keep the common case ergonomic.
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
- cancelled = self._state.cancel()
262
- # A linked source no longer needs to hold registrations after its own
263
- # cancellation has fired. This is safe even when called from a link callback.
264
- for registration in links:
265
- registration.dispose()
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 TinyTask's process-wide scheduler."""
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 OperationCanceledError(f"{self.name} was cancelled")
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 OperationCanceledError(f"{self.name} was cancelled")
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 not self._running:
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
- return max(0.0, deadline - time.monotonic())
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 coro_task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
1837
+ def asyncio_task(async_function: Callable[..., Any]) -> Callable[..., Task[Any]]:
1597
1838
  """
1598
- | Wraps an async coroutine function to be compatible with Task.run(coro)
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