diff --git a/app/api/pdf_extract.py b/app/api/pdf_extract.py index 6b20b45..f40bc12 100644 --- a/app/api/pdf_extract.py +++ b/app/api/pdf_extract.py @@ -5,21 +5,28 @@ from app.domain import pdf_ocr, fileUpload from app.model.TextSchema import TextExtractDocument +from app.services import storage router = APIRouter() @router.post("/extract_pdf", response_model=List[TextExtractDocument]) async def extract_pdf( - input_file: UploadFile = File(..., description="PDF file to process"), + input_file: Optional[UploadFile] = File(None, description="PDF file to process (required if source='upload')"), + source: str = Form("upload", description="Source of the file: 'upload' or 'object_storage'"), + client_id: Optional[str] = Form(None, description="Client ID for object storage (required if source='object_storage')"), + object_key: Optional[str] = Form(None, description="Object key (path) in the bucket (required if source='object_storage')"), lang: str = Form("eng+por", description="Language code (eng, por, eng+por). Use 'auto' to enable OSD."), mode: str = Form("fast", description="OCR mode: 'fast' or 'accurate'."), auto_detect: bool = Form(False, description="Enable Orientation and Script Detection (OSD)."), force_processing: bool = Form(False, description="Force processing even if page count > 10.") ): """ - Extract text from uploaded PDF document. + Extract text from uploaded PDF document or from object storage. - - **input_file**: PDF file to process. + - **input_file**: PDF file to process (required if source='upload'). + - **source**: Source of the file: 'upload' or 'object_storage'. Defaults to 'upload'. + - **client_id**: Client ID for object storage (required if source='object_storage'). + - **object_key**: Object key (path) in the bucket (required if source='object_storage'). - **lang**: Language(s) to use for OCR. Defaults to 'eng+por'. - **mode**: processing mode. 'fast' is quicker, 'accurate' performs preprocessing. - **auto_detect**: Explicitly enable OSD (Orientation and Script Detection). @@ -36,15 +43,61 @@ async def extract_pdf( lang = "eng+por" start_time = time.time() - print(f"Processing PDF: {input_file.filename}") + filename = "unknown" + temp_file = None - # Validate file type - fileUpload.validate_pdf_file(input_file) + try: + if source == "upload": + if not input_file: + raise HTTPException(status_code=400, detail="input_file is required when source is 'upload'.") - # Save file using tempfile - temp_file = fileUpload._save_file_to_server(input_file) + # Reject if object storage params are present to avoid ambiguity + if client_id or object_key: + raise HTTPException(status_code=400, detail="Ambiguous request: cannot provide both upload file and object storage parameters.") + + filename = input_file.filename + print(f"Processing PDF upload: {filename}") + + # Validate file type + fileUpload.validate_pdf_file(input_file) + + # Save file using tempfile + temp_file = fileUpload._save_file_to_server(input_file) + + elif source == "object_storage": + if input_file: + raise HTTPException(status_code=400, detail="Ambiguous request: cannot provide both upload file and object storage parameters.") + + if not client_id or not object_key: + raise HTTPException(status_code=400, detail="client_id and object_key are required when source is 'object_storage'.") + + filename = object_key + print(f"Processing PDF from storage: {client_id}/{object_key}") + + # Download file + try: + temp_file = storage.download_file_from_storage(client_id, object_key) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # Log error + print(f"Error downloading file: {e}") + raise HTTPException(status_code=500, detail="Error retrieving file from storage.") + + # Validate PDF header (simple check) + try: + with open(temp_file, "rb") as f: + header = f.read(5) + if header != b"%PDF-": + raise HTTPException(status_code=400, detail="Downloaded file is not a valid PDF.") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail=f"Error validating file: {e}") + + else: + raise HTTPException(status_code=400, detail="Invalid source. Use 'upload' or 'object_storage'.") - try: # Process PDF text = await pdf_ocr.process_pdf( file_path=temp_file, @@ -53,15 +106,21 @@ async def extract_pdf( auto=auto_detect, force_processing=force_processing ) + + except HTTPException as e: + raise e + except Exception as e: + # Log unexpected errors + print(f"Error processing PDF: {e}") + raise HTTPException(status_code=500, detail=f"Error processing PDF: {str(e)}") finally: - if os.path.exists(temp_file): + if temp_file and os.path.exists(temp_file): os.remove(temp_file) time_taken = str(round((time.time() - start_time), 2)) - # Return list with single document result (consistent with image endpoint structure) return [TextExtractDocument( - file_name=input_file.filename or "unknown", + file_name=filename or "unknown", text=text, time_taken=time_taken )] diff --git a/app/api/text_extract.py b/app/api/text_extract.py index 76c9ea9..76cd91d 100644 --- a/app/api/text_extract.py +++ b/app/api/text_extract.py @@ -5,20 +5,27 @@ from app.domain import ocr, fileUpload from app.model.TextSchema import TextExtractDocument +from app.services import storage router = APIRouter() @router.post("/extract_text", response_model=List[TextExtractDocument]) async def extract_text( - input_images: List[UploadFile] = File(...), + input_images: Optional[List[UploadFile]] = File(None, description="List of image files to process (required if source='upload')"), + source: str = Form("upload", description="Source of the file: 'upload' or 'object_storage'"), + client_id: Optional[str] = Form(None, description="Client ID for object storage (required if source='object_storage')"), + object_keys: Optional[List[str]] = Form(None, description="List of object keys (paths) in the bucket (required if source='object_storage')"), lang: str = Form("eng+por", description="Language code (eng, por, eng+por). Use 'auto' to enable OSD."), mode: str = Form("fast", description="OCR mode: 'fast' or 'accurate'."), auto_detect: bool = Form(False, description="Enable Orientation and Script Detection (OSD).") ): """ - Extract text from uploaded images using Tesseract OCR. + Extract text from uploaded images or from object storage using Tesseract OCR. - - **input_images**: List of image files to process. + - **input_images**: List of image files to process (required if source='upload'). + - **source**: Source of the file: 'upload' or 'object_storage'. Defaults to 'upload'. + - **client_id**: Client ID for object storage (required if source='object_storage'). + - **object_keys**: List of object keys (paths) in the bucket (required if source='object_storage'). - **lang**: Language(s) to use for OCR. Defaults to 'eng+por'. set to 'auto' to force OSD. - **mode**: processing mode. 'fast' is quicker, 'accurate' performs preprocessing (rescaling, etc.). - **auto_detect**: Explicitly enable OSD (Orientation and Script Detection). @@ -35,34 +42,82 @@ async def extract_text( results = [] - for img in input_images: - start_time = time.time() - print(f"Processing image: {img.filename}") - - # Validate file type - fileUpload.validate_image_file(img) - - # Save file using tempfile - temp_file = fileUpload._save_file_to_server(img) - - try: - # Process OCR - text = await ocr.read_image( - img_path=temp_file, - lang=lang, - mode=mode, - auto=auto_detect - ) - finally: - if os.path.exists(temp_file): - os.remove(temp_file) - - time_taken = str(round((time.time() - start_time), 2)) - - results.append(TextExtractDocument( - file_name=img.filename or "unknown", - text=text, - time_taken=time_taken - )) + if source == "upload": + if not input_images: + raise HTTPException(status_code=400, detail="input_images is required when source is 'upload'.") + + if client_id or object_keys: + raise HTTPException(status_code=400, detail="Ambiguous request: cannot provide both upload files and object storage parameters.") + + for img in input_images: + start_time = time.time() + print(f"Processing image: {img.filename}") + + # Validate file type + fileUpload.validate_image_file(img) + + # Save file using tempfile + temp_file = fileUpload._save_file_to_server(img) + + try: + # Process OCR + text = await ocr.read_image( + img_path=temp_file, + lang=lang, + mode=mode, + auto=auto_detect + ) + finally: + if os.path.exists(temp_file): + os.remove(temp_file) + + time_taken = str(round((time.time() - start_time), 2)) + + results.append(TextExtractDocument( + file_name=img.filename or "unknown", + text=text, + time_taken=time_taken + )) + + elif source == "object_storage": + if input_images: + raise HTTPException(status_code=400, detail="Ambiguous request: cannot provide both upload files and object storage parameters.") + + if not client_id or not object_keys: + raise HTTPException(status_code=400, detail="client_id and object_keys are required when source is 'object_storage'.") + + for key in object_keys: + start_time = time.time() + print(f"Processing image from storage: {client_id}/{key}") + + temp_file = None + try: + try: + temp_file = storage.download_file_from_storage(client_id, key) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + print(f"Error downloading file {key}: {e}") + raise HTTPException(status_code=500, detail=f"Error retrieving file {key} from storage.") + + # Process OCR + text = await ocr.read_image( + img_path=temp_file, + lang=lang, + mode=mode, + auto=auto_detect + ) + + time_taken = str(round((time.time() - start_time), 2)) + results.append(TextExtractDocument( + file_name=key, + text=text, + time_taken=time_taken + )) + finally: + if temp_file and os.path.exists(temp_file): + os.remove(temp_file) + else: + raise HTTPException(status_code=400, detail="Invalid source. Use 'upload' or 'object_storage'.") return results diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/tenants.py b/app/core/tenants.py new file mode 100644 index 0000000..c6e4790 --- /dev/null +++ b/app/core/tenants.py @@ -0,0 +1,40 @@ +import json +import os +from typing import Dict, Optional +from pydantic import BaseModel + +class TenantConfig(BaseModel): + bucket_name: str + endpoint_url: Optional[str] = None + aws_access_key_id: str + aws_secret_access_key: str + region_name: str + +# Cache for tenant configuration +_TENANTS_CACHE: Dict[str, TenantConfig] = {} + +def load_tenants(config_path: str = "tenants.json"): + """ + Load tenant configurations from a JSON file. + """ + global _TENANTS_CACHE + if not os.path.exists(config_path): + return + + try: + with open(config_path, "r") as f: + data = json.load(f) + for client_id, config in data.items(): + _TENANTS_CACHE[client_id] = TenantConfig(**config) + except Exception as e: + # In a real app, logging here would be good + print(f"Error loading tenants configuration: {e}") + +def get_tenant_config(client_id: str) -> Optional[TenantConfig]: + """ + Retrieve configuration for a specific tenant. + Initializes cache if empty. + """ + if not _TENANTS_CACHE: + load_tenants() + return _TENANTS_CACHE.get(client_id) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/storage.py b/app/services/storage.py new file mode 100644 index 0000000..95e7e5e --- /dev/null +++ b/app/services/storage.py @@ -0,0 +1,55 @@ +import boto3 +import tempfile +import os +import logging +from botocore.exceptions import ClientError +from app.core.tenants import get_tenant_config + +log = logging.getLogger("uvicorn") + +def download_file_from_storage(client_id: str, object_key: str) -> str: + """ + Downloads a file from object storage to a temporary file. + + Args: + client_id (str): The tenant identifier. + object_key (str): The path to the file in the bucket. + + Returns: + str: The path to the downloaded temporary file. + + Raises: + ValueError: If tenant configuration is missing. + ClientError: If S3 interaction fails (e.g. file not found). + """ + config = get_tenant_config(client_id) + if not config: + raise ValueError(f"Configuration for client {client_id} not found.") + + try: + # Determine file extension if possible + suffix = os.path.splitext(object_key)[1] + + # Create a temporary file (not automatically deleted on close, we delete it later) + # Using delete=False so we can return the path and use it. + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file: + log.info(f"Downloading {object_key} for client {client_id} from {config.endpoint_url or 'AWS S3'}...") + + s3_client = boto3.client( + "s3", + endpoint_url=config.endpoint_url, + aws_access_key_id=config.aws_access_key_id, + aws_secret_access_key=config.aws_secret_access_key, + region_name=config.region_name + ) + + s3_client.download_fileobj(config.bucket_name, object_key, tmp_file) + log.info(f"Downloaded to {tmp_file.name}") + return tmp_file.name + + except ClientError as e: + log.error(f"S3 ClientError downloading {object_key}: {e}") + raise e + except Exception as e: + log.error(f"Unexpected error downloading {object_key}: {e}") + raise e diff --git a/pyproject.toml b/pyproject.toml index 70f9430..cc77809 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.12" dependencies = [ + "boto3>=1.42.50", "fastapi>=0.129.0", "filetype>=1.2.0", "httpx>=0.28.1", diff --git a/tenants.json b/tenants.json new file mode 100644 index 0000000..67448f0 --- /dev/null +++ b/tenants.json @@ -0,0 +1,9 @@ +{ + "client_a": { + "bucket_name": "bucket-a", + "endpoint_url": "https://s3.example.com", + "aws_access_key_id": "mock_key", + "aws_secret_access_key": "mock_secret", + "region_name": "us-east-1" + } +} diff --git a/tests/test_storage_extract.py b/tests/test_storage_extract.py new file mode 100644 index 0000000..c12e2a7 --- /dev/null +++ b/tests/test_storage_extract.py @@ -0,0 +1,122 @@ +import os +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, MagicMock +from app.main import app + +client = TestClient(app) + +PDF_HEADER = b"%PDF-1.4\n" + +@pytest.fixture +def dummy_pdf(tmp_path): + p = tmp_path / "dummy.pdf" + p.write_bytes(PDF_HEADER + b"dummy content") + return str(p) + +@pytest.fixture +def mock_storage_download(dummy_pdf): + with patch("app.services.storage.download_file_from_storage") as mock: + # Return a copy so deletion doesn't affect source + def side_effect(client_id, key): + import shutil + dest = dummy_pdf + ".tmp" + shutil.copy(dummy_pdf, dest) + return dest + mock.side_effect = side_effect + yield mock + +def test_extract_pdf_storage_success(mock_storage_download): + with patch("app.domain.pdf_ocr.process_pdf") as mock_ocr: + mock_ocr.return_value = "Extracted PDF Text" + + response = client.post( + "/extract_pdf", + data={ + "source": "object_storage", + "client_id": "client_a", + "object_key": "path/to/file.pdf" + } + ) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["text"] == "Extracted PDF Text" + assert data[0]["file_name"] == "path/to/file.pdf" + + mock_storage_download.assert_called_once_with("client_a", "path/to/file.pdf") + mock_ocr.assert_called_once() + +def test_extract_pdf_storage_missing_params(): + response = client.post( + "/extract_pdf", + data={"source": "object_storage"} + ) + assert response.status_code == 400 + assert "client_id and object_key are required" in response.json()["detail"] + +def test_extract_pdf_ambiguous(dummy_pdf): + with open(dummy_pdf, "rb") as f: + response = client.post( + "/extract_pdf", + data={ + "source": "object_storage", + "client_id": "client_a", + "object_key": "path/to/file.pdf" + }, + files={"input_file": ("test.pdf", f, "application/pdf")} + ) + assert response.status_code == 400 + assert "Ambiguous request" in response.json()["detail"] + +def test_extract_text_storage_success(mock_storage_download): + with patch("app.domain.ocr.read_image") as mock_ocr: + mock_ocr.return_value = "Extracted Image Text" + + # Requests/TestClient handles list in data by repeating keys + data = { + "source": "object_storage", + "client_id": "client_a", + "object_keys": ["img1.png", "img2.jpg"] + } + + response = client.post("/extract_text", data=data) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["file_name"] == "img1.png" + assert data[0]["text"] == "Extracted Image Text" + assert data[1]["file_name"] == "img2.jpg" + + assert mock_storage_download.call_count == 2 + assert mock_ocr.call_count == 2 + +def test_extract_text_storage_missing_params(): + response = client.post( + "/extract_text", + data={"source": "object_storage"} + ) + assert response.status_code == 400 + assert "client_id and object_keys are required" in response.json()["detail"] + +def test_extract_pdf_invalid_pdf_header(tmp_path): + # Mock download returning a non-PDF file + p = tmp_path / "not_pdf.txt" + p.write_bytes(b"Not a PDF") + + with patch("app.services.storage.download_file_from_storage") as mock_download: + mock_download.return_value = str(p) + + response = client.post( + "/extract_pdf", + data={ + "source": "object_storage", + "client_id": "client_a", + "object_key": "bad.pdf" + } + ) + + assert response.status_code == 400 + assert "not a valid PDF" in response.json()["detail"] diff --git a/uv.lock b/uv.lock index 797763f..2cff92a 100644 --- a/uv.lock +++ b/uv.lock @@ -38,6 +38,7 @@ name = "app" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "boto3" }, { name = "fastapi" }, { name = "filetype" }, { name = "httpx" }, @@ -62,6 +63,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "boto3", specifier = ">=1.42.50" }, { name = "fastapi", specifier = ">=0.129.0" }, { name = "filetype", specifier = ">=1.2.0" }, { name = "httpx", specifier = ">=0.28.1" }, @@ -93,6 +95,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, ] +[[package]] +name = "boto3" +version = "1.42.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/41/7a7280875ec000e280b0392478a5d6247bc88e7ecf2ae6ec8f4ddb35b014/boto3-1.42.50.tar.gz", hash = "sha256:38545d7e6e855fefc8a11e899ccbd6d2c9f64671d6648c2acfb1c78c1057a480", size = 112851, upload-time = "2026-02-16T20:42:09.203Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/14/bf4077d843d737bec6f4176e113182a4435a1864e2a819ca07004da8a9ac/boto3-1.42.50-py3-none-any.whl", hash = "sha256:2fdf8f5349b130d62576068a6c47b3eec368a70bc28f16d8cce17c5f7e74fc2e", size = 140604, upload-time = "2026-02-16T20:42:06.652Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/fd/e63789133b2bf044c8550cd6766ec93628b0ac18a03f2aa0b80171f0697a/botocore-1.42.50.tar.gz", hash = "sha256:de1e128e4898f4e66877bfabbbb03c61f99366f27520442539339e8a74afe3a5", size = 14958074, upload-time = "2026-02-16T20:41:58.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/b8/b02ad16c5198e652eafdd8bad76aa62ac094afabbe1241b4be1cd4075666/botocore-1.42.50-py3-none-any.whl", hash = "sha256:3ec7004009d1557a881b1d076d54b5768230849fa9ccdebfd409f0571490e691", size = 14631256, upload-time = "2026-02-16T20:41:55.004Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -305,6 +335,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "mccabe" version = "0.7.0" @@ -605,6 +644,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -623,6 +674,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + [[package]] name = "sentry-sdk" version = "2.53.0" @@ -636,6 +699,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "starlette" version = "0.52.1"