kreuzberg 3.8.0__py3-none-any.whl → 3.8.2__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 (43) hide show
  1. kreuzberg/__init__.py +4 -0
  2. kreuzberg/_api/main.py +22 -1
  3. kreuzberg/_config.py +404 -0
  4. kreuzberg/_entity_extraction.py +4 -5
  5. kreuzberg/_extractors/_base.py +3 -5
  6. kreuzberg/_extractors/_image.py +18 -32
  7. kreuzberg/_extractors/_pandoc.py +3 -14
  8. kreuzberg/_extractors/_pdf.py +39 -57
  9. kreuzberg/_extractors/_spread_sheet.py +2 -3
  10. kreuzberg/_extractors/_structured.py +10 -7
  11. kreuzberg/_gmft.py +314 -10
  12. kreuzberg/_language_detection.py +1 -1
  13. kreuzberg/_mcp/server.py +58 -8
  14. kreuzberg/_ocr/__init__.py +1 -22
  15. kreuzberg/_ocr/_base.py +59 -0
  16. kreuzberg/_ocr/_easyocr.py +92 -1
  17. kreuzberg/_ocr/_paddleocr.py +90 -1
  18. kreuzberg/_ocr/_tesseract.py +556 -5
  19. kreuzberg/_playa.py +2 -3
  20. kreuzberg/_types.py +46 -24
  21. kreuzberg/_utils/_cache.py +35 -4
  22. kreuzberg/_utils/_device.py +10 -20
  23. kreuzberg/_utils/_errors.py +44 -45
  24. kreuzberg/_utils/_process_pool.py +2 -6
  25. kreuzberg/_utils/_quality.py +7 -11
  26. kreuzberg/_utils/_serialization.py +21 -16
  27. kreuzberg/_utils/_string.py +22 -12
  28. kreuzberg/_utils/_table.py +3 -4
  29. kreuzberg/cli.py +4 -5
  30. kreuzberg/exceptions.py +10 -0
  31. kreuzberg/extraction.py +6 -24
  32. kreuzberg-3.8.2.dist-info/METADATA +265 -0
  33. kreuzberg-3.8.2.dist-info/RECORD +53 -0
  34. kreuzberg/_cli_config.py +0 -175
  35. kreuzberg/_multiprocessing/__init__.py +0 -5
  36. kreuzberg/_multiprocessing/gmft_isolated.py +0 -330
  37. kreuzberg/_ocr/_pool.py +0 -357
  38. kreuzberg/_ocr/_sync.py +0 -566
  39. kreuzberg-3.8.0.dist-info/METADATA +0 -313
  40. kreuzberg-3.8.0.dist-info/RECORD +0 -57
  41. {kreuzberg-3.8.0.dist-info → kreuzberg-3.8.2.dist-info}/WHEEL +0 -0
  42. {kreuzberg-3.8.0.dist-info → kreuzberg-3.8.2.dist-info}/entry_points.txt +0 -0
  43. {kreuzberg-3.8.0.dist-info → kreuzberg-3.8.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,330 +0,0 @@
1
- """Isolated GMFT table extraction to handle segmentation faults."""
2
-
3
- from __future__ import annotations
4
-
5
- import multiprocessing as mp
6
- import pickle
7
- import queue
8
- import signal
9
- import traceback
10
- from typing import TYPE_CHECKING, Any
11
-
12
- if TYPE_CHECKING:
13
- from os import PathLike
14
-
15
- from kreuzberg._gmft import GMFTConfig
16
- from kreuzberg._types import TableData
17
-
18
-
19
- def _extract_tables_in_process(
20
- file_path: str | PathLike[str],
21
- config_dict: dict[str, Any],
22
- result_queue: queue.Queue[tuple[bool, Any]],
23
- ) -> None:
24
- """Extract tables in an isolated process to handle potential segfaults.
25
-
26
- Args:
27
- file_path: Path to the PDF file
28
- config_dict: Serialized GMFTConfig as a dict
29
- result_queue: Queue to put results or errors
30
- """
31
- signal.signal(signal.SIGINT, signal.SIG_IGN)
32
-
33
- try:
34
- from gmft.auto import AutoTableDetector, AutoTableFormatter # type: ignore[attr-defined]
35
- from gmft.detectors.tatr import TATRDetectorConfig # type: ignore[attr-defined]
36
- from gmft.formatters.tatr import TATRFormatConfig
37
- from gmft.pdf_bindings.pdfium import PyPDFium2Document
38
-
39
- from kreuzberg._gmft import GMFTConfig
40
-
41
- config = GMFTConfig(**config_dict)
42
-
43
- formatter = AutoTableFormatter( # type: ignore[no-untyped-call]
44
- config=TATRFormatConfig(
45
- verbosity=config.verbosity,
46
- formatter_base_threshold=config.formatter_base_threshold,
47
- cell_required_confidence=config.cell_required_confidence,
48
- remove_null_rows=config.remove_null_rows,
49
- enable_multi_header=config.enable_multi_header,
50
- semantic_spanning_cells=config.semantic_spanning_cells,
51
- semantic_hierarchical_left_fill=config.semantic_hierarchical_left_fill,
52
- large_table_if_n_rows_removed=config.large_table_if_n_rows_removed,
53
- large_table_threshold=config.large_table_threshold,
54
- large_table_row_overlap_threshold=config.large_table_row_overlap_threshold,
55
- large_table_maximum_rows=config.large_table_maximum_rows,
56
- force_large_table_assumption=config.force_large_table_assumption,
57
- )
58
- )
59
- detector = AutoTableDetector(config=TATRDetectorConfig(detector_base_threshold=config.detector_base_threshold)) # type: ignore[no-untyped-call]
60
-
61
- doc = PyPDFium2Document(str(file_path))
62
- cropped_tables = []
63
- dataframes = []
64
-
65
- try:
66
- for page in doc:
67
- cropped_tables.extend(detector.extract(page)) # type: ignore[attr-defined]
68
-
69
- for cropped_table in cropped_tables:
70
- formatted_table = formatter.extract(cropped_table) # type: ignore[attr-defined]
71
- dataframes.append(formatted_table.df())
72
-
73
- results = []
74
- for data_frame, cropped_table in zip(dataframes, cropped_tables, strict=False):
75
- import io
76
-
77
- img_bytes = io.BytesIO()
78
- cropped_image = cropped_table.image()
79
- cropped_image.save(img_bytes, format="PNG")
80
- img_bytes.seek(0)
81
-
82
- results.append(
83
- {
84
- "cropped_image_bytes": img_bytes.getvalue(),
85
- "page_number": cropped_table.page.page_number,
86
- "text": data_frame.to_markdown(),
87
- "df_pickle": pickle.dumps(data_frame),
88
- }
89
- )
90
-
91
- result_queue.put((True, results))
92
-
93
- finally:
94
- doc.close() # type: ignore[no-untyped-call]
95
-
96
- except Exception as e: # noqa: BLE001
97
- error_info = {"error": str(e), "type": type(e).__name__, "traceback": traceback.format_exc()}
98
- result_queue.put((False, error_info))
99
-
100
-
101
- def extract_tables_isolated(
102
- file_path: str | PathLike[str],
103
- config: GMFTConfig | None = None,
104
- timeout: float = 300.0,
105
- ) -> list[TableData]:
106
- """Extract tables using an isolated process to handle segfaults.
107
-
108
- Args:
109
- file_path: Path to the PDF file
110
- config: GMFT configuration
111
- timeout: Maximum time to wait for extraction
112
-
113
- Returns:
114
- List of extracted tables
115
-
116
- Raises:
117
- RuntimeError: If extraction fails or times out
118
- """
119
- from kreuzberg._gmft import GMFTConfig
120
- from kreuzberg._types import TableData
121
- from kreuzberg.exceptions import ParsingError
122
-
123
- config = config or GMFTConfig()
124
- config_dict = config.__dict__.copy()
125
-
126
- ctx = mp.get_context("spawn")
127
- result_queue = ctx.Queue()
128
-
129
- process = ctx.Process(
130
- target=_extract_tables_in_process,
131
- args=(str(file_path), config_dict, result_queue),
132
- )
133
-
134
- process.start()
135
-
136
- try:
137
- # Wait for result with timeout, checking for process death # ~keep
138
- import time
139
-
140
- start_time = time.time()
141
- while True:
142
- try:
143
- success, result = result_queue.get_nowait()
144
- break
145
- except queue.Empty:
146
- if time.time() - start_time > timeout:
147
- raise
148
-
149
- if not process.is_alive():
150
- # Process died without putting result # ~keep
151
- if process.exitcode == -signal.SIGSEGV:
152
- raise ParsingError(
153
- "GMFT process crashed with segmentation fault",
154
- context={
155
- "file_path": str(file_path),
156
- "exit_code": process.exitcode,
157
- },
158
- ) from None
159
- raise ParsingError(
160
- f"GMFT process died unexpectedly with exit code {process.exitcode}",
161
- context={
162
- "file_path": str(file_path),
163
- "exit_code": process.exitcode,
164
- },
165
- ) from None
166
-
167
- time.sleep(0.1)
168
-
169
- if success:
170
- tables = []
171
- for table_dict in result:
172
- import io
173
- import pickle
174
-
175
- from PIL import Image
176
-
177
- img = Image.open(io.BytesIO(table_dict["cropped_image_bytes"]))
178
- df = pickle.loads(table_dict["df_pickle"]) # noqa: S301
179
-
180
- tables.append(
181
- TableData(
182
- cropped_image=img,
183
- page_number=table_dict["page_number"],
184
- text=table_dict["text"],
185
- df=df,
186
- )
187
- )
188
-
189
- return tables
190
-
191
- error_info = result
192
- raise ParsingError(
193
- f"GMFT table extraction failed: {error_info['error']}",
194
- context={
195
- "file_path": str(file_path),
196
- "error_type": error_info["type"],
197
- "traceback": error_info["traceback"],
198
- },
199
- )
200
-
201
- except queue.Empty as e:
202
- raise ParsingError(
203
- "GMFT table extraction timed out",
204
- context={
205
- "file_path": str(file_path),
206
- "timeout": timeout,
207
- },
208
- ) from e
209
- finally:
210
- if process.is_alive():
211
- process.terminate()
212
- process.join(timeout=5)
213
- if process.is_alive():
214
- process.kill()
215
- process.join()
216
-
217
-
218
- async def extract_tables_isolated_async(
219
- file_path: str | PathLike[str],
220
- config: GMFTConfig | None = None,
221
- timeout: float = 300.0,
222
- ) -> list[TableData]:
223
- """Async version of extract_tables_isolated using asyncio.
224
-
225
- Args:
226
- file_path: Path to the PDF file
227
- config: GMFT configuration
228
- timeout: Maximum time to wait for extraction
229
-
230
- Returns:
231
- List of extracted tables
232
-
233
- Raises:
234
- RuntimeError: If extraction fails or times out
235
- """
236
- import anyio
237
-
238
- from kreuzberg._gmft import GMFTConfig
239
- from kreuzberg._types import TableData
240
- from kreuzberg.exceptions import ParsingError
241
-
242
- config = config or GMFTConfig()
243
- config_dict = config.__dict__.copy()
244
-
245
- ctx = mp.get_context("spawn")
246
- result_queue = ctx.Queue()
247
-
248
- process = ctx.Process(
249
- target=_extract_tables_in_process,
250
- args=(str(file_path), config_dict, result_queue),
251
- )
252
-
253
- process.start()
254
-
255
- try:
256
-
257
- async def wait_for_result() -> tuple[bool, Any]:
258
- while True:
259
- try:
260
- return result_queue.get_nowait() # type: ignore[no-any-return]
261
- except queue.Empty: # noqa: PERF203
262
- await anyio.sleep(0.1)
263
- if not process.is_alive():
264
- # Process died without putting result # ~keep
265
- if process.exitcode == -signal.SIGSEGV:
266
- raise ParsingError(
267
- "GMFT process crashed with segmentation fault",
268
- context={
269
- "file_path": str(file_path),
270
- "exit_code": process.exitcode,
271
- },
272
- ) from None
273
- raise ParsingError(
274
- f"GMFT process died unexpectedly with exit code {process.exitcode}",
275
- context={
276
- "file_path": str(file_path),
277
- "exit_code": process.exitcode,
278
- },
279
- ) from None
280
-
281
- with anyio.fail_after(timeout):
282
- success, result = await wait_for_result()
283
-
284
- if success:
285
- tables = []
286
- for table_dict in result:
287
- import io
288
- import pickle
289
-
290
- from PIL import Image
291
-
292
- img = Image.open(io.BytesIO(table_dict["cropped_image_bytes"]))
293
- df = pickle.loads(table_dict["df_pickle"]) # noqa: S301
294
-
295
- tables.append(
296
- TableData(
297
- cropped_image=img,
298
- page_number=table_dict["page_number"],
299
- text=table_dict["text"],
300
- df=df,
301
- )
302
- )
303
-
304
- return tables
305
-
306
- error_info = result
307
- raise ParsingError(
308
- f"GMFT table extraction failed: {error_info['error']}",
309
- context={
310
- "file_path": str(file_path),
311
- "error_type": error_info["type"],
312
- "traceback": error_info["traceback"],
313
- },
314
- )
315
-
316
- except TimeoutError as e:
317
- raise ParsingError(
318
- "GMFT table extraction timed out",
319
- context={
320
- "file_path": str(file_path),
321
- "timeout": timeout,
322
- },
323
- ) from e
324
- finally:
325
- if process.is_alive():
326
- process.terminate()
327
- await anyio.to_thread.run_sync(lambda: process.join(timeout=5))
328
- if process.is_alive():
329
- process.kill()
330
- await anyio.to_thread.run_sync(process.join)
kreuzberg/_ocr/_pool.py DELETED
@@ -1,357 +0,0 @@
1
- """Process pools for parallel OCR processing."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
- from typing import TYPE_CHECKING, Any
7
-
8
- from PIL import Image
9
- from typing_extensions import Self
10
-
11
- from kreuzberg._ocr._tesseract import TesseractConfig
12
- from kreuzberg._types import ExtractionResult
13
- from kreuzberg._utils._process_pool import ProcessPoolManager
14
-
15
- if TYPE_CHECKING:
16
- import types
17
-
18
-
19
- def _process_image_with_tesseract(
20
- image_path: str,
21
- config_dict: dict[str, Any],
22
- ) -> dict[str, Any]:
23
- """Process a single image with Tesseract in a separate process.
24
-
25
- This function is designed to be pickled and executed in a subprocess.
26
- It uses direct tesseract command execution to avoid async complications.
27
-
28
- Args:
29
- image_path: Path to the image file.
30
- config_dict: Tesseract configuration as dictionary.
31
-
32
- Returns:
33
- OCR result as dictionary.
34
- """
35
- try:
36
- import os
37
- import subprocess
38
- import tempfile
39
-
40
- with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp_file:
41
- output_base = tmp_file.name.replace(".txt", "")
42
-
43
- try:
44
- language = config_dict.get("language", "eng")
45
- psm = config_dict.get("psm", 3)
46
-
47
- command = [
48
- "tesseract",
49
- image_path,
50
- output_base,
51
- "-l",
52
- language,
53
- "--psm",
54
- str(psm),
55
- "--oem",
56
- "1",
57
- "--loglevel",
58
- "OFF",
59
- ]
60
-
61
- boolean_options = [
62
- "classify_use_pre_adapted_templates",
63
- "language_model_ngram_on",
64
- "tessedit_dont_blkrej_good_wds",
65
- "tessedit_dont_rowrej_good_wds",
66
- "tessedit_enable_dict_correction",
67
- "tessedit_use_primary_params_model",
68
- "textord_space_size_is_variable",
69
- "thresholding_method",
70
- ]
71
-
72
- for option in boolean_options:
73
- if option in config_dict:
74
- value = 1 if config_dict[option] else 0
75
- command.extend(["-c", f"{option}={value}"])
76
-
77
- env = os.environ.copy()
78
- env["OMP_THREAD_LIMIT"] = "1"
79
-
80
- result = subprocess.run(
81
- command,
82
- check=False,
83
- env=env,
84
- capture_output=True,
85
- text=True,
86
- timeout=30,
87
- )
88
-
89
- if result.returncode != 0:
90
- raise Exception(f"Tesseract failed with return code {result.returncode}: {result.stderr}")
91
-
92
- output_file = output_base + ".txt"
93
- with Path(output_file).open(encoding="utf-8") as f:
94
- text = f.read()
95
-
96
- from kreuzberg._utils._string import normalize_spaces
97
-
98
- text = normalize_spaces(text)
99
-
100
- return {
101
- "success": True,
102
- "text": text,
103
- "confidence": None,
104
- "error": None,
105
- }
106
-
107
- finally:
108
- for ext in [".txt"]:
109
- temp_file = output_base + ext
110
- temp_path = Path(temp_file)
111
- if temp_path.exists():
112
- temp_path.unlink()
113
-
114
- except Exception as e: # noqa: BLE001
115
- return {
116
- "success": False,
117
- "text": "",
118
- "confidence": None,
119
- "error": str(e),
120
- }
121
-
122
-
123
- def _process_image_bytes_with_tesseract(
124
- image_bytes: bytes,
125
- config_dict: dict[str, Any],
126
- ) -> dict[str, Any]:
127
- """Process image bytes with Tesseract in a separate process.
128
-
129
- Args:
130
- image_bytes: Image data as bytes.
131
- config_dict: Tesseract configuration as dictionary.
132
-
133
- Returns:
134
- OCR result as dictionary.
135
- """
136
- try:
137
- import io
138
- import tempfile
139
-
140
- with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_image:
141
- with Image.open(io.BytesIO(image_bytes)) as image:
142
- image.save(tmp_image.name, format="PNG")
143
- image_path = tmp_image.name
144
-
145
- try:
146
- return _process_image_with_tesseract(image_path, config_dict)
147
- finally:
148
- image_file = Path(image_path)
149
- if image_file.exists():
150
- image_file.unlink()
151
-
152
- except Exception as e: # noqa: BLE001
153
- return {
154
- "success": False,
155
- "text": "",
156
- "confidence": None,
157
- "error": str(e),
158
- }
159
-
160
-
161
- class TesseractProcessPool:
162
- """Process pool for parallel Tesseract OCR processing."""
163
-
164
- def __init__(
165
- self,
166
- config: TesseractConfig | None = None,
167
- max_processes: int | None = None,
168
- memory_limit_gb: float | None = None,
169
- ) -> None:
170
- """Initialize the Tesseract process pool.
171
-
172
- Args:
173
- config: Default Tesseract configuration.
174
- max_processes: Maximum number of processes.
175
- memory_limit_gb: Memory limit in GB.
176
- """
177
- self.config = config or TesseractConfig()
178
- self.process_manager = ProcessPoolManager(
179
- max_processes=max_processes,
180
- memory_limit_gb=memory_limit_gb,
181
- )
182
-
183
- def _config_to_dict(self, config: TesseractConfig | None = None) -> dict[str, Any]:
184
- """Convert TesseractConfig to dictionary for pickling."""
185
- cfg = config or self.config
186
-
187
- config_dict = {}
188
- for field_name in cfg.__dataclass_fields__:
189
- value = getattr(cfg, field_name)
190
-
191
- if hasattr(value, "value"):
192
- config_dict[field_name] = value.value
193
- else:
194
- config_dict[field_name] = value
195
-
196
- return config_dict
197
-
198
- def _result_from_dict(self, result_dict: dict[str, Any]) -> ExtractionResult:
199
- """Convert result dictionary back to OCRResult."""
200
- if not result_dict["success"]:
201
- from kreuzberg.exceptions import OCRError
202
-
203
- raise OCRError(f"Tesseract processing failed: {result_dict['error']}")
204
-
205
- from kreuzberg._mime_types import PLAIN_TEXT_MIME_TYPE
206
-
207
- return ExtractionResult(
208
- content=result_dict["text"],
209
- mime_type=PLAIN_TEXT_MIME_TYPE,
210
- metadata={"confidence": result_dict["confidence"]} if result_dict["confidence"] else {}, # type: ignore[typeddict-unknown-key]
211
- chunks=[],
212
- )
213
-
214
- async def process_image(
215
- self,
216
- image_path: str | Path,
217
- config: TesseractConfig | None = None,
218
- ) -> ExtractionResult:
219
- """Process a single image file with Tesseract.
220
-
221
- Args:
222
- image_path: Path to the image file.
223
- config: Tesseract configuration (uses default if None).
224
-
225
- Returns:
226
- OCR result.
227
- """
228
- config_dict = self._config_to_dict(config)
229
-
230
- task_memory_mb = 80
231
-
232
- result_dict = await self.process_manager.submit_task(
233
- _process_image_with_tesseract,
234
- str(image_path),
235
- config_dict,
236
- task_memory_mb=task_memory_mb,
237
- )
238
-
239
- return self._result_from_dict(result_dict)
240
-
241
- async def process_image_bytes(
242
- self,
243
- image_bytes: bytes,
244
- config: TesseractConfig | None = None,
245
- ) -> ExtractionResult:
246
- """Process image bytes with Tesseract.
247
-
248
- Args:
249
- image_bytes: Image data as bytes.
250
- config: Tesseract configuration (uses default if None).
251
-
252
- Returns:
253
- OCR result.
254
- """
255
- config_dict = self._config_to_dict(config)
256
-
257
- image_size_mb = len(image_bytes) / 1024 / 1024
258
- task_memory_mb = max(80, image_size_mb * 2 + 50)
259
-
260
- result_dict = await self.process_manager.submit_task(
261
- _process_image_bytes_with_tesseract,
262
- image_bytes,
263
- config_dict,
264
- task_memory_mb=task_memory_mb,
265
- )
266
-
267
- return self._result_from_dict(result_dict)
268
-
269
- async def process_batch_images(
270
- self,
271
- image_paths: list[str | Path],
272
- config: TesseractConfig | None = None,
273
- max_concurrent: int | None = None,
274
- ) -> list[ExtractionResult]:
275
- """Process a batch of images in parallel.
276
-
277
- Args:
278
- image_paths: List of image file paths.
279
- config: Tesseract configuration (uses default if None).
280
- max_concurrent: Maximum concurrent processes.
281
-
282
- Returns:
283
- List of OCR results in the same order as input.
284
- """
285
- if not image_paths:
286
- return []
287
-
288
- config_dict = self._config_to_dict(config)
289
-
290
- arg_batches = [(str(path), config_dict) for path in image_paths]
291
-
292
- task_memory_mb = 80
293
-
294
- result_dicts = await self.process_manager.submit_batch(
295
- _process_image_with_tesseract,
296
- arg_batches,
297
- task_memory_mb=task_memory_mb,
298
- max_concurrent=max_concurrent,
299
- )
300
-
301
- return [self._result_from_dict(result_dict) for result_dict in result_dicts]
302
-
303
- async def process_batch_bytes(
304
- self,
305
- image_bytes_list: list[bytes],
306
- config: TesseractConfig | None = None,
307
- max_concurrent: int | None = None,
308
- ) -> list[ExtractionResult]:
309
- """Process a batch of image bytes in parallel.
310
-
311
- Args:
312
- image_bytes_list: List of image data as bytes.
313
- config: Tesseract configuration (uses default if None).
314
- max_concurrent: Maximum concurrent processes.
315
-
316
- Returns:
317
- List of OCR results in the same order as input.
318
- """
319
- if not image_bytes_list:
320
- return []
321
-
322
- config_dict = self._config_to_dict(config)
323
-
324
- arg_batches = [(image_bytes, config_dict) for image_bytes in image_bytes_list]
325
-
326
- avg_image_size_mb = sum(len(img) for img in image_bytes_list) / len(image_bytes_list) / 1024 / 1024
327
- task_memory_mb = max(80, avg_image_size_mb * 2 + 50)
328
-
329
- result_dicts = await self.process_manager.submit_batch(
330
- _process_image_bytes_with_tesseract,
331
- arg_batches,
332
- task_memory_mb=task_memory_mb,
333
- max_concurrent=max_concurrent,
334
- )
335
-
336
- return [self._result_from_dict(result_dict) for result_dict in result_dicts]
337
-
338
- def get_system_info(self) -> dict[str, Any]:
339
- """Get system information from the process manager."""
340
- return self.process_manager.get_system_info()
341
-
342
- def shutdown(self, wait: bool = True) -> None:
343
- """Shutdown the process pool."""
344
- self.process_manager.shutdown(wait=wait)
345
-
346
- async def __aenter__(self) -> Self:
347
- """Async context manager entry."""
348
- return self
349
-
350
- async def __aexit__(
351
- self,
352
- exc_type: type[BaseException] | None,
353
- exc_val: BaseException | None,
354
- exc_tb: types.TracebackType | None,
355
- ) -> None:
356
- """Async context manager exit."""
357
- self.shutdown()