flpit 0.2.1.dev6__py3-none-any.whl → 0.2.1.dev8__py3-none-any.whl

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/json.py CHANGED
@@ -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:
flpit/task.py CHANGED
@@ -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(
@@ -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
- cancelled; inspect/await that task to observe
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 OperationCanceledError(f"{self.name} was cancelled")
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 OperationCanceledError(f"{self.name} was cancelled")
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 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:
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
- return max(0.0, deadline - time.monotonic())
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
+ """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flpit
3
- Version: 0.2.1.dev6
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,12 +1,12 @@
1
1
  flp/__init__.py,sha256=reAHhZtYt0f2LHeIYJbklta6Vatr6yeQCL1R-9TTyY0,688
2
2
  flpit/__init__.py,sha256=tx4tkpLxoKQTP7wu2PyUtQtLs9_pWHbx1jHh4Vdsrus,1275
3
3
  flpit/flp.py,sha256=i-FbnMeO92cxNRssYsgdlm2UGjILpveXgYUT468fWvY,999
4
- flpit/json.py,sha256=HYuZl_xLUTacsUusCct0PYhC0s2iVYghjTggay4ehjI,19210
4
+ flpit/json.py,sha256=NKWUf03rBFhlWKMbFW0VYl1JvViU3fcAX3F8762zjos,19951
5
5
  flpit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- flpit/task.py,sha256=FejyeqFxYrXKN_LMBtb2vWTCo82mIxMIk-vbZsbDhXc,60818
6
+ flpit/task.py,sha256=ztqwmROL1GmpF7-NZUtB0Bd-kEcAA0Aj0icXoraJYkU,72527
7
7
  flpit/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  flpit/core/linq.py,sha256=USO-L0z54pXcq-71j6kKzzFp0clHDMBRuhxOTC9XrdY,41468
9
- flpit-0.2.1.dev6.dist-info/METADATA,sha256=nSa2EEf7KeOLG9yDwGnL5aCD0UbVKYgAx7j0wIJITrc,13098
10
- flpit-0.2.1.dev6.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
- flpit-0.2.1.dev6.dist-info/licenses/LICENSE,sha256=bi5Vg_YP3Oq3jaOMQT4b7WZu0NfBcZr-nRyOExUKJrQ,1063
12
- flpit-0.2.1.dev6.dist-info/RECORD,,
9
+ flpit-0.2.1.dev8.dist-info/METADATA,sha256=OPbYiecW7jFn4BFr3G3oadl5CUCTce_AM8JnvOmKeq4,13098
10
+ flpit-0.2.1.dev8.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ flpit-0.2.1.dev8.dist-info/licenses/LICENSE,sha256=bi5Vg_YP3Oq3jaOMQT4b7WZu0NfBcZr-nRyOExUKJrQ,1063
12
+ flpit-0.2.1.dev8.dist-info/RECORD,,