krons 0.1.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.
- kronos/__init__.py +0 -0
- kronos/core/__init__.py +145 -0
- kronos/core/broadcaster.py +116 -0
- kronos/core/element.py +225 -0
- kronos/core/event.py +316 -0
- kronos/core/eventbus.py +116 -0
- kronos/core/flow.py +356 -0
- kronos/core/graph.py +442 -0
- kronos/core/node.py +982 -0
- kronos/core/pile.py +575 -0
- kronos/core/processor.py +494 -0
- kronos/core/progression.py +296 -0
- kronos/enforcement/__init__.py +57 -0
- kronos/enforcement/common/__init__.py +34 -0
- kronos/enforcement/common/boolean.py +85 -0
- kronos/enforcement/common/choice.py +97 -0
- kronos/enforcement/common/mapping.py +118 -0
- kronos/enforcement/common/model.py +102 -0
- kronos/enforcement/common/number.py +98 -0
- kronos/enforcement/common/string.py +140 -0
- kronos/enforcement/context.py +129 -0
- kronos/enforcement/policy.py +80 -0
- kronos/enforcement/registry.py +153 -0
- kronos/enforcement/rule.py +312 -0
- kronos/enforcement/service.py +370 -0
- kronos/enforcement/validator.py +198 -0
- kronos/errors.py +146 -0
- kronos/operations/__init__.py +32 -0
- kronos/operations/builder.py +228 -0
- kronos/operations/flow.py +398 -0
- kronos/operations/node.py +101 -0
- kronos/operations/registry.py +92 -0
- kronos/protocols.py +414 -0
- kronos/py.typed +0 -0
- kronos/services/__init__.py +81 -0
- kronos/services/backend.py +286 -0
- kronos/services/endpoint.py +608 -0
- kronos/services/hook.py +471 -0
- kronos/services/imodel.py +465 -0
- kronos/services/registry.py +115 -0
- kronos/services/utilities/__init__.py +36 -0
- kronos/services/utilities/header_factory.py +87 -0
- kronos/services/utilities/rate_limited_executor.py +271 -0
- kronos/services/utilities/rate_limiter.py +180 -0
- kronos/services/utilities/resilience.py +414 -0
- kronos/session/__init__.py +41 -0
- kronos/session/exchange.py +258 -0
- kronos/session/message.py +60 -0
- kronos/session/session.py +411 -0
- kronos/specs/__init__.py +25 -0
- kronos/specs/adapters/__init__.py +0 -0
- kronos/specs/adapters/_utils.py +45 -0
- kronos/specs/adapters/dataclass_field.py +246 -0
- kronos/specs/adapters/factory.py +56 -0
- kronos/specs/adapters/pydantic_adapter.py +309 -0
- kronos/specs/adapters/sql_ddl.py +946 -0
- kronos/specs/catalog/__init__.py +36 -0
- kronos/specs/catalog/_audit.py +39 -0
- kronos/specs/catalog/_common.py +43 -0
- kronos/specs/catalog/_content.py +59 -0
- kronos/specs/catalog/_enforcement.py +70 -0
- kronos/specs/factory.py +120 -0
- kronos/specs/operable.py +314 -0
- kronos/specs/phrase.py +405 -0
- kronos/specs/protocol.py +140 -0
- kronos/specs/spec.py +506 -0
- kronos/types/__init__.py +60 -0
- kronos/types/_sentinel.py +311 -0
- kronos/types/base.py +369 -0
- kronos/types/db_types.py +260 -0
- kronos/types/identity.py +66 -0
- kronos/utils/__init__.py +40 -0
- kronos/utils/_hash.py +234 -0
- kronos/utils/_json_dump.py +392 -0
- kronos/utils/_lazy_init.py +63 -0
- kronos/utils/_to_list.py +165 -0
- kronos/utils/_to_num.py +85 -0
- kronos/utils/_utils.py +375 -0
- kronos/utils/concurrency/__init__.py +205 -0
- kronos/utils/concurrency/_async_call.py +333 -0
- kronos/utils/concurrency/_cancel.py +122 -0
- kronos/utils/concurrency/_errors.py +96 -0
- kronos/utils/concurrency/_patterns.py +363 -0
- kronos/utils/concurrency/_primitives.py +328 -0
- kronos/utils/concurrency/_priority_queue.py +135 -0
- kronos/utils/concurrency/_resource_tracker.py +110 -0
- kronos/utils/concurrency/_run_async.py +67 -0
- kronos/utils/concurrency/_task.py +95 -0
- kronos/utils/concurrency/_utils.py +79 -0
- kronos/utils/fuzzy/__init__.py +14 -0
- kronos/utils/fuzzy/_extract_json.py +90 -0
- kronos/utils/fuzzy/_fuzzy_json.py +288 -0
- kronos/utils/fuzzy/_fuzzy_match.py +149 -0
- kronos/utils/fuzzy/_string_similarity.py +187 -0
- kronos/utils/fuzzy/_to_dict.py +396 -0
- kronos/utils/sql/__init__.py +13 -0
- kronos/utils/sql/_sql_validation.py +142 -0
- krons-0.1.0.dist-info/METADATA +70 -0
- krons-0.1.0.dist-info/RECORD +101 -0
- krons-0.1.0.dist-info/WHEEL +4 -0
- krons-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Async priority queue with condition-based synchronization.
|
|
5
|
+
|
|
6
|
+
Provides asyncio.PriorityQueue-like interface using anyio primitives.
|
|
7
|
+
Uses heapq internally with sequence numbers for stable ordering.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import heapq
|
|
13
|
+
from typing import Any, Generic, TypeVar
|
|
14
|
+
|
|
15
|
+
from ._primitives import Condition
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
__all__ = ("PriorityQueue", "QueueEmpty", "QueueFull")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class QueueEmpty(Exception): # noqa: N818
|
|
23
|
+
"""Exception raised when queue.get_nowait() is called on empty queue."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class QueueFull(Exception): # noqa: N818
|
|
27
|
+
"""Exception raised when queue.put_nowait() is called on full queue."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PriorityQueue(Generic[T]):
|
|
31
|
+
"""Async priority queue using heapq + anyio.Condition.
|
|
32
|
+
|
|
33
|
+
Unlike asyncio.PriorityQueue, nowait methods are async (require lock).
|
|
34
|
+
Items stored as (priority, seq, item) for stable ordering when priorities equal.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
maxsize: Maximum queue size. 0 means unlimited.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, maxsize: int = 0):
|
|
41
|
+
"""Initialize priority queue.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
maxsize: Max items allowed. 0 = unlimited.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: If maxsize < 0.
|
|
48
|
+
"""
|
|
49
|
+
if maxsize < 0:
|
|
50
|
+
raise ValueError("maxsize must be >= 0")
|
|
51
|
+
self.maxsize = maxsize
|
|
52
|
+
self._queue: list[Any] = []
|
|
53
|
+
self._seq = 0
|
|
54
|
+
self._condition = Condition()
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _get_priority(item: Any) -> Any:
|
|
58
|
+
"""Extract priority: first element if tuple/list, else item itself."""
|
|
59
|
+
if isinstance(item, (tuple, list)) and item:
|
|
60
|
+
return item[0]
|
|
61
|
+
return item
|
|
62
|
+
|
|
63
|
+
async def put(self, item: T) -> None:
|
|
64
|
+
"""Put item into queue, blocking if full.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
item: Item to enqueue. If tuple/list, first element is priority.
|
|
68
|
+
"""
|
|
69
|
+
async with self._condition:
|
|
70
|
+
while self.maxsize > 0 and len(self._queue) >= self.maxsize:
|
|
71
|
+
await self._condition.wait()
|
|
72
|
+
priority = self._get_priority(item)
|
|
73
|
+
entry = (priority, self._seq, item)
|
|
74
|
+
self._seq += 1
|
|
75
|
+
heapq.heappush(self._queue, entry)
|
|
76
|
+
self._condition.notify()
|
|
77
|
+
|
|
78
|
+
async def put_nowait(self, item: T) -> None:
|
|
79
|
+
"""Put item, raising QueueFull if at capacity. Async (requires lock).
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
item: Item to enqueue. If tuple/list, first element is priority.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
QueueFull: If queue is at maxsize.
|
|
86
|
+
"""
|
|
87
|
+
async with self._condition:
|
|
88
|
+
if self.maxsize > 0 and len(self._queue) >= self.maxsize:
|
|
89
|
+
raise QueueFull("Queue is full")
|
|
90
|
+
priority = self._get_priority(item)
|
|
91
|
+
entry = (priority, self._seq, item)
|
|
92
|
+
self._seq += 1
|
|
93
|
+
heapq.heappush(self._queue, entry)
|
|
94
|
+
self._condition.notify()
|
|
95
|
+
|
|
96
|
+
async def get(self) -> T:
|
|
97
|
+
"""Get highest priority item (lowest value), blocking if empty.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Item with lowest priority value.
|
|
101
|
+
"""
|
|
102
|
+
async with self._condition:
|
|
103
|
+
while not self._queue:
|
|
104
|
+
await self._condition.wait()
|
|
105
|
+
_priority, _seq, item = heapq.heappop(self._queue)
|
|
106
|
+
self._condition.notify()
|
|
107
|
+
return item
|
|
108
|
+
|
|
109
|
+
async def get_nowait(self) -> T:
|
|
110
|
+
"""Get item, raising QueueEmpty if none available. Async (requires lock).
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Item with lowest priority value.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
QueueEmpty: If queue is empty.
|
|
117
|
+
"""
|
|
118
|
+
async with self._condition:
|
|
119
|
+
if not self._queue:
|
|
120
|
+
raise QueueEmpty("Queue is empty")
|
|
121
|
+
_priority, _seq, item = heapq.heappop(self._queue)
|
|
122
|
+
self._condition.notify()
|
|
123
|
+
return item
|
|
124
|
+
|
|
125
|
+
def qsize(self) -> int:
|
|
126
|
+
"""Approximate queue size. Unlocked, may be stale. Use for monitoring."""
|
|
127
|
+
return len(self._queue)
|
|
128
|
+
|
|
129
|
+
def empty(self) -> bool:
|
|
130
|
+
"""Check if empty. Unlocked, may be stale. Use for monitoring."""
|
|
131
|
+
return len(self._queue) == 0
|
|
132
|
+
|
|
133
|
+
def full(self) -> bool:
|
|
134
|
+
"""Check if full. Unlocked, may be stale. Use for monitoring."""
|
|
135
|
+
return self.maxsize > 0 and len(self._queue) >= self.maxsize
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Resource leak detection via weakref-based tracking.
|
|
5
|
+
|
|
6
|
+
Tracks object lifetimes to detect resources that outlive their expected scope.
|
|
7
|
+
Uses weakref finalizers for automatic cleanup when objects are garbage collected.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import weakref
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
__all__ = (
|
|
18
|
+
"LeakInfo",
|
|
19
|
+
"LeakTracker",
|
|
20
|
+
"track_resource",
|
|
21
|
+
"untrack_resource",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class LeakInfo:
|
|
27
|
+
"""Metadata for a tracked resource.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
name: Identifier for the resource (auto-generated if not provided).
|
|
31
|
+
kind: Optional category/type label for grouping.
|
|
32
|
+
created_at: Unix timestamp when tracking began.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
kind: str | None
|
|
37
|
+
created_at: float
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LeakTracker:
|
|
41
|
+
"""Thread-safe tracker for detecting resource leaks.
|
|
42
|
+
|
|
43
|
+
Uses weakref finalizers to automatically remove entries when objects
|
|
44
|
+
are garbage collected. Call `live()` to inspect currently tracked objects.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> tracker = LeakTracker()
|
|
48
|
+
>>> tracker.track(my_connection, name="db_conn", kind="database")
|
|
49
|
+
>>> # ... later, check for leaks ...
|
|
50
|
+
>>> leaks = tracker.live()
|
|
51
|
+
>>> if leaks:
|
|
52
|
+
... print(f"Potential leaks: {leaks}")
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self._live: dict[int, LeakInfo] = {}
|
|
57
|
+
self._lock = threading.Lock()
|
|
58
|
+
|
|
59
|
+
def track(self, obj: object, *, name: str | None, kind: str | None) -> None:
|
|
60
|
+
"""Begin tracking an object for leak detection.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
obj: Object to track (must be weak-referenceable).
|
|
64
|
+
name: Identifier (defaults to "obj-{id}").
|
|
65
|
+
kind: Optional category label.
|
|
66
|
+
"""
|
|
67
|
+
info = LeakInfo(name=name or f"obj-{id(obj)}", kind=kind, created_at=time.time())
|
|
68
|
+
key = id(obj)
|
|
69
|
+
|
|
70
|
+
def _finalizer(_key: int = key) -> None:
|
|
71
|
+
with self._lock:
|
|
72
|
+
self._live.pop(_key, None)
|
|
73
|
+
|
|
74
|
+
with self._lock:
|
|
75
|
+
self._live[key] = info
|
|
76
|
+
weakref.finalize(obj, _finalizer)
|
|
77
|
+
|
|
78
|
+
def untrack(self, obj: object) -> None:
|
|
79
|
+
"""Manually stop tracking an object."""
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._live.pop(id(obj), None)
|
|
82
|
+
|
|
83
|
+
def live(self) -> list[LeakInfo]:
|
|
84
|
+
"""Return list of currently tracked (potentially leaked) resources."""
|
|
85
|
+
with self._lock:
|
|
86
|
+
return list(self._live.values())
|
|
87
|
+
|
|
88
|
+
def clear(self) -> None:
|
|
89
|
+
"""Remove all tracked entries."""
|
|
90
|
+
with self._lock:
|
|
91
|
+
self._live.clear()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_TRACKER = LeakTracker()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def track_resource(obj: object, name: str | None = None, kind: str | None = None) -> None:
|
|
98
|
+
"""Track an object using the global leak tracker.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
obj: Object to track.
|
|
102
|
+
name: Optional identifier.
|
|
103
|
+
kind: Optional category label.
|
|
104
|
+
"""
|
|
105
|
+
_TRACKER.track(obj, name=name, kind=kind)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def untrack_resource(obj: object) -> None:
|
|
109
|
+
"""Stop tracking an object in the global tracker."""
|
|
110
|
+
_TRACKER.untrack(obj)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Sync-to-async bridge for running coroutines from synchronous contexts."""
|
|
5
|
+
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Awaitable
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
import anyio
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
__all__ = ("run_async",)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_async(coro: Awaitable[T]) -> T:
|
|
18
|
+
"""Execute an async coroutine from a synchronous context.
|
|
19
|
+
|
|
20
|
+
Creates an isolated thread with its own event loop to run the coroutine,
|
|
21
|
+
avoiding conflicts with any existing event loop in the current thread.
|
|
22
|
+
Thread-safe and blocks until completion.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
coro: Awaitable to execute (coroutine, Task, or Future).
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The result of the awaited coroutine.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
BaseException: Any exception raised by the coroutine is re-raised.
|
|
32
|
+
RuntimeError: If the coroutine completes without producing a result.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
>>> async def fetch_data():
|
|
36
|
+
... return {"status": "ok"}
|
|
37
|
+
>>> result = run_async(fetch_data())
|
|
38
|
+
>>> result
|
|
39
|
+
{'status': 'ok'}
|
|
40
|
+
|
|
41
|
+
Note:
|
|
42
|
+
Use sparingly. Prefer native async patterns when possible.
|
|
43
|
+
Each call creates a new thread and event loop.
|
|
44
|
+
"""
|
|
45
|
+
result_container: list[Any] = []
|
|
46
|
+
exception_container: list[BaseException] = []
|
|
47
|
+
|
|
48
|
+
def run_in_thread() -> None:
|
|
49
|
+
try:
|
|
50
|
+
|
|
51
|
+
async def _runner() -> T:
|
|
52
|
+
return await coro
|
|
53
|
+
|
|
54
|
+
result = anyio.run(_runner)
|
|
55
|
+
result_container.append(result)
|
|
56
|
+
except BaseException as e:
|
|
57
|
+
exception_container.append(e)
|
|
58
|
+
|
|
59
|
+
thread = threading.Thread(target=run_in_thread, daemon=False)
|
|
60
|
+
thread.start()
|
|
61
|
+
thread.join()
|
|
62
|
+
|
|
63
|
+
if exception_container:
|
|
64
|
+
raise exception_container[0]
|
|
65
|
+
if not result_container: # pragma: no cover
|
|
66
|
+
raise RuntimeError("Coroutine did not produce a result")
|
|
67
|
+
return result_container[0]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Task group wrapper for structured concurrency.
|
|
5
|
+
|
|
6
|
+
Thin wrapper around anyio.TaskGroup to provide a consistent internal API.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from typing import Any, TypeVar
|
|
14
|
+
|
|
15
|
+
import anyio
|
|
16
|
+
import anyio.abc
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
R = TypeVar("R")
|
|
20
|
+
|
|
21
|
+
__all__ = (
|
|
22
|
+
"TaskGroup",
|
|
23
|
+
"create_task_group",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TaskGroup:
|
|
28
|
+
"""Wrapper around anyio.TaskGroup for structured concurrency.
|
|
29
|
+
|
|
30
|
+
All spawned tasks complete (or are cancelled) before the group exits.
|
|
31
|
+
Exceptions propagate and cancel sibling tasks.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__slots__ = ("_tg",)
|
|
35
|
+
|
|
36
|
+
def __init__(self, tg: anyio.abc.TaskGroup) -> None:
|
|
37
|
+
"""Initialize with underlying anyio task group.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
tg: The anyio TaskGroup to wrap.
|
|
41
|
+
"""
|
|
42
|
+
self._tg = tg
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def cancel_scope(self) -> anyio.CancelScope:
|
|
46
|
+
"""Cancel scope controlling the task group lifetime."""
|
|
47
|
+
return self._tg.cancel_scope
|
|
48
|
+
|
|
49
|
+
def start_soon(
|
|
50
|
+
self,
|
|
51
|
+
func: Callable[..., Awaitable[Any]],
|
|
52
|
+
*args: Any,
|
|
53
|
+
name: str | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Spawn task immediately without waiting for it to initialize.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
func: Async callable to run.
|
|
59
|
+
*args: Positional arguments for func.
|
|
60
|
+
name: Optional task name for debugging.
|
|
61
|
+
"""
|
|
62
|
+
self._tg.start_soon(func, *args, name=name)
|
|
63
|
+
|
|
64
|
+
async def start(
|
|
65
|
+
self,
|
|
66
|
+
func: Callable[..., Awaitable[R]],
|
|
67
|
+
*args: Any,
|
|
68
|
+
name: str | None = None,
|
|
69
|
+
) -> R:
|
|
70
|
+
"""Spawn task and wait for it to signal readiness via task_status.started().
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
func: Async callable that calls task_status.started(value).
|
|
74
|
+
*args: Positional arguments for func.
|
|
75
|
+
name: Optional task name for debugging.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Value passed to task_status.started().
|
|
79
|
+
"""
|
|
80
|
+
return await self._tg.start(func, *args, name=name)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@asynccontextmanager
|
|
84
|
+
async def create_task_group() -> AsyncIterator[TaskGroup]:
|
|
85
|
+
"""Create a task group context for structured concurrency.
|
|
86
|
+
|
|
87
|
+
Usage:
|
|
88
|
+
async with create_task_group() as tg:
|
|
89
|
+
tg.start_soon(some_async_func)
|
|
90
|
+
|
|
91
|
+
Yields:
|
|
92
|
+
TaskGroup instance.
|
|
93
|
+
"""
|
|
94
|
+
async with anyio.create_task_group() as tg:
|
|
95
|
+
yield TaskGroup(tg)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Concurrency utility functions.
|
|
5
|
+
|
|
6
|
+
Thin wrappers around anyio for time, sleep, thread offloading, and coroutine detection.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from functools import cache, partial
|
|
12
|
+
from typing import Any, ParamSpec, TypeVar
|
|
13
|
+
|
|
14
|
+
import anyio
|
|
15
|
+
import anyio.to_thread
|
|
16
|
+
|
|
17
|
+
P = ParamSpec("P")
|
|
18
|
+
R = TypeVar("R")
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
__all__ = ("current_time", "is_coro_func", "run_sync", "sleep")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@cache
|
|
25
|
+
def _is_coro_func_cached(func: Callable[..., Any]) -> bool:
|
|
26
|
+
"""Cached coroutine check. Internal: expects already-unwrapped func."""
|
|
27
|
+
return inspect.iscoroutinefunction(func)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_coro_func(func: Callable[..., Any]) -> bool:
|
|
31
|
+
"""Check if func is a coroutine function, unwrapping partials first.
|
|
32
|
+
|
|
33
|
+
Unwraps partials before caching to prevent unbounded cache growth
|
|
34
|
+
(each partial instance would otherwise be a separate cache key).
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
func: Callable to check (may be wrapped in partial).
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
True if underlying function is async def.
|
|
41
|
+
"""
|
|
42
|
+
while isinstance(func, partial):
|
|
43
|
+
func = func.func
|
|
44
|
+
return _is_coro_func_cached(func)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def current_time() -> float:
|
|
48
|
+
"""Get current monotonic time in seconds.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Monotonic clock value from anyio.
|
|
52
|
+
"""
|
|
53
|
+
return anyio.current_time()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def run_sync(func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
|
57
|
+
"""Run synchronous function in thread pool without blocking event loop.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
func: Synchronous callable.
|
|
61
|
+
*args: Positional arguments for func.
|
|
62
|
+
**kwargs: Keyword arguments for func.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Result of func(*args, **kwargs).
|
|
66
|
+
"""
|
|
67
|
+
if kwargs:
|
|
68
|
+
func_with_kwargs = partial(func, **kwargs)
|
|
69
|
+
return await anyio.to_thread.run_sync(func_with_kwargs, *args)
|
|
70
|
+
return await anyio.to_thread.run_sync(func, *args)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def sleep(seconds: float) -> None:
|
|
74
|
+
"""Async sleep without blocking the event loop.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
seconds: Duration to sleep.
|
|
78
|
+
"""
|
|
79
|
+
await anyio.sleep(seconds)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from ._extract_json import extract_json
|
|
2
|
+
from ._fuzzy_json import fuzzy_json
|
|
3
|
+
from ._fuzzy_match import fuzzy_match_keys
|
|
4
|
+
from ._string_similarity import SimilarityAlgo, string_similarity
|
|
5
|
+
from ._to_dict import to_dict
|
|
6
|
+
|
|
7
|
+
__all__ = (
|
|
8
|
+
"extract_json",
|
|
9
|
+
"fuzzy_json",
|
|
10
|
+
"fuzzy_match_keys",
|
|
11
|
+
"string_similarity",
|
|
12
|
+
"SimilarityAlgo",
|
|
13
|
+
"to_dict",
|
|
14
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""JSON extraction utilities for parsing JSON from strings and markdown blocks."""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import orjson
|
|
10
|
+
|
|
11
|
+
from ._fuzzy_json import MAX_JSON_INPUT_SIZE, fuzzy_json
|
|
12
|
+
|
|
13
|
+
__all__ = ("extract_json",)
|
|
14
|
+
|
|
15
|
+
_JSON_BLOCK_PATTERN = re.compile(r"```json\s*(.*?)\s*```", re.DOTALL)
|
|
16
|
+
"""Regex for extracting content from ```json ... ``` code blocks."""
|
|
17
|
+
|
|
18
|
+
_JSON_PARSE_ERRORS = (orjson.JSONDecodeError, ValueError, TypeError)
|
|
19
|
+
"""Exceptions indicating parse failure (not system errors like MemoryError)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extract_json(
|
|
23
|
+
input_data: str | list[str],
|
|
24
|
+
/,
|
|
25
|
+
*,
|
|
26
|
+
fuzzy_parse: bool = False,
|
|
27
|
+
return_one_if_single: bool = True,
|
|
28
|
+
max_size: int = MAX_JSON_INPUT_SIZE,
|
|
29
|
+
) -> Any | list[Any]:
|
|
30
|
+
"""Extract and parse JSON from string or markdown code blocks.
|
|
31
|
+
|
|
32
|
+
Parsing strategy:
|
|
33
|
+
1. Try direct JSON parsing of entire input
|
|
34
|
+
2. On failure, extract ```json ... ``` blocks and parse each
|
|
35
|
+
3. Return [] if no valid JSON found
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
input_data: String or list of strings (joined with newlines).
|
|
39
|
+
fuzzy_parse: Use fuzzy_json() for malformed JSON.
|
|
40
|
+
return_one_if_single: Return single result directly (not wrapped in list).
|
|
41
|
+
max_size: Max input size in bytes (default: 10MB). DoS protection.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
- Single parsed object if return_one_if_single and exactly one result
|
|
45
|
+
- List of parsed objects otherwise
|
|
46
|
+
- [] if no valid JSON found
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ValueError: Input exceeds max_size.
|
|
50
|
+
|
|
51
|
+
Edge Cases:
|
|
52
|
+
- Multiple ```json blocks: returns list of all successfully parsed
|
|
53
|
+
- Invalid JSON in some blocks: skipped silently
|
|
54
|
+
- Empty input: returns []
|
|
55
|
+
"""
|
|
56
|
+
input_str = "\n".join(input_data) if isinstance(input_data, list) else input_data
|
|
57
|
+
|
|
58
|
+
if len(input_str) > max_size:
|
|
59
|
+
msg = (
|
|
60
|
+
f"Input size ({len(input_str)} bytes) exceeds maximum "
|
|
61
|
+
f"({max_size} bytes). This limit prevents memory exhaustion."
|
|
62
|
+
)
|
|
63
|
+
raise ValueError(msg)
|
|
64
|
+
|
|
65
|
+
# Try direct parsing first
|
|
66
|
+
try:
|
|
67
|
+
parsed = fuzzy_json(input_str) if fuzzy_parse else orjson.loads(input_str)
|
|
68
|
+
return parsed if return_one_if_single else [parsed]
|
|
69
|
+
except _JSON_PARSE_ERRORS:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
# Extract from markdown code blocks
|
|
73
|
+
matches = _JSON_BLOCK_PATTERN.findall(input_str)
|
|
74
|
+
if not matches:
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
if return_one_if_single and len(matches) == 1:
|
|
78
|
+
try:
|
|
79
|
+
return fuzzy_json(matches[0]) if fuzzy_parse else orjson.loads(matches[0])
|
|
80
|
+
except _JSON_PARSE_ERRORS:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
results: list[Any] = []
|
|
84
|
+
for m in matches:
|
|
85
|
+
try:
|
|
86
|
+
parsed = fuzzy_json(m) if fuzzy_parse else orjson.loads(m)
|
|
87
|
+
results.append(parsed)
|
|
88
|
+
except _JSON_PARSE_ERRORS:
|
|
89
|
+
continue
|
|
90
|
+
return results
|