lionagi 0.15.13__py3-none-any.whl → 0.16.0__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.
- lionagi/config.py +1 -0
- lionagi/libs/validate/fuzzy_match_keys.py +5 -182
- lionagi/libs/validate/string_similarity.py +6 -331
- lionagi/ln/__init__.py +56 -66
- lionagi/ln/_async_call.py +13 -10
- lionagi/ln/_hash.py +33 -8
- lionagi/ln/_list_call.py +2 -35
- lionagi/ln/_to_list.py +51 -28
- lionagi/ln/_utils.py +156 -0
- lionagi/ln/concurrency/__init__.py +39 -31
- lionagi/ln/concurrency/_compat.py +65 -0
- lionagi/ln/concurrency/cancel.py +92 -109
- lionagi/ln/concurrency/errors.py +17 -17
- lionagi/ln/concurrency/patterns.py +249 -206
- lionagi/ln/concurrency/primitives.py +257 -216
- lionagi/ln/concurrency/resource_tracker.py +42 -155
- lionagi/ln/concurrency/task.py +55 -73
- lionagi/ln/concurrency/throttle.py +3 -0
- lionagi/ln/concurrency/utils.py +1 -0
- lionagi/ln/fuzzy/__init__.py +15 -0
- lionagi/ln/{_extract_json.py → fuzzy/_extract_json.py} +22 -9
- lionagi/ln/{_fuzzy_json.py → fuzzy/_fuzzy_json.py} +14 -8
- lionagi/ln/fuzzy/_fuzzy_match.py +172 -0
- lionagi/ln/fuzzy/_fuzzy_validate.py +46 -0
- lionagi/ln/fuzzy/_string_similarity.py +332 -0
- lionagi/ln/{_models.py → types.py} +153 -4
- lionagi/operations/flow.py +2 -1
- lionagi/operations/operate/operate.py +26 -16
- lionagi/protocols/contracts.py +46 -0
- lionagi/protocols/generic/event.py +6 -6
- lionagi/protocols/generic/processor.py +9 -5
- lionagi/protocols/ids.py +82 -0
- lionagi/protocols/types.py +10 -12
- lionagi/service/connections/match_endpoint.py +9 -0
- lionagi/service/connections/providers/nvidia_nim_.py +100 -0
- lionagi/utils.py +34 -64
- lionagi/version.py +1 -1
- {lionagi-0.15.13.dist-info → lionagi-0.16.0.dist-info}/METADATA +4 -2
- {lionagi-0.15.13.dist-info → lionagi-0.16.0.dist-info}/RECORD +41 -33
- lionagi/ln/_types.py +0 -146
- {lionagi-0.15.13.dist-info → lionagi-0.16.0.dist-info}/WHEEL +0 -0
- {lionagi-0.15.13.dist-info → lionagi-0.16.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,259 +1,302 @@
|
|
1
|
-
"""
|
1
|
+
"""Lion Async Concurrency Patterns - Structured concurrency coordination utilities.
|
2
|
+
|
3
|
+
This module provides async coordination patterns built on AnyIO's structured
|
4
|
+
concurrency primitives. All patterns are backend-neutral (asyncio/trio).
|
5
|
+
|
6
|
+
Key Features:
|
7
|
+
- gather: Concurrent execution with fail-fast or exception collection
|
8
|
+
- race: First-to-complete coordination
|
9
|
+
- bounded_map: Concurrent mapping with rate limiting
|
10
|
+
- CompletionStream: Stream results as they become available
|
11
|
+
- retry: Deadline-aware exponential backoff
|
12
|
+
|
13
|
+
Note on Structural Concurrency:
|
14
|
+
These patterns follow structured concurrency principles where possible. In
|
15
|
+
particular, CompletionStream provides an explicit lifecycle to avoid the
|
16
|
+
pitfalls of unstructured as_completed-like patterns when breaking early.
|
17
|
+
See individual function docstrings for details.
|
18
|
+
"""
|
2
19
|
|
3
20
|
from __future__ import annotations
|
4
21
|
|
5
|
-
import
|
6
|
-
from
|
7
|
-
from types import TracebackType
|
8
|
-
from typing import Any, TypeVar
|
22
|
+
from collections.abc import Awaitable, Callable, Iterable, Sequence
|
23
|
+
from typing import TypeVar
|
9
24
|
|
10
25
|
import anyio
|
11
26
|
|
12
|
-
from .
|
13
|
-
from .
|
14
|
-
from .
|
27
|
+
from ._compat import ExceptionGroup
|
28
|
+
from .cancel import effective_deadline
|
29
|
+
from .errors import is_cancelled
|
30
|
+
from .primitives import CapacityLimiter
|
15
31
|
from .task import create_task_group
|
16
32
|
|
17
|
-
logger = logging.getLogger(__name__)
|
18
|
-
|
19
33
|
T = TypeVar("T")
|
20
34
|
R = TypeVar("R")
|
21
|
-
Response = TypeVar("Response")
|
22
35
|
|
23
36
|
|
24
|
-
|
25
|
-
""
|
37
|
+
__all__ = (
|
38
|
+
"gather",
|
39
|
+
"race",
|
40
|
+
"bounded_map",
|
41
|
+
"CompletionStream",
|
42
|
+
"retry",
|
43
|
+
)
|
26
44
|
|
27
|
-
def __init__(
|
28
|
-
self,
|
29
|
-
max_connections: int,
|
30
|
-
connection_factory: Callable[[], Awaitable[T]],
|
31
|
-
):
|
32
|
-
"""Initialize a new connection pool."""
|
33
|
-
if max_connections < 1:
|
34
|
-
raise ValueError("max_connections must be >= 1")
|
35
|
-
if not callable(connection_factory):
|
36
|
-
raise ValueError("connection_factory must be callable")
|
37
45
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
46
|
+
async def gather(
|
47
|
+
*aws: Awaitable[T], return_exceptions: bool = False
|
48
|
+
) -> list[T | BaseException]:
|
49
|
+
"""Run awaitables concurrently, return list of results.
|
42
50
|
|
43
|
-
|
51
|
+
Args:
|
52
|
+
*aws: Awaitables to execute concurrently
|
53
|
+
return_exceptions: If True, exceptions are returned as results
|
54
|
+
If False, first exception cancels all tasks and re-raises
|
44
55
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
pass
|
56
|
+
Returns:
|
57
|
+
List of results in same order as input awaitables
|
58
|
+
"""
|
59
|
+
if not aws:
|
60
|
+
return []
|
51
61
|
|
52
|
-
|
53
|
-
"""Acquire a connection from the pool."""
|
54
|
-
await self._limiter.acquire()
|
62
|
+
results: list[T | BaseException | None] = [None] * len(aws)
|
55
63
|
|
64
|
+
async def _runner(idx: int, aw: Awaitable[T]) -> None:
|
56
65
|
try:
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
return await self._connection_factory()
|
63
|
-
except Exception:
|
64
|
-
self._limiter.release()
|
65
|
-
raise
|
66
|
+
results[idx] = await aw
|
67
|
+
except BaseException as exc:
|
68
|
+
results[idx] = exc
|
69
|
+
if not return_exceptions:
|
70
|
+
raise # Propagate to the TaskGroup
|
66
71
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
72
|
+
try:
|
73
|
+
async with create_task_group() as tg:
|
74
|
+
for i, aw in enumerate(aws):
|
75
|
+
tg.start_soon(_runner, i, aw)
|
76
|
+
except ExceptionGroup as eg:
|
77
|
+
if not return_exceptions:
|
78
|
+
# Find the first "real" exception and raise it.
|
79
|
+
non_cancel_excs = [e for e in eg.exceptions if not is_cancelled(e)]
|
80
|
+
if non_cancel_excs:
|
81
|
+
raise non_cancel_excs[0]
|
82
|
+
raise # Re-raise group if all were cancellations
|
74
83
|
|
75
|
-
|
76
|
-
|
77
|
-
|
84
|
+
return results # type: ignore
|
85
|
+
|
86
|
+
|
87
|
+
async def race(*aws: Awaitable[T]) -> T:
|
88
|
+
"""Run awaitables concurrently, return result of first completion.
|
78
89
|
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
90
|
+
Returns the first result to complete, whether success or failure.
|
91
|
+
All other tasks are cancelled when first task completes.
|
92
|
+
If first completion is an exception, it's re-raised.
|
93
|
+
|
94
|
+
Note: This returns first *completion*, not first *success*.
|
95
|
+
For first-success semantics, consider implementing a first_success variant.
|
96
|
+
"""
|
97
|
+
if not aws:
|
98
|
+
raise ValueError("race() requires at least one awaitable")
|
99
|
+
send, recv = anyio.create_memory_object_stream(0)
|
100
|
+
|
101
|
+
async def _runner(aw: Awaitable[T]) -> None:
|
102
|
+
try:
|
103
|
+
res = await aw
|
104
|
+
await send.send((True, res))
|
105
|
+
except BaseException as exc:
|
106
|
+
await send.send((False, exc))
|
107
|
+
|
108
|
+
async with send, recv, create_task_group() as tg:
|
109
|
+
for aw in aws:
|
110
|
+
tg.start_soon(_runner, aw)
|
111
|
+
ok, payload = await recv.receive()
|
112
|
+
tg.cancel_scope.cancel()
|
113
|
+
|
114
|
+
# Raise outside the TaskGroup context to avoid ExceptionGroup wrapping
|
115
|
+
if ok:
|
116
|
+
return payload # type: ignore[return-value]
|
117
|
+
raise payload # type: ignore[misc]
|
118
|
+
|
119
|
+
|
120
|
+
async def bounded_map(
|
121
|
+
func: Callable[[T], Awaitable[R]],
|
122
|
+
items: Iterable[T],
|
123
|
+
*,
|
124
|
+
limit: int,
|
125
|
+
return_exceptions: bool = False,
|
126
|
+
) -> list[R | BaseException]:
|
127
|
+
"""Apply async function to items with concurrency limit.
|
97
128
|
|
98
129
|
Args:
|
99
|
-
|
100
|
-
|
101
|
-
|
130
|
+
func: Async function to apply to each item
|
131
|
+
items: Items to process
|
132
|
+
limit: Maximum concurrent operations
|
133
|
+
return_exceptions: If True, exceptions are returned as results.
|
134
|
+
If False, first exception cancels all tasks and re-raises.
|
102
135
|
|
103
136
|
Returns:
|
104
|
-
List of
|
137
|
+
List of results in same order as input items.
|
138
|
+
If return_exceptions is True, exceptions are included in results.
|
105
139
|
"""
|
106
|
-
if
|
140
|
+
if limit <= 0:
|
141
|
+
raise ValueError("limit must be >= 1")
|
142
|
+
|
143
|
+
seq = list(items)
|
144
|
+
if not seq:
|
107
145
|
return []
|
108
146
|
|
109
|
-
|
147
|
+
out: list[R | BaseException | None] = [None] * len(seq)
|
148
|
+
limiter = CapacityLimiter(limit)
|
110
149
|
|
111
|
-
async def
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
150
|
+
async def _runner(i: int, x: T) -> None:
|
151
|
+
async with limiter:
|
152
|
+
try:
|
153
|
+
out[i] = await func(x)
|
154
|
+
except BaseException as exc:
|
155
|
+
out[i] = exc
|
156
|
+
if not return_exceptions:
|
157
|
+
raise # Propagate to the TaskGroup
|
116
158
|
|
117
159
|
try:
|
118
160
|
async with create_task_group() as tg:
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
raise e.exceptions[0]
|
127
|
-
else:
|
161
|
+
for i, x in enumerate(seq):
|
162
|
+
tg.start_soon(_runner, i, x)
|
163
|
+
except ExceptionGroup as eg:
|
164
|
+
if not return_exceptions:
|
165
|
+
non_cancel_excs = [e for e in eg.exceptions if not is_cancelled(e)]
|
166
|
+
if non_cancel_excs:
|
167
|
+
raise non_cancel_excs[0]
|
128
168
|
raise
|
129
169
|
|
130
|
-
return
|
170
|
+
return out # type: ignore
|
131
171
|
|
132
172
|
|
133
|
-
|
134
|
-
|
135
|
-
max_retries: int = 3,
|
136
|
-
timeout: float = 30.0,
|
137
|
-
backoff_factor: float = 1.0,
|
138
|
-
) -> T:
|
139
|
-
"""Retry an async function with exponential backoff and timeout.
|
140
|
-
|
141
|
-
Args:
|
142
|
-
func: The async function to retry
|
143
|
-
max_retries: Maximum number of retries
|
144
|
-
timeout: Timeout for each attempt
|
145
|
-
backoff_factor: Multiplier for exponential backoff
|
173
|
+
class CompletionStream:
|
174
|
+
"""Structured-concurrency-safe completion stream with explicit lifecycle management.
|
146
175
|
|
147
|
-
|
148
|
-
|
176
|
+
This provides a safer alternative to as_completed() that allows explicit cancellation
|
177
|
+
of remaining tasks when early termination is needed.
|
149
178
|
|
150
|
-
|
151
|
-
|
179
|
+
Usage:
|
180
|
+
async with CompletionStream(awaitables, limit=10) as stream:
|
181
|
+
async for index, result in stream:
|
182
|
+
if some_condition:
|
183
|
+
break # Remaining tasks are automatically cancelled
|
152
184
|
"""
|
153
|
-
last_exception = None
|
154
|
-
|
155
|
-
for attempt in range(max_retries):
|
156
|
-
try:
|
157
|
-
with move_on_after(timeout) as cancel_scope:
|
158
|
-
result = await func()
|
159
|
-
if not cancel_scope.cancelled_caught:
|
160
|
-
return result
|
161
|
-
else:
|
162
|
-
raise TimeoutError(f"Function timed out after {timeout}s")
|
163
|
-
except Exception as e:
|
164
|
-
last_exception = e
|
165
|
-
if attempt < max_retries - 1:
|
166
|
-
delay = backoff_factor * (2**attempt)
|
167
|
-
await anyio.sleep(delay)
|
168
|
-
continue
|
169
|
-
|
170
|
-
if last_exception:
|
171
|
-
raise last_exception
|
172
|
-
else:
|
173
|
-
raise RuntimeError("Retry failed without capturing exception")
|
174
|
-
|
175
|
-
|
176
|
-
class WorkerPool:
|
177
|
-
"""A pool of worker tasks that process items from a queue."""
|
178
185
|
|
179
186
|
def __init__(
|
180
|
-
self,
|
187
|
+
self, aws: Sequence[Awaitable[T]], *, limit: int | None = None
|
181
188
|
):
|
182
|
-
|
183
|
-
|
184
|
-
raise ValueError("num_workers must be >= 1")
|
185
|
-
if not callable(worker_func):
|
186
|
-
raise ValueError("worker_func must be callable")
|
187
|
-
|
188
|
-
self._num_workers = num_workers
|
189
|
-
self._worker_func = worker_func
|
190
|
-
self._queue = anyio.create_memory_object_stream(1000)
|
189
|
+
self.aws = aws
|
190
|
+
self.limit = limit
|
191
191
|
self._task_group = None
|
192
|
+
self._send = None
|
193
|
+
self._recv = None
|
194
|
+
self._completed_count = 0
|
195
|
+
self._total_count = len(aws)
|
196
|
+
|
197
|
+
async def __aenter__(self):
|
198
|
+
n = len(self.aws)
|
199
|
+
self._send, self._recv = anyio.create_memory_object_stream(n)
|
200
|
+
self._task_group = anyio.create_task_group()
|
201
|
+
await self._task_group.__aenter__()
|
192
202
|
|
193
|
-
|
194
|
-
|
195
|
-
def __del__(self):
|
196
|
-
"""Clean up resource tracking."""
|
197
|
-
try:
|
198
|
-
untrack_resource(self)
|
199
|
-
except Exception:
|
200
|
-
pass
|
203
|
+
limiter = CapacityLimiter(self.limit) if self.limit else None
|
201
204
|
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
205
|
+
async def _runner(i: int, aw: Awaitable[T]) -> None:
|
206
|
+
if limiter:
|
207
|
+
await limiter.acquire()
|
208
|
+
try:
|
209
|
+
res = await aw
|
210
|
+
await self._send.send((i, res))
|
211
|
+
finally:
|
212
|
+
if limiter:
|
213
|
+
limiter.release()
|
206
214
|
|
207
|
-
|
208
|
-
|
215
|
+
# Start all tasks
|
216
|
+
for i, aw in enumerate(self.aws):
|
217
|
+
self._task_group.start_soon(_runner, i, aw)
|
209
218
|
|
210
|
-
|
211
|
-
for i in range(self._num_workers):
|
212
|
-
await self._task_group.start_soon(self._worker_loop)
|
219
|
+
return self
|
213
220
|
|
214
|
-
async def
|
215
|
-
|
216
|
-
if self._task_group
|
217
|
-
|
221
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
222
|
+
# Cancel remaining tasks and clean up
|
223
|
+
if self._task_group:
|
224
|
+
await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
|
225
|
+
if self._send:
|
226
|
+
await self._send.aclose()
|
227
|
+
if self._recv:
|
228
|
+
await self._recv.aclose()
|
229
|
+
return False
|
230
|
+
|
231
|
+
def __aiter__(self):
|
232
|
+
if not self._recv:
|
233
|
+
raise RuntimeError(
|
234
|
+
"CompletionStream must be used as async context manager"
|
235
|
+
)
|
236
|
+
return self
|
218
237
|
|
219
|
-
|
220
|
-
|
238
|
+
async def __anext__(self):
|
239
|
+
if self._completed_count >= self._total_count:
|
240
|
+
raise StopAsyncIteration
|
221
241
|
|
222
|
-
# Wait for all workers to finish
|
223
|
-
try:
|
224
|
-
await self._task_group.__aexit__(None, None, None)
|
225
|
-
finally:
|
226
|
-
self._task_group = None
|
227
|
-
|
228
|
-
async def submit(self, item: Any) -> None:
|
229
|
-
"""Submit an item for processing."""
|
230
|
-
if self._task_group is None:
|
231
|
-
raise RuntimeError("Worker pool is not started")
|
232
|
-
await self._queue[0].send(item)
|
233
|
-
|
234
|
-
async def _worker_loop(self) -> None:
|
235
|
-
"""Main loop for worker tasks."""
|
236
242
|
try:
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
243
|
+
result = await self._recv.receive()
|
244
|
+
self._completed_count += 1
|
245
|
+
return result
|
246
|
+
except anyio.EndOfStream:
|
247
|
+
raise StopAsyncIteration
|
248
|
+
|
249
|
+
|
250
|
+
async def retry(
|
251
|
+
fn: Callable[[], Awaitable[T]],
|
252
|
+
*,
|
253
|
+
attempts: int = 3,
|
254
|
+
base_delay: float = 0.1,
|
255
|
+
max_delay: float = 2.0,
|
256
|
+
retry_on: tuple[type[BaseException], ...] = (Exception,),
|
257
|
+
jitter: float = 0.1,
|
258
|
+
) -> T:
|
259
|
+
"""Deadline-aware exponential backoff retry.
|
260
|
+
|
261
|
+
If an ambient effective deadline exists, cap each sleep so the retry loop
|
262
|
+
never outlives its parent scope.
|
263
|
+
|
264
|
+
Args:
|
265
|
+
fn: Async function to retry (takes no args)
|
266
|
+
attempts: Maximum retry attempts
|
267
|
+
base_delay: Initial delay between retries
|
268
|
+
max_delay: Maximum delay between retries
|
269
|
+
retry_on: Exception types that trigger retry
|
270
|
+
jitter: Random jitter added to delay (0.0 to 1.0)
|
271
|
+
|
272
|
+
Returns:
|
273
|
+
Result of successful function call
|
251
274
|
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
275
|
+
Raises:
|
276
|
+
Last exception if all attempts fail
|
277
|
+
"""
|
278
|
+
attempt = 0
|
279
|
+
deadline = effective_deadline()
|
280
|
+
while True:
|
281
|
+
try:
|
282
|
+
return await fn()
|
283
|
+
except retry_on as exc:
|
284
|
+
attempt += 1
|
285
|
+
if attempt >= attempts:
|
286
|
+
raise
|
287
|
+
|
288
|
+
delay = min(max_delay, base_delay * (2 ** (attempt - 1)))
|
289
|
+
if jitter:
|
290
|
+
import random
|
291
|
+
|
292
|
+
delay *= 1 + random.random() * jitter
|
293
|
+
|
294
|
+
# Cap by ambient deadline if one exists
|
295
|
+
if deadline is not None:
|
296
|
+
remaining = deadline - anyio.current_time()
|
297
|
+
if remaining <= 0:
|
298
|
+
# Out of time; surface the last error
|
299
|
+
raise
|
300
|
+
delay = min(delay, remaining)
|
301
|
+
|
302
|
+
await anyio.sleep(delay)
|