lionagi 0.15.14__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.
Files changed (39) hide show
  1. lionagi/libs/validate/fuzzy_match_keys.py +5 -182
  2. lionagi/libs/validate/string_similarity.py +6 -331
  3. lionagi/ln/__init__.py +56 -66
  4. lionagi/ln/_async_call.py +13 -10
  5. lionagi/ln/_hash.py +33 -8
  6. lionagi/ln/_list_call.py +2 -35
  7. lionagi/ln/_to_list.py +51 -28
  8. lionagi/ln/_utils.py +156 -0
  9. lionagi/ln/concurrency/__init__.py +39 -31
  10. lionagi/ln/concurrency/_compat.py +65 -0
  11. lionagi/ln/concurrency/cancel.py +92 -109
  12. lionagi/ln/concurrency/errors.py +17 -17
  13. lionagi/ln/concurrency/patterns.py +249 -206
  14. lionagi/ln/concurrency/primitives.py +257 -216
  15. lionagi/ln/concurrency/resource_tracker.py +42 -155
  16. lionagi/ln/concurrency/task.py +55 -73
  17. lionagi/ln/concurrency/throttle.py +3 -0
  18. lionagi/ln/concurrency/utils.py +1 -0
  19. lionagi/ln/fuzzy/__init__.py +15 -0
  20. lionagi/ln/{_extract_json.py → fuzzy/_extract_json.py} +22 -9
  21. lionagi/ln/{_fuzzy_json.py → fuzzy/_fuzzy_json.py} +14 -8
  22. lionagi/ln/fuzzy/_fuzzy_match.py +172 -0
  23. lionagi/ln/fuzzy/_fuzzy_validate.py +46 -0
  24. lionagi/ln/fuzzy/_string_similarity.py +332 -0
  25. lionagi/ln/{_models.py → types.py} +153 -4
  26. lionagi/operations/flow.py +2 -1
  27. lionagi/operations/operate/operate.py +26 -16
  28. lionagi/protocols/contracts.py +46 -0
  29. lionagi/protocols/generic/event.py +6 -6
  30. lionagi/protocols/generic/processor.py +9 -5
  31. lionagi/protocols/ids.py +82 -0
  32. lionagi/protocols/types.py +10 -12
  33. lionagi/utils.py +34 -64
  34. lionagi/version.py +1 -1
  35. {lionagi-0.15.14.dist-info → lionagi-0.16.0.dist-info}/METADATA +4 -2
  36. {lionagi-0.15.14.dist-info → lionagi-0.16.0.dist-info}/RECORD +38 -31
  37. lionagi/ln/_types.py +0 -146
  38. {lionagi-0.15.14.dist-info → lionagi-0.16.0.dist-info}/WHEEL +0 -0
  39. {lionagi-0.15.14.dist-info → lionagi-0.16.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,259 +1,302 @@
1
- """Common concurrency patterns for structured concurrency."""
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 logging
6
- from collections.abc import Awaitable, Callable
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 .cancel import move_on_after
13
- from .primitives import CapacityLimiter, Lock
14
- from .resource_tracker import track_resource, untrack_resource
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
- class ConnectionPool:
25
- """A pool of reusable connections."""
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
- self._connection_factory = connection_factory
39
- self._limiter = CapacityLimiter(max_connections)
40
- self._connections: list[T] = []
41
- self._lock = Lock()
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
- track_resource(self, f"ConnectionPool-{id(self)}", "ConnectionPool")
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
- def __del__(self):
46
- """Clean up resource tracking."""
47
- try:
48
- untrack_resource(self)
49
- except Exception:
50
- pass
56
+ Returns:
57
+ List of results in same order as input awaitables
58
+ """
59
+ if not aws:
60
+ return []
51
61
 
52
- async def acquire(self) -> T:
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
- async with self._lock:
58
- if self._connections:
59
- return self._connections.pop()
60
-
61
- # No pooled connection available, create new one
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
- async def release(self, connection: T) -> None:
68
- """Release a connection back to the pool."""
69
- try:
70
- async with self._lock:
71
- self._connections.append(connection)
72
- finally:
73
- self._limiter.release()
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
- async def __aenter__(self) -> ConnectionPool[T]:
76
- """Enter the connection pool context."""
77
- return self
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
- async def __aexit__(
80
- self,
81
- exc_type: type[BaseException] | None,
82
- exc_val: BaseException | None,
83
- exc_tb: TracebackType | None,
84
- ) -> None:
85
- """Exit the connection pool context."""
86
- # Clean up any remaining connections
87
- async with self._lock:
88
- self._connections.clear()
89
-
90
-
91
- async def parallel_requests(
92
- inputs: list[str],
93
- func: Callable[[str], Awaitable[Response]],
94
- max_concurrency: int = 10,
95
- ) -> list[Response]:
96
- """Execute requests in parallel with controlled concurrency.
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
- inputs: List of inputs
100
- fetch_func: Async function
101
- max_concurrency: Maximum number of concurrent requests
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 responses in the same order as inputs
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 not inputs:
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
- results: list[Response | None] = [None] * len(inputs)
147
+ out: list[R | BaseException | None] = [None] * len(seq)
148
+ limiter = CapacityLimiter(limit)
110
149
 
111
- async def bounded_fetch(
112
- semaphore: anyio.Semaphore, idx: int, url: str
113
- ) -> None:
114
- async with semaphore:
115
- results[idx] = await func(url)
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
- semaphore = anyio.Semaphore(max_concurrency)
120
-
121
- for i, inp in enumerate(inputs):
122
- await tg.start_soon(bounded_fetch, semaphore, i, inp)
123
- except BaseException as e:
124
- # Re-raise the first exception directly instead of ExceptionGroup
125
- if hasattr(e, "exceptions") and e.exceptions:
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 results # type: ignore
170
+ return out # type: ignore
131
171
 
132
172
 
133
- async def retry_with_timeout(
134
- func: Callable[[], Awaitable[T]],
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
- Returns:
148
- The result of the successful function call
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
- Raises:
151
- Exception: The last exception raised by the function
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, num_workers: int, worker_func: Callable[[Any], Awaitable[None]]
187
+ self, aws: Sequence[Awaitable[T]], *, limit: int | None = None
181
188
  ):
182
- """Initialize a new worker pool."""
183
- if num_workers < 1:
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
- track_resource(self, f"WorkerPool-{id(self)}", "WorkerPool")
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
- async def start(self) -> None:
203
- """Start the worker pool."""
204
- if self._task_group is not None:
205
- raise RuntimeError("Worker pool is already started")
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
- self._task_group = create_task_group()
208
- await self._task_group.__aenter__()
215
+ # Start all tasks
216
+ for i, aw in enumerate(self.aws):
217
+ self._task_group.start_soon(_runner, i, aw)
209
218
 
210
- # Start worker tasks
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 stop(self) -> None:
215
- """Stop the worker pool."""
216
- if self._task_group is None:
217
- return
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
- # Close the queue to signal workers to stop
220
- await self._queue[0].aclose()
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
- async with self._queue[1]:
238
- async for item in self._queue[1]:
239
- try:
240
- await self._worker_func(item)
241
- except Exception as e:
242
- logger.error(f"Worker error processing item: {e}")
243
- except anyio.ClosedResourceError:
244
- # Queue was closed, worker should exit gracefully
245
- pass
246
-
247
- async def __aenter__(self) -> WorkerPool:
248
- """Enter the worker pool context."""
249
- await self.start()
250
- return self
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
- async def __aexit__(
253
- self,
254
- exc_type: type[BaseException] | None,
255
- exc_val: BaseException | None,
256
- exc_tb: TracebackType | None,
257
- ) -> None:
258
- """Exit the worker pool context."""
259
- await self.stop()
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)