kreuzberg 3.1.7__py3-none-any.whl → 3.3.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.
- kreuzberg/__init__.py +3 -0
- kreuzberg/__main__.py +8 -0
- kreuzberg/_cli_config.py +175 -0
- kreuzberg/_extractors/_image.py +39 -4
- kreuzberg/_extractors/_pandoc.py +158 -18
- kreuzberg/_extractors/_pdf.py +199 -19
- kreuzberg/_extractors/_presentation.py +1 -1
- kreuzberg/_extractors/_spread_sheet.py +65 -7
- kreuzberg/_gmft.py +222 -16
- kreuzberg/_mime_types.py +62 -16
- kreuzberg/_multiprocessing/__init__.py +6 -0
- kreuzberg/_multiprocessing/gmft_isolated.py +332 -0
- kreuzberg/_multiprocessing/process_manager.py +188 -0
- kreuzberg/_multiprocessing/sync_tesseract.py +261 -0
- kreuzberg/_multiprocessing/tesseract_pool.py +359 -0
- kreuzberg/_ocr/_easyocr.py +66 -10
- kreuzberg/_ocr/_paddleocr.py +86 -7
- kreuzberg/_ocr/_tesseract.py +136 -46
- kreuzberg/_playa.py +43 -0
- kreuzberg/_utils/_cache.py +372 -0
- kreuzberg/_utils/_device.py +356 -0
- kreuzberg/_utils/_document_cache.py +220 -0
- kreuzberg/_utils/_errors.py +232 -0
- kreuzberg/_utils/_pdf_lock.py +72 -0
- kreuzberg/_utils/_process_pool.py +100 -0
- kreuzberg/_utils/_serialization.py +82 -0
- kreuzberg/_utils/_string.py +1 -1
- kreuzberg/_utils/_sync.py +21 -0
- kreuzberg/cli.py +338 -0
- kreuzberg/extraction.py +247 -36
- {kreuzberg-3.1.7.dist-info → kreuzberg-3.3.0.dist-info}/METADATA +95 -34
- kreuzberg-3.3.0.dist-info/RECORD +48 -0
- {kreuzberg-3.1.7.dist-info → kreuzberg-3.3.0.dist-info}/WHEEL +1 -2
- kreuzberg-3.3.0.dist-info/entry_points.txt +2 -0
- kreuzberg-3.1.7.dist-info/RECORD +0 -33
- kreuzberg-3.1.7.dist-info/top_level.txt +0 -1
- {kreuzberg-3.1.7.dist-info → kreuzberg-3.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,232 @@
|
|
1
|
+
"""Enhanced error handling utilities."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import platform
|
6
|
+
import traceback
|
7
|
+
from datetime import datetime, timezone
|
8
|
+
from typing import TYPE_CHECKING, Any
|
9
|
+
|
10
|
+
import psutil
|
11
|
+
|
12
|
+
if TYPE_CHECKING:
|
13
|
+
from pathlib import Path
|
14
|
+
|
15
|
+
|
16
|
+
def create_error_context(
|
17
|
+
*,
|
18
|
+
operation: str,
|
19
|
+
file_path: Path | str | None = None,
|
20
|
+
error: Exception | None = None,
|
21
|
+
**extra: Any,
|
22
|
+
) -> dict[str, Any]:
|
23
|
+
"""Create comprehensive error context.
|
24
|
+
|
25
|
+
Args:
|
26
|
+
operation: The operation being performed (e.g., "extract_file", "convert_pdf_to_images")
|
27
|
+
file_path: The file being processed, if applicable
|
28
|
+
error: The original exception, if any
|
29
|
+
**extra: Additional context fields
|
30
|
+
|
31
|
+
Returns:
|
32
|
+
Dictionary with error context including system info
|
33
|
+
"""
|
34
|
+
context: dict[str, Any] = {
|
35
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
36
|
+
"operation": operation,
|
37
|
+
}
|
38
|
+
|
39
|
+
if file_path:
|
40
|
+
from pathlib import Path
|
41
|
+
|
42
|
+
path = Path(file_path) if isinstance(file_path, str) else file_path
|
43
|
+
context["file"] = {
|
44
|
+
"path": str(path),
|
45
|
+
"name": path.name,
|
46
|
+
"exists": path.exists(),
|
47
|
+
"size": path.stat().st_size if path.exists() else None,
|
48
|
+
}
|
49
|
+
|
50
|
+
if error:
|
51
|
+
context["error"] = {
|
52
|
+
"type": type(error).__name__,
|
53
|
+
"message": str(error),
|
54
|
+
"traceback": traceback.format_exception_only(type(error), error),
|
55
|
+
}
|
56
|
+
|
57
|
+
if (
|
58
|
+
any(keyword in str(error).lower() for keyword in ["memory", "resource", "process", "thread"])
|
59
|
+
if error
|
60
|
+
else False
|
61
|
+
):
|
62
|
+
try:
|
63
|
+
mem = psutil.virtual_memory()
|
64
|
+
context["system"] = {
|
65
|
+
"memory_available_mb": mem.available / 1024 / 1024,
|
66
|
+
"memory_percent": mem.percent,
|
67
|
+
"cpu_percent": psutil.cpu_percent(interval=0.1),
|
68
|
+
"process_count": len(psutil.pids()),
|
69
|
+
"platform": platform.platform(),
|
70
|
+
}
|
71
|
+
except Exception: # noqa: BLE001
|
72
|
+
pass
|
73
|
+
|
74
|
+
context.update(extra)
|
75
|
+
|
76
|
+
return context
|
77
|
+
|
78
|
+
|
79
|
+
def is_transient_error(error: Exception) -> bool:
|
80
|
+
"""Check if an error is likely transient and worth retrying.
|
81
|
+
|
82
|
+
Args:
|
83
|
+
error: The exception to check
|
84
|
+
|
85
|
+
Returns:
|
86
|
+
True if the error is likely transient
|
87
|
+
"""
|
88
|
+
transient_types = (
|
89
|
+
OSError,
|
90
|
+
PermissionError,
|
91
|
+
TimeoutError,
|
92
|
+
ConnectionError,
|
93
|
+
BrokenPipeError,
|
94
|
+
)
|
95
|
+
|
96
|
+
if isinstance(error, transient_types):
|
97
|
+
return True
|
98
|
+
|
99
|
+
transient_patterns = [
|
100
|
+
"temporary",
|
101
|
+
"locked",
|
102
|
+
"in use",
|
103
|
+
"access denied",
|
104
|
+
"permission",
|
105
|
+
"timeout",
|
106
|
+
"connection",
|
107
|
+
"network",
|
108
|
+
"too many open files",
|
109
|
+
"cannot allocate memory",
|
110
|
+
"resource temporarily unavailable",
|
111
|
+
"broken pipe",
|
112
|
+
"subprocess",
|
113
|
+
"signal",
|
114
|
+
]
|
115
|
+
|
116
|
+
error_str = str(error).lower()
|
117
|
+
return any(pattern in error_str for pattern in transient_patterns)
|
118
|
+
|
119
|
+
|
120
|
+
def is_resource_error(error: Exception) -> bool:
|
121
|
+
"""Check if an error is related to system resources.
|
122
|
+
|
123
|
+
Args:
|
124
|
+
error: The exception to check
|
125
|
+
|
126
|
+
Returns:
|
127
|
+
True if the error is resource-related
|
128
|
+
"""
|
129
|
+
resource_patterns = [
|
130
|
+
"memory",
|
131
|
+
"out of memory",
|
132
|
+
"cannot allocate",
|
133
|
+
"too many open files",
|
134
|
+
"file descriptor",
|
135
|
+
"resource",
|
136
|
+
"exhausted",
|
137
|
+
"limit",
|
138
|
+
"cpu",
|
139
|
+
"thread",
|
140
|
+
"process",
|
141
|
+
]
|
142
|
+
|
143
|
+
error_str = str(error).lower()
|
144
|
+
return any(pattern in error_str for pattern in resource_patterns)
|
145
|
+
|
146
|
+
|
147
|
+
def should_retry(error: Exception, attempt: int, max_attempts: int = 3) -> bool:
|
148
|
+
"""Determine if an operation should be retried.
|
149
|
+
|
150
|
+
Args:
|
151
|
+
error: The exception that occurred
|
152
|
+
attempt: Current attempt number (1-based)
|
153
|
+
max_attempts: Maximum number of attempts
|
154
|
+
|
155
|
+
Returns:
|
156
|
+
True if the operation should be retried
|
157
|
+
"""
|
158
|
+
if attempt >= max_attempts:
|
159
|
+
return False
|
160
|
+
|
161
|
+
from kreuzberg.exceptions import ValidationError
|
162
|
+
|
163
|
+
if isinstance(error, ValidationError):
|
164
|
+
return False
|
165
|
+
|
166
|
+
return is_transient_error(error)
|
167
|
+
|
168
|
+
|
169
|
+
class BatchExtractionResult:
|
170
|
+
"""Result container for batch operations with partial success support."""
|
171
|
+
|
172
|
+
def __init__(self) -> None:
|
173
|
+
"""Initialize batch result container."""
|
174
|
+
self.successful: list[tuple[int, Any]] = []
|
175
|
+
self.failed: list[tuple[int, dict[str, Any]]] = []
|
176
|
+
self.total_count: int = 0
|
177
|
+
|
178
|
+
def add_success(self, index: int, result: Any) -> None:
|
179
|
+
"""Add a successful result."""
|
180
|
+
self.successful.append((index, result))
|
181
|
+
|
182
|
+
def add_failure(self, index: int, error: Exception, context: dict[str, Any]) -> None:
|
183
|
+
"""Add a failed result with context."""
|
184
|
+
error_info = {
|
185
|
+
"error": {
|
186
|
+
"type": type(error).__name__,
|
187
|
+
"message": str(error),
|
188
|
+
},
|
189
|
+
"context": context,
|
190
|
+
}
|
191
|
+
self.failed.append((index, error_info))
|
192
|
+
|
193
|
+
@property
|
194
|
+
def success_count(self) -> int:
|
195
|
+
"""Number of successful operations."""
|
196
|
+
return len(self.successful)
|
197
|
+
|
198
|
+
@property
|
199
|
+
def failure_count(self) -> int:
|
200
|
+
"""Number of failed operations."""
|
201
|
+
return len(self.failed)
|
202
|
+
|
203
|
+
@property
|
204
|
+
def success_rate(self) -> float:
|
205
|
+
"""Success rate as a percentage."""
|
206
|
+
if self.total_count == 0:
|
207
|
+
return 0.0
|
208
|
+
return (self.success_count / self.total_count) * 100
|
209
|
+
|
210
|
+
def get_ordered_results(self) -> list[Any | None]:
|
211
|
+
"""Get results in original order with None for failures."""
|
212
|
+
results = [None] * self.total_count
|
213
|
+
for index, result in self.successful:
|
214
|
+
results[index] = result
|
215
|
+
return results
|
216
|
+
|
217
|
+
def get_summary(self) -> dict[str, Any]:
|
218
|
+
"""Get summary of batch operation."""
|
219
|
+
return {
|
220
|
+
"total": self.total_count,
|
221
|
+
"successful": self.success_count,
|
222
|
+
"failed": self.failure_count,
|
223
|
+
"success_rate": f"{self.success_rate:.1f}%",
|
224
|
+
"failures": [
|
225
|
+
{
|
226
|
+
"index": idx,
|
227
|
+
"error": info["error"]["type"],
|
228
|
+
"message": info["error"]["message"],
|
229
|
+
}
|
230
|
+
for idx, info in self.failed
|
231
|
+
],
|
232
|
+
}
|
@@ -0,0 +1,72 @@
|
|
1
|
+
"""PDF processing lock utilities for thread-safe pypdfium2 operations."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import hashlib
|
6
|
+
import threading
|
7
|
+
from contextlib import contextmanager
|
8
|
+
from pathlib import Path
|
9
|
+
from typing import TYPE_CHECKING, Any
|
10
|
+
from weakref import WeakValueDictionary
|
11
|
+
|
12
|
+
if TYPE_CHECKING:
|
13
|
+
from collections.abc import Generator
|
14
|
+
|
15
|
+
|
16
|
+
_PYPDFIUM_LOCK = threading.RLock()
|
17
|
+
|
18
|
+
|
19
|
+
_FILE_LOCKS_CACHE = WeakValueDictionary[str, threading.RLock]()
|
20
|
+
_FILE_LOCKS_LOCK = threading.Lock()
|
21
|
+
|
22
|
+
|
23
|
+
def _get_file_key(file_path: Path | str) -> str:
|
24
|
+
"""Get a consistent key for a file path."""
|
25
|
+
path_str = str(Path(file_path).resolve())
|
26
|
+
return hashlib.md5(path_str.encode()).hexdigest() # noqa: S324
|
27
|
+
|
28
|
+
|
29
|
+
def _get_file_lock(file_path: Path | str) -> threading.RLock:
|
30
|
+
"""Get or create a lock for a specific file."""
|
31
|
+
file_key = _get_file_key(file_path)
|
32
|
+
|
33
|
+
with _FILE_LOCKS_LOCK:
|
34
|
+
if file_key in _FILE_LOCKS_CACHE:
|
35
|
+
return _FILE_LOCKS_CACHE[file_key]
|
36
|
+
|
37
|
+
lock = threading.RLock()
|
38
|
+
_FILE_LOCKS_CACHE[file_key] = lock
|
39
|
+
return lock
|
40
|
+
|
41
|
+
|
42
|
+
@contextmanager
|
43
|
+
def pypdfium_lock() -> Generator[None, None, None]:
|
44
|
+
"""Context manager for thread-safe pypdfium2 operations.
|
45
|
+
|
46
|
+
This prevents segmentation faults on macOS where pypdfium2
|
47
|
+
is not fork-safe when used concurrently.
|
48
|
+
"""
|
49
|
+
with _PYPDFIUM_LOCK:
|
50
|
+
yield
|
51
|
+
|
52
|
+
|
53
|
+
@contextmanager
|
54
|
+
def pypdfium_file_lock(file_path: Path | str) -> Generator[None, None, None]:
|
55
|
+
"""Context manager for per-file pypdfium2 operations.
|
56
|
+
|
57
|
+
This allows concurrent processing of different files while
|
58
|
+
preventing segfaults. Document caching handles same-file issues.
|
59
|
+
"""
|
60
|
+
lock = _get_file_lock(file_path)
|
61
|
+
with lock:
|
62
|
+
yield
|
63
|
+
|
64
|
+
|
65
|
+
def with_pypdfium_lock(func: Any) -> Any:
|
66
|
+
"""Decorator to wrap functions with pypdfium2 lock."""
|
67
|
+
|
68
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
69
|
+
with pypdfium_lock():
|
70
|
+
return func(*args, **kwargs)
|
71
|
+
|
72
|
+
return wrapper
|
@@ -0,0 +1,100 @@
|
|
1
|
+
"""Process pool utilities for CPU-intensive operations."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import multiprocessing as mp
|
6
|
+
from concurrent.futures import ProcessPoolExecutor
|
7
|
+
from contextlib import contextmanager
|
8
|
+
from typing import TYPE_CHECKING, Any, Callable, TypeVar
|
9
|
+
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from collections.abc import Generator
|
12
|
+
|
13
|
+
T = TypeVar("T")
|
14
|
+
|
15
|
+
|
16
|
+
_PROCESS_POOL: ProcessPoolExecutor | None = None
|
17
|
+
_POOL_SIZE = max(1, mp.cpu_count() - 1)
|
18
|
+
|
19
|
+
|
20
|
+
def _init_process_pool() -> ProcessPoolExecutor:
|
21
|
+
"""Initialize the global process pool."""
|
22
|
+
global _PROCESS_POOL
|
23
|
+
if _PROCESS_POOL is None:
|
24
|
+
_PROCESS_POOL = ProcessPoolExecutor(max_workers=_POOL_SIZE)
|
25
|
+
return _PROCESS_POOL
|
26
|
+
|
27
|
+
|
28
|
+
@contextmanager
|
29
|
+
def process_pool() -> Generator[ProcessPoolExecutor, None, None]:
|
30
|
+
"""Get the global process pool."""
|
31
|
+
pool = _init_process_pool()
|
32
|
+
try:
|
33
|
+
yield pool
|
34
|
+
except Exception: # noqa: BLE001
|
35
|
+
shutdown_process_pool()
|
36
|
+
pool = _init_process_pool()
|
37
|
+
yield pool
|
38
|
+
|
39
|
+
|
40
|
+
def submit_to_process_pool(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
41
|
+
"""Submit a function to the process pool and wait for result."""
|
42
|
+
with process_pool() as pool:
|
43
|
+
future = pool.submit(func, *args, **kwargs)
|
44
|
+
return future.result()
|
45
|
+
|
46
|
+
|
47
|
+
def shutdown_process_pool() -> None:
|
48
|
+
"""Shutdown the global process pool."""
|
49
|
+
global _PROCESS_POOL
|
50
|
+
if _PROCESS_POOL is not None:
|
51
|
+
_PROCESS_POOL.shutdown(wait=True)
|
52
|
+
_PROCESS_POOL = None
|
53
|
+
|
54
|
+
|
55
|
+
def _extract_pdf_text_worker(pdf_path: str) -> tuple[str, str]:
|
56
|
+
"""Worker function for extracting PDF text in a separate process."""
|
57
|
+
import pypdfium2
|
58
|
+
|
59
|
+
pdf = None
|
60
|
+
try:
|
61
|
+
pdf = pypdfium2.PdfDocument(pdf_path)
|
62
|
+
text_parts = []
|
63
|
+
for page in pdf:
|
64
|
+
text_page = page.get_textpage()
|
65
|
+
text = text_page.get_text_range()
|
66
|
+
text_parts.append(text)
|
67
|
+
text_page.close()
|
68
|
+
page.close()
|
69
|
+
return (pdf_path, "".join(text_parts))
|
70
|
+
except Exception as e: # noqa: BLE001
|
71
|
+
return (pdf_path, f"ERROR: {e}")
|
72
|
+
finally:
|
73
|
+
if pdf:
|
74
|
+
pdf.close()
|
75
|
+
|
76
|
+
|
77
|
+
def _extract_pdf_images_worker(pdf_path: str, scale: float = 4.25) -> tuple[str, list[bytes]]:
|
78
|
+
"""Worker function for converting PDF to images in a separate process."""
|
79
|
+
import io
|
80
|
+
|
81
|
+
import pypdfium2
|
82
|
+
|
83
|
+
pdf = None
|
84
|
+
try:
|
85
|
+
pdf = pypdfium2.PdfDocument(pdf_path)
|
86
|
+
image_bytes = []
|
87
|
+
for page in pdf:
|
88
|
+
bitmap = page.render(scale=scale)
|
89
|
+
pil_image = bitmap.to_pil()
|
90
|
+
img_bytes = io.BytesIO()
|
91
|
+
pil_image.save(img_bytes, format="PNG")
|
92
|
+
image_bytes.append(img_bytes.getvalue())
|
93
|
+
bitmap.close()
|
94
|
+
page.close()
|
95
|
+
return (pdf_path, image_bytes)
|
96
|
+
except Exception: # noqa: BLE001
|
97
|
+
return (pdf_path, [])
|
98
|
+
finally:
|
99
|
+
if pdf:
|
100
|
+
pdf.close()
|
@@ -0,0 +1,82 @@
|
|
1
|
+
"""Fast serialization utilities using msgspec."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from dataclasses import asdict, is_dataclass
|
6
|
+
from enum import Enum
|
7
|
+
from typing import Any, TypeVar, cast
|
8
|
+
|
9
|
+
from msgspec import MsgspecError
|
10
|
+
from msgspec.msgpack import decode, encode
|
11
|
+
|
12
|
+
T = TypeVar("T")
|
13
|
+
|
14
|
+
|
15
|
+
def encode_hook(obj: Any) -> Any:
|
16
|
+
"""Custom encoder for complex objects."""
|
17
|
+
if callable(obj):
|
18
|
+
return None
|
19
|
+
|
20
|
+
if isinstance(obj, Exception):
|
21
|
+
return {"message": str(obj), "type": type(obj).__name__}
|
22
|
+
|
23
|
+
for key in (
|
24
|
+
"to_dict",
|
25
|
+
"as_dict",
|
26
|
+
"dict",
|
27
|
+
"model_dump",
|
28
|
+
"json",
|
29
|
+
"to_list",
|
30
|
+
"tolist",
|
31
|
+
):
|
32
|
+
if hasattr(obj, key) and callable(getattr(obj, key)):
|
33
|
+
return getattr(obj, key)()
|
34
|
+
|
35
|
+
if is_dataclass(obj) and not isinstance(obj, type):
|
36
|
+
return {k: v if not isinstance(v, Enum) else v.value for (k, v) in asdict(obj).items()}
|
37
|
+
|
38
|
+
if hasattr(obj, "save") and hasattr(obj, "format"):
|
39
|
+
return None
|
40
|
+
|
41
|
+
raise TypeError(f"Unsupported type: {type(obj)!r}")
|
42
|
+
|
43
|
+
|
44
|
+
def deserialize(value: str | bytes, target_type: type[T]) -> T:
|
45
|
+
"""Deserialize bytes/string to target type.
|
46
|
+
|
47
|
+
Args:
|
48
|
+
value: Serialized data
|
49
|
+
target_type: Type to deserialize to
|
50
|
+
|
51
|
+
Returns:
|
52
|
+
Deserialized object
|
53
|
+
|
54
|
+
Raises:
|
55
|
+
ValueError: If deserialization fails
|
56
|
+
"""
|
57
|
+
try:
|
58
|
+
return decode(cast("bytes", value), type=target_type, strict=False)
|
59
|
+
except MsgspecError as e:
|
60
|
+
raise ValueError(f"Failed to deserialize to {target_type.__name__}: {e}") from e
|
61
|
+
|
62
|
+
|
63
|
+
def serialize(value: Any, **kwargs: Any) -> bytes:
|
64
|
+
"""Serialize value to bytes.
|
65
|
+
|
66
|
+
Args:
|
67
|
+
value: Object to serialize
|
68
|
+
**kwargs: Additional data to merge with value if it's a dict
|
69
|
+
|
70
|
+
Returns:
|
71
|
+
Serialized bytes
|
72
|
+
|
73
|
+
Raises:
|
74
|
+
ValueError: If serialization fails
|
75
|
+
"""
|
76
|
+
if isinstance(value, dict) and kwargs:
|
77
|
+
value = value | kwargs
|
78
|
+
|
79
|
+
try:
|
80
|
+
return encode(value, enc_hook=encode_hook)
|
81
|
+
except (MsgspecError, TypeError) as e:
|
82
|
+
raise ValueError(f"Failed to serialize {type(value).__name__}: {e}") from e
|
kreuzberg/_utils/_string.py
CHANGED
@@ -20,7 +20,7 @@ def safe_decode(byte_data: bytes, encoding: str | None = None) -> str:
|
|
20
20
|
|
21
21
|
encodings = [encoding, detect(byte_data).get("encoding", ""), "utf-8"]
|
22
22
|
|
23
|
-
for enc in [e for e in encodings if e]:
|
23
|
+
for enc in [e for e in encodings if e]:
|
24
24
|
with suppress(UnicodeDecodeError, LookupError):
|
25
25
|
return byte_data.decode(enc)
|
26
26
|
|
kreuzberg/_utils/_sync.py
CHANGED
@@ -119,3 +119,24 @@ def run_maybe_async(fn: Callable[P, T | Awaitable[T]], *args: P.args, **kwargs:
|
|
119
119
|
T: The return value of the executed function, resolved if asynchronous.
|
120
120
|
"""
|
121
121
|
return cast("T", fn(*args, **kwargs) if not iscoroutinefunction(fn) else anyio.run(partial(fn, **kwargs), *args))
|
122
|
+
|
123
|
+
|
124
|
+
def run_sync_only(fn: Callable[P, T | Awaitable[T]], *args: P.args, **kwargs: P.kwargs) -> T:
|
125
|
+
"""Runs a function, but only if it's synchronous. Raises error if async.
|
126
|
+
|
127
|
+
This is used for pure sync code paths where we cannot handle async functions.
|
128
|
+
|
129
|
+
Args:
|
130
|
+
fn: The function to be executed, must be synchronous.
|
131
|
+
*args: Positional arguments to be passed to the function.
|
132
|
+
**kwargs: Keyword arguments to be passed to the function.
|
133
|
+
|
134
|
+
Returns:
|
135
|
+
T: The return value of the executed function.
|
136
|
+
|
137
|
+
Raises:
|
138
|
+
RuntimeError: If the function is asynchronous.
|
139
|
+
"""
|
140
|
+
if iscoroutinefunction(fn):
|
141
|
+
raise RuntimeError(f"Cannot run async function {fn.__name__} in sync-only context")
|
142
|
+
return cast("T", fn(*args, **kwargs))
|