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.
Files changed (101) hide show
  1. kronos/__init__.py +0 -0
  2. kronos/core/__init__.py +145 -0
  3. kronos/core/broadcaster.py +116 -0
  4. kronos/core/element.py +225 -0
  5. kronos/core/event.py +316 -0
  6. kronos/core/eventbus.py +116 -0
  7. kronos/core/flow.py +356 -0
  8. kronos/core/graph.py +442 -0
  9. kronos/core/node.py +982 -0
  10. kronos/core/pile.py +575 -0
  11. kronos/core/processor.py +494 -0
  12. kronos/core/progression.py +296 -0
  13. kronos/enforcement/__init__.py +57 -0
  14. kronos/enforcement/common/__init__.py +34 -0
  15. kronos/enforcement/common/boolean.py +85 -0
  16. kronos/enforcement/common/choice.py +97 -0
  17. kronos/enforcement/common/mapping.py +118 -0
  18. kronos/enforcement/common/model.py +102 -0
  19. kronos/enforcement/common/number.py +98 -0
  20. kronos/enforcement/common/string.py +140 -0
  21. kronos/enforcement/context.py +129 -0
  22. kronos/enforcement/policy.py +80 -0
  23. kronos/enforcement/registry.py +153 -0
  24. kronos/enforcement/rule.py +312 -0
  25. kronos/enforcement/service.py +370 -0
  26. kronos/enforcement/validator.py +198 -0
  27. kronos/errors.py +146 -0
  28. kronos/operations/__init__.py +32 -0
  29. kronos/operations/builder.py +228 -0
  30. kronos/operations/flow.py +398 -0
  31. kronos/operations/node.py +101 -0
  32. kronos/operations/registry.py +92 -0
  33. kronos/protocols.py +414 -0
  34. kronos/py.typed +0 -0
  35. kronos/services/__init__.py +81 -0
  36. kronos/services/backend.py +286 -0
  37. kronos/services/endpoint.py +608 -0
  38. kronos/services/hook.py +471 -0
  39. kronos/services/imodel.py +465 -0
  40. kronos/services/registry.py +115 -0
  41. kronos/services/utilities/__init__.py +36 -0
  42. kronos/services/utilities/header_factory.py +87 -0
  43. kronos/services/utilities/rate_limited_executor.py +271 -0
  44. kronos/services/utilities/rate_limiter.py +180 -0
  45. kronos/services/utilities/resilience.py +414 -0
  46. kronos/session/__init__.py +41 -0
  47. kronos/session/exchange.py +258 -0
  48. kronos/session/message.py +60 -0
  49. kronos/session/session.py +411 -0
  50. kronos/specs/__init__.py +25 -0
  51. kronos/specs/adapters/__init__.py +0 -0
  52. kronos/specs/adapters/_utils.py +45 -0
  53. kronos/specs/adapters/dataclass_field.py +246 -0
  54. kronos/specs/adapters/factory.py +56 -0
  55. kronos/specs/adapters/pydantic_adapter.py +309 -0
  56. kronos/specs/adapters/sql_ddl.py +946 -0
  57. kronos/specs/catalog/__init__.py +36 -0
  58. kronos/specs/catalog/_audit.py +39 -0
  59. kronos/specs/catalog/_common.py +43 -0
  60. kronos/specs/catalog/_content.py +59 -0
  61. kronos/specs/catalog/_enforcement.py +70 -0
  62. kronos/specs/factory.py +120 -0
  63. kronos/specs/operable.py +314 -0
  64. kronos/specs/phrase.py +405 -0
  65. kronos/specs/protocol.py +140 -0
  66. kronos/specs/spec.py +506 -0
  67. kronos/types/__init__.py +60 -0
  68. kronos/types/_sentinel.py +311 -0
  69. kronos/types/base.py +369 -0
  70. kronos/types/db_types.py +260 -0
  71. kronos/types/identity.py +66 -0
  72. kronos/utils/__init__.py +40 -0
  73. kronos/utils/_hash.py +234 -0
  74. kronos/utils/_json_dump.py +392 -0
  75. kronos/utils/_lazy_init.py +63 -0
  76. kronos/utils/_to_list.py +165 -0
  77. kronos/utils/_to_num.py +85 -0
  78. kronos/utils/_utils.py +375 -0
  79. kronos/utils/concurrency/__init__.py +205 -0
  80. kronos/utils/concurrency/_async_call.py +333 -0
  81. kronos/utils/concurrency/_cancel.py +122 -0
  82. kronos/utils/concurrency/_errors.py +96 -0
  83. kronos/utils/concurrency/_patterns.py +363 -0
  84. kronos/utils/concurrency/_primitives.py +328 -0
  85. kronos/utils/concurrency/_priority_queue.py +135 -0
  86. kronos/utils/concurrency/_resource_tracker.py +110 -0
  87. kronos/utils/concurrency/_run_async.py +67 -0
  88. kronos/utils/concurrency/_task.py +95 -0
  89. kronos/utils/concurrency/_utils.py +79 -0
  90. kronos/utils/fuzzy/__init__.py +14 -0
  91. kronos/utils/fuzzy/_extract_json.py +90 -0
  92. kronos/utils/fuzzy/_fuzzy_json.py +288 -0
  93. kronos/utils/fuzzy/_fuzzy_match.py +149 -0
  94. kronos/utils/fuzzy/_string_similarity.py +187 -0
  95. kronos/utils/fuzzy/_to_dict.py +396 -0
  96. kronos/utils/sql/__init__.py +13 -0
  97. kronos/utils/sql/_sql_validation.py +142 -0
  98. krons-0.1.0.dist-info/METADATA +70 -0
  99. krons-0.1.0.dist-info/RECORD +101 -0
  100. krons-0.1.0.dist-info/WHEEL +4 -0
  101. 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