diff --git a/datalab_sdk/client.py b/datalab_sdk/client.py index 703e9f8..52695e7 100644 --- a/datalab_sdk/client.py +++ b/datalab_sdk/client.py @@ -1,12 +1,13 @@ """ -Datalab API client - async core with sync wrapper +Datalab API client using httpx - async and sync clients. """ import asyncio import mimetypes -import aiohttp +import httpx from pathlib import Path -from typing import Union, Optional, Dict, Any +from typing import Union, Optional, Dict, Any, Tuple +import time from datalab_sdk.exceptions import ( DatalabAPIError, @@ -24,9 +25,7 @@ from datalab_sdk.settings import settings -class AsyncDatalabClient: - """Asynchronous client for Datalab API""" - +class BaseClient: def __init__( self, api_key: str | None = None, @@ -34,12 +33,12 @@ def __init__( timeout: int = 300, ): """ - Initialize the async Datalab client + Initialize the Datalab client. Args: - api_key: Your Datalab API key - base_url: Base URL for the API (default: https://www.datalab.to) - timeout: Default timeout for requests in seconds + api_key: Your Datalab API key. + base_url: Base URL for the API (default: https://www.datalab.to). + timeout: Default timeout for requests in seconds. """ if api_key is None: api_key = settings.DATALAB_API_KEY @@ -49,77 +48,120 @@ def __init__( self.api_key = api_key self.base_url = base_url.rstrip("/") self.timeout = timeout - self._session = None - async def __aenter__(self): - """Async context manager entry""" - await self._ensure_session() - return self + def _prepare_file_data(self, file_path: Union[str, Path]) -> Tuple[str, bytes, str]: + """Prepare file data for upload.""" + file_path = Path(file_path) + if not file_path.exists(): + raise DatalabFileError(f"File not found: {file_path}") - async def __aexit__(self, exc_type, exc_val, exc_tb): - """Async context manager exit""" - await self.close() + mime_type, _ = mimetypes.guess_type(str(file_path)) + if not mime_type: + extension = file_path.suffix.lower() + mime_type = MIMETYPE_MAP.get(extension, "application/octet-stream") + + return file_path.name, file_path.read_bytes(), mime_type + + def get_form_params( + self, file_path: Union[str, Path], options: ProcessingOptions + ) -> Tuple[Dict[str, Any], Dict[str, Tuple[str, bytes, str]]]: + """Prepare form data for httpx, separating fields and file.""" + filename, file_data, mime_type = self._prepare_file_data(file_path) + + files = {"file": (filename, file_data, mime_type)} + data = options.to_form_data() + + # The options.to_form_data() method returns a dictionary where values + # might be tuples. httpx expects a simple dictionary for the `data` + # parameter. We'll extract the string value. + cleaned_data = { + key: str(value[1]) if isinstance(value, tuple) else str(value) + for key, value in data.items() + } + return cleaned_data, files - async def _ensure_session(self): - """Ensure aiohttp session is created""" - if self._session is None: - timeout = aiohttp.ClientTimeout(total=self.timeout) - self._session = aiohttp.ClientSession( - timeout=timeout, + +class AsyncDatalabClient(BaseClient): + """Asynchronous client for Datalab API using httpx.""" + + def __init__( + self, + api_key: str | None = None, + base_url: str = settings.DATALAB_HOST, + timeout: int = 300, + ): + """ + Initialize the async Datalab client. + + Args: + api_key: Your Datalab API key. + base_url: Base URL for the API (default: https://www.datalab.to). + timeout: Default timeout for requests in seconds. + """ + super().__init__(api_key, base_url, timeout) + + self._client: httpx.AsyncClient | None = None + + async def _ensure_client(self): + """Ensure httpx client is created.""" + if self._client is None: + self._client = httpx.AsyncClient( headers={ "X-Api-Key": self.api_key, "User-Agent": f"datalab-python-sdk/{settings.VERSION}", }, + base_url=self.base_url, + timeout=self.timeout, ) async def close(self): - """Close the aiohttp session""" - if self._session: - await self._session.close() - self._session = None + """Close the httpx client.""" + if self._client: + await self._client.aclose() + self._client = None + + async def __aenter__(self): + """Async context manager entry.""" + await self._ensure_client() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.close() async def _make_request( self, method: str, endpoint: str, **kwargs ) -> Dict[str, Any]: - """Make an async request to the API""" - await self._ensure_session() - - url = endpoint - if not endpoint.startswith("http"): - url = f"{self.base_url}/{endpoint.lstrip('/')}" - + """Make an async request to the API.""" + await self._ensure_client() try: - async with self._session.request(method, url, **kwargs) as response: - response.raise_for_status() - return await response.json() - except asyncio.TimeoutError: - raise DatalabTimeoutError(f"Request timed out after {self.timeout} seconds") - except aiohttp.ClientResponseError as e: + response = await self._client.request(method, endpoint, **kwargs) + response.raise_for_status() + return response.json() + except httpx.TimeoutException as e: + raise DatalabTimeoutError( + f"Request timed out after {self.timeout} seconds" + ) from e + except httpx.HTTPStatusError as e: try: - error_data = await response.json() + error_data = e.response.json() error_message = error_data.get("error", str(e)) except Exception: error_message = str(e) raise DatalabAPIError( error_message, - e.status, + e.response.status_code, error_data if "error_data" in locals() else None, - ) - except aiohttp.ClientError as e: - raise DatalabAPIError(f"Request failed: {str(e)}") + ) from e + except httpx.RequestError as e: + raise DatalabAPIError(f"Request failed: {str(e)}") from e async def _poll_result( self, check_url: str, max_polls: int = 300, poll_interval: int = 1 ) -> Dict[str, Any]: - """Poll for result completion""" - full_url = ( - check_url - if check_url.startswith("http") - else f"{self.base_url}/{check_url.lstrip('/')}" - ) - + """Poll for result completion.""" for i in range(max_polls): - data = await self._make_request("GET", full_url) + data = await self._make_request("GET", check_url) if data.get("status") == "complete": return data @@ -135,51 +177,21 @@ async def _poll_result( f"Polling timed out after {max_polls * poll_interval} seconds" ) - def _prepare_file_data(self, file_path: Union[str, Path]) -> tuple: - """Prepare file data for upload""" - file_path = Path(file_path) - - if not file_path.exists(): - raise DatalabFileError(f"File not found: {file_path}") - - # Determine MIME type - mime_type, _ = mimetypes.guess_type(str(file_path)) - if not mime_type: - # Try to detect from extension - extension = file_path.suffix.lower() - mime_type = MIMETYPE_MAP.get(extension, "application/octet-stream") - - return file_path.name, file_path.read_bytes(), mime_type - - def get_form_params(self, file_path, options): - filename, file_data, mime_type = self._prepare_file_data(file_path) - - form_data = aiohttp.FormData() - form_data.add_field( - "file", file_data, filename=filename, content_type=mime_type - ) - - for key, value in options.to_form_data().items(): - if isinstance(value, tuple): - form_data.add_field(key, str(value[1])) - else: - form_data.add_field(key, str(value)) - - return form_data - - # Convenient endpoint-specific methods + async def convert( self, file_path: Union[str, Path], - options: Optional[ProcessingOptions] = None, + options: Optional[ConvertOptions] = None, save_output: Optional[Union[str, Path]] = None, ) -> ConversionResult: - """Convert a document using the marker endpoint""" + """Convert a document using the marker endpoint.""" if options is None: options = ConvertOptions() + data, files = self.get_form_params(file_path, options) + initial_data = await self._make_request( - "POST", "/api/v1/marker", data=self.get_form_params(file_path, options) + "POST", "/api/v1/marker", data=data, files=files ) if not initial_data.get("success"): @@ -202,7 +214,6 @@ async def convert( status=result_data.get("status", "complete"), ) - # Save output if requested if save_output and result.success: output_path = Path(save_output) output_path.parent.mkdir(parents=True, exist_ok=True) @@ -213,15 +224,17 @@ async def convert( async def ocr( self, file_path: Union[str, Path], - options: Optional[ProcessingOptions] = None, + options: Optional[OCROptions] = None, save_output: Optional[Union[str, Path]] = None, ) -> OCRResult: - """Perform OCR on a document""" + """Perform OCR on a document.""" if options is None: options = OCROptions() + data, files = self.get_form_params(file_path, options) + initial_data = await self._make_request( - "POST", "/api/v1/ocr", data=self.get_form_params(file_path, options) + "POST", "/api/v1/ocr", data=data, files=files ) if not initial_data.get("success"): @@ -239,7 +252,6 @@ async def ocr( status=result_data.get("status", "complete"), ) - # Save output if requested if save_output and result.success: output_path = Path(save_output) output_path.parent.mkdir(parents=True, exist_ok=True) @@ -248,8 +260,8 @@ async def ocr( return result -class DatalabClient: - """Synchronous wrapper around AsyncDatalabClient""" +class DatalabClient(BaseClient): + """Synchronous client for Datalab API using httpx.""" def __init__( self, @@ -258,45 +270,149 @@ def __init__( timeout: int = 300, ): """ - Initialize the Datalab client + Initialize the Datalab client. Args: - api_key: Your Datalab API key - base_url: Base URL for the API (default: https://www.datalab.to) - timeout: Default timeout for requests in seconds + api_key: Your Datalab API key. + base_url: Base URL for the API (default: https://www.datalab.to). + timeout: Default timeout for requests in seconds. """ - self._async_client = AsyncDatalabClient(api_key, base_url, timeout) - - def _run_async(self, coro): - """Run async coroutine in sync context""" + super().__init__(api_key, base_url, timeout) + self._client :httpx.Client | None = None + + def _ensure_client(self): + """Ensure httpx client is created.""" + if self._client is None: + self._client = httpx.Client( + headers={ + "X-Api-Key": self.api_key, + "User-Agent": f"datalab-python-sdk/{settings.VERSION}", + }, + base_url=self.base_url, + timeout=self.timeout, + ) + + def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]: + """Make a sync request to the API.""" + self._ensure_client() try: - loop = asyncio.get_event_loop() - return loop.run_until_complete(self._async_wrapper(coro)) - except RuntimeError: - # No event loop exists, create and clean up - return asyncio.run(self._async_wrapper(coro)) + response = self._client.request(method, endpoint, **kwargs) + response.raise_for_status() + return response.json() + except httpx.TimeoutException as e: + raise DatalabTimeoutError( + f"Request timed out after {self.timeout} seconds" + ) from e + except httpx.HTTPStatusError as e: + try: + error_data = e.response.json() + error_message = error_data.get("error", str(e)) + except Exception: + error_message = str(e) + raise DatalabAPIError( + error_message, + e.response.status_code, + error_data if "error_data" in locals() else None, + ) from e + except httpx.RequestError as e: + raise DatalabAPIError(f"Request failed: {str(e)}") from e + + def _poll_result( + self, check_url: str, max_polls: int = 300, poll_interval: int = 1 + ) -> Dict[str, Any]: + """Poll for result completion.""" + for i in range(max_polls): + data = self._make_request("GET", check_url) + + if data.get("status") == "complete": + return data - async def _async_wrapper(self, coro): - """Wrapper to ensure session management""" - async with self._async_client: - return await coro + if not data.get("success", True) and not data.get("status") == "processing": + raise DatalabAPIError( + f"Processing failed: {data.get('error', 'Unknown error')}" + ) + + time.sleep(poll_interval) + + raise DatalabTimeoutError( + f"Polling timed out after {max_polls * poll_interval} seconds" + ) def convert( self, file_path: Union[str, Path], - options: Optional[ProcessingOptions] = None, + options: Optional[ConvertOptions] = None, save_output: Optional[Union[str, Path]] = None, ) -> ConversionResult: - """Convert a document using the marker endpoint (sync version)""" - return self._run_async( - self._async_client.convert(file_path, options, save_output) + """Convert a document using the marker endpoint (sync version).""" + if options is None: + options = ConvertOptions() + + data, files = self.get_form_params(file_path, options) + + initial_data = self._make_request( + "POST", "/api/v1/marker", data=data, files=files + ) + + if not initial_data.get("success"): + raise DatalabAPIError( + f"Request failed: {initial_data.get('error', 'Unknown error')}" + ) + + result_data = self._poll_result(initial_data["request_check_url"]) + + result = ConversionResult( + success=result_data.get("success", False), + output_format=result_data.get("output_format", options.output_format), + markdown=result_data.get("markdown"), + html=result_data.get("html"), + json=result_data.get("json"), + images=result_data.get("images"), + metadata=result_data.get("metadata"), + error=result_data.get("error"), + page_count=result_data.get("page_count"), + status=result_data.get("status", "complete"), ) + if save_output and result.success: + output_path = Path(save_output) + output_path.parent.mkdir(parents=True, exist_ok=True) + result.save_output(output_path) + + return result + def ocr( self, file_path: Union[str, Path], - options: Optional[ProcessingOptions] = None, + options: Optional[OCROptions] = None, save_output: Optional[Union[str, Path]] = None, ) -> OCRResult: - """Perform OCR on a document (sync version)""" - return self._run_async(self._async_client.ocr(file_path, options, save_output)) + """Perform OCR on a document (sync version).""" + if options is None: + options = OCROptions() + + data, files = self.get_form_params(file_path, options) + + initial_data = self._make_request("POST", "/api/v1/ocr", data=data, files=files) + + if not initial_data.get("success"): + raise DatalabAPIError( + f"Request failed: {initial_data.get('error', 'Unknown error')}" + ) + + result_data = self._poll_result(initial_data["request_check_url"]) + + result = OCRResult( + success=result_data.get("success", False), + pages=result_data.get("pages", []), + error=result_data.get("error"), + page_count=result_data.get("page_count"), + status=result_data.get("status", "complete"), + ) + + if save_output and result.success: + output_path = Path(save_output) + output_path.parent.mkdir(parents=True, exist_ok=True) + result.save_output(output_path) + + return result \ No newline at end of file diff --git a/datalab_sdk/exceptions.py b/datalab_sdk/exceptions.py index c605248..50067ee 100644 --- a/datalab_sdk/exceptions.py +++ b/datalab_sdk/exceptions.py @@ -13,7 +13,7 @@ class DatalabAPIError(DatalabError): """Exception raised when the API returns an error response""" def __init__( - self, message: str, status_code: int = None, response_data: dict = None + self, message: str, status_code: int|None = None, response_data: dict|None = None ): super().__init__(message) self.status_code = status_code @@ -35,4 +35,4 @@ class DatalabFileError(DatalabError): class DatalabValidationError(DatalabError): """Exception raised when input validation fails""" - pass + pass \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 3b8167a..0df2784 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,12 @@ readme = "README.md" license = "MIT" repository = "https://github.com/datalab-to/sdk" keywords = ["datalab", "sdk", "document-intelligence", "api"] -version = "0.1.4" +version = "0.1.5" description = "SDK for the Datalab document intelligence API" requires-python = ">=3.10" dependencies = [ - "aiohttp>=3.12.14", "click>=8.2.1", + "httpx>=0.28.1", "pydantic>=2.11.7,<3.0.0", "pydantic-settings>=2.10.1,<3.0.0", ] diff --git a/tests/test_client_methods.py b/tests/test_client_methods.py index 9b62d79..8ae9d41 100644 --- a/tests/test_client_methods.py +++ b/tests/test_client_methods.py @@ -3,7 +3,7 @@ """ import pytest -from unittest.mock import patch, AsyncMock +from unittest.mock import patch, AsyncMock, Mock import json from datalab_sdk import DatalabClient, AsyncDatalabClient @@ -151,10 +151,10 @@ def test_convert_sync_with_processing_options(self, temp_dir): client = DatalabClient(api_key="test-key") with patch.object( - client._async_client, "_make_request", new_callable=AsyncMock + client, "_make_request", new_callable=Mock ) as mock_request: with patch.object( - client._async_client, "_poll_result", new_callable=AsyncMock + client, "_poll_result", new_callable=Mock ) as mock_poll: # Setup mocks mock_request.return_value = mock_initial_response @@ -329,10 +329,10 @@ def test_ocr_sync_with_max_pages(self, temp_dir): client = DatalabClient(api_key="test-key") with patch.object( - client._async_client, "_make_request", new_callable=AsyncMock + client, "_make_request", new_callable=Mock ) as mock_request: with patch.object( - client._async_client, "_poll_result", new_callable=AsyncMock + client, "_poll_result", new_callable=Mock ) as mock_poll: # Setup mocks mock_request.return_value = mock_initial_response @@ -406,7 +406,7 @@ def test_convert_unsuccessful_response(self, temp_dir): client = DatalabClient(api_key="test-key") with patch.object( - client._async_client, "_make_request", new_callable=AsyncMock + client, "_make_request", new_callable=Mock ) as mock_request: # Setup mock mock_request.return_value = mock_initial_response @@ -415,4 +415,4 @@ def test_convert_unsuccessful_response(self, temp_dir): with pytest.raises( DatalabAPIError, match="Request failed: Processing failed" ): - client.convert(pdf_file) + client.convert(pdf_file) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 810a5dd..38e7407 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -119,6 +119,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -137,6 +152,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -169,11 +193,11 @@ wheels = [ [[package]] name = "datalab-python-sdk" -version = "0.1.3" +version = "0.1.5" source = { editable = "." } dependencies = [ - { name = "aiohttp" }, { name = "click" }, + { name = "httpx" }, { name = "pydantic" }, { name = "pydantic-settings" }, ] @@ -191,8 +215,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.12.14" }, { name = "click", specifier = ">=8.2.1" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2.11.7,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.10.1,<3.0.0" }, ] @@ -341,6 +365,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "identify" version = "2.6.12" @@ -857,6 +918,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/30/f3eaf6563c637b6e66238ed6535f6775480db973c836336e4122161986fc/ruff-0.12.3-py3-none-win_arm64.whl", hash = "sha256:5f9c7c9c8f84c2d7f27e93674d27136fbf489720251544c4da7fb3d742e011b1", size = 10805855, upload-time = "2025-07-11T13:21:13.547Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "tomli" version = "2.2.1"