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,182 +1,69 @@
|
|
1
|
-
"""
|
1
|
+
"""Lightweight resource tracking (debug aid)."""
|
2
2
|
|
3
|
-
|
4
|
-
to address the security vulnerabilities identified in the hardening tests.
|
5
|
-
"""
|
3
|
+
from __future__ import annotations
|
6
4
|
|
5
|
+
import threading
|
6
|
+
import time
|
7
7
|
import weakref
|
8
8
|
from dataclasses import dataclass
|
9
|
-
from datetime import datetime
|
10
|
-
from typing import Any
|
11
9
|
|
10
|
+
__all__ = (
|
11
|
+
"track_resource",
|
12
|
+
"untrack_resource",
|
13
|
+
"LeakInfo",
|
14
|
+
"LeakTracker",
|
15
|
+
)
|
12
16
|
|
13
|
-
@dataclass
|
14
|
-
class ResourceInfo:
|
15
|
-
"""Information about a tracked resource."""
|
16
17
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
19
|
+
class LeakInfo:
|
17
20
|
name: str
|
18
|
-
|
19
|
-
|
21
|
+
kind: str | None
|
22
|
+
created_at: float
|
20
23
|
|
21
24
|
|
22
|
-
class
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
practical resource management without complex abstraction layers.
|
27
|
-
"""
|
28
|
-
|
29
|
-
def __init__(self):
|
30
|
-
"""Initialize a new resource tracker."""
|
31
|
-
self._active_resources: dict[int, ResourceInfo] = {}
|
32
|
-
self._weak_refs: weakref.WeakKeyDictionary = (
|
33
|
-
weakref.WeakKeyDictionary()
|
34
|
-
)
|
25
|
+
class LeakTracker:
|
26
|
+
def __init__(self) -> None:
|
27
|
+
self._live: dict[int, LeakInfo] = {}
|
28
|
+
self._lock = threading.Lock()
|
35
29
|
|
36
30
|
def track(
|
37
|
-
self,
|
31
|
+
self, obj: object, *, name: str | None, kind: str | None
|
38
32
|
) -> None:
|
39
|
-
|
40
|
-
|
41
|
-
Args:
|
42
|
-
resource: The resource to track
|
43
|
-
name: Human-readable name for the resource
|
44
|
-
resource_type: Optional type classification
|
45
|
-
"""
|
46
|
-
if resource_type is None:
|
47
|
-
resource_type = type(resource).__name__
|
48
|
-
|
49
|
-
resource_info = ResourceInfo(
|
50
|
-
name=name,
|
51
|
-
creation_time=datetime.now(),
|
52
|
-
resource_type=resource_type,
|
33
|
+
info = LeakInfo(
|
34
|
+
name=name or f"obj-{id(obj)}", kind=kind, created_at=time.time()
|
53
35
|
)
|
36
|
+
key = id(obj)
|
54
37
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
def untrack(self, resource: Any) -> None:
|
60
|
-
"""Manually untrack a resource.
|
61
|
-
|
62
|
-
Args:
|
63
|
-
resource: The resource to stop tracking
|
64
|
-
"""
|
65
|
-
resource_id = id(resource)
|
66
|
-
self._active_resources.pop(resource_id, None)
|
67
|
-
self._weak_refs.pop(resource, None)
|
68
|
-
|
69
|
-
def cleanup_check(self) -> list[ResourceInfo]:
|
70
|
-
"""Check for potentially leaked resources.
|
38
|
+
def _finalizer(_key: int = key) -> None:
|
39
|
+
with self._lock:
|
40
|
+
self._live.pop(_key, None)
|
71
41
|
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
# Clean up references to garbage collected objects
|
76
|
-
current_resources = []
|
77
|
-
for resource, info in list(self._weak_refs.items()):
|
78
|
-
current_resources.append(info)
|
42
|
+
with self._lock:
|
43
|
+
self._live[key] = info
|
44
|
+
weakref.finalize(obj, _finalizer)
|
79
45
|
|
80
|
-
|
46
|
+
def untrack(self, obj: object) -> None:
|
47
|
+
with self._lock:
|
48
|
+
self._live.pop(id(obj), None)
|
81
49
|
|
82
|
-
def
|
83
|
-
|
50
|
+
def live(self) -> list[LeakInfo]:
|
51
|
+
with self._lock:
|
52
|
+
return list(self._live.values())
|
84
53
|
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
return len(self._weak_refs)
|
54
|
+
def clear(self) -> None:
|
55
|
+
with self._lock:
|
56
|
+
self._live.clear()
|
89
57
|
|
90
|
-
def get_resource_summary(self) -> dict[str, int]:
|
91
|
-
"""Get a summary of tracked resources by type.
|
92
58
|
|
93
|
-
|
94
|
-
Dictionary mapping resource types to counts
|
95
|
-
"""
|
96
|
-
summary = {}
|
97
|
-
for info in self._weak_refs.values():
|
98
|
-
resource_type = info.resource_type
|
99
|
-
summary[resource_type] = summary.get(resource_type, 0) + 1
|
100
|
-
return summary
|
101
|
-
|
102
|
-
|
103
|
-
# Global tracker instance for convenience
|
104
|
-
_global_tracker = ResourceTracker()
|
59
|
+
_TRACKER = LeakTracker()
|
105
60
|
|
106
61
|
|
107
62
|
def track_resource(
|
108
|
-
|
63
|
+
obj: object, name: str | None = None, kind: str | None = None
|
109
64
|
) -> None:
|
110
|
-
|
111
|
-
|
112
|
-
Args:
|
113
|
-
resource: The resource to track
|
114
|
-
name: Human-readable name for the resource
|
115
|
-
resource_type: Optional type classification
|
116
|
-
"""
|
117
|
-
_global_tracker.track(resource, name, resource_type)
|
118
|
-
|
119
|
-
|
120
|
-
def untrack_resource(resource: Any) -> None:
|
121
|
-
"""Untrack a resource using the global tracker.
|
122
|
-
|
123
|
-
Args:
|
124
|
-
resource: The resource to stop tracking
|
125
|
-
"""
|
126
|
-
_global_tracker.untrack(resource)
|
127
|
-
|
128
|
-
|
129
|
-
def get_global_tracker() -> ResourceTracker:
|
130
|
-
"""Get the global resource tracker instance.
|
131
|
-
|
132
|
-
Returns:
|
133
|
-
The global ResourceTracker instance
|
134
|
-
"""
|
135
|
-
return _global_tracker
|
136
|
-
|
137
|
-
|
138
|
-
def cleanup_check() -> list[ResourceInfo]:
|
139
|
-
"""Check for potentially leaked resources using global tracker.
|
140
|
-
|
141
|
-
Returns:
|
142
|
-
List of resource info for resources that may have leaked
|
143
|
-
"""
|
144
|
-
return _global_tracker.cleanup_check()
|
145
|
-
|
146
|
-
|
147
|
-
class resource_leak_detector:
|
148
|
-
"""Context manager for resource leak detection in tests and production.
|
149
|
-
|
150
|
-
Example:
|
151
|
-
async with resource_leak_detector() as tracker:
|
152
|
-
lock = Lock()
|
153
|
-
tracker.track(lock, "test_lock")
|
154
|
-
# ... use lock
|
155
|
-
# Automatically checks for leaks on exit
|
156
|
-
"""
|
157
|
-
|
158
|
-
def __init__(self, raise_on_leak: bool = False):
|
159
|
-
"""Initialize the leak detector.
|
160
|
-
|
161
|
-
Args:
|
162
|
-
raise_on_leak: Whether to raise an exception if leaks are detected
|
163
|
-
"""
|
164
|
-
self.raise_on_leak = raise_on_leak
|
165
|
-
self.tracker = ResourceTracker()
|
166
|
-
self._initial_count = 0
|
167
|
-
|
168
|
-
async def __aenter__(self) -> ResourceTracker:
|
169
|
-
"""Enter the context and return the tracker."""
|
170
|
-
self._initial_count = self.tracker.get_active_count()
|
171
|
-
return self.tracker
|
65
|
+
_TRACKER.track(obj, name=name, kind=kind)
|
172
66
|
|
173
|
-
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
174
|
-
"""Exit the context and check for leaks."""
|
175
|
-
leaked_resources = self.tracker.cleanup_check()
|
176
67
|
|
177
|
-
|
178
|
-
|
179
|
-
raise RuntimeError(
|
180
|
-
f"Resource leak detected: {len(leaked_resources)} resources "
|
181
|
-
f"still active. Summary: {resource_summary}"
|
182
|
-
)
|
68
|
+
def untrack_resource(obj: object) -> None:
|
69
|
+
_TRACKER.untrack(obj)
|
lionagi/ln/concurrency/task.py
CHANGED
@@ -1,9 +1,9 @@
|
|
1
|
-
"""Task group
|
1
|
+
"""Task group wrapper (thin facade over anyio.create_task_group)."""
|
2
2
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
|
-
from collections.abc import Awaitable, Callable
|
6
|
-
from
|
5
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
6
|
+
from contextlib import asynccontextmanager
|
7
7
|
from typing import Any, TypeVar
|
8
8
|
|
9
9
|
import anyio
|
@@ -11,33 +11,55 @@ import anyio
|
|
11
11
|
T = TypeVar("T")
|
12
12
|
R = TypeVar("R")
|
13
13
|
|
14
|
+
__all__ = (
|
15
|
+
"TaskGroup",
|
16
|
+
"create_task_group",
|
17
|
+
)
|
18
|
+
|
14
19
|
|
15
20
|
class TaskGroup:
|
16
|
-
"""
|
21
|
+
"""Structured concurrency task group (anyio.abc.TaskGroup wrapper).
|
22
|
+
|
23
|
+
Manages a group of concurrent tasks with structured lifecycle.
|
24
|
+
If any task fails, all other tasks in the group are cancelled.
|
25
|
+
|
26
|
+
Note: Lifecycle is managed by the create_task_group() context manager.
|
27
|
+
Do not instantiate directly.
|
28
|
+
|
29
|
+
Usage:
|
30
|
+
async with create_task_group() as tg:
|
31
|
+
tg.start_soon(worker_task, arg1)
|
32
|
+
tg.start_soon(worker_task, arg2)
|
33
|
+
# All tasks complete before exiting context
|
34
|
+
"""
|
35
|
+
|
36
|
+
__slots__ = ("_tg",)
|
17
37
|
|
18
|
-
def __init__(self):
|
19
|
-
|
20
|
-
self._task_group = None
|
38
|
+
def __init__(self, tg: anyio.abc.TaskGroup) -> None:
|
39
|
+
self._tg = tg
|
21
40
|
|
22
|
-
|
41
|
+
@property
|
42
|
+
def cancel_scope(self) -> anyio.CancelScope:
|
43
|
+
"""Cancel scope controlling this task group's lifetime.
|
44
|
+
|
45
|
+
Use this to cancel all tasks: tg.cancel_scope.cancel()
|
46
|
+
"""
|
47
|
+
return self._tg.cancel_scope
|
48
|
+
|
49
|
+
def start_soon(
|
23
50
|
self,
|
24
51
|
func: Callable[..., Awaitable[Any]],
|
25
52
|
*args: Any,
|
26
53
|
name: str | None = None,
|
27
54
|
) -> None:
|
28
|
-
"""Start a
|
55
|
+
"""Start a task without waiting for it to initialize.
|
29
56
|
|
30
57
|
Args:
|
31
|
-
func:
|
32
|
-
*args:
|
33
|
-
name: Optional name for the task
|
34
|
-
|
35
|
-
Note:
|
36
|
-
This method does not wait for the task to initialize.
|
58
|
+
func: Async function to run as a task
|
59
|
+
*args: Arguments to pass to the function
|
60
|
+
name: Optional name for the task (for debugging)
|
37
61
|
"""
|
38
|
-
|
39
|
-
raise RuntimeError("Task group is not active")
|
40
|
-
self._task_group.start_soon(func, *args, name=name)
|
62
|
+
self._tg.start_soon(func, *args, name=name)
|
41
63
|
|
42
64
|
async def start(
|
43
65
|
self,
|
@@ -45,67 +67,27 @@ class TaskGroup:
|
|
45
67
|
*args: Any,
|
46
68
|
name: str | None = None,
|
47
69
|
) -> R:
|
48
|
-
"""Start a
|
49
|
-
|
50
|
-
Args:
|
51
|
-
func: The coroutine function to call
|
52
|
-
*args: Positional arguments to pass to the function
|
53
|
-
name: Optional name for the task
|
70
|
+
"""Start a task and wait for it to initialize.
|
54
71
|
|
55
|
-
|
56
|
-
The value passed to task_status.started()
|
57
|
-
|
58
|
-
Note:
|
59
|
-
The function must accept a task_status keyword argument and call
|
60
|
-
task_status.started() once initialization is complete.
|
61
|
-
"""
|
62
|
-
if self._task_group is None:
|
63
|
-
raise RuntimeError("Task group is not active")
|
64
|
-
return await self._task_group.start(func, *args, name=name)
|
65
|
-
|
66
|
-
async def __aenter__(self) -> TaskGroup:
|
67
|
-
"""Enter the task group context.
|
68
|
-
|
69
|
-
Returns:
|
70
|
-
The task group instance.
|
71
|
-
"""
|
72
|
-
task_group = anyio.create_task_group()
|
73
|
-
self._task_group = await task_group.__aenter__()
|
74
|
-
return self
|
72
|
+
The task function should use task_status.started() to signal initialization.
|
75
73
|
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
exc_tb: TracebackType | None,
|
81
|
-
) -> bool:
|
82
|
-
"""Exit the task group context.
|
83
|
-
|
84
|
-
This will wait for all tasks in the group to complete.
|
85
|
-
If any task raised an exception, it will be propagated.
|
86
|
-
If multiple tasks raised exceptions, they will be combined into an ExceptionGroup.
|
74
|
+
Args:
|
75
|
+
func: Async function to run as a task
|
76
|
+
*args: Arguments to pass to the function
|
77
|
+
name: Optional name for the task (for debugging)
|
87
78
|
|
88
79
|
Returns:
|
89
|
-
|
80
|
+
Value passed to task_status.started() by the task
|
90
81
|
"""
|
91
|
-
|
92
|
-
return False
|
82
|
+
return await self._tg.start(func, *args, name=name)
|
93
83
|
|
94
|
-
try:
|
95
|
-
return await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
|
96
|
-
finally:
|
97
|
-
self._task_group = None
|
98
84
|
|
85
|
+
@asynccontextmanager
|
86
|
+
async def create_task_group() -> AsyncIterator[TaskGroup]:
|
87
|
+
"""Create a new task group for structured concurrency.
|
99
88
|
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
Returns:
|
104
|
-
A new task group instance.
|
105
|
-
|
106
|
-
Example:
|
107
|
-
async with create_task_group() as tg:
|
108
|
-
await tg.start_soon(task1)
|
109
|
-
await tg.start_soon(task2)
|
89
|
+
Returns an async context manager that yields a TaskGroup.
|
90
|
+
All tasks started within the group complete before the context exits.
|
110
91
|
"""
|
111
|
-
|
92
|
+
async with anyio.create_task_group() as tg:
|
93
|
+
yield TaskGroup(tg)
|
@@ -6,12 +6,15 @@ from collections.abc import Callable
|
|
6
6
|
from time import sleep, time
|
7
7
|
from typing import Any, TypeVar
|
8
8
|
|
9
|
+
from typing_extensions import deprecated
|
10
|
+
|
9
11
|
T = TypeVar("T")
|
10
12
|
|
11
13
|
|
12
14
|
__all__ = ("Throttle",)
|
13
15
|
|
14
16
|
|
17
|
+
@deprecated("Throttle is deprecated and will be removed in a future release.")
|
15
18
|
class Throttle:
|
16
19
|
"""
|
17
20
|
Provide a throttling mechanism for function calls.
|
lionagi/ln/concurrency/utils.py
CHANGED
@@ -0,0 +1,15 @@
|
|
1
|
+
from ._extract_json import extract_json
|
2
|
+
from ._fuzzy_json import fuzzy_json
|
3
|
+
from ._fuzzy_match import FuzzyMatchKeysParams, fuzzy_match_keys
|
4
|
+
from ._fuzzy_validate import fuzzy_validate_pydantic
|
5
|
+
from ._string_similarity import SIMILARITY_TYPE, string_similarity
|
6
|
+
|
7
|
+
__all__ = (
|
8
|
+
"fuzzy_json",
|
9
|
+
"fuzzy_match_keys",
|
10
|
+
"extract_json",
|
11
|
+
"string_similarity",
|
12
|
+
"SIMILARITY_TYPE",
|
13
|
+
"fuzzy_validate_pydantic",
|
14
|
+
"FuzzyMatchKeysParams",
|
15
|
+
)
|
@@ -1,13 +1,16 @@
|
|
1
1
|
import re
|
2
2
|
from typing import Any
|
3
3
|
|
4
|
-
import
|
4
|
+
import msgspec
|
5
5
|
|
6
6
|
from ._fuzzy_json import fuzzy_json
|
7
7
|
|
8
8
|
# Precompile the regex for extracting JSON code blocks
|
9
9
|
_JSON_BLOCK_PATTERN = re.compile(r"```json\s*(.*?)\s*```", re.DOTALL)
|
10
10
|
|
11
|
+
# Initialize a decoder for efficiency
|
12
|
+
_decoder = msgspec.json.Decoder(type=Any)
|
13
|
+
|
11
14
|
|
12
15
|
def extract_json(
|
13
16
|
input_data: str | list[str],
|
@@ -37,7 +40,7 @@ def extract_json(
|
|
37
40
|
try:
|
38
41
|
if fuzzy_parse:
|
39
42
|
return fuzzy_json(input_str)
|
40
|
-
return
|
43
|
+
return _decoder.decode(input_str.encode("utf-8"))
|
41
44
|
except Exception:
|
42
45
|
pass
|
43
46
|
|
@@ -49,12 +52,22 @@ def extract_json(
|
|
49
52
|
# If only one match, return single dict; if multiple, return list of dicts
|
50
53
|
if return_one_if_single and len(matches) == 1:
|
51
54
|
data_str = matches[0]
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
+
try:
|
56
|
+
if fuzzy_parse:
|
57
|
+
return fuzzy_json(data_str)
|
58
|
+
return _decoder.decode(data_str.encode("utf-8"))
|
59
|
+
except Exception:
|
60
|
+
return []
|
55
61
|
|
56
62
|
# Multiple matches
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
63
|
+
results = []
|
64
|
+
for m in matches:
|
65
|
+
try:
|
66
|
+
if fuzzy_parse:
|
67
|
+
results.append(fuzzy_json(m))
|
68
|
+
else:
|
69
|
+
results.append(_decoder.decode(m.encode("utf-8")))
|
70
|
+
except Exception:
|
71
|
+
# Skip invalid JSON blocks
|
72
|
+
continue
|
73
|
+
return results
|
@@ -2,7 +2,9 @@ import contextlib
|
|
2
2
|
import re
|
3
3
|
from typing import Any
|
4
4
|
|
5
|
-
import
|
5
|
+
import msgspec
|
6
|
+
|
7
|
+
_decoder = msgspec.json.Decoder(type=Any)
|
6
8
|
|
7
9
|
|
8
10
|
def fuzzy_json(str_to_parse: str, /) -> dict[str, Any] | list[dict[str, Any]]:
|
@@ -10,7 +12,7 @@ def fuzzy_json(str_to_parse: str, /) -> dict[str, Any] | list[dict[str, Any]]:
|
|
10
12
|
Attempt to parse a JSON string, trying a few minimal "fuzzy" fixes if needed.
|
11
13
|
|
12
14
|
Steps:
|
13
|
-
1. Parse directly with
|
15
|
+
1. Parse directly with msgspec.
|
14
16
|
2. Replace single quotes with double quotes, normalize spacing, and try again.
|
15
17
|
3. Attempt to fix unmatched brackets using fix_json_string.
|
16
18
|
4. If all fail, raise ValueError.
|
@@ -27,19 +29,21 @@ def fuzzy_json(str_to_parse: str, /) -> dict[str, Any] | list[dict[str, Any]]:
|
|
27
29
|
"""
|
28
30
|
_check_valid_str(str_to_parse)
|
29
31
|
|
32
|
+
# Use .encode("utf-8") as msgspec typically works with bytes for optimal performance
|
33
|
+
|
30
34
|
# 1. Direct attempt
|
31
|
-
with contextlib.suppress(
|
32
|
-
return
|
35
|
+
with contextlib.suppress(msgspec.DecodeError):
|
36
|
+
return _decoder.decode(str_to_parse.encode("utf-8"))
|
33
37
|
|
34
38
|
# 2. Try cleaning: replace single quotes with double and normalize
|
35
39
|
cleaned = _clean_json_string(str_to_parse.replace("'", '"'))
|
36
|
-
with contextlib.suppress(
|
37
|
-
return
|
40
|
+
with contextlib.suppress(msgspec.DecodeError):
|
41
|
+
return _decoder.decode(cleaned.encode("utf-8"))
|
38
42
|
|
39
43
|
# 3. Try fixing brackets
|
40
44
|
fixed = fix_json_string(cleaned)
|
41
|
-
with contextlib.suppress(
|
42
|
-
return
|
45
|
+
with contextlib.suppress(msgspec.DecodeError):
|
46
|
+
return _decoder.decode(fixed.encode("utf-8"))
|
43
47
|
|
44
48
|
# If all attempts fail
|
45
49
|
raise ValueError("Invalid JSON string")
|
@@ -59,6 +63,8 @@ def _clean_json_string(s: str) -> str:
|
|
59
63
|
s = re.sub(r"(?<!\\)'", '"', s)
|
60
64
|
# Collapse multiple whitespaces
|
61
65
|
s = re.sub(r"\s+", " ", s)
|
66
|
+
# Remove trailing commas before closing brackets/braces
|
67
|
+
s = re.sub(r",\s*([}\]])", r"\1", s)
|
62
68
|
# Ensure keys are quoted
|
63
69
|
# This attempts to find patterns like { key: value } and turn them into {"key": value}
|
64
70
|
s = re.sub(r'([{,])\s*([^"\s]+)\s*:', r'\1"\2":', s)
|